Un PDF di 20 KB che blocca un processo di servizio finché l'OOM killer non lo elimina non è un bug nel tuo codice, è una bomba di decompressione. HotPDF, il componente PDF VCL nativo per Delphi e C++Builder, ne vincola una con DecodeBudgetBytes, un ceiling per catena di filtri che ha valore predefinito 268435456 byte e addebita ogni stadio di decodifica su un unico budget condiviso
Il file da 20 KB che ha divorato un processo worker
La forma dell'incidente è sempre la stessa. Un worker di coda che renderizza thumbnail preleva un upload, la memoria residente sale oltre 12 GB in meno di due secondi, e il processo scompare senza uno stack trace. Il file è di 20 KB. Ha una pagina, un content stream, e un array /Filter con cinque voci. Ogni nome in quell'array è un filtro che la specifica definisce, ogni stadio decodifica senza errore, e niente nel file è malformato. È questo che rende scomoda questa classe di input: non c'è alcun byte corrotto da rifiutare
Questo non è lo stesso problema di decodificare correttamente un singolo filtro. Fare bene LZWDecode e il predictor /DecodeParms è un argomento a sé, trattato in la panoramica su LZW, predictor e DecodeParms sui documenti caricati. Qui ogni decoder è già corretto. Il fallimento è ciò che fanno decoder corretti quando ne esegui cinque di fila e nessuno conta il totale. ISO 32000-1 §7.4 è esplicito nel dire che /Filter può essere un singolo nome o un array di nomi, e che un array viene applicato in sequenza, prima voce prima. Non dice nulla su quanto uno stadio possa espandere il proprio input, e nulla sull'aggregato lungo la catena. Uno stadio ASCIIHexDecode dimezza grosso modo il proprio input, il che sembra innocuo. Uno stadio FlateDecode su una sequenza di byte zero raggiunge rapporti nell'ordine delle migliaia. Concatenali e l'aritmetica è moltiplicativa: 20 KB diventano 20 MB diventano 20 GB, e ogni singolo passo è una decodifica conforme di uno stream legale
Perché un limite per singolo filtro non riesce a fermare una decode bomb?
Perché un limite per singolo filtro viene riarmato a ogni elemento dell'array /Filter. Una catena di cinque stadi sotto un tetto per stadio di 256 MiB autorizza 1,25 GiB, e lo stadio finale inizia comunque con un'indennità completamente nuova indipendentemente da ciò che hanno prodotto i quattro precedenti. Il limite viene applicato onestamente e non vincola nulla che conti. HotPDF aveva esattamente questa forma prima della v2.447.0, e aveva accanto un secondo varco. Il decompressore LZW portava un tetto MaxOutputBytes e il percorso del predictor immagine contabilizzava le proprie righe, quindi quei due erano vincolati localmente. FlateDecode, ASCIIHexDecode, ASCII85Decode e RunLengthDecode non avevano alcun tetto: ognuno scriveva in un TMemoryStream finché non esauriva l'input o l'allocatore non si arrendeva. Quindi una catena ostile aveva due strade. Poteva usare un filtro interamente non protetto, oppure poteva usare filtri protetti e semplicemente aggiungerne di più
C'è un terzo dettaglio che una correzione ingenua manca. Il numero a cui tieni non è la dimensione dell'output decodificato finale. È il picco, e il picco di solito vive in un buffer intermedio. Una catena che termina in un modesto content stream da 4 MB può allocare 8 GB allo stadio tre e restituire qualcosa che appare interamente ragionevole. Controllare la lunghezza del risultato a posteriori non ti dice nulla sull'allocazione che ha ucciso il processo
Un tracker di budget per catena di filtri
La correzione in HotPDF v2.447.0 è far sì che la contabilità copra la catena piuttosto che lo stadio. Ogni catena di filtri costruisce un THPDFDecodeBudgetTracker, e ogni decoder scrive attraverso un THPDFBudgetWriteStream che avvolge il target reale. Il wrapper chiama Budget.Consume(Count) prima di inoltrare anche un solo byte, così il rifiuto avviene mentre lo stream target ha ancora la sua vecchia dimensione. Quell'ordinamento è l'intero punto: un controllo eseguito dopo che il buffer è già cresciuto è una diagnostica, non una difesa
// 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;
I tetti locali non sono spariti, sono diventati proiezioni del budget condiviso. Lo stadio LZW ora imposta Decoder.MaxOutputBytes := Budget.RemainingBytes, così il suo ceiling privato è qualunque cosa sia rimasta alla catena piuttosto che un'indennità indipendente. Lo stadio del predictor immagine si apre con BeginFilter e addebita il proprio fabbisogno di righe tramite Consume prima di allocare, il che significa che l'output del predictor viene fatturato allo stesso budget dei filtri generici che lo hanno alimentato. Questo conta in particolare sul percorso immagine, dove la catena di filtri e il predictor sono due metà di un'unica operazione, come trattato in estrarre immagini da documenti caricati attraverso i loro filtri di decodifica
Cosa vede il chiamante quando il budget rifiuta?
In fondo allo stack, un rifiuto solleva EHPDFDecodeBudgetError. Sopra quel livello, la risposta dipende dal contratto che l'API chiamante aveva già. I metodi di lettura ad alto livello che riportavano il fallimento tramite False o nil continuano a fare esattamente quello, perché trasformare un risultato booleano documentato in un'eccezione romperebbe i chiamanti che già gestivano correttamente input malformati. Il percorso del contenuto pagina caricato è l'eccezione deliberata: rilancia EHPDFDecodeBudgetError invece di lasciare che un content stream troncato venga renderizzato come una pagina semplicemente risultata vuota. Questo design significa che un semplice False è ambiguo di per sé, quindi il budget pubblica accanto ad esso un record diagnostico: THotPDF.GetLastDecodeBudgetInfo restituisce lo stato della catena più recente decodificata dall'istanza
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;
Leggi quei campi insieme e separano le due forme di attacco. Quando PeakStageBytes è vicino a DecodedBytes, uno stadio ha fatto tutto il danno e stai guardando un singolo filtro ad alto rapporto. Quando PeakStageBytes è una piccola frazione di DecodedBytes e FilterCount è alto, nessuno stadio individuale è stato esagerato e la catena si è accumulata fino a superare il ceiling, che è precisamente il caso che un limite per singolo filtro non può vedere. Un avvertimento che vale la pena scrivere nel tuo handler: GetLastDecodeBudgetInfo restituisce False finché l'istanza non ha decodificato almeno un filtro, quindi un False da esso non è prova che il documento fosse pulito
Dove si azzera il budget, e quando zero è la risposta onesta
DecodeBudgetBytes vincola una catena di stream, non un documento, e quel confine è deliberato ma facile da fraintendere. Ogni content stream, ogni file incorporato, ogni cross-reference stream e ogni object stream inizia con un nuovo budget di 256 MiB. Un documento di 4.000 pagine ha quindi 4.000 occasioni indipendenti di spendere l'intero ceiling, e gli object stream moltiplicano ulteriormente il conteggio perché ognuno è a sua volta un contenitore compresso che contiene molti oggetti, come descritto in le note su object stream e aggiornamenti incrementali. Se il tuo vero requisito è un limite sulla memoria totale del processo, questa proprietà è un input di quel calcolo, non il tutto, e dovrebbe stare dietro un tetto a livello di job o di container
Zero significa illimitato, ed è un'impostazione legittima piuttosto che una via di fuga. Impostalo quando possiedi tu l'input: una pipeline di rielaborazione di archivi su documenti prodotti dal tuo stesso sistema, oppure uno stadio di rasterizzazione dove una singola catena di scansione a colori a 600 dpi ha genuinamente bisogno di più di qualsiasi ceiling che ti sentiresti a tuo agio a fissare nel codice. I valori negativi vengono rifiutati subito con ERangeError, perché un budget negativo non ha alcun significato coerente e limitarlo silenziosamente ne nasconderebbe un bug di configurazione
// 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;
Scegliere il numero merita più attenzione di quanta di solito ne riceva, perché un budget impostato troppo basso è un'interruzione di servizio autoinflitta. Esegui il tuo corpus esistente con il valore predefinito, registra PeakStageBytes e DecodedBytes per ogni catena, e imposta il ceiling sopra il massimo osservato con un margine reale. Un numero tondo scelto perché suonava sicuro rifiuterà una scansione grande legittima nel momento peggiore possibile, e il fallimento nei tuoi log sembrerà esattamente un attacco
La copia che non avviene più
Instradare ogni stadio attraverso un wrapper di budget ha finito per rendere la catena più economica invece che più costosa. Quando uno stream ha filtri, il primo stadio ora legge direttamente lo stream sorgente invece di copiare prima i byte codificati in un buffer temporaneo, e da lì solo due buffer sono vivi contemporaneamente: l'input corrente e l'output dello stadio in scrittura. La copia grezza sopravvive nei due casi che ne hanno bisogno, cioè uno stream senza alcun filtro e un'immagine dove il chiamante vuole preservata l'ultima codifica, perché entrambi restituiscono uno stream che il chiamante possiede e può posizionare in modo indipendente. La versione non protetta di questo codice allocava di più e vincolava di meno, che è la relazione abituale tra i due. Vale comunque la pena affermarlo chiaramente: niente di tutto questo rende un PDF arbitrario sicuro da caricare. Chiude un vettore di denial-of-service specifico e molto economico, quello in cui un file piccolo compra una grande allocazione attraverso filtri annidati. L'overflow di interi nella contabilità dei byte è protetto separatamente, e la questione più ampia di analizzare documenti ostili senza fidarsi dei loro offset interni è una disciplina diversa. Un budget di decodifica è uno dei vari limiti, e il suo valore sta nell'essere quello che puoi impostare da una singola proprietà prima ancora di toccare il file
Il budget per catena, il suo record diagnostico, e i percorsi di decodifica dei documenti caricati che protegge sono tutti distribuiti come parte del componente stesso, senza alcuna dipendenza esterna di decompressione da configurare o applicare patch. Se stai valutando come vincolare input PDF non fidati dentro un servizio Delphi o C++Builder, la pagina HotPDF Delphi PDF component elenca il toolkit per documenti caricati a cui questi limiti si applicano