Tehnički članak

PDF decode bombe u Delphi: HotPDF budžeti lanca filtera

PDF od 20 KB koji prikuje servisni proces dok ga OOM killer ne ubije nije bag u vašem kodu, već dekompresiona bomba. HotPDF, nativna VCL PDF komponenta za Delphi i C++Builder, ograničava je pomoću DecodeBudgetBytes, tavanicom po lancu filtera koja je podrazumevano 268435456 bajtova i naplaćuje svaku fazu dekodiranja naspram jednog deljenog budžeta

Fajl od 20 KB koji je pojeo radni proces

Oblik incidenta je uvek isti. Worker iz reda koji renderuje thumbnail-ove pokupi upload, rezidentna memorija skoči preko 12 GB za manje od dve sekunde, i proces nestane bez stack trace-a. Fajl je 20 KB. Ima jednu stranicu, jedan tok sadržaja, i niz /Filter sa pet unosa. Svako ime u tom nizu je filter koji specifikacija definiše, svaka faza se dekoduje bez greške, i ništa u fajlu nije deformisano. To je ono što ovu klasu ulaza čini nezgodnom: nema pokvarenog bajta za odbacivanje

Ovo nije isti problem kao ispravno dekodovanje jednog filtera. Da li se LZWDecode i prediktor /DecodeParms rade ispravno je sopstvena tema, pokrivena u pregledu LZW, prediktora i DecodeParms na učitanim dokumentima. Ovde je svaki dekoder već ispravan. Neuspeh je ono što ispravni dekoderi rade kad pokrenete pet njih redom, jedan za drugim, a niko ne broji ukupno. ISO 32000-1 §7.4 je eksplicitan da /Filter može biti jedno ime ili niz imena, i da se niz primenjuje u redosledu, prvi unos prvi. Ne kaže ništa o tome koliko faza sme proširiti svoj ulaz, i ništa o agregatu kroz lanac. Faza ASCIIHexDecode otprilike prepolovi svoj ulaz, što zvuči bezopasno. Faza FlateDecode preko niza nula bajtova dostiže odnose u hiljadama. Ulančite ih i aritmetika je multiplikativna: 20 KB postaje 20 MB postaje 20 GB, a svaki pojedinačni korak je usklađeno dekodovanje legalnog toka

Zašto ograničenje po filteru ne zaustavlja decode bombu?

Zato što se ograničenje po filteru ponovo naoruža na svakom elementu niza /Filter. Lanac od pet faza pod tavanicom od 256 MiB po fazi ovlašćuje 1,25 GiB, a poslednja faza i dalje počinje sa potpuno svežom dozvolom bez obzira šta su prethodne četiri proizvele. Ograničenje se primenjuje pošteno i ne ograničava ništa bitno. HotPDF je imao tačno taj oblik pre v2.447.0, i imao je drugu rupu pored njega. LZW dekompresor je nosio tavanicu MaxOutputBytes, a putanja prediktora slike je obračunavala sopstvene redove, tako da su ta dva bila lokalno ograničena. FlateDecode, ASCIIHexDecode, ASCII85Decode i RunLengthDecode uopšte nisu imali tavanicu: svaki je pisao u TMemoryStream dok mu ne ponestane ulaza ili dok alokator ne odustane. Tako je neprijateljski lanac imao dva puta kroz: mogao je koristiti potpuno nezaštićen filter, ili je mogao koristiti zaštićene i jednostavno dodati još njih

Postoji treći detalj koji naivna ispravka propušta. Broj koji vas zanima nije veličina konačnog dekodovanog izlaza. To je pik, a pik obično živi u međubaferu. Lanac koji se završava skromnim tokom sadržaja od 4 MB može alocirati 8 GB u trećoj fazi i vratiti nešto što izgleda potpuno razumno. Proveravanje dužine rezultata naknadno vam ne govori ništa o alokaciji koja je ubila proces

Jedan pratilac budžeta po lancu filtera

Ispravka u HotPDF v2.447.0 je da se obračunavanje proširi na lanac, a ne na fazu. Svaki lanac filtera konstruiše jedan THPDFDecodeBudgetTracker, i svaki dekoder piše kroz THPDFBudgetWriteStream koji obavija stvarni cilj. Omotač poziva Budget.Consume(Count) pre nego što prosledi ijedan bajt, tako da se odbijanje dešava dok je ciljni tok i dalje svoje stare veličine. Taj redosled je čitava poenta: provera izvedena posle što je bafer već narastao je dijagnostika, ne odbrana

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

Lokalne tavanice nisu nestale, postale su projekcije deljenog budžeta. LZW faza sada postavlja Decoder.MaxOutputBytes := Budget.RemainingBytes, tako da je njena privatna tavanica ono što je lancu ostalo, a ne nezavisna dozvola. Faza prediktora slike se otvara sa BeginFilter i naplaćuje svoj zahtev reda kroz Consume pre alociranja, što znači da se izlaz prediktora naplaćuje istom budžetu kao generički filteri koji su ga hranili. To je posebno bitno na putanji slike, gde su lanac filtera i prediktor dve polovine jedne operacije, kako je pokriveno u izvlačenju slika iz učitanih dokumenata kroz njihove decode filtere

Šta pozivalac vidi kad budžet odbije?

