Odborný článok

PDF decode bomby v Delphi: rozpočty reťazca filtrov HotPDF

20 KB PDF, ktorý pripne procesu služby pamäť, kým ho nezastaví OOM killer, nie je chyba vo vašom kóde, je to dekompresná bomba. HotPDF, natívna VCL PDF komponenta pre Delphi a C++Builder, jednu takú ohraničí pomocou DecodeBudgetBytes, stropu na jeden reťazec filtrov, ktorý je predvolene 268435456 bajtov a účtuje každú dekódovaciu etapu voči jedinému zdieľanému rozpočtu

20 KB súbor, ktorý zožral worker proces

Tvar incidentu je vždy rovnaký. Worker fronty, ktorý renderuje náhľady, vyzdvihne upload, rezidentná pamäť vyskočí nad 12 GB za menej než dve sekundy, a proces zmizne bez stack trace. Súbor má 20 KB. Má jednu stranu, jeden obsahový stream a pole /Filter s piatimi položkami. Každé meno v tomto poli je filter, ktorý definuje špecifikácia, každá etapa sa dekóduje bez chyby, a nič v súbore nie je znetvorené. Práve to robí túto triedu vstupu nepríjemnou: neexistuje žiadny poškodený bajt, ktorý by bolo možné odmietnuť

Toto nie je ten istý problém ako správne dekódovanie jedného filtra. Správne zvládnutie LZWDecode a prediktora /DecodeParms je samostatná téma, pokrytá v prehľade LZW, prediktorov a DecodeParms na načítaných dokumentoch. Tu je každý dekodér už správny. Zlyhaním je to, čo správne dekodéry robia, keď ich spustíte päť za sebou a nikto nepočíta celkový súčet. ISO 32000-1 §7.4 výslovne hovorí, že /Filter môže byť jedno meno alebo pole mien, a že pole sa aplikuje postupne, prvá položka prvá. Nehovorí nič o tom, koľko môže etapa svoj vstup rozšíriť, a nič o súčte naprieč reťazcom. Etapa ASCIIHexDecode zhruba znižuje svoj vstup na polovicu, čo znie neškodne. Etapa FlateDecode nad sériou nulových bajtov dosahuje pomery v tisícoch. Zreťazte ich, a aritmetika je multiplikatívna: z 20 KB sa stane 20 MB, z toho sa stane 20 GB, a každý jednotlivý krok je konformné dekódovanie legálneho streamu

Prečo limit na jeden filter nezastaví decode bombu?

Pretože limit na jeden filter sa nabíja nanovo pri každom prvku poľa /Filter. Reťazec piatich etáp pod stropom 256 MiB na etapu autorizuje 1,25 GiB, a posledná etapa stále začína s úplne čerstvým prídelom bez ohľadu na to, čo vyprodukovali predchádzajúce štyri. Limit je vynucovaný úprimne a neobmedzuje nič, na čom záleží. HotPDF mal presne tento tvar pred v2.447.0, a popri ňom mal druhú medzeru. LZW dekompresor niesol strop MaxOutputBytes a cesta obrazového prediktora si počítala vlastné riadky, takže tieto dve boli obmedzené lokálne. FlateDecode, ASCIIHexDecode, ASCII85Decode a RunLengthDecode nemali žiadny strop vôbec: každý zapisoval do TMemoryStream, kým mu nedošiel vstup alebo kým sa nevzdal alokátor. Takže nepriateľský reťazec mal dve cesty dovnútra. Mohol použiť úplne nechránený filter, alebo mohol použiť chránené filtre a jednoducho ich pridať viac

Existuje tretí detail, ktorý naivná oprava prehliadne. Číslo, na ktorom záleží, nie je veľkosť konečného dekódovaného výstupu. Je to vrchol, a vrchol zvyčajne žije v medziľahlom bufferi. Reťazec, ktorý skončí skromným 4 MB obsahovým streamom, môže v tretej etape alokovať 8 GB a vrátiť niečo, čo vyzerá úplne rozumne. Kontrola dĺžky výsledku dodatočne vám nič nepovie o alokácii, ktorá zabila proces

