20KB soubor PDF, který zastaví service proces až do zásahu OOM killeru, není chyba ve vašem kódu — je to dekompresní bomba. HotPDF, nativní VCL komponenta PDF pro Delphi a C++Builder, takový případ omezuje pomocí DecodeBudgetBytes, stropu pro celý filtrační řetěz, který má výchozí hodnotu 268435456 bajtů a účtuje každou fázi dekódování proti jednomu sdílenému rozpočtu
20KB soubor, který sežral worker proces
Podoba incidentu je vždy stejná. Worker fronty, který generuje náhledy, vyzvedne nahraný soubor, rezidentní paměť vyskočí přes 12 GB za méně než dvě sekundy a proces zmizí bez stack trace. Soubor má 20 KB. Má jednu stránku, jeden content stream a pole /Filter s pěti položkami. Každý název v tomto poli je filtr definovaný specifikací, každá fáze se dekóduje bez chyby a nic v souboru není poškozené. Právě to dělá tuto třídu vstupu obtížnou: neexistuje žádný poškozený bajt, který by šlo odmítnout
Nejde o stejný problém jako správné dekódování jednoho filtru. Správně nastavit LZWDecode a prediktor v /DecodeParms je samostatné téma, popsané v průvodci LZW, prediktory a DecodeParms u načtených dokumentů. Zde je každý dekodér už sám o sobě správný. Chyba je v tom, co dělají správné dekodéry, když jich pustíte pět za sebou a nikdo nepočítá součet. ISO 32000-1 §7.4 výslovně říká, že /Filter může být buď jeden název, nebo pole názvů, a že pole se aplikuje postupně, první položka jako první. Nic neříká o tom, o kolik smí jedna fáze zvětšit svůj vstup, ani o součtu za celý řetěz. Fáze ASCIIHexDecode zhruba zdvojnásobí velikost vstupu, což zní neškodně. Fáze FlateDecode nad sekvencí nulových bajtů dosahuje poměrů v řádu tisíců. Zřetězíte-li je, aritmetika je násobící: 20 KB se stane 20 MB, které se stanou 20 GB, a každý jednotlivý krok je konformní dekódování legálního streamu
Proč limit na jeden filtr nezastaví decode bombu?
Protože limit na jeden filtr se znovu natáhne u každého prvku pole /Filter. Řetěz pěti fází se stropem 256 MiB na fázi povoluje celkem 1,25 GiB, a poslední fáze stále začíná s úplně čerstvým přídělem bez ohledu na to, co vyprodukovaly čtyři předchozí. Limit je dodržován poctivě, ale neomezuje nic podstatného. HotPDF měl přesně tuto podobu před verzí v2.447.0 a vedle toho měl ještě druhou mezeru. LZW dekompresor nesl strop MaxOutputBytes a cesta obrazového prediktoru počítala vlastní řádky, takže tyto dvě věci byly omezené lokálně. FlateDecode, ASCIIHexDecode, ASCII85Decode a RunLengthDecode neměly strop žádný: každý zapisoval do TMemoryStream, dokud mu nedošel vstup nebo dokud to nevzdal alokátor. Nepřátelský řetěz tak měl dvě cesty dovnitř. Mohl použít úplně nechráněný filtr, nebo mohl použít chráněné filtry a jednoduše jich přidat víc
Existuje ještě třetí detail, který naivní oprava přehlédne. Číslo, na kterém záleží, není velikost konečného dekódovaného výstupu. Je to špička, a špička obvykle žije v mezilehlém bufferu. Řetěz, který skončí skromným 4MB content streamem, může ve třetí fázi alokovat 8 GB a předat výsledek, který vypadá naprosto rozumně. Kontrola délky výsledku dodatečně vám o alokaci, která zabila proces, neřekne vůbec nic
Jeden sledovač rozpočtu na filtrační řetěz
Oprava v HotPDF v2.447.0 spočívá v tom, že účtování pokrývá celý řetěz, ne jednotlivou fázi. Každý filtrační řetěz vytvoří jeden THPDFDecodeBudgetTracker a každý dekodér zapisuje přes THPDFBudgetWriteStream, který obaluje skutečný cíl. Wrapper zavolá Budget.Consume(Count) ještě před tím, než přepošle jediný bajt, takže k odmítnutí dojde, když cílový stream má stále svou starou velikost. Právě na tomto pořadí to celé stojí: kontrola provedená až poté, co buffer už narostl, je diagnostika, ne obrana
// 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;
Lokální stropy nezmizely, staly se z nich projekce sdíleného rozpočtu. Fáze LZW nyní nastavuje Decoder.MaxOutputBytes := Budget.RemainingBytes, takže její soukromý strop je to, co řetězu ještě zbývá, ne nezávislý příděl. Fáze obrazového prediktoru začíná voláním BeginFilter a účtuje svůj požadavek na řádky přes Consume ještě před alokací, což znamená, že výstup prediktoru je účtován do stejného rozpočtu jako obecné filtry, které ho krmily. To má význam zejména na obrazové cestě, kde filtrační řetěz a prediktor tvoří dvě poloviny jedné operace, jak popisuje extrakce obrázků z načtených dokumentů přes jejich dekódovací filtry
Co uvidí volající, když rozpočet odmítne?
Na dně zásobníku odmítnutí vyvolá EHPDFDecodeBudgetError. Nad tím záleží odpověď na kontraktu, který volané API již mělo. Vysokoúrovňové čtecí metody, které dosud hlásily selhání přes False nebo nil, to dělají dál, protože změna zdokumentovaného booleovského výsledku na výjimku by porušila volající, kteří již správně zpracovávali poškozený vstup. Cesta obsahu načtené stránky je záměrná výjimka: znovu vyvolá EHPDFDecodeBudgetError, místo aby nechala oříznutý content stream vykreslit se jako stránka, která prostě vyšla prázdná. To znamená, že samotné False je nejednoznačné, a rozpočet proto vedle něj zveřejňuje diagnostický záznam: THotPDF.GetLastDecodeBudgetInfo vrací stav posledního řetězu, který instance dekódovala
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;
Přečtete-li tato pole společně, rozliší se dva tvary útoku. Když je PeakStageBytes blízko DecodedBytes, veškerou škodu způsobila jedna fáze a jde o jediný filtr s vysokým poměrem. Když je PeakStageBytes jen malým zlomkem DecodedBytes a FilterCount je vysoké, žádná jednotlivá fáze nebyla nijak extrémní a řetěz se přes strop dostal postupným hromaděním — což je přesně případ, který limit na jeden filtr nedokáže rozpoznat. Jedna poznámka, kterou stojí za to zapsat do vašeho handleru: GetLastDecodeBudgetInfo vrací False, dokud instance nedekódovala aspoň jeden filtr, takže False z ní není důkazem, že dokument byl čistý
Kde se rozpočet resetuje a kdy je nula poctivou odpovědí
DecodeBudgetBytes omezuje jeden řetěz streamu, ne jeden dokument, a tato hranice je záměrná, ale snadno se špatně čte. Každý content stream, každý vložený soubor, každý cross-reference stream a každý object stream začíná s čerstvými 256 MiB. Dokument o 4 000 stránkách má tedy 4 000 nezávislých šancí utratit celý strop, a object streamy počet dále násobí, protože každý z nich je sám o sobě komprimovaný kontejner obsahující mnoho objektů, jak popisují poznámky o object streamech a přírůstkových aktualizacích. Pokud je vaším skutečným požadavkem omezit celkovou paměť procesu, tato vlastnost je jedním ze vstupů k tomu, ne celým řešením, a měla by stát za stropem na úrovni úlohy nebo kontejneru
Nula znamená neomezeně a je to legitimní nastavení, ne únikový poklop. Nastavte ji, když vlastníte vstup: pipeline, která znovu zpracovává archiv dokumentů vyprodukovaných vaším vlastním systémem, nebo krok rastrizace, kde jeden řetěz barevného skenu při 600 dpi skutečně potřebuje víc, než jaký strop byste byli ochotni natvrdo zapsat do kódu. Záporné hodnoty jsou rovnou odmítnuty výjimkou ERangeError, protože záporný rozpočet nemá žádný soudržný význam a tiché ořezání by skrylo konfigurační chybu
// 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;
Volba čísla si zaslouží víc péče, než se jí obvykle dostává, protože příliš nízko nastavený rozpočet je vlastnoručně způsobený výpadek. Spusťte svůj existující korpus s výchozím nastavením, zaznamenejte PeakStageBytes a DecodedBytes pro každý řetěz a nastavte strop nad pozorované maximum s reálnou rezervou. Kulaté číslo zvolené proto, že znělo bezpečně, odmítne legitimní velký scan v tom nejhorším možném okamžiku a selhání bude ve vašich logách vypadat přesně jako útok
Kopie, ke které už nedochází
Vedení každé fáze přes wrapper rozpočtu se ukázalo jako způsob, jak řetěz zlevnit, ne prodražit. Když má stream filtry, první fáze nyní čte zdrojový stream přímo, místo aby nejprve zkopírovala zakódované bajty do pomocného bufferu, a odtud jsou naživu jen dva buffery zároveň: aktuální vstup a výstup fáze, který se právě zapisuje. Syrová kopie přežívá ve dvou případech, kdy je potřeba, konkrétně u streamu bez jakýchkoli filtrů a u obrázku, kde volající chce zachovat poslední kódování, protože v obou případech se vrací stream, který volající vlastní a může v něm nezávisle vyhledávat. Nechráněná verze tohoto kódu alokovala víc a omezovala míň, což je obvyklý vztah mezi těmito dvěma věcmi. Přesto stojí za to říct otevřeně: nic z toho nedělá libovolný PDF bezpečným k načtení. Uzavírá jeden konkrétní a velmi levný vektor odepření služby, ten, kde malý soubor koupí velkou alokaci přes vnořené filtry. Přetečení celého čísla v účtování bajtů je hlídáno samostatně a širší otázka parsování nepřátelských dokumentů bez důvěry jejich vnitřním offsetům je jiná disciplína. Rozpočet dekódování je jedno omezení mezi několika a jeho hodnota spočívá v tom, že je to to jediné, které lze nastavit jedinou vlastností ještě předtím, než se dotknete souboru
Rozpočet na řetěz, jeho diagnostický záznam a cesty dekódování načtených dokumentů, které chrání, jsou součástí komponenty samotné, bez jakékoli externí dekompresní závislosti ke konfiguraci nebo záplatování. Pokud zvažujete, jak omezit nedůvěryhodný vstup PDF uvnitř služby v Delphi nebo C++Builder, stránka komponenty HotPDF pro Delphi uvádí sadu nástrojů pro načtené dokumenty, na které se tyto limity vztahují