Na dnu steka, odbijanje izaziva EHPDFDecodeBudgetError. Iznad toga, odgovor zavisi od ugovora koji je pozivajući API već imao. API metode visokog nivoa za čitanje koje su prijavljivale neuspeh kroz False ili nil nastavljaju tačno tako, jer bi pretvaranje dokumentovanog boolean rezultata u izuzetak razbilo pozivaoce koji su već ispravno obrađivali deformisan ulaz. Putanja sadržaja učitane stranice je namerni izuzetak: ponovo izaziva EHPDFDecodeBudgetError umesto da dozvoli skraćenom toku sadržaja da se renderuje kao stranica koja je samo ispala prazna. Taj dizajn znači da je goli False sam po sebi dvosmislen, tako da budžet objavljuje dijagnostički zapis uz njega: THotPDF.GetLastDecodeBudgetInfo vraća stanje najskorijeg lanca koji je instanca dekodovala

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;

Pročitajte ta polja zajedno i ona razdvajaju dva oblika napada. Kad je PeakStageBytes blizu DecodedBytes, jedna faza je napravila svu štetu i gledate jedan filter visokog odnosa. Kad je PeakStageBytes mali deo DecodedBytes i FilterCount je visok, nijedna pojedinačna faza nije bila skandalozna i lanac se nagomilao preko tavanice, što je tačno slučaj koji ograničenje po filteru ne može videti. Jedno upozorenje vredno upisivanja u vaš handler: GetLastDecodeBudgetInfo vraća False dok instanca ne dekoduje bar jedan filter, tako da False od nje nije dokaz da je dokument bio čist

Gde se budžet resetuje, i kad je nula iskren odgovor

DecodeBudgetBytes ograničava jedan lanac toka, ne jedan dokument, i ta granica je namerna, ali laka za pogrešno čitanje. Svaki tok sadržaja, svaki ugrađeni fajl, svaki cross-reference tok i svaki tok objekata počinje sa svežih 256 MiB. Dokument od 4.000 stranica zato ima 4.000 nezavisnih prilika da potroši punu tavanicu, a tokovi objekata dodatno umnožavaju broj jer je svaki od njih sam po sebi komprimovan kontejner koji drži mnogo objekata, kako je opisano u belešci o object streams i inkrementalnim ažuriranjima. Ako je vaš stvarni zahtev granica na ukupnu memoriju procesa, ovo svojstvo je jedan ulaz u to, ne celina, i treba da sedi iza tavanice na nivou posla ili kontejnera

Nula znači neograničeno, i to je legitimno podešavanje, a ne izlaz za bekstvo. Postavite je kad posedujete ulaz: pipeline ponovne obrade arhive nad dokumentima koje je vaš sopstveni sistem proizveo, ili korak rasterizacije gde lanac jednog skena u boji na 600 dpi zaista treba više nego bilo koja tavanica koju biste udobno tvrdo kodirali. Negativne vrednosti se odbacuju unapred sa ERangeError, jer negativan budžet nema koherentno značenje, a tiho ograničavanje bi sakrilo grešku konfiguracije

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

Biranje broja zaslužuje više pažnje nego što obično dobija, jer budžet postavljen prenisko je samonanet ispad. Pokrenite svoj postojeći korpus sa podrazumevanim, zabeležite PeakStageBytes i DecodedBytes za svaki lanac, i postavite tavanicu iznad primećenog maksimuma sa stvarnim prostorom. Okrugao broj izabran zato što je zvučao bezbedno će odbaciti legitiman veliki sken u najgorem mogućem trenutku, i neuspeh će u vašim logovima izgledati tačno kao napad

Kopija koja se više ne dešava

Usmeravanje svake faze kroz omotač budžeta ispostavilo se da čini lanac jeftinijim umesto skupljim. Kad tok ima filtere, prva faza sada čita izvorni tok direktno umesto da prvo kopira kodirane bajtove u privremeni bafer, a odatle su samo dva bafera živa istovremeno: trenutni ulaz i izlaz faze koji se piše. Sirova kopija preživljava u dva slučaja kojima je potrebna, naime tok bez ikakvih filtera i slika gde pozivalac želi da se poslednje kodovanje sačuva, jer oba vraćaju tok koji pozivalac poseduje i može nezavisno pretraživati. Nezaštićena verzija ovog koda je alocirala više i ograničavala manje, što je uobičajen odnos između to dvoje. Vredi jasno reći: ništa od ovoga ne čini proizvoljan PDF bezbednim za učitavanje. Zatvara jedan specifičan i veoma jeftin vektor uskraćivanja usluge, onaj gde mali fajl kupuje veliku alokaciju kroz ugnežđene filtere. Prekoračenje celog broja u obračunu bajtova je odvojeno zaštićeno, a šire pitanje parsiranja neprijateljskih dokumenata bez poverenja u njihove interne offsete je drugačija disciplina. Budžet dekodovanja je jedna granica među nekoliko, a njegova vrednost je što je jedina koju možete postaviti iz jednog svojstva pre nego što dodirnete fajl

Budžet po lancu, njegov dijagnostički zapis, i putanje dekodovanja učitanog dokumenta koje štiti svi se isporučuju kao deo same komponente, bez ikakve eksterne zavisnosti za dekompresiju koju treba konfigurisati ili zakrpiti. Ako procenjujete kako ograničiti nepouzdani PDF ulaz unutar Delphi ili C++Builder servisa, stranica HotPDF Delphi PDF komponente navodi alatku za učitane dokumente na koju se ova ograničenja primenjuju