Technical Article

Memperkeras Parser PDF Pascal Terhadap File Berbahaya

PDF bukanlah dokumen yang biasa Anda buka. Ini adalah program kecil yang Anda jalankan. Setiap font yang disematkan adalah interpreter berbasis stack yang menunggu charstrings, setiap gambar adalah dekoder yang diberi kolom lebar, tinggi, dan kedalaman bit yang dipilih oleh file, dan setiap stream tiba dalam keadaan terbungkus dalam filter yang parameternya diatur oleh file. Tidak ada satu pun dari angka-angka tersebut yang merupakan milik Anda. Angka-angka itu berasal dari siapa pun yang memproduksi file tersebut, yang dalam beban kerja nyata dapat berupa faktur pelanggan atau lampiran dari pengirim yang tidak dikenal. Dekoder yang mengubah byte tersebut menjadi piksel dan glyph adalah permukaan serangan (attack surface), and parser yang mempercayai inputnya di sana dapat mengalami crash atau hal yang lebih buruk hanya karena satu file yang salah bentuk.

PDFlibPas melalui tahap pengerasan yang memperlakukan seluruh jalur dekode sebagai area yang tidak aman, di seluruh program font (TrueType, Type1, CFF, dan tabel CMap), dekoder gambar (PNG, GIF, TIFF, JBIG2, dan CCITT Grup 3 dan Grup 4), serta filter stream (LZW, ASCII85, dan prediktor Flate). Berikut ini adalah lima kelas cacat yang berhasil ditutupnya, masing-masing didasarkan pada perilaku spesifik Delphi yang memungkinkannya terjadi. Cacat-cacat ini telah diperbaiki dalam rilis saat ini, dan bentuk kesalahan yang sama sering berulang dalam kode Pascal mana pun yang mengurai input tidak tepercaya.

Integer overflow yang memberi Anda buffer berukuran kekecilan

Bug keamanan memori klasik dalam dekoder gambar adalah hasil perkalian dimensi yang meluap dan membungkus (wrap). Dekoder membaca lebar, tinggi, jumlah komponen, dan kedalaman bit, mengalikannya untuk menentukan ukuran outputnya, mengalokasikan byte sebanyak itu, lalu menulis gambar pada dimensi sebenarnya. Jika perkalian dilakukan dalam aritmatika 32-bit, hasil perkalian dapat membungkus ke nilai yang kecil bahkan ketika setiap faktor individu berada dalam kisaran yang wajar, sehingga alokasi berhasil, keluarannya jauh terlalu kecil, dan dekode berjalan melampaui akhir alokasi tersebut. Ini adalah CWE-190, integer overflow, yang mengarah ke heap out-of-bounds write (CWE-787) satu langkah kemudian.

Jalur gambar bersama (shared image path) sudah membatasi setiap dimensi hingga 65535; dekoder mandiri tidak semuanya mewarisi batasan tersebut. Ekspresi row-bytes-times-height seperti ByteCount * FHeight, atau ekspresi per-piksel seperti FWidth * Components * BitDepth, adalah hasil perkalian 32-bit di Delphi ketika kedua operan merupakan integer 32-bit, terlepas dari seberapa lebar variabel tujuan Anda menetapkan hasilnya. Lebar dan tinggi 60000 masing-masing masuk akal untuk pemindaian besar, tetapi hasil perkaliannya dalam byte melampaui rentang 32-bit bertanda dan panjangnya keluar dalam ukuran kecil. Jebakan yang sama juga terdapat dalam langkah prediktor ZLib, BitsPerComponent * Colors * Columns.

Perbaikannya adalah menjadikan setidaknya satu operan Int64 sehingga seluruh ekspresi dievaluasi dalam 64-bit, kemudian bandingkan dengan MaxInt dan tolak file sebelum mempersempitnya kembali untuk memanggil SetLength.

// Reject before allocating, not after writing.
// Evaluate the product in Int64 so it cannot wrap at 32 bits.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
  Exit;  // hostile or unsupportable dimensions; refuse the image
SetLength(Buffer, RowBytes * FHeight);

Yang membuat ini menjadi masalah spesifik Delphi dan bukan masalah umum adalah penyempitan tipe data yang senyap (silent narrowing). Menetapkan ekspresi yang terlalu lebar ke tujuan 32-bit adalah konversi legal yang tidak akan diperingatkan oleh kompiler secara default, dan pemeriksaan rentang (range checking) tidak menangkap pembungkusan nilai yang terjadi sebelum nilai tersebut digunakan sebagai indeks. Biarkan hasil perkalian pada 32 bit maka bahasa tersebut secara diam-diam memberi Anda panjang yang salah tentang seberapa banyak memori yang akan disentuh oleh dekode.

