Sebuah pipeline penerimaan dokumen menerima file yang ditulis orang asing. Faktur, hasil pindaian, lampiran dari sebuah formulir web: masing-masing mengaku sebagai PDF dan membawa ratusan angka yang diharapkan ditindaklanjuti parser Anda. Panjang stream, dimensi gambar, offset byte, referensi objek — setiap satunya dipilih oleh siapa pun yang menghasilkan file itu, dan sebuah unggahan yang terpotong atau dokumen yang sengaja dibuat cacat pada akhirnya akan menaruh salah satu angka itu di tempat yang merusak. Perbedaan antara parser yang selamat dari file semacam itu dan yang crash, atau yang terus berjalan dengan memori yang sudah rusak, adalah sekumpulan kecil kebiasaan yang tidak bergantung pada library PDF tertentu
Kebiasaan-kebiasaan itu berbagi satu premis: sebuah nilai yang dibaca dari file adalah klaim, bukan pengukuran. Ia baru bisa dipakai setelah diperiksa terhadap sesuatu yang diukur sendiri oleh parser-nya — ukuran file yang sebenarnya, jumlah byte yang sungguh dihasilkan decoder, kedalaman rekursi yang sesungguhnya. Yang menyusul adalah premis itu diterapkan pada tempat-tempat yang benar-benar membuat parser dokumen jebol
Panjang yang dideklarasikan adalah klaim, bukan pengukuran
Ketidakcocokan yang paling sederhana adalah panjang stream. Sebuah objek stream PDF mendeklarasikan jumlah byte-nya pada kunci /Length, sementara data sebenarnya duduk di antara kata kunci stream dan endstream. Tidak ada yang memaksa keduanya sepakat. File yang terpotong menyimpan byte nyata lebih sedikit daripada jumlah yang dideklarasikan; file dari generator yang rusak bisa mendeklarasikan panjang yang menjangkau melewati ujung file atau masuk ke objek tetangganya. Alokasikan dari nilai yang dideklarasikan lalu salin sampai endstream dan Anda melampaui buffer-nya; baca persis sejumlah yang dideklarasikan tanpa memeriksa ketersediaannya dan Anda berjalan melewati ujung file. Biarkan nilai yang dideklarasikan itu menggerakkan alokasi hanya setelah dijepit terhadap jarak terukur menuju akhir datanya, dan perlakukan ketidaksepakatan sebagai titik keputusan — perbaiki dengan memindai endstream, atau tolak stream-nya — jangan pernah sebagai sesuatu yang dipercaya diam-diam
Parameter gambar yang menggambarkan raster lebih besar daripada yang Anda alokasikan
Stream gambar menaikkan taruhannya karena dua kumpulan angka yang saling bebas menggambarkan piksel yang sama. Dictionary gambar membawa /Width dan /Height, dan buffer raster biasanya diukur dari keduanya. Filter decode-nya membawa geometri miliknya sendiri: CCITTFaxDecode mengambil /Columns, /Rows, dan /K dari DecodeParms-nya, tempat /K memilih skema Group 3 atau Group 4 dan decoder-nya memancarkan (Columns + 7) div 8 byte per scanline. Sebuah file yang mendeklarasikan /Width 100 namun menyodorkan /Columns 1728 ke filternya — nilai defaultnya — membuat decoder menghasilkan lebih dari enam belas kali lipat byte per baris dibanding yang diharapkan buffer-nya, dan luapannya mendarat satu scanline demi satu scanline ke apa pun yang duduk setelah alokasinya. Ketika /Rows tidak ada, decoder-nya berjalan sampai datanya bilang berhenti, jadi batasi juga jumlah barisnya. DCTDecode punya jahitan yang sama: data JPEG-nya membawa lebar dan tingginya sendiri di dalam marker SOF, dan tidak ada yang mewajibkan keduanya cocok dengan dictionary-nya
Aturan defensifnya bersifat mekanis: hitung ukuran raster yang diharapkan dari parameter decode yang sudah divalidasi — /Columns dan /Rows milik filter itu sendiri untuk CCITT, dimensi SOF untuk DCT — periksa terhadap batasan Anda, alokasikan darinya, lalu verifikasi selama decode bahwa keluarannya tidak pernah melewati alokasinya. Ketika dictionary dan filter berbeda pendapat soal geometri, damaikan keduanya atau tolak gambarnya. Yang tidak boleh pernah dilakukan sebuah parser adalah mengukur buffer-nya dari satu kumpulan angka lalu membiarkan decoder-nya berjalan dengan kumpulan yang lain
Jebakan aritmetika dan alokasi di Delphi
Tiga perilaku Delphi merongrong bahkan parser yang berniat memvalidasi. Yang pertama adalah perkalian 32 bit: Delphi mengevaluasi hasil kali dua operand Integer pada 32 bit tanpa peduli lebar tujuannya, sehingga Width * Height * BytesPerPixel bisa melingkar bahkan ketika setiap faktornya lolos pemeriksaan kewarasannya sendiri. Sebuah pindaian 30000 kali 30000 pada tiga byte per piksel adalah 2,7 miliar byte, yang melingkar menjadi negatif dalam aritmetika 32 bit bertanda; faktor yang sedikit berbeda melingkar menjadi panjang positif kecil yang teralokasi namun kekecilan buffer-nya. Paksa seluruh ekspresinya menjadi lebar dengan meng-cast operand pertamanya — Size := Int64(Width) * Height * BytesPerPixel — lalu bandingkan terhadap plafon yang eksplisit sebelum apa pun sampai ke SetLength
Yang kedua adalah pemeriksaan rentang. Konfigurasi release bawaan Delphi hadir dengan fitur itu dimatikan, sehingga indeks di luar rentang yang dihitung dari data file tidak melempar apa pun — ia membaca atau menulis memori yang bertetangga dengan array-nya. Nyalakan kembali dengan {$R+} (dan {$Q+} untuk luapan aritmetika) di puncak setiap unit yang mengindeks dengan nilai turunan file. Biayanya tak terukur di samping I/O yang toh dilakukan sebuah parser, dan ia mengubah korupsi yang senyap menjadi ERangeError yang bisa ditangkap
Yang ketiga adalah TMemoryStream.SetSize dengan Int64 yang disodorkan file. Pada RTL masa kini ia mengalokasikan apa pun yang diminta file itu, sehingga satu stream yang mengaku empat gigabyte menjadi kegagalan kehabisan memori di tengah penerimaan. Pada RTL yang lebih lama, tempat SetSize menerima Longint, nilainya dipersempit diam-diam lebih dulu: deklarasi $100000010 menjadi 16, alokasinya berhasil, dan penulisan data sebenarnya berjalan jauh melewatinya. Validasi setiap ukuran terhadap ukuran sumber yang terukur dan sebuah plafon keras sebelum panggilan alokasi mana pun melihatnya
Offset yang menunjuk ke luar file
Tabel referensi silang memetakan nomor objek ke offset byte absolut, dan parser-nya mencari ke mana pun ia menunjuk. Pada file yang rusak atau bermusuhan, offset itu mendarat melewati ujung file atau ke dalam struktur yang tidak berkaitan. TStream membuat kegagalannya senyap: menyetel Position melampaui Size bukanlah error, dan sebuah Read biasa melewati ujungnya sekadar mengembalikan byte lebih sedikit daripada yang diminta, sehingga kode yang melewatkan pemeriksaan jumlahnya terus mem-parsing byte basi dari objek sebelumnya. Pertahanannya adalah sebuah titik cekik — satu helper yang dilalui setiap pencarian dan pembacaan yang digerakkan file, yang memvalidasi offset dan jumlahnya terhadap ukuran file terukur sebelum stream-nya bergerak
uses
System.SysUtils, System.Classes;
const
MAX_OBJECT_BYTES = 64 * 1024 * 1024; // tak satu objek boleh melebihi 64 MB
type
EPdfBoundsError = class(Exception);
// Setiap pencarian dan pembacaan yang digerakkan file lewat sini. Offset dan
// Count adalah klaim dari file; Source.Size adalah ukuran yang harus memuatnya.
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;
Rutekan offset referensi silang, rentang stream, dan pembacaan file tertanam melaluinya, maka sebuah offset yang buruk menjadi penolakan yang bersih yang menyebutkan angkanya alih-alih menjadi access violation tiga panggilan kemudian
Siklus dan kedalaman di dalam graf objek
Sebuah PDF adalah graf, bukan pohon. Nilai apa pun boleh berupa referensi tak langsung, sebuah referensi boleh menguraikan diri ke referensi lain — /Length 12 0 R, tempat objek 12 menyimpan 13 0 R — dan tidak ada yang mencegah sebuah rantai menutup kembali ke dirinya sendiri. Sebuah resolver yang mengikuti referensi secara naif akan berekursi sampai stack native-nya habis, dan kehabisan stack bukanlah sesuatu yang bisa Anda tangkap; ia mengakhiri prosesnya. Array dan dictionary yang bersarang sangat dalam mencapai ujung yang sama tanpa siklus sama sekali
Pakai dua penjaga sekaligus: penghitung kedalaman yang eksplisit membatasi kasus jujur-tetapi-dalam pada batas yang tidak didekati file sah mana pun, sementara himpunan yang sudah dikunjungi menangkap siklus sungguhan pada kunjungan keduanya, mengubahnya menjadi error yang presisi dan bisa dilaporkan alih-alih sekadar batas yang tersandung
uses
System.SysUtils, System.Generics.Collections;
const
MAX_RESOLVE_DEPTH = 32; // jauh lebih dalam dari rantai referensi mana pun
type
EPdfStructureError = class(Exception);
TPdfValueKind = (pvNull, pvNumber, pvName, pvString, pvArray,
pvDictionary, pvStream, pvReference);
TPdfValue = record
Kind: TPdfValueKind;
RefNumber: Integer; // bermakna ketika Kind = pvReference
// ... field muatan untuk jenis-jenis lainnya
end;
// LoadObject adalah rutin Anda sendiri: ia mencari offset xref untuk
// ObjNumber, membaca objeknya dengan ReadBounded, lalu mem-parsingnya.
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 // mis. /Length 12 0 R
Result := ResolveObject(Result.RefNumber, Depth + 1, Visited);
finally
Visited.Remove(ObjNumber); // saudara sekandung sah berbagi objek ini
end;
end;
Dekompresi adalah penguat
Beberapa kilobyte masukan FlateDecode bisa mengembang menjadi gigabyte; kompresi serbaguna memberi ganjaran pada teks biasa yang berulang, dan seorang penyerang bisa membuatnya berulang semaksimal mungkin. Beri plafon pada ukuran mengembang setiap stream sebesar yang masuk akal dibutuhkan konsumennya, dan pelihara anggaran kedua per dokumen: lima ratus stream yang masing-masing pas di bawah plafon per stream akan menghabiskan memori sama pastinya dengan satu stream raksasa. Pemeriksaannya berada di dalam loop pengembangannya, menghitung byte keluaran sambil dihasilkan lalu membatalkan saat batasnya dilanggar, bukan setelah loop-nya ketika memorinya sudah telanjur habis. Anggaran dokumen yang dinyatakan sebagai kelipatan ukuran file terkompresinya bekerja dengan baik, karena dokumen yang sah berkerumun jauh di bawah rasio yang dicapai stream yang dirancang jahat
Pertahanan berlapis di luar unit Anda sendiri
Kelas cacat yang sama juga hidup di dalam library. Dua studi kasus di blog ini menyusuri contoh nyatanya: pelingkaran bilangan bulat, rekursi tanpa batas, dan buffer yang tak terinisialisasi yang ditutup pada sebuah engine Pascal native di Memperkeras Parser PDF Pascal Terhadap File Berbahaya, serta bahaya konvensi pemanggilan, lebar bilangan bulat, dan kepemilikan saat mengikat sebuah engine C di Memperkeras Binding PDFium Component. Untuk penerimaan yang benar-benar tak tepercaya — formulir unggah publik, kotak surat tanpa autentikasi — jalankan juga pekerjaan parsing dan decode-nya di dalam proses terpisah berhak rendah, sehingga file yang mengalahkan setiap penjaga di dalam proses hanya berbiaya satu pekerjaan gagal alih-alih layanan yang tumbang
Sebuah checklist preflight
Sebelum build berikutnya dirilis, telusuri parser-nya terhadap daftar ini: setiap buffer stream diukur dari panjang yang sudah dijepit alih-alih dari yang dideklarasikan; setiap raster diukur dari parameter decoder yang tervalidasi dan diperiksa terhadap keluaran decoder-nya; setiap hasil kali dimensi dievaluasi dalam Int64 dan dibandingkan terhadap plafon yang eksplisit; {$R+} aktif di setiap unit yang mengindeks dengan nilai turunan file; setiap pencarian diperiksa batasnya terhadap ukuran file terukur; setiap penguraian referensi dibatasi kedalamannya dan diperiksa siklusnya; setiap loop pengembangan menghitung keluarannya terhadap anggaran per stream dan per dokumen. Tak satu pun pemeriksaan ini berbiaya waktu yang terukur pada dokumen yang sah, dan masing-masing mengubah korupsi memori menjadi penolakan yang bersih dan bisa dicatat
Catatan: HotPDF Delphi Component, PDF Library for Delphi Delphi PDF Library, dan PDFium Component dari losLab menerapkan pemeriksaan batas, batas kedalaman, dan plafon pengembangan ini secara internal, sehingga pipeline penerimaan yang dibangun di atasnya bermula dari baseline yang sudah diperkeras