Articol tehnic

Bombe de decodare PDF în Delphi: bugetele lanțului de filtre HotPDF

Un PDF de 20 KB care fixează un proces de serviciu până când OOM killer îl elimină nu este un bug în codul dumneavoastră, este o bombă de decompresie. HotPDF, componenta VCL PDF nativă pentru Delphi și C++Builder, mărginește una cu DecodeBudgetBytes, un plafon per lanț de filtre care este implicit 268435456 octeți și taxează fiecare etapă de decodare față de un singur buget partajat

Fișierul de 20 KB care a devorat un proces worker

Forma incidentului este mereu aceeași. Un worker de coadă care randează thumbnail-uri preia un upload, memoria rezidentă urcă peste 12 GB în mai puțin de două secunde, iar procesul dispare fără nicio urmă de stivă. Fișierul are 20 KB. Are o pagină, un flux de conținut și un array /Filter cu cinci intrări. Fiecare nume din acel array este un filtru pe care specificația îl definește, fiecare etapă se decodează fără eroare, iar nimic din fișier nu este malformat. Aceasta este ceea ce face această clasă de intrare dificilă: nu există niciun octet corupt de respins

Aceasta nu este aceeași problemă ca decodarea corectă a unui singur filtru. A face bine LZWDecode și predictorul /DecodeParms este propriul subiect, tratat în prezentarea despre LZW, predictori și DecodeParms pe documente încărcate. Aici fiecare decodor este deja corect. Eșecul este ce fac decodoarele corecte când rulați cinci dintre ele unul după altul și nimeni nu numără totalul. ISO 32000-1 §7.4 este explicit că /Filter poate fi un singur nume sau un array de nume, și că un array este aplicat secvențial, prima intrare prima. Nu spune nimic despre cât de mult poate expanda o etapă intrarea sa, și nimic despre agregatul de-a lungul lanțului. O etapă ASCIIHexDecode reduce aproximativ la jumătate intrarea, ceea ce pare inofensiv. O etapă FlateDecode peste o secvență de octeți zero atinge rapoarte în miile. Înlănțuiți-le, iar aritmetica este multiplicativă: 20 KB devine 20 MB devine 20 GB, iar fiecare pas individual este o decodare conformă a unui flux legal

De ce o limită per filtru nu reușește să oprească o bombă de decodare?

Pentru că o limită per filtru este rearmată la fiecare element al array-ului /Filter. Un lanț de cinci etape sub un plafon per etapă de 256 MiB autorizează 1,25 GiB, iar etapa finală tot începe cu o alocație complet nouă, indiferent ce au produs cele patru dinainte. Limita este aplicată onest și nu constrânge nimic care contează. HotPDF avea exact această formă înainte de v2.447.0, și avea un al doilea gol alături. Decompresorul LZW purta un plafon MaxOutputBytes, iar calea predictorului de imagine își contabiliza propriile rânduri, așa că acele două erau mărginite local. FlateDecode, ASCIIHexDecode, ASCII85Decode și RunLengthDecode nu aveau niciun plafon deloc: fiecare scria într-un TMemoryStream până când rămânea fără intrare sau alocatorul renunța. Așa că un lanț ostil avea două căi de trecere. Putea folosi un filtru complet nepăzit, sau putea folosi unele păzite și pur și simplu să adauge mai multe dintre ele

Există un al treilea detaliu pe care o soluție naivă îl ratează. Numărul care contează nu este dimensiunea ieșirii finale decodate. Este vârful, iar vârful trăiește de obicei într-un buffer intermediar. Un lanț care se termină cu un flux de conținut modest de 4 MB poate aloca 8 GB la etapa trei și poate returna ceva ce arată complet rezonabil. Verificarea lungimii rezultatului ulterior nu vă spune nimic despre alocarea care a ucis procesul

Un tracker de buget per lanț de filtre

Soluția din HotPDF v2.447.0 este să facă ca și contabilizarea să acopere lanțul, nu etapa. Fiecare lanț de filtre construiește un singur THPDFDecodeBudgetTracker, iar fiecare decodor scrie printr-un THPDFBudgetWriteStream care înfășoară ținta reală. Wrapper-ul apelează Budget.Consume(Count) înainte de a înainta măcar un singur octet, astfel încât refuzul are loc în timp ce fluxul țintă are încă dimensiunea veche. Acea ordonare este întregul rost: o verificare efectuată după ce buffer-ul a crescut deja este un diagnostic, nu o apărare

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

Plafoanele locale nu au dispărut, au devenit proiecții ale bugetului partajat. Etapa LZW acum setează Decoder.MaxOutputBytes := Budget.RemainingBytes, astfel încât plafonul ei privat este orice a mai rămas din lanț, nu o alocație independentă. Etapa predictorului de imagine se deschide cu BeginFilter și își taxează cerința de rând prin Consume înainte de a aloca, ceea ce înseamnă că ieșirea predictorului este facturată aceluiași buget ca filtrele generice care au alimentat-o. Asta contează în special pe calea imaginilor, unde lanțul de filtre și predictorul sunt cele două jumătăți ale unei singure operații, așa cum este tratat în extragerea imaginilor din documente încărcate prin filtrele lor de decodare

Ce vede apelantul când bugetul refuză?