Tipe kolom yang membuat pelindung tidak mungkin dipicu

File TIFF adalah rantai direktori file gambar (image file directories/IFD), masing-masing membawa offset byte dari direktori berikutnya. File berbahaya dapat mengarahkan rantai tersebut kembali ke dirinya sendiri, dan pembaca yang menelusurinya tanpa kondisi berhenti akan berjalan selamanya. Itu adalah CWE-835, loop tak terbatas yang didorong oleh input yang dikendalikan penyerang, dan pertahanannya adalah pencacah (counter) yang berhenti setelah melewati batas yang tidak akan dicapai oleh file sah mana pun.

Pencacah halaman dideklarasikan sebagai Word, yang di Delphi menampung nilai 0 hingga 65535. Loop membawa pelindung penghentian berupa "berhenti ketika jumlah halaman melebihi 65535," yang terdengar benar sampai Anda menyadari operan dan ambang batas (threshold) berbagi batas atas yang sama. Nilai Word tidak pernah bisa lebih besar dari 65535, sehingga perbandingan tersebut secara struktural selalu bernilai salah: ketika pencacah mencapai 65535, kenaikan berikutnya membungkusnya kembali ke 0, pelindung tidak pernah melihat nilai di atas batas, dan rantai IFD yang berulang terus membuat pembaca berputar.

Perbaikannya adalah memperlebar kolom sehingga pelindung dapat mengekspresikan nilai yang sebenarnya dapat ditampung oleh pencacah. Dengan TPDFTIFF.FPageCount yang dideklarasikan sebagai Integer, perbandingan FPageCount > 65535 yang sama menjadi dapat dicapai, loop berhenti, dan properti publik PageCount berubah tipe untuk mencocokkannya tanpa merusak pemanggil apa pun. Kapan pun pemeriksaan batas memiliki bentuk Value > MaxValueOfType(Value) dan operannya sudah diketik tepat pada nilai maksimum tersebut, kondisinya adalah nilai salah yang konstan: perlebar tipenya, atau uji kesetaraan terhadap nilai maksimum tersebut agar dapat dipicu.

Pemeriksaan rentang dinonaktifkan pada jalur sibuk (hot path)

Dengan pemeriksaan rentang (range checking) aktif, Delphi menyisipkan pemeriksaan batas pada setiap indeks array dan string, yang merupakan pembeda antara indeks di luar rentang yang memunculkan ERangeError yang dapat ditangkap dan indeks yang sama membaca atau menulis memori yang bukan milik struktur tersebut. Jalur sibuk (hot path) terkadang menonaktifkannya dengan direktif {$R-} lokal, yang dapat dipertahankan sampai indeks tersebut tidak lagi dapat dipercaya.

Pengakses daftar yang disandarkan oleh interpreter font, TPDFlibStringList.Get, is exactly such a path. On Windows it is compiled with range checking off and indexes its backing store directly, so an out-of-range index is not an error but a raw memory access. That is fine when the index is always valid, and it stops being fine inside a CFF or Type2 charstring interpreter, where the index can come from the file. A charstring that pops an operand off an empty stack produces an index of negative one; a glyph identifier off by one against the glyph count indexes one slot past the end. With range checking off, both become a genuine out-of-bounds access instead of a catchable exception, and because the slots hold reference-counted AnsiString values, a stray read can also corrupt a string's reference count.

Pengeras tidak mengaktifkan kembali pemeriksaan rentang untuk jalur sibuk (hot path). Ini membuat indeks terbukti valid terlebih dahulu: sebelum mengambil bagian atas stack operan, interpreter memeriksa bahwa stack tidak kosong, dan setiap pelindung indeks ditulis sebagai kurang-dari yang ketat terhadap jumlahnya, bukan kurang-dari-atau-sama-dengan yang menerima kesalahan meleset-satu (off-by-one). Direktif memindahkan tanggung jawab batas dari kompiler ke Anda, dan validasi yang dihapusnya harus dimasukkan kembali secara manual di setiap titik masuk.

Rekursi tanpa batas dalam interpreter charstring

Charstring Type2 dapat memanggil subrutin, dan subrutin itu sendiri adalah charstring yang dapat memanggil subrutin lainnya, sehingga operator panggilan subrutin lokal dan global membiarkan file memutuskan seberapa dalam pemanggilan tersebut berjalan. Subrutin yang memanggil dirinya sendiri, secara langsung atau melalui siklus, melakukan rekursi tanpa akhir sampai stack asli habis dan prosesnya mati. Itu adalah CWE-674, rekursi tidak terkendali (uncontrolled recursion).

