Bài viết kỹ thuật

Bom giải nén PDF trong Delphi: Ngân sách filter chain của HotPDF

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ệ

Giải phẫu một quả bom decode PDF trong Delphi: một tệp tải lên hai mươi kilobyte với năm tầng /Filter lồng nhau hợp lệ nhân lên phần mở rộng của nó cho đến khi hàng gigabyte được cấp phát và tiến trình worker chết
Năm filter đều hợp lệ nhân các tỉ lệ giãn nở của chúng cho tới khi một bản tải lên 20 KB cấp phát hàng gigabyte, mà không có byte hỏng nào để từ chối

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ủ

// Rút gọn từ bộ giải mã chuỗi filter của HotPDF: một tracker cho toàn bộ
// mảng /Filter, một wrapper stream có giới hạn cho mỗi giai đoạn
Budget := THPDFDecodeBudgetTracker.Create(Doc.DecodeBudgetBytes);
try
  for I := 0 to FilterCount - 1 do
  begin
    if I = 0 then
      InputStream := StreamObj.Stream    // đọc nguồn, không copy nó
    else
      InputStream := CurrentStream;
    NextStream := TMemoryStream.Create;
    InputStream.Position := 0;
    // BeginFilter đặt tên giai đoạn và tăng FilterCount; wrapper
    // stream gọi Budget.Consume trước khi ghi vào 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;   // chặt hơn giá trị mặc định
    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 DecodedBytesFilterCount 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

So sánh các giới hạn theo từng bộ lọc được nạp lại tại mỗi tầng của một mảng /Filter, và THPDFDecodeBudgetTracker dùng chung của HotPDF chặn toàn chuỗi bộ lọc qua Budget.Consume trước khi byte được chuyển tiếp trong Delphi
Một tracker dùng chung tính phí mọi giai đoạn trên cùng một trần, và mỗi wrapper stream gọi Budget.Consume trước khi chuyển tiếp dù chỉ một byte

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

// Pipeline lưu trữ đáng tin cậy: nêu rõ ý định thay vì đoán một mức trần
ArchivePdf.DecodeBudgetBytes := 0;              // tường minh là không giới hạn

// Tệp tải lên không đáng tin: đặt mức trần dựa trên nhu cầu thực tế của corpus
IngestPdf.DecodeBudgetBytes := 96 * 1024 * 1024;

// Lỗi cấu hình sẽ báo lỗi rõ ràng thay vì âm thầm kẹp giá trị
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 PeakStageBytesDecodedBytes 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

Luồng quyết định cho thấy caller đọc GetLastDecodeBudgetInfo thế nào sau khi HotPDF từ chối một lần decode nhiều tầng bộ lọc trong Delphi: byte đỉnh tầng gần với byte đã giải mã chỉ ra một bộ lọc tham lam, trong khi đỉnh thấp với nhiều bộ lọc chỉ ra một chuỗi tích lũy
Đối chiếu byte giải mã của giai đoạn đỉnh với tổng số giúp phân biệt một filter tham lam với một chuỗi đã tích lũy vượt trầ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