Một file PDF 20 KB ghim một tiến trình dịch vụ cho đến khi OOM killer hạ gục nó không phải một lỗi trong code của bạn, đó là một bom giải nén. HotPDF, component PDF VCL bản địa cho Delphi và C++Builder, giới hạn nó bằng DecodeBudgetBytes, một trần cho mỗi filter chain mặc định là 268435456 byte và tính mọi giai đoạn giải mã vào một ngân sách chung duy nhất
File 20 KB đã ăn sạch một tiến trình worker
Hình dạng của sự cố này luôn giống nhau. Một worker hàng đợi render thumbnail nhận một file upload, bộ nhớ thường trú tăng vọt qua 12 GB trong chưa đầy hai giây, và tiến trình biến mất không để lại stack trace. File này chỉ 20 KB. Nó có một trang, một content stream, và một mảng /Filter với năm mục. Mỗi tên trong mảng đó là một filter mà đặc tả có định nghĩa, mỗi giai đoạn giải mã không lỗi, và không gì trong file bị lỗi cấu trúc. Đó chính là điều khiến lớp đầu vào này khó xử: không có byte hỏng nào để từ chối
Đây không phải cùng vấn đề với việc giải mã đúng một filter đơn lẻ. Làm đúng LZWDecode và bộ dự đoán (predictor) trong /DecodeParms là một chủ đề riêng, được trình bày trong bài về LZW, predictor và DecodeParms trên tài liệu đã tải. Ở đây mọi bộ giải mã đều đã đúng. Thất bại nằm ở chỗ những bộ giải mã đúng làm gì khi bạn chạy năm cái liên tiếp và không ai đếm tổng. ISO 32000-1 §7.4 nói rõ rằng /Filter có thể là một tên đơn hoặc một mảng tên, và một mảng được áp dụng tuần tự, mục đầu tiên trước. Nó không nói gì về việc một giai đoạn được phép mở rộng đầu vào của nó bao nhiêu, và không nói gì về tổng tích lũy qua cả chuỗi. Một giai đoạn ASCIIHexDecode gần như giảm một nửa đầu vào của nó, nghe có vẻ vô hại. Một giai đoạn FlateDecode trên một chuỗi byte không đạt tỷ lệ hàng nghìn lần. Nối chúng lại và phép tính trở thành nhân dồn: 20 KB thành 20 MB thành 20 GB, và mỗi bước riêng lẻ là một lần giải mã tuân thủ trên một stream hợp lệ
Vì sao một giới hạn theo từng filter không ngăn được bom giải nén?
Vì một giới hạn theo từng filter được nạp lại ở mỗi phần tử của mảng /Filter. Một chuỗi năm giai đoạn dưới trần 256 MiB mỗi giai đoạn cho phép tới 1,25 GiB, và giai đoạn cuối cùng vẫn bắt đầu với một hạn mức hoàn toàn mới bất kể bốn giai đoạn trước đó đã tạo ra gì. Giới hạn được thực thi trung thực nhưng không ràng buộc bất cứ điều gì quan trọng. HotPDF từng có đúng hình dạng đó trước v2.447.0, và nó còn có một khoảng hở thứ hai đi kèm. Bộ giải nén LZW mang một trần MaxOutputBytes và đường xử lý predictor ảnh tự tính hàng của riêng nó, nên hai cái đó bị giới hạn cục bộ. FlateDecode, ASCIIHexDecode, ASCII85Decode, và RunLengthDecode hoàn toàn không có trần: mỗi cái ghi vào một TMemoryStream cho đến khi hết đầu vào hoặc bộ cấp phát bó tay. Vậy nên một chuỗi thù địch có hai đường đi. Nó có thể dùng một filter hoàn toàn không được canh gác, hoặc nó có thể dùng các filter được canh gác và đơn giản là thêm nhiều filter hơn
Có một chi tiết thứ ba mà một bản sửa ngây thơ sẽ bỏ sót. Con số bạn quan tâm không phải kích thước của đầu ra đã giải mã cuối cùng. Đó là đỉnh, và đỉnh thường nằm trong một buffer trung gian. Một chuỗi kết thúc bằng một content stream khiêm tốn 4 MB có thể cấp phát 8 GB ở giai đoạn ba và trả về thứ trông hoàn toàn hợp lý. Kiểm tra độ dài kết quả sau khi xong không cho bạn biết gì về việc cấp phát đã giết chết tiến trình
Một bộ theo dõi ngân sách cho mỗi filter chain
Bản sửa trong HotPDF v2.447.0 là khiến việc tính toán bao trùm cả chuỗi thay vì chỉ từng giai đoạn. Mỗi filter chain xây một THPDFDecodeBudgetTracker, và mọi bộ giải mã ghi qua một THPDFBudgetWriteStream bọc bên ngoài đích thực. Wrapper này gọi Budget.Consume(Count) trước khi chuyển tiếp dù chỉ một byte, nên việc từ chối xảy ra khi stream đích vẫn còn ở kích thước cũ của nó. Thứ tự đó chính là toàn bộ mấu chốt: một phép kiểm tra thực hiện sau khi buffer đã lớn lên rồi chỉ là một chẩn đoán, không phải một hàng rào phòng thủ
// Simplified from the HotPDF chain decoder: one tracker for the whole
// /Filter array, one bounded wrapper stream per stage
Budget := THPDFDecodeBudgetTracker.Create(Doc.DecodeBudgetBytes);
try
for I := 0 to FilterCount - 1 do
begin
if I = 0 then
InputStream := StreamObj.Stream // read the source, do not copy it
else
InputStream := CurrentStream;
NextStream := TMemoryStream.Create;
InputStream.Position := 0;
// BeginFilter names the stage and bumps FilterCount; the wrapper
// stream calls Budget.Consume before writing into NextStream
Doc.LoadUnFlateLZW(InputStream, NextStream, Filters[I], True, Budget);
CurrentStream.Free;
CurrentStream := NextStream;
end;
finally
Budget.FinishFilter;
Budget.Free;
end;
Các trần cục bộ không biến mất, chúng trở thành hình chiếu của ngân sách chung. Giai đoạn LZW giờ đặt Decoder.MaxOutputBytes := Budget.RemainingBytes, nên trần riêng của nó là bất cứ gì ngân sách chung còn lại thay vì một hạn mức độc lập. Giai đoạn predictor ảnh mở đầu bằng BeginFilter và tính yêu cầu hàng của nó qua Consume trước khi cấp phát, nghĩa là đầu ra của predictor được tính vào cùng ngân sách với các filter chung đã cấp dữ liệu cho nó. Điều này đặc biệt quan trọng trên đường xử lý ảnh, nơi filter chain và predictor là hai nửa của một thao tác, như được trình bày trong trích xuất ảnh từ tài liệu đã tải qua các decode filter của chúng
Bên gọi thấy gì khi ngân sách từ chối?
Ở đáy ngăn xếp, một lần từ chối raise EHPDFDecodeBudgetError. Bên trên đó, câu trả lời phụ thuộc vào hợp đồng mà API gọi đã có sẵn. Các phương thức đọc mức cao vốn báo lỗi qua False hoặc nil vẫn tiếp tục làm đúng như vậy, vì biến một kết quả boolean đã tài liệu hóa thành một exception sẽ phá vỡ những bên gọi vốn đã xử lý đúng đầu vào lỗi cấu trúc. Đường xử lý nội dung trang đã tải là ngoại lệ có chủ đích: nó re-raise EHPDFDecodeBudgetError thay vì để một content stream bị cắt cụt render như một trang chỉ đơn giản trống rỗng. Thiết kế đó có nghĩa một False trơn tự nó mập mờ, nên ngân sách công bố một bản ghi chẩn đoán đi kèm: THotPDF.GetLastDecodeBudgetInfo trả về trạng thái của chuỗi gần nhất mà instance đã giải mã
type
THPDFDecodeBudgetInfo = record
LimitBytes: Int64;
DecodedBytes: Int64;
PeakStageBytes: Int64;
FilterCount: Integer;
Exceeded: Boolean;
ExceededFilter: AnsiString;
end;
var
Pdf: THotPDF;
Info: THPDFDecodeBudgetInfo;
PageText: UnicodeString;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.DecodeBudgetBytes := 64 * 1024 * 1024; // tighter than the default
Pdf.LoadFromFile('untrusted.pdf');
if not Pdf.ExtractLoadedPageText(0, PageText) then
if Pdf.GetLastDecodeBudgetInfo(Info) and Info.Exceeded then
LogWarning(Format(
'decode refused in %s after %d bytes, peak stage %d, %d filters',
[String(Info.ExceededFilter), Info.DecodedBytes,
Info.PeakStageBytes, Info.FilterCount]));
finally
Pdf.Free;
end;
end;
Đọc các trường đó cùng nhau và chúng tách biệt hai kiểu tấn công. Khi PeakStageBytes gần với DecodedBytes, một giai đoạn duy nhất đã gây toàn bộ thiệt hại và bạn đang nhìn vào một filter tỷ lệ cao đơn lẻ. Khi PeakStageBytes chỉ là một phần nhỏ của DecodedBytes và FilterCount cao, không giai đoạn đơn lẻ nào quá đáng và cả chuỗi đã tích lũy vượt trần, đó chính xác là trường hợp một giới hạn theo từng filter không thể nhìn thấy. Một lưu ý đáng viết vào trình xử lý của bạn: GetLastDecodeBudgetInfo trả về False cho đến khi instance đã giải mã ít nhất một filter, nên một False từ nó không phải bằng chứng rằng tài liệu sạch
Ngân sách reset ở đâu, và khi nào không giới hạn là câu trả lời trung thực
DecodeBudgetBytes giới hạn một chuỗi stream, không phải một tài liệu, và ranh giới đó là có chủ đích nhưng dễ đọc sai. Mỗi content stream, mỗi file nhúng, mỗi cross-reference stream và mỗi object stream bắt đầu với một mức 256 MiB mới tinh. Một tài liệu 4.000 trang do đó có 4.000 cơ hội độc lập để tiêu hết toàn bộ trần, và object stream còn nhân số đó lên nữa vì mỗi object stream tự nó là một container nén chứa nhiều object, như mô tả trong bài về object stream và cập nhật gia tăng. Nếu yêu cầu thực sự của bạn là một giới hạn trên tổng bộ nhớ tiến trình, đặc tính này chỉ là một đầu vào cho điều đó, không phải toàn bộ, và nó nên nằm sau một trần ở mức job hoặc container
Bằng không có nghĩa là không giới hạn, và đó là một thiết lập hợp pháp chứ không phải một cửa thoát hiểm. Đặt nó khi bạn sở hữu đầu vào: một pipeline xử lý lại kho lưu trữ trên các tài liệu do hệ thống của chính bạn tạo ra, hoặc một bước raster hóa nơi một chuỗi scan màu 600 dpi đơn lẻ thực sự cần nhiều hơn bất kỳ trần nào bạn thấy thoải mái khi hard-code. Giá trị âm bị từ chối ngay từ đầu với ERangeError, vì một ngân sách âm không có ý nghĩa mạch lạc nào và âm thầm kẹp nó lại sẽ che giấu một lỗi cấu hình
// Trusted archive pipeline: state the intent rather than guess a ceiling
ArchivePdf.DecodeBudgetBytes := 0; // explicit unlimited
// Untrusted upload: size the ceiling from what your corpus actually needs
IngestPdf.DecodeBudgetBytes := 96 * 1024 * 1024;
// Configuration mistakes fail loudly instead of clamping
try
IngestPdf.DecodeBudgetBytes := -1;
except
on E: ERangeError do
LogWarning('DecodeBudgetBytes cannot be negative');
end;
Chọn con số này xứng đáng được chăm chút hơn mức thường thấy, vì một ngân sách đặt quá thấp là một sự cố tự gây ra. Chạy kho dữ liệu hiện có của bạn với giá trị mặc định, ghi lại PeakStageBytes và DecodedBytes cho mỗi chuỗi, và đặt trần cao hơn mức tối đa quan sát được với dư địa thực sự. Một con số tròn được chọn vì nghe có vẻ an toàn sẽ từ chối một bản scan lớn hợp lệ vào lúc tệ nhất, và thất bại đó sẽ trông y hệt một cuộc tấn công trong log của bạn
Bản sao không còn xảy ra nữa
Định tuyến mọi giai đoạn qua một wrapper ngân sách hóa ra khiến chuỗi này rẻ hơn thay vì đắt hơn. Khi một stream có filter, giai đoạn đầu tiên giờ đọc trực tiếp stream nguồn thay vì copy các byte đã mã hóa vào một buffer nháp trước, và từ đó chỉ có hai buffer tồn tại cùng lúc: đầu vào hiện tại và đầu ra giai đoạn đang được ghi. Bản copy thô vẫn tồn tại trong hai trường hợp cần đến nó, cụ thể là một stream hoàn toàn không có filter và một ảnh mà bên gọi muốn giữ mã hóa cuối cùng, vì cả hai đều trả về một stream mà bên gọi sở hữu và có thể seek độc lập. Phiên bản không được canh gác của đoạn code này cấp phát nhiều hơn và giới hạn ít hơn, đó là mối quan hệ thường thấy giữa hai điều này. Tuy nhiên đáng nói rõ: không điều nào trong số này khiến một PDF tùy ý an toàn để tải. Nó chỉ đóng một vector từ chối dịch vụ cụ thể và rất rẻ, vector nơi một file nhỏ mua được một lần cấp phát lớn qua các filter lồng nhau. Tràn số nguyên trong việc tính byte được canh gác riêng, và câu hỏi rộng hơn về phân tích tài liệu thù địch mà không tin tưởng các offset nội bộ của chúng là một kỷ luật khác. Một ngân sách giải mã là một giới hạn trong số nhiều giới hạn, và giá trị của nó là bạn có thể đặt nó từ một thuộc tính duy nhất trước khi bạn chạm vào file
Ngân sách theo từng chuỗi, bản ghi chẩn đoán của nó, và các đường giải mã tài liệu đã tải mà nó bảo vệ đều đi kèm trong chính component, không cần phụ thuộc giải nén bên ngoài nào để cấu hình hoặc vá. Nếu bạn đang đánh giá cách giới hạn đầu vào PDF không đáng tin cậy bên trong một dịch vụ Delphi hoặc C++Builder, trang component PDF Delphi HotPDF liệt kê bộ công cụ tài liệu đã tải mà những giới hạn này áp dụng lên