Các tìm kiếm substring như Pos('/Length', Dict) là công cụ sai lầm để đọc một dictionary PDF, vì các name key trong PDF chia sẻ các tiền tố chung: /Length là một tiền tố của /Length1, và /Encrypt là một tiền tố của /EncryptMetadata. ISO 32000-1 §7.3.5 định nghĩa một name là một token chỉ kết thúc ở một ký tự phân tách (delimiter) hoặc khoảng trắng, do đó một key chỉ được tính là tìm thấy khi byte đứng sau nó là một trong những ký tự đó. Một trình đọc dictionary bỏ qua kiểm tra duy nhất đó cuối cùng sẽ đọc sai giá trị từ một file hoàn toàn hợp lệ
Lỗi đã dạy cho chúng tôi bài học này trông không giống như một vấn đề của trình phân tích từ vựng (lexer). Một lượt quét tuân thủ bắt đầu báo cáo một stream FontFile bị hỏng: chương trình font được giải nén là một mảnh vụn, bị cắt đứt giữa bảng. File mở bình thường trong mọi trình xem. Dữ liệu stream trên đĩa vẫn nguyên vẹn. Nguyên nhân gốc rễ nằm ở một dòng duy nhất trong trình đọc dictionary dùng chung của chúng tôi: Pos('/Length', ...) đã khớp với /Length1, một key tiêu chuẩn mà các dictionary stream FontFile mang theo theo ISO 32000-1 Bảng 127, và trình đọc đã lấy số nguyên sau /Length1 làm độ dài stream. Người viết của file cụ thể đó đã tuần tự hóa /Length1 trước /Length, điều mà họ hoàn toàn được tự do thực hiện, vì các mục trong dictionary không có thứ tự theo §7.3.7. Stream bị cắt bớt thành một số lượng byte rác, và mọi kiểm tra phía sau tiêu thụ nó đều âm thầm bị mù
Tại sao khớp substring làm hỏng phân tích cú pháp dictionary PDF?
Khớp substring thất bại vì không gian tên PDF chứa đầy các nhóm tiền tố cố ý, và thứ tự các mục trong dictionary là không xác định. Bảng 127 của ISO 32000-1 định nghĩa /Length1, /Length2 và /Length3 cho các stream font được nhúng, tất cả đều nằm cạnh một /Length trong cùng một dictionary. Dictionary mã hóa ghép cặp /Encrypt trong trailer với /EncryptMetadata bên trong nó. Các key ngắn còn tệ hơn: một phép tra cứu được xây dựng dưới dạng Pos('/' + Key, ...) với Key = 'N' sẽ dễ dàng khớp vào /Name hoặc /Nums. Không có va chạm nào trong số này yêu cầu một file bị lỗi cấu trúc. Một trình viết sắp xếp /Length1 trước /Length là hoàn toàn tuân thủ, có nghĩa là lỗi substring không phải là một lỗ hổng độ bền trước đầu vào bị hỏng — nó là một lỗ hổng về tính chính xác trước đầu vào hợp lệ
Chế độ lỗi này cũng thuộc loại âm thầm. Một /Length sai không đưa ra exception; nó bàn giao cho bạn một lát cắt byte ngắn hơn hoặc dài hơn so với những gì stream thực sự chiếm giữ. Nếu lát cắt đó cung cấp cho một kiểm tra font-subset, một phân tích cú pháp CMap hoặc một quét siêu dữ liệu, bộ tiêu thụ sẽ nhìn thấy dữ liệu rác và thường không báo cáo gì cả, vì một nửa stream zlib đơn giản là không giải nén được và code tiếp tục chạy. Chúng tôi đã xuất xưởng chính xác nhóm lỗi này và đã sửa nó trong phiên bản v2.14.3 của trình đọc dùng chung, sau khi một đợt kiểm toán từng điều khoản của ISO 32000-1 §7.2–§7.3 gắn cờ mọi hoạt động tra cứu key kiểu Pos là đáng ngờ
ISO 32000-1 §7.3.5 thực sự định nghĩa một name là gì
Phần 7.3.5 ngắn gọn và chính xác: một name object là một dấu gạch chéo (solidus) theo sau bởi một chuỗi các ký tự thông thường, và token được kết thúc bởi ký tự phân tách hoặc khoảng trắng đầu tiên. Các ký tự phân tách là tám ký tự ngoặc cộng với dấu gạch chéo và dấu phần trăm — ( ) < > [ ] { } / % — và khoảng trắng là null, tab, line feed, form feed, carriage return và dấu cách (§7.2.2–§7.2.3). Quy tắc kết thúc đó là toàn bộ câu chuyện. /Length1 không phải là "/Length theo sau bởi số 1"; nó là một token đơn nhất, không thể chia cắt, giống như LengthOne và Length là các mã định danh khác nhau trong Pascal. Bất kỳ trình đọc nào tìm kiếm key bằng cách tìm kiếm byte thô đều đang triển khai lại trình phân tích từ vựng với quy tắc kết thúc bị xóa bỏ
Đây là dạng của lỗi, được giảm thiểu xuống các yếu tố thiết yếu của nó. Phiên bản này biên dịch, vượt qua các bài kiểm thử đối với các file mà trình viết sắp xếp /Length trước, và làm hỏng các stream đối với các trình viết không làm như vậy
// WRONG: matches /Length1, /Length2, /Length3 as well
function ReadStreamLength(const Dict: AnsiString): Integer;
var
P: Integer;
begin
Result := -1;
P := Pos('/Length', Dict);
if P > 0 then
Result := ReadIntAt(Dict, P + Length('/Length'));
end;
Khớp toàn bộ token: kiểm tra byte sau key
Vị từ chính xác tuân theo trực tiếp từ §7.3.5: một ứng viên khớp chỉ là một key thực sự nếu ký tự ngay sau nó là một ký tự phân tách, khoảng trắng, hoặc điểm kết thúc của buffer. Mọi thứ khác đều là một name dài hơn mà chỉ đơn thuần chia sẻ một tiền tố, vì vậy việc tìm kiếm phải tiếp tục vượt qua nó thay vì bỏ cuộc. Giải pháp trong trình đọc của chúng tôi đã thay thế mọi phép tra cứu Pos thô bằng một routine dùng chung duy nhất được xây dựng trên quy tắc này
function IsPdfDelimOrWs(C: AnsiChar): Boolean;
begin
Result := C in [#0, #9, #10, #12, #13, ' ',
'(', ')', '<', '>', '[', ']', '{', '}', '/', '%'];
end;
// Correct: whole-token match per ISO 32000-1 §7.3.5
function FindDictKey(const Dict, Key: AnsiString): Integer;
var
P, After: Integer;
begin
Result := 0;
P := Pos(Key, Dict);
while P > 0 do
begin
After := P + Length(Key);
if (After > Length(Dict)) or IsPdfDelimOrWs(Dict[After]) then
Exit(P); // token ends here: genuine key
P := PosEx(Key, Dict, P + 1); // prefix of a longer name: keep looking
end;
end;
Hai chi tiết trong vòng lặp đó mang lại giá trị. Thứ nhất, nó tiếp tục tìm kiếm thay vì trả về thất bại ngay từ va chạm tiền tố đầu tiên, vì /Length1 120 /Length 4076 là một thứ tự hợp lệ và key thực sự vẫn ở phía trước. Thứ hai, trường hợp kết thúc buffer được tính là một ký tự kết thúc, vì một đoạn dictionary có thể kết thúc hợp lệ ngay sau một name. Một điểm tinh tế hơn đáng để kiểm toán trong code của riêng bạn: quy tắc tương tự áp dụng cho phía bên trí của kết quả khớp nếu chuỗi tìm kiếm của bạn không bao gồm dấu gạch chéo, nếu không Pos('Length', ...) có thể khớp vào bên trong /PieceLength. Việc neo chuỗi tìm kiếm với ký tự / dẫn đầu, như trên, xử lý cạnh trái vì bản thân / là một ký tự phân tách kết thúc token trước đó
Làm thế nào một file PDF độc hại có thể biến một lỗi parser thành một đợt cấp phát gigabyte?
Một file bị lỗi cấu trúc hoặc độc hại sẽ leo thang các lỗi từ vựng này thành cạn kiệt tài nguyên, vì các số nguyên trong dictionary thường xuyên cung cấp kích thước cấp phát bộ nhớ. Đợt kiểm toán của chúng tôi đã tìm thấy một chuỗi có hình dạng chính xác này trong quá trình mở rộng stream đối tượng (object-stream). Mục /N của một dictionary ObjStm cho biết stream giữ bao nhiêu đối tượng được nén, và code mở rộng đã gọi SetLength trên một mảng có kích thước theo nó. Tuy nhiên, trình phân tích số nguyên đã giữ nguyên tham số out của nó khi thất bại trong khi vẫn trả về nó — do đó một /N phi số đã bàn giao cho SetLength một giá trị stack chưa được khởi tạo. Một số nguyên dương rác ở đó có nghĩa là một yêu cầu cấp phát bộ nhớ tính bằng gigabyte, được kích hoạt bởi vài byte đầu vào bị hỏng, trong khi bạn chỉ đang quét một tài liệu mà bạn thậm chí chưa đồng ý tin tưởng
Việc sửa chữa có hai phần độc lập, và cả hai đều mang tính tổng quát. Trình phân tích cú pháp hiện trả về giá trị 0 rõ ràng khi thất bại, không bao giờ trả về bộ nhớ stack chưa khởi tạo. Và bộ tiêu thụ không còn tin tưởng vào số học /N một cách tùy tiện: vùng header ObjStm trước /First lưu trữ một cặp số nguyên — số đối tượng và offset — cho mỗi đối tượng được nén, và mỗi cặp chiếm ít nhất bốn byte bao gồm cả ký tự phân tách. Do đó, bất kỳ /N nào vượt quá FirstVal div 4 + 1 là không thể tồn tại về mặt vật lý đối với kích thước header được khai báo và bị từ chối trước khi bất kỳ đợt cấp phát nào xảy ra. Giới hạn này chỉ tốn một phép so sánh và được suy ra từ dữ liệu đã có sẵn, đó là mô hình cần tìm kiếm: một trần giới hạn mà chính file tự chứng minh, không phải là một hằng số tùy ý
// /N is attacker-controlled; bound it by what /First can hold
if not TryReadDictInt(Dict, '/N', NVal) then
NVal := 0; // explicit zero, never stack garbage
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
Exit; // header cannot contain that many pairs
// /Length can never exceed the file that contains the stream
if (LenVal < 0) or (LenVal > SourceSize) then
Exit; // refuse before allocating the buffer
Hai trần giới hạn nữa hoàn thiện vành đai phòng thủ
Hai trần giới hạn nữa hoàn thiện vành đai phòng thủ trong trình đọc của chúng tôi, cả hai đều được xuất xưởng trong phiên bản v2.12.0. Trình đọc stream từ chối bất kỳ /Length nào lớn hơn toàn bộ file trước khi cấp phát buffer kết quả — một stream không thể lớn hơn container chứa nó, vì vậy kiểm tra này không có lỗi nhận diện sai (false positive). Và đường dẫn giải nén giới hạn đầu ra ở mức 256 MiB, giúp dập tắt quả bom zlib (zlib bomb) cổ điển nơi một vài kilobyte đầu vào phình to không giới hạn; trần này là rộng rãi cho bất kỳ stream PDF thực tế nào trong khi vẫn giữ cho trường hợp tệ nhất có thể sống sót. Chủ đề xuyên suốt cả ba là như nhau: mọi kích thước mà một file khai báo là một tuyên bố, và trình phân tích cú pháp xác minh từng tuyên bố đó với thứ gì đó mà nó có thể đo lường trước khi cam kết bộ nhớ cho nó. Tư thế kiểm toán tương tự áp dụng ở một lớp thấp hơn tại ranh giới liên kết, được đề cập trong củng cố an toàn bộ nhớ và ABI PDFium trong Delphi
Nơi quy tắc toàn bộ token là không đủ
Những giới hạn thực tế, để bạn không tin tưởng quá mức vào routine trên. Khớp toàn bộ token giải quyết việc xác định key, nhưng tìm kiếm byte phẳng trên một phạm vi dictionary vẫn không thể phân biệt được kết quả khớp nằm bên trong một dictionary lồng nhau, một chuỗi literal hay một bình luận — FindDictKey trên một đối tượng trang có thể khớp vào một key bên trong subdictionary /Resources của nó nếu bạn đưa cho nó một phạm vi quá rộng. Trình đọc của chúng tôi giới hạn phạm vi trong một body đối tượng đơn lẻ trước tiên và coi ngữ cảnh chuỗi và bình luận là một hạng mục kiểm toán riêng biệt vẫn đang bỏ ngỏ. An toàn substring là một nấc của chiếc thang, không phải toàn bộ chiếc thang: tính nhất quán của cross-reference là kỷ luật riêng của nó, được đề cập trong xác thực stream đối tượng và xref, và danh mục mối đe dọa rộng hơn cho các tài liệu bạn không phải là tác giả nằm trong kiểm toán các rủiai ro bảo mật PDF
Nếu bạn duy trì một trình đọc dictionary viết tay trong Delphi hoặc Lazarus, danh sách kiểm tra từ sự cố này rất ngắn gọn. Hãy dùng grep cho mọi Pos('/ trong codebase và chuyển hướng các kết quả khớp qua một helper toàn bộ token. Liệt kê các nhóm tiền tố mà các key của bạn tham gia — /Length, /Encrypt, /N, /Type so với /Type1 đều xuất hiện trong các file thực tế. Sau đó duyệt qua mọi số nguyên đến SetLength, GetMem hoặc một vòng lặp sao chép và tự hỏi điều gì giới hạn nó: kích thước file, một trần giới hạn được suy ra từ header, hay không có gì. Lớp phân tích cú pháp được mô tả ở đây là nền tảng bên dưới PDFium Component của chúng tôi, nơi trình đọc cấp byte và engine dựng hình chéo kiểm tra lẫn nhau trên mỗi tài liệu mà chúng chạm vào