Interpreter Type1 sudah menjaga dari hal ini. Ia membawa pencacah kedalaman-panggilan (call-depth counter) dan batas atas, PLType1MaxCallDepth, dan menolak untuk turun melewatinya, yang mencerminkan batas kedalaman yang disebutkan dalam spesifikasi Type1 itu sendiri. Interpreter Type2, yang ditambahkan kemudian dan secara struktural serupa, tidak membawa pelindung yang sama, dan font buatan tangan dengan subrutin yang memanggil nomornya sendiri berjalan langsung melalui pemeriksaan yang hilang tersebut ke dalam stack overflow.

// The shape of the Type1 guard the Type2 path was missing.
// Track depth across nested calls and refuse to recurse past it.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
  Exit;  // hostile self-referential subroutine; stop descending
// ... interpret the subroutine, then Dec(CallDepth) on the way out

Perbaikannya adalah memberikan jalur Type2 kedalaman terbatas yang sama dengan yang sudah dimiliki oleh saudara Type1-nya. Setiap penurunan rekursif atas struktur yang dikendalikan penyerang, baik subrutin font, array bersarang, atau rantai referensi silang, memerlukan batas atas kedalaman yang tidak dapat dinaikkan oleh input.

Memori yang tidak diinisialisasi bocor ke dalam output

Cacat yang paling halus membocorkan konten heap ke dalam output terdekripsi, dan penyebabnya adalah sifat dari SetLength yang mudah dilupakan. Ketika Anda memperbesar AnsiString dengan SetLength, Delphi mengalokasikan byte tetapi tidak mengosongkannya (zeroing), sehingga wilayah baru menampung apa pun yang sebelumnya ada di memori heap tersebut. Jika setiap byte kemudian ditulis, ini tidak pernah menjadi masalah; jika suatu jalur membiarkan sebagian buffer tidak tertulis dan kemudian mengembalikannya sebagai data, byte usang tersebut akan keluar bersama hasilnya. Itu adalah CWE-457, penggunaan memori yang tidak diinisialisasi (use of uninitialized memory), dan ketika hasilnya melintasi batas kepercayaan, itu menjadi kebocoran informasi.

Jalur dekripsi AES-CBC mengalami hal ini. Buffer output diukur dengan SetLength dan dekriptor memproses ciphertext satu blok 16-byte setiap kali. Ketika panjang ciphertext bukan kelipatan 16, panjang yang dapat dipilih oleh penyerang, blok parsial akhir tidak pernah ditulis, sehingga byte terakhir tersebut menyimpan konten heap yang ditinggalkan SetLength dan buffer diserahkan kembali sebagai plaintext terdekripsi dari objek dokumen. Solusinya adalah dua pelindung, dan tidak satu pun dari keduanya yang cukup jika berdiri sendiri: titik masuk dekripsi sekarang menolak ciphertext apa pun yang panjangnya bukan kelipatan dari ukuran blok, dan sebagai cadangan (backstop) output dibersihkan dengan FillChar sebelum digunakan sehingga jalur apa pun yang gagal menulis wilayah akan mengembalikan nol, bukan residu heap.

Apa yang tersisa bagi Anda setelah langkah ini

Kelima cacat tersebut adalah bug yang berbeda, tetapi mereka serupa. Lebar integer yang membungkus hasil perkalian, tipe kolom yang menyematkan pelindung pada nilai salah yang konstan, pemeriksaan rentang yang dinonaktifkan di mana indeks tidak lagi aman, rekursi tanpa dasar, dan buffer yang ditolak oleh bahasa untuk dikosongkan. Di masing-masing cacat tersebut, Delphi melakukan persis seperti yang didefinisikannya, karena bahasa tersebut memberi Anda aritmatika yang meluap, penyempitan yang senyap, pemeriksaan rentang yang dapat Anda matikan, rekursi tanpa batas bawaan, dan alokasi yang tidak menginisialisasi. Itulah kontraknya, dan parser Pascal memenuhinya dengan menangani empat hal secara manual di setiap batas yang dikendalikan file: lebar integer, pemeriksaan rentang, kedalaman rekursi, dan inisialisasi buffer.

Cacat-cacat ini telah ditutup dalam rilis PDFlibPas saat ini, mesin untuk Delphi dan C++Builder. Jika pekerjaan Anda juga menjangkau bagaimana suatu file diklaim dilindungi, catatan pendamping tentang mengaudit enkripsi dan izin serta pada preflight PDF/A dan PDF/UA mencakup sisi analisis dari parser yang sama, dan semuanya dikirimkan di dalam PDFlibPas Delphi PDF Library bersama dengan API pemuatan, rendering, dan penandatanganan yang dibahas di bagian lain di blog ini.