Jeden sledovač rozpočtu na reťazec filtrov

Oprava v HotPDF v2.447.0 spočíva v tom, že účtovníctvo pokrýva celý reťazec, nie jednu etapu. Každý reťazec filtrov vytvorí jeden THPDFDecodeBudgetTracker, a každý dekodér zapisuje cez THPDFBudgetWriteStream, ktorý obaľuje skutočný cieľ. Wrapper zavolá Budget.Consume(Count) skôr, než presunie čo i len jeden bajt, takže odmietnutie nastane, kým je cieľový stream stále svojej starej veľkosti. Toto poradie je celým bodom: kontrola vykonaná potom, čo buffer už narástol, je diagnostika, nie 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álne stropy nezmizli, stali sa projekciami zdieľaného rozpočtu. Etapa LZW teraz nastavuje Decoder.MaxOutputBytes := Budget.RemainingBytes, takže jej súkromný strop je to, čo reťazcu zostáva, namiesto nezávislého prídelu. Etapa obrazového prediktora sa otvára s BeginFilter a účtuje svoju požiadavku na riadky cez Consume pred alokáciou, čo znamená, že výstup prediktora sa účtuje voči tomu istému rozpočtu ako generické filtre, ktoré ho napájali. To je dôležité najmä na obrazovej ceste, kde sú reťazec filtrov a prediktor dve polovice jednej operácie, ako je pokryté v extrahovaní obrázkov z načítaných dokumentov cez ich dekódovacie filtre

Čo volajúci vidí, keď rozpočet odmietne?

Na spodku zásobníka odmietnutie vyvolá EHPDFDecodeBudgetError. Nad tým závisí odpoveď od kontraktu, ktorý volajúce API už malo. Vysokoúrovňové metódy čítania, ktoré hlásili zlyhanie cez False alebo nil, to robia presne rovnako naďalej, pretože zmena zdokumentovaného booleovského výsledku na výnimku by rozbila volajúcich, ktorí už znetvorený vstup spracovávali správne. Cesta obsahu načítanej strany je zámerná výnimka: znovu vyvolá EHPDFDecodeBudgetError namiesto toho, aby nechala skrátený obsahový stream vykresliť sa ako strana, ktorá jednoducho vyšla prázdna. Tento dizajn znamená, že holé False je samo osebe nejednoznačné, takže rozpočet spolu s ním publikuje diagnostický záznam: THotPDF.GetLastDecodeBudgetInfo vracia stav najposlednejšieho reťazca, ktorý inštancia 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;

Čítajte tieto polia spolu a oddelia sa vám dva tvary útoku. Keď je PeakStageBytes blízko DecodedBytes, jedna etapa spôsobila celú škodu a pozeráte sa na jediný filter s vysokým pomerom. Keď je PeakStageBytes malým zlomkom DecodedBytes a FilterCount je vysoké, žiadna jednotlivá etapa nebola pobúrujúca a reťazec sa nahromadil za strop postupne, čo je presne prípad, ktorý limit na jeden filter nevidí. Jedna výhrada, ktorú stojí za to zapísať do vášho handlera: GetLastDecodeBudgetInfo vracia False, kým inštancia nedekódovala aspoň jeden filter, takže False od neho nie je dôkaz, že dokument bol čistý

Kde sa rozpočet resetuje, a kedy je nula úprimná odpoveď

