Technisch artikel

PDF Decode Bombs in Delphi: HotPDF Filter Chain Budgets

Een PDF van 20 KB die een serviceproces vastpint totdat de OOM-killer het opruimt, is geen bug in je code, het is een decompressiebom. HotPDF, het native VCL PDF-component voor Delphi en C++Builder, begrenst er een met DecodeBudgetBytes, een plafond per filterketen dat standaard 268435456 bytes bedraagt en elke decodeerfase belast tegen één gedeeld budget

Het bestand van 20 KB dat een workerproces opat

De vorm van het incident is altijd hetzelfde. Een queue-worker die thumbnails rendert pakt een upload op, resident geheugen klimt binnen twee seconden voorbij 12 GB, en het proces verdwijnt zonder stack trace. Het bestand is 20 KB. Het heeft één pagina, één contentstream, en een /Filter-array met vijf items. Elke naam in die array is een filter die de specificatie definieert, elke fase decodeert zonder fout, en niets in het bestand is misvormd. Dat is wat deze klasse invoer lastig maakt: er is geen corrupte byte om af te wijzen

Dit is niet hetzelfde probleem als een enkel filter correct decoderen. LZWDecode en de /DecodeParms-predictor goed doen is een eigen onderwerp, behandeld in de doorloop van LZW, predictors en DecodeParms op geladen documenten. Hier is elke decoder al correct. Het falen is wat correcte decoders doen wanneer je er vijf achter elkaar draait en niemand het totaal telt. ISO 32000-1 §7.4 is expliciet dat /Filter een enkele naam of een array van namen mag zijn, en dat een array in volgorde wordt toegepast, eerste item eerst. Er staat niets over hoeveel een fase zijn invoer mag laten uitzetten, en niets over het totaal over de hele keten. Een ASCIIHexDecode-fase halveert ruwweg zijn invoer, wat onschuldig klinkt. Een FlateDecode-fase over een reeks nulbytes bereikt ratio's in de duizenden. Ketent men ze, dan is de rekenkunde vermenigvuldigend: 20 KB wordt 20 MB wordt 20 GB, en elke individuele stap is een conforme decodering van een legale stream

Waarom slaagt een per-filterlimiet er niet in een decode-bom tegen te houden?

Omdat een per-filterlimiet bij elk element van de /Filter-array opnieuw wordt bewapend. Een keten van vijf fasen onder een plafond van 256 MiB per fase autoriseert 1,25 GiB, en de laatste fase begint nog steeds met een volkomen verse toelage, ongeacht wat de vier ervoor produceerden. De limiet wordt eerlijk gehandhaafd en beperkt niets dat ertoe doet. HotPDF had precies die vorm vóór v2.447.0, en had er een tweede gat naast. De LZW-decompressor droeg een MaxOutputBytes-plafond en het beeldpredictor-pad verrekende zijn eigen rijen, dus die twee waren lokaal begrensd. FlateDecode, ASCIIHexDecode, ASCII85Decode en RunLengthDecode hadden helemaal geen plafond: elk schreef naar een TMemoryStream totdat de invoer op was of de allocator het opgaf. Een vijandige keten had dus twee wegen erdoorheen. Ze kon een volledig onbeschermd filter gebruiken, of ze kon beschermde filters gebruiken en er simpelweg meer van toevoegen

Er is een derde detail dat een naïeve fix mist. Het getal waar het je om gaat, is niet de grootte van de uiteindelijk gedecodeerde output. Het is de piek, en de piek zit meestal in een tussenliggende buffer. Een keten die eindigt in een bescheiden contentstream van 4 MB kan bij fase drie 8 GB alloceren en iets teruggeven dat volledig redelijk oogt. De lengte van het resultaat achteraf controleren vertelt je niets over de allocatie die het proces doodde

Eén budgettracker per filterketen

De fix in HotPDF v2.447.0 is om de boekhouding over de hele keten te laten lopen in plaats van over de fase. Elke filterketen construeert één THPDFDecodeBudgetTracker, en elke decoder schrijft door een THPDFBudgetWriteStream die het echte doel omhult. De wrapper roept Budget.Consume(Count) aan voordat hij ook maar één byte doorstuurt, dus de weigering gebeurt terwijl de doelstream nog zijn oude grootte heeft. Die volgorde is precies het punt: een controle die uitgevoerd wordt nadat de buffer al gegroeid is, is een diagnose, geen verdediging

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

De lokale plafonds verdwenen niet, ze werden projecties van het gedeelde budget. De LZW-fase stelt nu Decoder.MaxOutputBytes := Budget.RemainingBytes in, dus zijn private plafond is wat de keten nog over heeft in plaats van een onafhankelijke toelage. De beeldpredictor-fase opent met BeginFilter en verrekent zijn rijvereiste via Consume vóór het alloceren, wat betekent dat predictor-output op hetzelfde budget wordt geboekt als de generieke filters die het voedden. Dat doet er in het bijzonder toe op het afbeeldingspad, waar de filterketen en de predictor twee helften van één bewerking zijn, zoals behandeld in het extraheren van afbeeldingen uit geladen documenten via hun decodeerfilters

Wat ziet de aanroeper wanneer het budget weigert?

