PDF 20 KB yang mengunci proses layanan hingga OOM killer menghabisinya bukanlah bug di kode Anda, itu adalah bom dekompresi. HotPDF, komponen VCL PDF native untuk Delphi dan C++Builder, membatasinya dengan DecodeBudgetBytes, ceiling per-filter-chain yang defaultnya 268435456 byte dan membebankan setiap tahap decode ke satu budget bersama
File 20 KB yang menghabisi proses worker
Bentuk insiden ini selalu sama. Worker antrean yang me-render thumbnail mengambil satu upload, memori residennya melonjak melewati 12 GB dalam kurang dari dua detik, dan prosesnya menghilang tanpa stack trace. Filenya 20 KB. Ia punya satu halaman, satu content stream, dan array /Filter dengan lima entri. Setiap nama di array itu adalah filter yang didefinisikan spesifikasi, setiap tahap ter-decode tanpa error, dan tidak ada apa pun dalam file itu yang cacat. Itulah yang membuat kelas input ini janggal: tidak ada byte korup untuk ditolak
Ini bukan masalah yang sama dengan men-decode satu filter dengan benar. Membenarkan LZWDecode dan predictor /DecodeParms adalah topik tersendiri, dibahas di ulasan LZW, predictor, dan DecodeParms pada dokumen yang dimuat. Di sini setiap decoder sudah benar. Kegagalannya adalah apa yang dilakukan decoder yang benar saat Anda menjalankan lima decoder berturut-turut dan tidak ada yang menghitung totalnya. ISO 32000-1 §7.4 secara eksplisit menyatakan bahwa /Filter boleh berupa satu nama atau array nama, dan array diterapkan berurutan, entri pertama lebih dulu. Ia tidak menyebutkan apa pun tentang seberapa banyak satu tahap boleh memperbesar inputnya, dan tidak menyebutkan apa pun tentang agregat sepanjang chain. Tahap ASCIIHexDecode kira-kira memperkecil setengah dari inputnya, terdengar tidak berbahaya. Tahap FlateDecode atas rentetan byte nol mencapai rasio dalam ribuan. Rangkaikan mereka dan aritmatikanya multiplikatif: 20 KB menjadi 20 MB menjadi 20 GB, dan setiap langkah individual adalah decode yang sah dari stream yang legal
Kenapa limit per-filter gagal menghentikan decode bomb?
Karena limit per-filter dipersenjatai ulang di setiap elemen array /Filter. Chain lima tahap dengan cap 256 MiB per-tahap mengizinkan 1.25 GiB, dan tahap terakhir tetap dimulai dengan alokasi yang sepenuhnya baru terlepas dari apa yang dihasilkan empat tahap sebelumnya. Limitnya diberlakukan dengan jujur dan tidak membatasi apa pun yang benar-benar penting. HotPDF punya bentuk persis seperti itu sebelum v2.447.0, dan ia punya celah kedua di sampingnya. Dekompresor LZW membawa cap MaxOutputBytes dan jalur predictor gambar menghitung barisnya sendiri, jadi keduanya dibatasi secara lokal. FlateDecode, ASCIIHexDecode, ASCII85Decode, dan RunLengthDecode sama sekali tidak punya cap: masing-masing menulis ke TMemoryStream sampai kehabisan input atau allocator menyerah. Jadi chain yang berbahaya punya dua jalan masuk. Ia bisa memakai filter yang sepenuhnya tidak dijaga, atau memakai filter yang dijaga dan sekadar menambah lebih banyak lagi
Ada detail ketiga yang terlewat oleh perbaikan naif. Angka yang Anda pedulikan bukan ukuran output terdekode akhir. Itu adalah puncaknya, dan puncak itu biasanya berada di buffer perantara. Chain yang berakhir dengan content stream 4 MB yang sederhana bisa mengalokasikan 8 GB pada tahap ketiga dan mengembalikan sesuatu yang terlihat sepenuhnya wajar. Memeriksa panjang hasil setelah faktanya tidak memberi tahu Anda apa pun tentang alokasi yang menghabisi proses tersebut
Satu budget tracker per filter chain
Perbaikan di HotPDF v2.447.0 adalah membuat akuntansinya mencakup seluruh chain, bukan per tahap. Setiap filter chain membangun satu THPDFDecodeBudgetTracker, dan setiap decoder menulis lewat THPDFBudgetWriteStream yang membungkus target sebenarnya. Wrapper itu memanggil Budget.Consume(Count) sebelum meneruskan satu byte pun, sehingga penolakan terjadi saat stream target masih ukuran lamanya. Urutan itulah keseluruhan intinya: pemeriksaan yang dilakukan setelah buffer sudah membesar adalah diagnostik, bukan pertahanan
// Simplified from the HotPDF chain decoder: one tracker for the whole
// /Filter array, one bounded wrapper stream per stage
Budget := THPDFDecodeBudgetTracker.Create(Doc.DecodeBudgetBytes);
try
for I := 0 to FilterCount - 1 do
begin
if I = 0 then
InputStream := StreamObj.Stream // read the source, do not copy it
else
InputStream := CurrentStream;
NextStream := TMemoryStream.Create;
InputStream.Position := 0;
// BeginFilter names the stage and bumps FilterCount; the wrapper
// stream calls Budget.Consume before writing into NextStream
Doc.LoadUnFlateLZW(InputStream, NextStream, Filters[I], True, Budget);
CurrentStream.Free;
CurrentStream := NextStream;
end;
finally
Budget.FinishFilter;
Budget.Free;
end;
Cap lokal itu tidak hilang, mereka menjadi proyeksi dari budget bersama. Tahap LZW sekarang mengatur Decoder.MaxOutputBytes := Budget.RemainingBytes, jadi ceiling privatnya adalah apa pun yang tersisa dari chain, bukan alokasi independen. Tahap predictor gambar dibuka dengan BeginFilter dan membebankan kebutuhan barisnya lewat Consume sebelum mengalokasikan, artinya output predictor dibebankan ke budget yang sama dengan filter generik yang memasoknya. Ini penting khususnya pada jalur gambar, di mana filter chain dan predictor adalah dua bagian dari satu operasi, sebagaimana dibahas di mengekstrak gambar dari dokumen yang dimuat lewat filter decode-nya
Apa yang dilihat pemanggil saat budget menolak?
Di dasar stack, penolakan memicu EHPDFDecodeBudgetError. Di atas itu, jawabannya tergantung kontrak API pemanggil yang sudah ada. Metode baca level tinggi yang melaporkan kegagalan lewat False atau nil tetap melakukan persis itu, karena mengubah hasil boolean yang sudah terdokumentasi menjadi exception akan merusak pemanggil yang sudah menangani input cacat dengan benar. Jalur konten halaman yang dimuat adalah pengecualian yang disengaja: ia memicu ulang EHPDFDecodeBudgetError alih-alih membiarkan content stream yang terpotong ter-render sebagai halaman yang sekadar kosong. Desain itu berarti False saja bersifat ambigu, jadi budget itu menerbitkan record diagnostik di sampingnya: THotPDF.GetLastDecodeBudgetInfo mengembalikan state dari chain terakhir yang di-decode instance tersebut
type
THPDFDecodeBudgetInfo = record
LimitBytes: Int64;
DecodedBytes: Int64;
PeakStageBytes: Int64;
FilterCount: Integer;
Exceeded: Boolean;
ExceededFilter: AnsiString;
end;
var
Pdf: THotPDF;
Info: THPDFDecodeBudgetInfo;
PageText: UnicodeString;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.DecodeBudgetBytes := 64 * 1024 * 1024; // tighter than the default
Pdf.LoadFromFile('untrusted.pdf');
if not Pdf.ExtractLoadedPageText(0, PageText) then
if Pdf.GetLastDecodeBudgetInfo(Info) and Info.Exceeded then
LogWarning(Format(
'decode refused in %s after %d bytes, peak stage %d, %d filters',
[String(Info.ExceededFilter), Info.DecodedBytes,
Info.PeakStageBytes, Info.FilterCount]));
finally
Pdf.Free;
end;
end;
Baca field-field itu bersamaan dan mereka memisahkan dua bentuk serangan. Ketika PeakStageBytes mendekati DecodedBytes, satu tahap yang melakukan semua kerusakan dan Anda sedang melihat satu filter rasio tinggi. Ketika PeakStageBytes hanya sebagian kecil dari DecodedBytes dan FilterCount tinggi, tidak ada satu tahap pun yang berlebihan dan chain-nya menumpuk hingga melewati ceiling, yang persis kasus yang tidak bisa dilihat oleh limit per-filter. Satu peringatan yang layak ditulis ke handler Anda: GetLastDecodeBudgetInfo mengembalikan False sampai instance tersebut sudah men-decode setidaknya satu filter, jadi False darinya bukan bukti bahwa dokumen itu bersih
Di mana budget-nya reset, dan kapan nol adalah jawaban yang jujur
DecodeBudgetBytes membatasi satu stream chain, bukan satu dokumen, dan batas itu disengaja tapi mudah salah dibaca. Setiap content stream, setiap embedded file, setiap cross-reference stream dan setiap object stream mulai dengan 256 MiB yang baru. Dokumen 4.000 halaman karena itu punya 4.000 kesempatan independen untuk menghabiskan ceiling penuh, dan object stream mengalikan jumlah itu lebih jauh karena masing-masing adalah kontainer terkompresi sendiri yang menampung banyak objek, sebagaimana dijelaskan di catatan tentang object stream dan incremental update. Jika kebutuhan sebenarnya Anda adalah batas total memori proses, properti ini adalah salah satu input untuk itu, bukan keseluruhannya, dan itu harus berada di belakang cap level-job atau level-kontainer
Nol berarti tak terbatas, dan itu adalah pengaturan yang sah, bukan pintu keluar darurat. Aturlah itu saat Anda memiliki inputnya sendiri: pipeline pemrosesan ulang arsip atas dokumen yang dihasilkan sistem Anda sendiri, atau langkah rasterisasi di mana satu chain scan warna 600 dpi memang membutuhkan lebih dari ceiling apa pun yang nyaman Anda hard-code. Nilai negatif ditolak sejak awal dengan ERangeError, karena budget negatif tidak punya makna yang koheren dan menjepitnya diam-diam akan menyembunyikan bug konfigurasi
// Trusted archive pipeline: state the intent rather than guess a ceiling
ArchivePdf.DecodeBudgetBytes := 0; // explicit unlimited
// Untrusted upload: size the ceiling from what your corpus actually needs
IngestPdf.DecodeBudgetBytes := 96 * 1024 * 1024;
// Configuration mistakes fail loudly instead of clamping
try
IngestPdf.DecodeBudgetBytes := -1;
except
on E: ERangeError do
LogWarning('DecodeBudgetBytes cannot be negative');
end;
Memilih angkanya layak mendapat lebih banyak perhatian dari yang biasanya diberikan, karena budget yang diatur terlalu rendah adalah outage yang Anda ciptakan sendiri. Jalankan korpus yang sudah Anda miliki dengan default, catat PeakStageBytes dan DecodedBytes untuk setiap chain, dan atur ceiling di atas maksimum yang teramati dengan headroom nyata. Angka bulat yang dipilih karena terdengar aman akan menolak scan besar yang sah pada saat terburuk, dan kegagalannya akan terlihat persis seperti serangan di log Anda
Salinan yang tidak lagi terjadi
Merutekan setiap tahap lewat budget wrapper ternyata membuat chain-nya lebih murah, bukan lebih mahal. Saat sebuah stream punya filter, tahap pertama sekarang membaca stream sumber secara langsung alih-alih menyalin byte terenkode ke buffer sementara lebih dulu, dan dari situ hanya dua buffer yang hidup sekaligus: input saat ini dan output tahap yang sedang ditulis. Salinan mentahnya bertahan pada dua kasus yang membutuhkannya, yaitu stream tanpa filter sama sekali dan gambar di mana pemanggil ingin encoding terakhir dipertahankan, karena keduanya mengembalikan stream yang dimiliki pemanggil dan bisa di-seek secara independen. Versi tak terjaga dari kode ini mengalokasikan lebih banyak dan membatasi lebih sedikit, yang merupakan relasi biasa antara keduanya. Layak dinyatakan dengan jelas, meski begitu: tidak satu pun dari ini membuat PDF sembarangan aman untuk dimuat. Ini menutup satu vektor denial-of-service yang spesifik dan sangat murah, yaitu ketika file kecil membeli alokasi besar lewat filter bersarang. Integer overflow dalam akuntansi byte dijaga secara terpisah, dan pertanyaan yang lebih luas tentang mem-parsing dokumen berbahaya tanpa mempercayai offset internalnya adalah disiplin yang berbeda. Decode budget adalah satu batas di antara beberapa, dan nilainya adalah karena inilah satu-satunya yang bisa Anda atur dari satu properti sebelum Anda menyentuh filenya
Budget per-chain, record diagnostiknya, dan jalur decode dokumen-yang-dimuat yang dilindunginya semuanya hadir sebagai bagian dari komponen itu sendiri, tanpa dependensi dekompresi eksternal yang perlu dikonfigurasi atau di-patch. Jika Anda sedang mengevaluasi cara membatasi input PDF tidak tepercaya di dalam layanan Delphi atau C++Builder, halaman komponen PDF Delphi HotPDF mendaftar toolkit dokumen-yang-dimuat tempat limit ini berlaku