DecodeBudgetBytes ohraničuje jeden reťazec streamov, nie jeden dokument, a táto hranica je zámerná, ale ľahko sa nesprávne chápe. Každý obsahový stream, každý vložený súbor, každý cross-reference stream a každý objektový stream začína s čerstvými 256 MiB. 4 000-stranový dokument teda má 4 000 nezávislých šancí vyčerpať celý strop, a objektové streamy tento počet ešte znásobujú, pretože každý z nich je sám osebe komprimovaný kontajner držiaci mnoho objektov, ako je opísané v poznámkach o objektových streamoch a inkrementálnych aktualizáciách. Ak je vašou skutočnou požiadavkou hranica na celkovú pamäť procesu, táto vlastnosť je jeden vstup do toho, nie celé riešenie, a mala by sedieť za stropom na úrovni úlohy alebo kontajnera

Nula znamená neobmedzené, a je to legitímne nastavenie, nie únikový poklop. Nastavte ju, keď vlastníte vstup: pipeline na opätovné spracovanie archívu nad dokumentmi, ktoré vyprodukoval váš vlastný systém, alebo rasterizačný krok, kde jeden 600 dpi farebný skenovací reťazec skutočne potrebuje viac než akýkoľvek strop, ktorý by ste boli ochotní natvrdo zakódovať. Záporné hodnoty sa rovno odmietnu s ERangeError, pretože záporný rozpočet nemá žiadny koherentný zmysel a jeho tiché orezanie by skrylo chybu konfigurácie

// 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;

Voľba čísla si zaslúži viac starostlivosti, než sa jej zvyčajne dostáva, pretože príliš nízko nastavený rozpočet je vlastnoručne spôsobený výpadok. Spustite svoj existujúci korpus s predvoleným nastavením, zaznamenajte PeakStageBytes a DecodedBytes pre každý reťazec, a nastavte strop nad pozorovaným maximom s reálnou rezervou. Okrúhle číslo zvolené preto, že znelo bezpečne, odmietne legitímny veľký sken v najhoršej možnej chvíli, a zlyhanie bude vo vašich logoch vyzerať presne ako útok

Kopírovanie, ktoré sa už nedeje

Smerovanie každej etapy cez wrapper rozpočtu sa ukázalo, že robí reťazec lacnejším, nie drahším. Keď má stream filtre, prvá etapa teraz číta zdrojový stream priamo namiesto toho, aby najprv skopírovala zakódované bajty do pomocného buffera, a odtiaľ sú naraz živé iba dva buffery: aktuálny vstup a výstup etapy, ktorý sa zapisuje. Surová kópia prežíva v dvoch prípadoch, ktoré ju potrebujú, konkrétne stream bez akýchkoľvek filtrov a obrázok, kde volajúci chce zachovať posledné kódovanie, pretože oba vracajú stream, ktorý volajúci vlastní a môže v ňom nezávisle posúvať pozíciu. Nechránená verzia tohto kódu alokovala viac a ohraničovala menej, čo je zvyčajný vzťah medzi týmito dvoma. Stojí to za jasné vyslovenie: nič z tohto nerobí ľubovoľný PDF bezpečným na načítanie. Uzatvára jeden konkrétny a veľmi lacný vektor odopretia služby, ten, kde si malý súbor kupuje veľkú alokáciu cez vnorené filtre. Pretečenie celého čísla v účtovaní bajtov je chránené samostatne, a širšia otázka parsovania nepriateľských dokumentov bez dôvery ich interným offsetom je odlišná disciplína. Rozpočet dekódovania je jedna hranica spomedzi viacerých, a jeho hodnota spočíva v tom, že je to tá, ktorú môžete nastaviť jedinou vlastnosťou skôr, než sa súboru vôbec dotknete

Rozpočet na jeden reťazec, jeho diagnostický záznam a cesty dekódovania načítaných dokumentov, ktoré chráni, sú súčasťou samotnej komponenty, bez akejkoľvek externej dekompresnej závislosti na konfiguráciu alebo záplatovanie. Ak zvažujete, ako ohraničiť nedôveryhodný PDF vstup vnútri Delphi alebo C++Builder služby, stránka HotPDF Delphi PDF komponenty uvádza sadu nástrojov pre načítané dokumenty, na ktoré sa tieto limity vzťahujú