Teknisk artikel

PDF-avkodningsbomber i Delphi: HotPDF filterkedjebudgetar

En 20 KB PDF som pinnar en tjänsteprocess tills OOM-mördaren tar den är inte en bugg i din kod, det är en dekomprimeringsbomb. HotPDF, den native VCL-PDF-komponenten för Delphi och C++Builder, begränsar en sådan med DecodeBudgetBytes, ett tak per filterkedja som som standard är 268435456 byte och debiterar varje avkodningssteg mot en enda delad budget

20 KB-filen som åt upp en arbetarprocess

Formen på incidenten är alltid densamma. En köarbetare som renderar miniatyrer plockar upp en uppladdning, det resida minnet klättrar förbi 12 GB på under två sekunder, och processen försvinner utan stackspårning. Filen är 20 KB. Den har en sida, en innehållsström, och en /Filter-array med fem poster. Varje namn i den arrayen är ett filter specifikationen definierar, varje steg avkodas utan fel, och inget i filen är missformat. Det är det som gör den här klassen av indata besvärlig: det finns ingen korrupt byte att avvisa

Det här är inte samma problem som att avkoda ett enda filter korrekt. Att få LZWDecode och /DecodeParms-prediktorn rätt är sitt eget ämne, täckt i genomgången av LZW, prediktorer, och DecodeParms på laddade dokument. Här är varje avkodare redan korrekt. Felet är vad korrekta avkodare gör när du kör fem av dem efter varandra och ingen räknar totalen. ISO 32000-1 §7.4 är explicit att /Filter kan vara ett enda namn eller en array av namn, och att en array tillämpas i sekvens, första posten först. Den säger ingenting om hur mycket ett steg får expandera sin indata, och ingenting om aggregatet över kedjan. Ett ASCIIHexDecode-steg ungefär halverar sin indata, vilket låter ofarligt. Ett FlateDecode-steg över en körning av nollbyte når kvoter i tusentalen. Kedja dem och aritmetiken är multiplikativ: 20 KB blir 20 MB blir 20 GB, och varje enskilt steg är en konform avkodning av en laglig ström

Varför misslyckas en gräns per filter att stoppa en avkodningsbomb?

Eftersom en gräns per filter återladdas vid varje element i /Filter-arrayen. En kedja på fem steg under ett tak på 256 MiB per steg auktoriserar 1,25 GiB, och det sista steget börjar fortfarande med en helt fräsch tilldelning oavsett vad de fyra före den producerade. Gränsen upprätthålls ärligt och begränsar ingenting som spelar roll. HotPDF hade exakt den formen före v2.447.0, och den hade ett andra hål bredvid det. LZW-dekompressorn bar ett MaxOutputBytes-tak och bildprediktorvägen räknade sina egna rader, så de två var lokalt begränsade. FlateDecode, ASCIIHexDecode, ASCII85Decode, och RunLengthDecode hade inget tak alls: varje skrev till en TMemoryStream tills den slut på indata eller allokatorn gav upp. Så en fientlig kedja hade två vägar igenom. Den kunde använda ett helt oskyddat filter, eller den kunde använda skyddade sådana och helt enkelt lägga till fler av dem

Det finns en tredje detalj en naiv fix missar. Siffran du bryr dig om är inte storleken på den slutliga avkodade utdatan. Det är toppen, och toppen bor vanligtvis i en mellanliggande buffert. En kedja som slutar i en blygsam 4 MB-innehållsström kan allokera 8 GB i steg tre och lämna tillbaka något som ser helt rimligt ut. Att kontrollera resultatets längd i efterhand talar inte om något om allokeringen som dödade processen

En budgetspårare per filterkedja

Fixet i HotPDF v2.447.0 är att göra bokföringen sträcka sig över kedjan snarare än steget. Varje filterkedja konstruerar en THPDFDecodeBudgetTracker, och varje avkodare skriver genom en THPDFBudgetWriteStream som omsluter det verkliga målet. Omslaget anropar Budget.Consume(Count) innan den vidarebefordrar en enda byte, så avvisningen sker medan målströmmen fortfarande är sin gamla storlek. Den ordningen är hela poängen: en kontroll utförd efter att bufferten redan har växt är en diagnos, inte ett försvar

// 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 lokala taken försvann inte, de blev projektioner av den delade budgeten. LZW-steget sätter nu Decoder.MaxOutputBytes := Budget.RemainingBytes, så dess privata tak är vad kedjan har kvar snarare än en oberoende tilldelning. Bildprediktorsteget öppnar med BeginFilter och debiterar sitt radbehov genom Consume innan det allokerar, vilket betyder att prediktorutdata debiteras samma budget som de generiska filter som matade den. Det spelar roll på bildvägen i synnerhet, där filterkedjan och prediktorn är två halvor av en operation, som täckt i att extrahera bilder från laddade dokument genom deras avkodningsfilter

Vad ser anroparen när budgeten vägrar?