La baza stivei, un refuz ridică EHPDFDecodeBudgetError. Deasupra, răspunsul depinde de contractul pe care API-ul apelant îl avea deja. Metodele de citire de nivel înalt care raportau eșecul prin False sau nil continuă să facă exact asta, deoarece transformarea unui rezultat boolean documentat într-o excepție ar strica apelanții care gestionau deja corect intrarea malformată. Calea conținutului paginii încărcate este excepția deliberată: reridică EHPDFDecodeBudgetError în loc să lase un flux de conținut trunchiat să se randeze ca o pagină care pur și simplu a ieșit goală. Acest design înseamnă că un simplu False este ambiguu de la sine, așa că bugetul publică o înregistrare de diagnostic alături de el: THotPDF.GetLastDecodeBudgetInfo returnează starea celui mai recent lanț pe care instanța l-a decodat

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;

Citiți acele câmpuri împreună și ele separă cele două forme de atac. Când PeakStageBytes este apropiat de DecodedBytes, o singură etapă a făcut toată paguba și vă uitați la un singur filtru cu raport ridicat. Când PeakStageBytes este o fracțiune mică din DecodedBytes și FilterCount este mare, nicio etapă individuală nu a fost exagerată, iar lanțul s-a acumulat treptat peste plafon, exact cazul pe care o limită per filtru nu îl poate vedea. O avertizare care merită scrisă în handler-ul dumneavoastră: GetLastDecodeBudgetInfo returnează False până când instanța a decodat cel puțin un filtru, așa că un False de la ea nu este dovadă că documentul era curat

Unde se resetează bugetul, și când zero este răspunsul onest

DecodeBudgetBytes mărginește un lanț de flux, nu un document, iar acea limită este deliberată dar ușor de citit greșit. Fiecare flux de conținut, fiecare fișier încorporat, fiecare flux cross-reference și fiecare flux de obiecte începe cu 256 MiB proaspete. Un document de 4.000 de pagini are deci 4.000 de șanse independente de a cheltui plafonul complet, iar fluxurile de obiecte multiplică numărul și mai mult, deoarece fiecare este el însuși un container comprimat ce conține multe obiecte, așa cum este descris în notele despre fluxurile de obiecte și actualizările incrementale. Dacă cerința dumneavoastră reală este o limită a memoriei totale a procesului, această proprietate este un input pentru asta, nu întregul, și ar trebui să stea în spatele unui plafon la nivel de job sau container

Zero înseamnă nelimitat, și este o setare legitimă, nu o portiță de scăpare. Setați-o când dețineți intrarea: un pipeline de reprocesare arhivă peste documente pe care propriul dumneavoastră sistem le-a produs, sau un pas de rasterizare unde un singur lanț de scanare color la 600 dpi are într-adevăr nevoie de mai mult decât orice plafon cu care v-ați simți confortabil să codificați direct. Valorile negative sunt respinse din start cu ERangeError, deoarece un buget negativ nu are niciun sens coerent, iar limitarea lui tacită ar ascunde un bug de configurare

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

Alegerea numărului merită mai multă grijă decât primește de obicei, deoarece un buget setat prea jos este o întrerupere auto-provocată. Rulați corpusul dumneavoastră existent cu valoarea implicită, înregistrați PeakStageBytes și DecodedBytes pentru fiecare lanț, și setați plafonul deasupra maximului observat cu marjă reală. Un număr rotund ales pentru că suna sigur va respinge o scanare mare legitimă la cel mai nepotrivit moment, iar eșecul va arăta exact ca un atac în jurnalele dumneavoastră

Copia care nu mai are loc

Direcționarea fiecărei etape printr-un wrapper de buget s-a dovedit a face lanțul mai ieftin, nu mai scump. Când un flux are filtre, prima etapă acum citește direct fluxul sursă în loc să copieze mai întâi octeții codificați într-un buffer temporar, iar de acolo doar două buffer-e sunt vii simultan: intrarea curentă și ieșirea etapei fiind scrisă. Copia brută supraviețuiește în cele două cazuri care au nevoie de ea, anume un flux fără niciun filtru și o imagine unde apelantul dorește păstrată ultima codificare, deoarece ambele returnează un flux pe care apelantul îl deține și îl poate căuta independent. Versiunea nepăzită a acestui cod aloca mai mult și mărginea mai puțin, care este relația obișnuită dintre cele două. Merită totuși enunțat clar: nimic din aceasta nu face un PDF arbitrar sigur de încărcat. Închide un vector specific și foarte ieftin de refuz de serviciu, cel în care un fișier mic cumpără o alocație mare prin filtre imbricate. Overflow-ul de întreg în contabilizarea de octeți este păzit separat, iar întrebarea mai largă a analizării documentelor ostile fără a avea încredere în offset-urile lor interne este o disciplină diferită. Un buget de decodare este o limită printre altele, iar valoarea lui este că este cea pe care o puteți seta dintr-o singură proprietate înainte să atingeți fișierul

Bugetul per lanț, înregistrarea sa de diagnostic și căile de decodare a documentelor încărcate pe care le protejează vin toate ca parte a componentei în sine, fără nicio dependență externă de decompresie de configurat sau corectat. Dacă evaluați cum să mărginiți intrarea PDF nesigură într-un serviciu Delphi sau C++Builder, pagina componentei PDF HotPDF pentru Delphi listează toolkit-ul de documente încărcate căruia i se aplică aceste limite