Tehnički članak

PDF decode bombe u Delphiju: HotPDF proračuni lanca filtera

PDF od 20 KB koji prikuje uslužni proces dok ga OOM killer ne ubije nije greška u vašem kodu, nego dekompresijska bomba. HotPDF, izvorna VCL PDF komponenta za Delphi i C++Builder, ograničava je pomoću DecodeBudgetBytes, granice po lancu filtera koja zadano iznosi 268435456 bajtova i naplaćuje svaku fazu dekodiranja protiv jednog zajedničkog proračuna

Datoteka od 20 KB koja je pojela radni proces

Oblik incidenta uvijek je isti. Worker iz reda koji renderira sličice preuzme upload, rezidentna memorija skoči preko 12 GB za manje od dvije sekunde, i proces nestane bez trace-a steka. Datoteka je 20 KB. Ima jednu stranicu, jedan sadržajni stream i niz /Filter s pet unosa. Svako ime u tom nizu je filter koji specifikacija definira, svaka faza se dekodira bez greške, i ništa u datoteci nije neispravno. To je ono što ovu klasu ulaza čini nezgodnom: nema oštećenog bajta koji bi se odbio

Ovo nije isti problem kao ispravno dekodiranje jednog filtera. Ispravno postavljanje LZWDecode i prediktora /DecodeParms vlastita je tema, obrađena u vodiču kroz LZW, prediktore i DecodeParms na učitanim dokumentima. Ovdje je svaki dekoder već ispravan. Neuspjeh je ono što ispravni dekoderi rade kad pokrenete pet njih zaredom, a nitko ne broji ukupno. ISO 32000-1 §7.4 izričito kaže da /Filter može biti jedno ime ili niz imena, i da se niz primjenjuje redom, prvi unos prvi. Ne kaže ništa o tome koliko faza smije proširiti svoj ulaz, niti o zbroju preko cijelog lanca. Faza ASCIIHexDecode otprilike prepolovi svoj ulaz, što zvuči bezopasno. Faza FlateDecode preko niza nula bajtova doseže omjere u tisućama. Ulančajte ih i aritmetika je multiplikativna: 20 KB postaje 20 MB, postaje 20 GB, a svaki pojedinačni korak je usklađeno dekodiranje legalnog streama

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

Zato što se ograničenje po filteru ponovno naoružava na svakom elementu niza /Filter. Lanac od pet faza pod granicom od 256 MiB po fazi odobrava 1,25 GiB, a posljednja faza i dalje počinje s posve svježim dopuštenjem bez obzira što su četiri prije nje proizvele. Granica se provodi pošteno, a ne ograničava ništa bitno. HotPDF je imao točno taj oblik prije v2.447.0, i uz njega drugi propust. Dekompresor LZW nosio je granicu MaxOutputBytes, a put prediktora slike računao je svoje vlastite retke, pa su ta dva bila lokalno ograničena. FlateDecode, ASCIIHexDecode, ASCII85Decode i RunLengthDecode nisu imali nikakvu granicu: svaki je pisao u TMemoryStream dok mu ulaz ne bi ponestao ili alokator odustao. Tako je neprijateljski lanac imao dva puta prolaza. Mogao je koristiti posve nečuvani filter, ili je mogao koristiti čuvane i jednostavno ih dodati više

Postoji treći detalj koji naivan popravak propušta. Broj koji vas zanima nije veličina konačnog dekodiranog izlaza. To je vrhunac, a vrhunac obično živi u međuspremniku. Lanac koji završava skromnim sadržajnim streamom od 4 MB može alocirati 8 GB u trećoj fazi i vratiti nešto što izgleda posve razumno. Provjera duljine rezultata naknadno ne govori vam ništa o alokaciji koja je ubila proces

Jedan pratitelj proračuna po lancu filtera

Popravak u HotPDF v2.447.0 je učiniti da računovodstvo obuhvati lanac, a ne fazu. Svaki lanac filtera gradi jedan THPDFDecodeBudgetTracker, a svaki dekoder piše kroz THPDFBudgetWriteStream koji omata stvarni cilj. Omotač poziva Budget.Consume(Count) prije nego proslijedi ijedan bajt, tako da se odbijanje dogodi dok je odredišni stream još njegove stare veličine. Taj redoslijed je cijela poanta: provjera izvedena nakon što je spremnik već narastao je dijagnostika, 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;

Lokalne granice nisu nestale, postale su projekcije zajedničkog proračuna. Faza LZW sad postavlja Decoder.MaxOutputBytes := Budget.RemainingBytes, tako da je njena privatna granica ono što je lancu preostalo, a ne neovisno dopuštenje. Faza prediktora slike otvara se s BeginFilter i naplaćuje svoj zahtjev za retke kroz Consume prije alokacije, što znači da se izlaz prediktora naplaćuje istom proračunu kao generički filteri koji su ga hranili. To je posebno bitno na putu slike, gdje su lanac filtera i prediktor dvije polovice jedne operacije, kako je pokriveno u ekstrakciji slika iz učitanih dokumenata kroz njihove filtere dekodiranja

Što pozivatelj vidi kad proračun odbije?

