Một pipeline tiếp nhận tài liệu chấp nhận các tệp do những người lạ viết. Hóa đơn, bản quét, tệp đính kèm từ một biểu mẫu web: mỗi thứ đều tự nhận là một PDF và mang theo hàng trăm con số mà trình phân tích cú pháp của bạn được kỳ vọng sẽ dựa vào đó để hành động. Độ dài luồng, kích thước hình ảnh, độ lệch byte, tham chiếu đối tượng — mỗi một con số đều được chọn bởi bất kỳ ai tạo ra tệp đó, và một bản tải lên bị cắt cụt hoặc một tài liệu bị làm sai định dạng có chủ ý cuối cùng sẽ đặt một trong những con số đó vào nơi nó gây ra thiệt hại. Sự khác biệt giữa một trình phân tích cú pháp sống sót qua tệp đó và một trình bị sụp đổ, hoặc tiếp tục chạy với bộ nhớ bị hỏng, là một tập hợp nhỏ các thói quen không phụ thuộc vào bất kỳ thư viện PDF cụ thể nào
Các thói quen này chia sẻ một tiền đề: một giá trị đọc từ tệp là một lời tuyên bố, không phải một phép đo. Nó chỉ trở nên hữu dụng sau khi được kiểm tra chéo với thứ gì đó mà trình phân tích cú pháp tự đo lường — kích thước thực của tệp, số byte thực sự mà bộ giải mã tạo ra, độ sâu thực sự của một phép đệ quy. Những gì tiếp theo đây là tiền đề đó được áp dụng vào những nơi mà các trình phân tích cú pháp tài liệu thực sự bị gãy vỡ
Độ dài được khai báo là một lời tuyên bố, không phải phép đo
Sự không khớp đơn giản nhất là độ dài luồng. Một đối tượng luồng PDF khai báo số lượng byte của nó trong khóa /Length, và dữ liệu thực tế nằm giữa các từ khóa stream và endstream. Không có gì bắt buộc cả hai phải thống nhất với nhau. Một tệp bị cắt cụt chứa ít byte thực tế hơn số đếm được khai báo; một tệp từ một trình tạo bị hỏng có thể khai báo một độ dài vượt quá phần cuối của tệp hoặc lấn sang một đối tượng lân cận. Phân bổ từ giá trị được khai báo và sao chép cho đến endstream và bạn sẽ làm tràn bộ đệm; đọc chính xác số đếm được khai báo mà không kiểm tra tính sẵn có và bạn sẽ đi ra khỏi phần cuối của tệp. Chỉ để giá trị được khai báo điều khiển việc phân bổ sau khi đã kẹp (clamp) nó với khoảng cách được đo đến phần cuối của dữ liệu, và coi sự bất đồng như một điểm quyết định — sửa chữa bằng cách quét tìm endstream, hoặc từ chối luồng — không bao giờ coi nó là thứ để âm thầm tin tưởng
Các tham số hình ảnh mô tả một raster lớn hơn mức bạn đã phân bổ
Các luồng hình ảnh làm tăng rủi ro vì hai tập hợp số độc lập cùng mô tả các pixel. Từ điển hình ảnh mang /Width và /Height, và các bộ đệm raster thường được định cỡ từ chúng. Bộ lọc giải mã mang đặc tính hình học riêng của nó: CCITTFaxDecode lấy /Columns, /Rows, và /K từ DecodeParms của nó, trong đó /K chọn lược đồ Group 3 hoặc Group 4 và bộ giải mã phát ra (Columns + 7) div 8 byte mỗi dòng quét (scanline). Một tệp khai báo /Width 100 nhưng lại giao cho bộ lọc /Columns 1728 — giá trị mặc định — khiến bộ giải mã tạo ra số lượng byte mỗi hàng gấp mười sáu lần mức bộ đệm mong đợi, và phần tràn sẽ đáp xuống mỗi lần một dòng quét vào bất cứ thứ gì nằm sau vùng phân bổ. Khi /Rows vắng mặt, bộ giải mã sẽ chạy cho đến khi dữ liệu ra lệnh dừng, vì vậy hãy giới hạn cả số lượng hàng. DCTDecode cũng có khe hở tương tự: dữ liệu JPEG mang chiều rộng và chiều cao riêng của nó trong điểm đánh dấu SOF, và không có gì bắt buộc chúng phải khớp với từ điển
Quy tắc phòng thủ mang tính cơ học: tính toán kích thước raster mong đợi từ các tham số giải mã đã được xác thực — chính /Columns và /Rows của bộ lọc đối với CCITT, kích thước SOF đối với DCT — kiểm tra nó với các giới hạn của bạn, phân bổ từ đó, và xác minh trong quá trình giải mã rằng đầu ra không bao giờ chạy qua khỏi phần phân bổ. Khi từ điển và bộ lọc không đồng nhất về đặc tính hình học, hãy đối chiếu chúng hoặc từ chối hình ảnh. Điều mà một trình phân tích cú pháp tuyệt đối không bao giờ được làm là định cỡ bộ đệm từ một tập hợp số và để bộ giải mã chạy theo tập hợp số khác
Số học Delphi và những cạm bẫy phân bổ
Ba hành vi của Delphi làm suy yếu ngay cả một trình phân tích cú pháp có ý định xác thực. Điều đầu tiên là phép nhân 32-bit: Delphi ước lượng tích của hai toán hạng Integer ở 32 bit bất kể chiều rộng của đích, vì vậy Width * Height * BytesPerPixel có thể bị bọc lại (wrap) ngay cả khi mỗi thừa số đều vượt qua cuộc kiểm tra tính hợp lý của chính nó. Một bản quét 30000 x 30000 ở ba byte mỗi pixel là 2,7 tỷ byte, mức sẽ bị wrap âm trong số học 32-bit có dấu; các thừa số hơi khác đi sẽ wrap lại thành một độ dài dương nhỏ giúp phân bổ và định cỡ bộ đệm quá nhỏ. Buộc toàn bộ biểu thức phải rộng bằng cách ép kiểu toán hạng đầu tiên — Size := Int64(Width) * Height * BytesPerPixel — sau đó so sánh với một mức giới hạn tường minh trước khi bất cứ thứ gì tiếp cận SetLength
Điều thứ hai là kiểm tra phạm vi. Cấu hình bản phát hành mặc định của Delphi xuất xưởng với nó bị tắt, do đó một chỉ mục nằm ngoài phạm vi được tính toán từ dữ liệu tệp sẽ không ném lỗi — nó đọc hoặc ghi bộ nhớ liền kề với mảng. Hãy bật lại nó bằng {$R+} (và {$Q+} cho tràn số học) ở phần đầu của mọi unit mà lập chỉ mục với các giá trị được dẫn xuất từ tệp. Chi phí là không thể đo lường được bên cạnh các I/O mà dù sao trình phân tích cú pháp cũng thực hiện, và nó chuyển sự hỏng hóc âm thầm thành một ERangeError có thể bắt được
Điều thứ ba là TMemoryStream.SetSize với một Int64 do tệp cung cấp. Trên một RTL hiện tại, nó phân bổ bất cứ thứ gì tệp yêu cầu, vì vậy một luồng duy nhất đòi bốn gigabyte sẽ trở thành một lỗi hết bộ nhớ (out-of-memory) giữa chừng quá trình tiếp nhận. Trên các RTL cũ hơn, nơi SetSize nhận một Longint, giá trị sẽ bị thu hẹp một cách âm thầm trước: một khai báo $100000010 trở thành 16, việc phân bổ thành công, và việc ghi dữ liệu thực sự sẽ chạy vượt xa mức đó. Xác thực mọi kích thước đối chiếu với kích thước nguồn được đo và một giới hạn cứng trước khi bất kỳ cuộc gọi phân bổ nào nhìn thấy nó
Độ lệch chỉ ra bên ngoài tệp
Bảng tham chiếu chéo ánh xạ các số đối tượng thành các độ lệch byte tuyệt đối, và trình phân tích cú pháp tìm kiếm bất cứ nơi nào nó chỉ tới. Trong một tệp bị hỏng hoặc thù địch, các độ lệch đó hạ cánh vượt qua điểm kết thúc của tệp hoặc bên trong các cấu trúc không liên quan. TStream khiến cho lỗi này trở nên im lặng: việc đặt Position vượt qua Size không phải là một lỗi, và một thao tác Read thông thường vượt quá phần cuối chỉ đơn giản trả về ít byte hơn mức yêu cầu, do đó đoạn mã bỏ qua cuộc kiểm tra số đếm sẽ tiếp tục phân tích cú pháp những byte cũ từ đối tượng trước đó. Phương pháp phòng thủ là một điểm nghẽn (chokepoint) — một trình trợ giúp mà mọi thao tác seek và read dựa trên tệp phải đi qua, xác thực độ lệch và số đếm dựa trên kích thước tệp đo được trước khi luồng di chuyển
uses
System.SysUtils, System.Classes;
const
MAX_OBJECT_BYTES = 64 * 1024 * 1024; // no single object may exceed 64 MB
type
EPdfBoundsError = class(Exception);
// Every file-driven seek and read goes through here. Offset and Count are
// file-supplied claims; Source.Size is the measurement they must fit.
procedure ReadBounded(Source: TStream; Offset, Count: Int64;
var Buffer: TBytes);
begin
if (Offset < 0) or (Count < 0) or (Count > MAX_OBJECT_BYTES) or
(Offset > Source.Size) or (Count > Source.Size - Offset) then
raise EPdfBoundsError.CreateFmt(
'object extent %d+%d exceeds file size %d',
[Offset, Count, Source.Size]);
SetLength(Buffer, Count);
if Count = 0 then
Exit;
Source.Position := Offset;
Source.ReadBuffer(Buffer[0], Count);
end;
Định tuyến các độ lệch tham chiếu chéo, các mức độ của luồng, và các bản đọc tệp nhúng thông qua đó, và một độ lệch tồi tệ sẽ trở thành một sự từ chối sạch sẽ gọi tên các con số thay vì một vi phạm truy cập diễn ra ở ba cuộc gọi sau đó
Chu trình và độ sâu trong đồ thị đối tượng
Một PDF là một đồ thị, không phải một cái cây. Bất kỳ giá trị nào cũng có thể là một tham chiếu gián tiếp, một tham chiếu có thể phân giải đến một tham chiếu khác — /Length 12 0 R, nơi đối tượng 12 giữ 13 0 R — và không gì ngăn cản một chuỗi tự đóng kín lại chính nó. Một trình phân giải tuân theo các tham chiếu một cách ngây thơ sẽ đệ quy cho đến khi ngăn xếp (stack) cục bộ cạn kiệt, và cạn kiệt ngăn xếp không phải là thứ bạn có thể bắt được (catch); nó kết thúc tiến trình. Các mảng và từ điển được lồng sâu sẽ đi đến cùng một kết thúc mà hoàn toàn không có bất kỳ chu trình nào
Sử dụng hai lính canh cùng nhau: một bộ đếm độ sâu tường minh bao bọc trường hợp trung thực nhưng quá sâu ở một giới hạn mà không tệp hợp pháp nào chạm tới, và một tập hợp (set) đã truy cập sẽ bắt được một chu trình thực sự ngay trong lần ghé thăm thứ hai của nó, biến nó thành một lỗi có thể báo cáo, chính xác thay vì một cuộc vấp phải giới hạn
uses
System.SysUtils, System.Generics.Collections;
const
MAX_RESOLVE_DEPTH = 32; // far deeper than any legitimate reference chain
type
EPdfStructureError = class(Exception);
TPdfValueKind = (pvNull, pvNumber, pvName, pvString, pvArray,
pvDictionary, pvStream, pvReference);
TPdfValue = record
Kind: TPdfValueKind;
RefNumber: Integer; // meaningful when Kind = pvReference
// ... payload fields for the remaining kinds
end;
// LoadObject is your own routine: it looks up the xref offset for
// ObjNumber, reads the object with ReadBounded, and parses it.
function ResolveObject(ObjNumber, Depth: Integer;
Visited: TDictionary<Integer, Boolean>): TPdfValue;
begin
if Depth > MAX_RESOLVE_DEPTH then
raise EPdfStructureError.Create('reference chain exceeds depth limit');
if Visited.ContainsKey(ObjNumber) then
raise EPdfStructureError.CreateFmt(
'circular reference through object %d', [ObjNumber]);
Visited.Add(ObjNumber, True);
try
Result := LoadObject(ObjNumber);
if Result.Kind = pvReference then // e.g. /Length 12 0 R
Result := ResolveObject(Result.RefNumber, Depth + 1, Visited);
finally
Visited.Remove(ObjNumber); // siblings may legally share this object
end;
end;
Giải nén là một bộ khuếch đại
Một vài kilobyte đầu vào FlateDecode có thể phình to lên thành hàng gigabyte; nén đa mục đích thưởng cho văn bản gốc mang tính lặp đi lặp lại, và một kẻ tấn công có thể làm cho nó lặp lại tối đa. Hãy giới hạn (cap) kích thước phình to của mỗi luồng ở mức mà thực thể tiêu thụ của nó có thể cần một cách hợp lý, và giữ một ngân sách thứ hai dành cho toàn tài liệu: năm trăm luồng mà mỗi luồng chỉ ở ngay dưới mức giới hạn mỗi luồng sẽ làm cạn kiệt bộ nhớ chắc chắn như một luồng khổng lồ duy nhất. Việc kiểm tra thuộc về bên trong vòng lặp phình to, đếm số byte đầu ra khi được tạo ra và hủy bỏ (abort) khi vi phạm, không phải sau vòng lặp khi bộ nhớ đã bị tiêu thụ. Một ngân sách tài liệu được biểu thị bằng bội số của kích thước tệp nén hoạt động tốt, vì các tài liệu hợp pháp tập trung ở mức thấp hơn nhiều so với tỷ lệ mà một luồng được chế tác có thể đạt tới
Phòng thủ theo chiều sâu vượt ra ngoài unit của riêng bạn
Các lớp khiếm khuyết tương tự cũng sống bên trong các thư viện. Hai nghiên cứu điển hình trên blog này sẽ hướng dẫn qua các trường hợp thực tế: bọc lại số nguyên, đệ quy không giới hạn, và bộ đệm chưa khởi tạo được đóng lại trong một engine Pascal cục bộ ở Củng cố trình phân tích PDF Pascal chống lại các tệp độc hại, và quy ước gọi (calling-convention), độ rộng số nguyên, và rủi ro quyền sở hữu khi liên kết (binding) một engine C ở Củng cố một ràng buộc PDFium Component. Đối với nguồn tiếp nhận thực sự không được tin tưởng — một biểu mẫu tải lên công khai, một hộp thư không được xác thực — hãy chạy công việc phân tích cú pháp và giải mã trong một tiến trình có đặc quyền thấp riêng biệt, như vậy tệp đánh bại được mọi lính canh bên trong tiến trình sẽ chỉ phải trả giá bằng một công việc bị thất bại thay vì một dịch vụ bị sập
Danh sách kiểm tra trước khi bay
Trước khi bản dựng (build) tiếp theo xuất xưởng, hãy cho trình phân tích cú pháp bước qua danh sách này: mọi bộ đệm luồng được định cỡ từ một độ dài bị kẹp (clamped length) thay vì được khai báo; mọi raster được định cỡ từ các tham số bộ giải mã đã xác thực và đối chiếu với đầu ra bộ giải mã; mọi phép nhân kích thước được đánh giá bằng Int64 và so sánh với mức giới hạn tường minh; {$R+} kích hoạt trong mọi unit lập chỉ mục với các giá trị được dẫn xuất từ tệp; mọi tìm kiếm (seek) được kiểm tra ranh giới đối chiếu với kích thước tệp đo được; mọi phân giải tham chiếu đều được giới hạn độ sâu và kiểm tra chu trình; mọi vòng lặp phình to (inflation) đều đếm số đầu ra đối chiếu với ngân sách theo luồng và theo tài liệu. Không cái nào trong số các bước kiểm tra này tiêu tốn thời gian đáng kể trên một tài liệu hợp pháp, và mỗi cái đều chuyển tình trạng hỏng bộ nhớ thành một sự từ chối sạch sẽ, có thể ghi nhật ký (log)
Lưu ý: HotPDF Component, PDFlibPas Delphi PDF Library, và PDFium Component của losLab áp dụng các cuộc kiểm tra ranh giới, giới hạn độ sâu, và hạn mức mở rộng này ở bên trong, vì vậy một pipeline tiếp nhận được xây dựng dựa trên chúng sẽ bắt đầu từ một đường cơ sở đã được củng cố vững chắc