Längst ner i stacken kastar en vägran EHPDFDecodeBudgetError. Ovanför det beror svaret på kontraktet det anropande API:et redan hade. API-metoder på hög nivå för läsning som rapporterade fel genom False eller nil fortsätter göra exakt det, eftersom att förvandla ett dokumenterat booleskt resultat till ett undantag skulle bryta anropare som redan hanterade missformad indata korrekt. Den laddade sidinnehållsvägen är det avsiktliga undantaget: den kastar om EHPDFDecodeBudgetError i stället för att låta en trunkerad innehållsström renderas som en sida som bara kom ut tom. Den designen betyder att ett rent False är tvetydigt i sig själv, så budgeten publicerar en diagnostikpost vid sidan om det: THotPDF.GetLastDecodeBudgetInfo returnerar tillståndet för den senaste kedjan instansen avkodade

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;

Läs de fälten tillsammans och de separerar de två attackformerna. När PeakStageBytes ligger nära DecodedBytes gjorde ett steg all skadan och du tittar på ett enda högkvot-filter. När PeakStageBytes är en liten bråkdel av DecodedBytes och FilterCount är högt var inget enskilt steg upprörande och kedjan ackumulerade sig förbi taket, vilket är exakt fallet en gräns per filter inte kan se. En varning värd att skriva in i din hanterare: GetLastDecodeBudgetInfo returnerar False tills instansen har avkodat minst ett filter, så ett False från den är inte bevis på att dokumentet var rent

Var budgeten återställs, och när noll är det ärliga svaret

DecodeBudgetBytes begränsar en strömkedja, inte ett dokument, och den gränsen är avsiktlig men lätt att missläsa. Varje innehållsström, varje inbäddad fil, varje korsreferensström och varje objektström börjar med en fräsch 256 MiB. Ett dokument på 4 000 sidor har därför 4 000 oberoende chanser att spendera hela taket, och objektströmmar multiplicerar antalet ytterligare eftersom varje sådan i sig är en komprimerad container som håller många objekt, som beskrivet i anteckningarna om objektströmmar och inkrementella uppdateringar. Om ditt verkliga krav är en gräns på total processminnesanvändning är den här egenskapen en indata till det, inte hela det, och den bör sitta bakom ett jobb-nivå- eller container-nivå-tak

Noll betyder obegränsat, och det är en legitim inställning snarare än en flyktväg. Sätt den när du äger indatan: en arkivomprocesseringspipeline över dokument ditt eget system producerade, eller ett rastreringssteg där en enda 600 dpi-färgskanningkedja genuint behöver mer än något tak du skulle vara bekväm med att hårdkoda. Negativa värden avvisas på förhand med ERangeError, eftersom en negativ budget inte har någon sammanhängande betydelse och att tyst klämma fast den skulle dölja en konfigurationsbugg

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

Att välja siffran förtjänar mer omsorg än den vanligtvis får, eftersom en budget satt för lågt är ett självförvållat avbrott. Kör din befintliga korpus med standardvärdet, registrera PeakStageBytes och DecodedBytes för varje kedja, och sätt taket över det observerade maxvärdet med verklig marginal. En rund siffra vald för att den lät säker kommer avvisa en legitim stor skanning vid den värsta möjliga tidpunkten, och felet kommer se ut exakt som en attack i dina loggar

Kopian som inte längre sker

Att dirigera varje steg genom ett budgetomslag visade sig göra kedjan billigare snarare än dyrare. När en ström har filter läser det första steget nu källströmmen direkt i stället för att kopiera de kodade byten till en skrapbuffert först, och därifrån är bara två buffertar levande samtidigt: den aktuella indatan och stegutdatan som skrivs. Rå kopiering överlever i de två fall som behöver den, nämligen en ström utan filter alls och en bild där anroparen vill ha den senaste kodningen bevarad, eftersom båda lämnar tillbaka en ström anroparen äger och kan söka i oberoende. Den oskyddade versionen av den här koden allokerade mer och begränsade mindre, vilket är det vanliga förhållandet mellan de två. Värt att uttrycka rakt på sak, dock: inget av detta gör en godtycklig PDF säker att ladda. Det stänger en specifik och mycket billig överbelastningsattack-vektor, den där en liten fil köper en stor allokering genom nästlade filter. Heltalsöverflöde i bytebokföringen skyddas separat, och den bredare frågan om att tolka fientliga dokument utan att lita på deras interna offsetar är en annan disciplin. En avkodningsbudget är en gräns bland flera, och dess värde är att det är den du kan sätta från en enda egenskap innan du rör filen

Budgeten per kedja, dess diagnostikpost, och de laddade dokumentens avkodningsvägar den skyddar levereras alla som del av komponenten själv, utan något externt dekomprimeringsberoende att konfigurera eller patcha. Om du utvärderar hur man begränsar opålitlig PDF-indata inuti en Delphi- eller C++Builder-tjänst listar sidan för HotPDF Delphi-PDF-komponenten verktygslådan för laddade dokument dessa gränser gäller för