Na dnu stoga, odbijanje izaziva EHPDFDecodeBudgetError. Iznad toga, odgovor ovisi o ugovoru koji je pozivajući API već imao. Visokorazinske metode čitanja koje su neuspjeh prijavljivale kroz False ili nil nastavljaju to raditi, jer bi pretvaranje dokumentiranog bulean rezultata u iznimku pokvarilo pozivatelje koji su već ispravno rukovali neispravnim ulazom. Put sadržaja učitane stranice namjerna je iznimka: ponovno izaziva EHPDFDecodeBudgetError umjesto da dopusti da se skraćeni sadržajni stream renderira kao stranica koja je jednostavno izašla prazna. Taj dizajn znači da je goli False sam po sebi nejednoznačan, pa proračun uz njega objavljuje dijagnostički zapis: THotPDF.GetLastDecodeBudgetInfo vraća stanje najnovijeg lanca koji je instanca dekodirala

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 je faza napravila svu štetu i gledate jedan filter visokog omjera. Kad je PeakStageBytes mali dio DecodedBytes, a FilterCount je visok, nijedna pojedinačna faza nije bila skandalozna, i lanac se nakupio preko granice, što je upravo slučaj koji ograničenje po filteru ne može vidjeti. Jedno upozorenje vrijedno upisivanja u vaš rukovatelj: GetLastDecodeBudgetInfo vraća False dok instanca ne dekodira barem jedan filter, pa False iz nje nije dokaz da je dokument bio čist

Gdje se proračun resetira, i kad je nula iskren odgovor

DecodeBudgetBytes ograničava jedan lanac streama, ne jedan dokument, i ta je granica namjerna, ali laka za pogrešno čitanje. Svaki sadržajni stream, svaka ugrađena datoteka, svaki cross-reference stream i svaki object stream počinje sa svježih 256 MiB. Dokument od 4.000 stranica stoga ima 4.000 neovisnih prilika potrošiti punu granicu, a object streamovi dodatno umnožavaju broj jer je svaki od njih sam kontejner koji drži mnogo objekata, kako je opisano u bilješkama o object streamovima i prirasnim ažuriranjima. Ako je vaš stvarni zahtjev granica ukupne memorije procesa, ovo svojstvo je jedan ulaz u to, ne cijela stvar, i trebalo bi sjediti iza granice na razini posla ili kontejnera

Nula znači neograničeno, i to je legitimna postavka, a ne izlaz u nuždi. Postavite je kad posjedujete ulaz: pipeline ponovne obrade arhive nad dokumentima koje je proizveo vaš vlastiti sustav, ili korak rasterizacije gdje lancu jednog skena u boji od 600 dpi doista treba više nego bilo koja granica koju biste ugodno hardkodirali. Negativne vrijednosti odbijaju se unaprijed s ERangeError, jer negativan proračun nema koherentno značenje, a tiho ograničavanje bi sakrilo konfiguracijsku grešku

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

Odabir broja zaslužuje više pažnje nego što obično dobiva, jer preniski proračun je sam sebi nanesen prekid rada. Pokrenite svoj postojeći korpus sa zadanom vrijednošću, zabilježite PeakStageBytes i DecodedBytes za svaki lanac, i postavite granicu iznad opaženog maksimuma s pravim manevarskim prostorom. Okrugla brojka odabrana jer je zvučala sigurno odbit će legitiman veliki sken u najgorem mogućem trenutku, a neuspjeh će u vašim zapisnicima izgledati točno kao napad

Kopiranje koje se više ne događa

Usmjeravanje svake faze kroz omotač proračuna pokazalo se da čini lanac jeftinijim, a ne skupljim. Kad stream ima filtere, prva faza sad izravno čita izvorni stream umjesto da prvo kopira kodirane bajtove u pomoćni spremnik, a odatle su istovremeno živa samo dva spremnika: trenutni ulaz i izlaz faze koji se piše. Sirovo kopiranje preživljava u dva slučaja koja to trebaju, naime stream posve bez filtera i slika gdje pozivatelj želi sačuvano posljednje kodiranje, jer oboje vraćaju stream koji pozivatelj posjeduje i može neovisno tražiti poziciju. Nečuvana verzija ovog koda alocirala je više, a ograničavala manje, što je uobičajen odnos između to dvoje. Vrijedi jasno reći, ipak: ništa od ovoga ne čini proizvoljan PDF sigurnim za učitavanje. Zatvara jedan specifičan i vrlo jeftin vektor uskraćivanja usluge, onaj gdje mala datoteka kupuje veliku alokaciju kroz ugniježđene filtere. Cjelobrojno prelijevanje u računovodstvu bajtova čuva se odvojeno, a šire pitanje raščlambe neprijateljskih dokumenata bez povjerenja njihovim internim offsetima drugačija je disciplina. Proračun dekodiranja jedna je granica među nekoliko, a njena je vrijednost u tome što je ona koju možete postaviti s jednog svojstva prije nego dotaknete datoteku

Proračun po lancu, njegov dijagnostički zapis i putevi dekodiranja učitanog dokumenta koje štiti svi se isporučuju kao dio same komponente, bez vanjske ovisnosti o dekompresiji koju treba konfigurirati ili zakrpati. Ako procjenjujete kako ograničiti nepouzdan PDF ulaz unutar Delphi ili C++Builder usluge, stranica HotPDF Delphi PDF komponente navodi alate za učitane dokumente na koje se ova ograničenja primjenjuju