Onderaan de stapel werpt een weigering EHPDFDecodeBudgetError. Daarboven hangt het antwoord af van het contract dat de aanroepende API al had. High-level leesmethoden die falen rapporteerden via False of nil blijven precies dat doen, omdat het omzetten van een gedocumenteerd booleaans resultaat in een exception aanroepers zou breken die misvormde invoer al correct afhandelden. Het pad voor geladen paginacontent is de bewuste uitzondering: het werpt EHPDFDecodeBudgetError opnieuw in plaats van een afgekapte contentstream te laten renderen als een pagina die simpelweg leeg uitkwam. Dat ontwerp betekent dat een kale False op zichzelf dubbelzinnig is, dus het budget publiceert een diagnoserecord ernaast: THotPDF.GetLastDecodeBudgetInfo retourneert de staat van de meest recente keten die de instantie decodeerde

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;

Lees die velden samen en ze scheiden de twee aanvalsvormen. Wanneer PeakStageBytes dicht bij DecodedBytes ligt, deed één fase alle schade en kijk je naar een enkel filter met hoge ratio. Wanneer PeakStageBytes een klein deel van DecodedBytes is en FilterCount hoog is, was geen enkele fase op zichzelf schokkend en heeft de keten zich stukje bij beetje voorbij het plafond opgestapeld, precies het geval dat een per-filterlimiet niet kan zien. Eén kanttekening die het waard is om in je handler te schrijven: GetLastDecodeBudgetInfo retourneert False totdat de instantie ten minste één filter gedecodeerd heeft, dus een False daarvan is geen bewijs dat het document schoon was

Waar het budget reset, en wanneer nul het eerlijke antwoord is

DecodeBudgetBytes begrenst één streamketen, niet één document, en die grens is bewust maar makkelijk verkeerd te lezen. Elke contentstream, elk ingebed bestand, elke cross-reference-stream en elke objectstream begint met een verse 256 MiB. Een document van 4000 pagina's heeft dus 4000 onafhankelijke kansen om het volledige plafond te besteden, en objectstreams vermenigvuldigen het aantal verder omdat elk daarvan zelf een gecomprimeerde container is met vele objecten, zoals beschreven in de notities over objectstreams en incrementele updates. Als je echte vereiste een grens op het totale procesgeheugen is, is deze eigenschap één input daarvoor, niet het geheel, en zou die achter een job-niveau- of container-niveau-plafond moeten zitten

Nul betekent onbegrensd, en het is een legitieme instelling, geen achterdeurtje. Zet het wanneer je de invoer zelf bezit: een archiefherverwerkingspipeline over documenten die je eigen systeem produceerde, of een rasterisatiestap waar één kleurscanketen op 600 dpi echt meer nodig heeft dan elk plafond waar je je comfortabel bij zou voelen als je het hard codeert. Negatieve waarden worden vooraf geweigerd met ERangeError, omdat een negatief budget geen coherente betekenis heeft en het stilzwijgend afkappen ervan een configuratiefout zou verbergen

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

Het kiezen van het getal verdient meer zorg dan het meestal krijgt, want een te laag ingesteld budget is een zelf veroorzaakte storing. Draai je bestaande corpus met de standaardwaarde, registreer PeakStageBytes en DecodedBytes voor elke keten, en zet het plafond boven het waargenomen maximum met echte marge. Een rond getal gekozen omdat het veilig klonk, wijst een legitieme grote scan af op het slechtst mogelijke moment, en het falen zal er in je logs precies uitzien als een aanval

De kopie die niet meer plaatsvindt

Elke fase via een budget-wrapper routeren bleek de keten juist goedkoper te maken in plaats van duurder. Wanneer een stream filters heeft, leest de eerste fase nu de bronstream rechtstreeks in plaats van de gecodeerde bytes eerst in een kladbuffer te kopiëren, en vanaf daar zijn slechts twee buffers tegelijk actief: de huidige invoer en de fase-output die geschreven wordt. De ruwe kopie overleeft in de twee gevallen die haar nodig hebben, namelijk een stream zonder enig filter en een afbeelding waarbij de aanroeper de laatste codering behouden wil, omdat beide een stream teruggeven die de aanroeper bezit en onafhankelijk kan seeken. De onbeschermde versie van deze code alloceerde meer en begrensde minder, wat de gebruikelijke relatie tussen de twee is. Het is de moeite waard om helder te stellen: niets hiervan maakt een willekeurige PDF veilig om te laden. Het sluit één specifieke en zeer goedkope denial-of-service-vector, die waar een klein bestand een grote allocatie koopt via geneste filters. Integer-overflow in de bytesboekhouding wordt apart bewaakt, en de bredere vraag van het parsen van vijandige documenten zonder hun interne offsets te vertrouwen is een andere discipline. Een decode-budget is één grens onder meerdere, en de waarde ervan is dat het de grens is die je vanuit één enkele eigenschap kunt instellen voordat je het bestand aanraakt

Het budget per keten, het diagnoserecord ervan, en de decodeerpaden voor geladen documenten die het beschermt, worden allemaal met het component zelf geleverd, zonder externe decompressie-afhankelijkheid om te configureren of te patchen. Evalueer je hoe je onvertrouwde PDF-invoer binnen een Delphi- of C++Builder-service kunt begrenzen, dan somt de pagina van het HotPDF Delphi PDF-component de toolkit voor geladen documenten op waarop deze limieten van toepassing zijn