Pencarian substring seperti Pos('/Length', Dict) adalah alat yang salah untuk membaca kamus PDF, karena kunci nama PDF berbagi awalan (prefixes): /Length adalah awalan dari /Length1, dan /Encrypt adalah awalan dari /EncryptMetadata. ISO 32000-1 §7.3.5 mendefinisikan nama sebagai token yang hanya berakhir pada pembatas (delimiter) atau spasi putih (whitespace), sehingga kunci dianggap ditemukan hanya ketika byte setelahnya adalah salah satu dari karakter tersebut. Pembaca kamus yang melewatkan satu pemeriksaan tersebut pada akhirnya akan membaca nilai yang salah dari berkas yang benar-benar valid
Bug yang mengajarkan kami hal ini sama sekali tidak terlihat seperti masalah penganalisis leksikal (lexer). Pemindaian kepatuhan mulai melaporkan aliran (stream) FontFile sebagai rusak: program font yang didekompresi berupa fragmen, terpotong di tengah tabel. Berkas terbuka dengan baik di setiap viewer. Data aliran pada disk tetap utuh. Akar penyebabnya terletak pada satu baris pembaca kamus bersama kami: Pos('/Length', ...) telah cocok dengan /Length1, kunci standar yang dibawa oleh kamus aliran FontFile per ISO 32000-1 Tabel 127, dan pembaca mengambil bilangan bulat (integer) setelah /Length1 sebagai panjang aliran. Penulis berkas tertentu kebetulan menserialisasikan /Length1 sebelum /Length, yang sepenuhnya bebas dilakukan, karena entri kamus tidak berurutan per §7.3.7. Aliran tersebut terpotong menjadi jumlah byte sampah, dan setiap pemeriksaan hilir yang mengonsumsinya menjadi tidak mendeteksi kesalahan secara diam-diam
Mengapa pencocokan substring merusak penguraian kamus PDF?
Pencocokan substring rusak karena ruang nama PDF penuh dengan keluarga awalan yang disengaja, dan karena urutan entri kamus tidak ditentukan. Tabel 127 dari ISO 32000-1 mendefinisikan /Length1, /Length2, dan /Length3 untuk aliran font yang disematkan, semuanya berada di sebelah /Length dalam kamus yang sama. Kamus enkripsi memasangkan /Encrypt di trailer dengan /EncryptMetadata di dalamnya. Kunci pendek lebih buruk: pencarian yang dibangun sebagai Pos('/' + Key, ...) dengan Key = 'N' akan dengan mudah mendarat di /Name atau /Nums. Tidak ada satu pun tabrakan ini yang memerlukan berkas yang cacat. Penulis yang mengurutkan /Length1 sebelum /Length sepenuhnya patuh, yang berarti bug substring bukanlah celah ketahanan terhadap masukan yang rusak — ini adalah celah kebenaran terhadap masukan yang valid
Mode kegagalan ini juga merupakan jenis yang tidak bersuara. /Length yang salah tidak memicu pengecualian; ia memberi Anda irisan byte yang lebih pendek atau lebih panjang dari yang sebenarnya ditempati aliran. Jika irisan tersebut memberi makan pemeriksaan subset font, penguraian CMap, atau pemindaian metadata, konsumen melihat sampah dan biasanya tidak melaporkan apa pun, karena setengah dari aliran zlib gagal didekompresi dan kode terus berjalan. Kami mengirimkan kelas cacat yang persis seperti ini dan memperbaikinya di versi 2.14.3 dari pembaca bersama kami, setelah audit klausul demi klausul dari ISO 32000-1 §7.2–§7.3 menandai setiap pencarian kunci gaya Pos sebagai mencurigakan
Bagaimana ISO 32000-1 §7.3.5 mendefinisikan sebuah nama yang sebenarnya
Bagian 7.3.5 pendek dan tepat: objek nama adalah solidus (garis miring) yang diikuti oleh urutan karakter biasa, dan token diakhiri oleh pembatas pertama atau karakter spasi putih. Pembatasnya adalah delapan karakter tanda kurung ditambah solidus dan tanda persen — ( ) < > [ ] { } / % — dan spasi putih adalah null, tab, umpan baris (line feed), umpan formulir (form feed), carriage return, dan spasi (§7.2.2–§7.2.3). Aturan pengakhiran tersebut adalah intinya. /Length1 bukanlah "/Length diikuti oleh angka 1"; itu adalah token tunggal yang tidak dapat dibagi, persis seperti LengthOne dan Length adalah pengenal (identifiers) yang berbeda dalam Pascal. Pembaca mana pun yang menemukan kunci dengan pencarian byte mentah sedang menerapkan kembali penganalisis leksikal dengan aturan pengakhiran yang dihapus
Berikut adalah bentuk cacat tersebut, yang disederhanakan ke bagian intinya. Versi ini dapat dikompilasi, lolos pengujian terhadap berkas yang penulisnya mengurutkan /Length terlebih dahulu, dan merusak aliran untuk penulis yang tidak melakukannya
// SALAH: mencocokkan /Length1, /Length2, /Length3 juga
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;
Pencocokan token lengkap: periksa byte setelah kunci
Predikat yang benar mengikuti langsung dari §7.3.5: kecocokan kandidat adalah kunci nyata hanya jika karakter setelahnya adalah pembatas, spasi putih, atau akhir buffer. Segala hal lainnya adalah nama yang lebih panjang yang hanya berbagi awalan, sehingga pencarian harus dilanjutkan melewatinya alih-alih menyerah. Perbaikan pada pembaca kami menggantikan setiap pencarian Pos mentah dengan satu rutinitas bersama yang dibangun di atas aturan ini
function IsPdfDelimOrWs(C: AnsiChar): Boolean;
begin
Result := C in [#0, #9, #10, #12, #13, ' ',
'(', ')', '<', '>', '[', ']', '{', '}', '/', '%'];
end;
// Benar: pencocokan token lengkap 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 berakhir di sini: kunci asli
P := PosEx(Key, Dict, P + 1); // awalan dari nama yang lebih panjang: terus cari
end;
end;
Dua detail dalam loop tersebut sangat penting. Pertama, ia terus mencari alih-alih mengembalikan kegagalan pada tabrakan awalan pertama, karena /Length1 120 /Length 4076 adalah urutan yang sah dan kunci yang sebenarnya masih di depan. Kedua, kasus akhir buffer dihitung sebagai terminator, karena fragmen kamus dapat berakhir secara sah tepat setelah nama. Poin yang lebih halus yang perlu diaudit dalam kode Anda sendiri: aturan yang sama berlaku di sisi kiri pencocokan jika string pencarian Anda tidak menyertakan solidus, jika tidak, Pos('Length', ...) dapat mendarat di dalam /PieceLength. Menjangkar string pencarian dengan awalan /, seperti di atas, menangani tepi kiri karena / itu sendiri adalah pembatas yang mengakhiri token sebelumnya
Bagaimana PDF yang berbahaya dapat mengubah bug parser menjadi alokasi gigabyte?
Berkas yang cacat atau berbahaya meningkatkan kesalahan leksikal ini menjadi kehabisan sumber daya, karena bilangan bulat kamus sering kali memberi makan ukuran alokasi. Audit kami menemukan rantai dengan bentuk persis seperti ini dalam perluasan aliran-objek (object-stream). Entri /N dari kamus ObjStm menyatakan berapa banyak objek terkompresi yang ditampung aliran, dan kode perluasan memanggil SetLength pada array yang ukurannya disesuaikan dengannya. Namun, parser bilangan bulat membiarkan parameter keluarannya (out-parameter) tidak tersentuh saat gagal sementara tetap mengembalikannya — sehingga /N non-numerik menyerahkan nilai tumpukan (stack) yang tidak diinisialisasi ke SetLength. Bilangan bulat positif sampah di sana berarti permintaan alokasi dalam gigabyte, dipicu oleh beberapa byte masukan yang rusak, sementara Anda baru sekadar memindai dokumen yang bahkan belum Anda setujui untuk dipercaya
Perbaikan tersebut memiliki dua bagian yang independen, dan keduanya digeneralisasikan. Parser sekarang mengembalikan nilai 0 eksplisit pada kegagalan, tidak pernah memori yang tidak diinisialisasi. Dan konsumen tidak lagi mempercayai aritmatika bebas /N: wilayah header ObjStm sebelum /First menyimpan sepasang bilangan bulat — nomor objek dan offset — untuk setiap objek terkompresi, dan setiap pasangan menempati setidaknya empat byte termasuk pemisah. Setiap /N di atas FirstVal div 4 + 1 secara fisik tidak mungkin terjadi untuk ukuran header yang dideklarasikan dan ditolak sebelum alokasi apa pun terjadi. Batasan tersebut memerlukan biaya satu perbandingan dan diturunkan dari data yang sudah ada, yang merupakan pola yang dicari: batas atas yang dibuktikan oleh berkas itu sendiri, bukan konstanta sembarangan
// /N dikendalikan oleh penyerang; batasi dengan apa yang dapat ditampung /First
if not TryReadDictInt(Dict, '/N', NVal) then
NVal := 0; // nol eksplisit, tidak pernah sampah tumpukan
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
Exit; // header tidak dapat menampung pasangan sebanyak itu
// /Length tidak pernah dapat melebihi berkas yang menampung aliran
if (LenVal < 0) or (LenVal > SourceSize) then
Exit; // tolak sebelum mengalokasikan buffer
Dua batas atas lagi melengkapi batas pertahanan di pembaca kami, keduanya dikirimkan di versi 2.12.0. Pembaca aliran menolak /Length yang lebih besar dari seluruh berkas sebelum mengalokasikan buffer hasil — aliran tidak boleh lebih besar dari kontainer tempat ia berada, sehingga pemeriksaan bebas dari kesalahan positif (false positives). Dan jalur inflate membatasi keluaran terdekompresi pada 256 MiB, yang mematikan bom zlib klasik di mana beberapa kilobyte masukan mengembang tanpa batas; batas tersebut cukup besar untuk aliran PDF nyata apa pun sambil menjaga kasus terburuk tetap dapat bertahan. Tema di ketiganya sama: setiap ukuran yang dideklarasikan berkas adalah sebuah klaim, dan parser memverifikasi setiap klaim terhadap sesuatu yang dapat diukurnya sebelum menyerahkan memori untuk itu. Sikap audit yang sama berlaku satu lapisan di bawah pada batas pengikatan (binding boundary), yang dibahas dalam memperkuat ABI PDFium dan keamanan memori di Delphi
Di mana aturan token lengkap tidak cukup
Batasan yang jujur, agar Anda tidak terlalu memercayai rutinitas di atas. Pencocokan token lengkap memperbaiki identifikasi kunci, tetapi pencarian byte datar di atas rentang kamus tetap tidak dapat memberi tahu apakah kecocokan berada di dalam kamus bersarang, string literal, atau komentar — fungsi FindDictKey pada objek halaman dapat mendarat pada kunci di dalam subkamus /Resources miliknya jika Anda menyerahkan rentang yang terlalu luas. Pembaca kami membatasi rentang ke satu badan objek terlebih dahulu dan memperlakukan konteks string dan komentar sebagai item audit terpisah yang masih terbuka. Keamanan substring adalah satu anak tangga, bukan seluruh tangga: konsistensi referensi silang adalah disiplinnya sendiri, dibahas dalam memvalidasi objek dan aliran xref, dan katalog ancaman yang lebih luas untuk dokumen yang tidak Anda buat ada di mengaudit risiko keamanan PDF
Jika Anda memelihara pembaca kamus yang ditulis secara manual di Delphi atau Lazarus, daftar periksa dari insiden ini singkat. Grep untuk setiap Pos('/ di basis kode dan arahkan hasil yang cocok melalui satu pembantu token lengkap (whole-token helper). Cantumkan keluarga awalan yang diikuti oleh kunci Anda — /Length, /Encrypt, /N, /Type terhadap /Type1 semuanya muncul dalam berkas nyata. Kemudian susuri setiap bilangan bulat yang mencapai SetLength, GetMem, atau loop salin dan tanyakan apa yang membatasinya: ukuran berkas, batas atas yang diturunkan dari header, atau tidak sama sekali. Lapisan penguraian yang dijelaskan di sini adalah fondasi di bawah PDFium Component kami, di mana pembaca tingkat byte dan mesin rendering saling memeriksa pada setiap dokumen yang mereka sentuh