En 20 KB PDF som spikrer en tjenesteprosess til OOM-killeren tar den, er ikke en feil i koden din, det er en dekomprimeringsbombe. HotPDF, den native VCL-PDF-komponenten for Delphi og C++Builder, avgrenser en slik med DecodeBudgetBytes, et tak per filterkjede som som standard er 268435456 bytes og belaster hvert dekodetrinn mot ett delt budsjett
20 KB-filen som spiste en arbeidsprosess
Formen på hendelsen er alltid den samme. En køarbeider som tegner miniatyrbilder plukker opp en opplasting, ressursminnet klatrer forbi 12 GB på under to sekunder, og prosessen forsvinner uten stakksporing. Filen er 20 KB. Den har én side, én innholdsstrøm, og en /Filter-array med fem oppføringer. Hvert navn i den arrayen er et filter spesifikasjonen definerer, hvert trinn dekoder uten feil, og ingenting i filen er feilformet. Det er det som gjør denne klassen av inndata vanskelig: det finnes ingen korrupt byte å avvise
Dette er ikke samme problem som å dekode ett enkelt filter korrekt. Å få LZWDecode og /DecodeParms-prediktoren riktig er sitt eget tema, dekket i gjennomgangen av LZW, prediktorer og DecodeParms på innlastede dokumenter. Her er hver dekoder allerede korrekt. Feilen er hva korrekte dekodere gjør når du kjører fem av dem etter hverandre og ingen teller totalen. ISO 32000-1 §7.4 er eksplisitt på at /Filter kan være et enkelt navn eller en array av navn, og at en array anvendes i sekvens, første oppføring først. Den sier ingenting om hvor mye et trinn kan utvide inndataen sin, og ingenting om summen på tvers av kjeden. Et ASCIIHexDecode-trinn omtrent halverer inndataen sin, som høres ufarlig ut. Et FlateDecode-trinn over en rekke null-bytes når forhold i tusentalls. Kjed dem sammen, og aritmetikken er multiplikativ: 20 KB blir 20 MB blir 20 GB, og hvert enkelt steg er en samsvarende dekoding av en lovlig strøm
Hvorfor klarer ikke en grense per filter å stoppe en dekomprimeringsbombe?
Fordi en grense per filter re-armeres ved hvert element i /Filter-arrayen. En kjede med fem trinn under et 256 MiB-tak per trinn autoriserer 1,25 GiB, og det siste trinnet begynner fortsatt med en helt fersk tillatelse uansett hva de fire foran det produserte. Grensen håndheves ærlig og begrenser ingenting som betyr noe. HotPDF hadde nøyaktig den formen før v2.447.0, og hadde et andre hull ved siden av det. LZW-dekomprimereren bar et MaxOutputBytes-tak og bildeprediktorstien regnskapsførte sine egne rader, så de to var lokalt avgrenset. FlateDecode, ASCIIHexDecode, ASCII85Decode, og RunLengthDecode hadde ingen grense i det hele tatt: hver skrev inn i en TMemoryStream til den gikk tom for inndata eller allokatoren ga opp. Så en fiendtlig kjede hadde to veier gjennom. Den kunne bruke et helt ubevoktet filter, eller den kunne bruke bevoktede og bare legge til flere av dem
Det er en tredje detalj en naiv fiks overser. Tallet du bryr deg om er ikke størrelsen på den endelige dekodede utdataen. Det er toppen, og toppen bor vanligvis i en mellomliggende buffer. En kjede som ender i en beskjeden 4 MB innholdsstrøm kan allokere 8 GB ved trinn tre og levere tilbake noe som ser helt rimelig ut. Å sjekke lengden på resultatet i etterkant forteller deg ingenting om allokeringen som drepte prosessen
Én budsjettsporer per filterkjede
Fiksen i HotPDF v2.447.0 er å få regnskapet til å spenne over kjeden i stedet for trinnet. Hver filterkjede konstruerer én THPDFDecodeBudgetTracker, og hver dekoder skriver gjennom en THPDFBudgetWriteStream som pakker inn det reelle målet. Wrapperen kaller Budget.Consume(Count) før den videresender en eneste byte, så avvisningen skjer mens målstrømmen fortsatt har sin gamle størrelse. Den rekkefølgen er hele poenget: en sjekk utført etter at bufferen allerede har vokst er en diagnose, ikke et forsvar
// 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 taket forsvant ikke, de ble projeksjoner av det delte budsjettet. LZW-trinnet setter nå Decoder.MaxOutputBytes := Budget.RemainingBytes, så dets private tak er hva enn kjeden har igjen i stedet for en uavhengig tillatelse. Bildeprediktortrinnet åpner med BeginFilter og belaster sitt radkrav gjennom Consume før allokering, som betyr at prediktorutdata belastes samme budsjett som de generiske filtrene som matet den. Det betyr noe spesielt på bildestien, hvor filterkjeden og prediktoren er to halvdeler av én operasjon, som dekket i uttrekk av bilder fra innlastede dokumenter gjennom deres dekodefiltre
Hva ser den som kaller når budsjettet nekter?
Nederst i stakken utløser en avvisning EHPDFDecodeBudgetError. Over det avhenger svaret av kontrakten API-et som kaller allerede hadde. Høynivå leseefunksjoner som rapporterte feil gjennom False eller nil fortsetter å gjøre nøyaktig det, fordi å gjøre om et dokumentert boolsk resultat til et unntak ville ødelegge klienter som allerede håndterte feilformede inndata korrekt. Stien for innlastet sideinnhold er det bevisste unntaket: den kaster EHPDFDecodeBudgetError på nytt i stedet for å la en avkortet innholdsstrøm tegnes som en side som bare kom ut tom. Det designet betyr at en bar False er tvetydig alene, så budsjettet publiserer en diagnosepost ved siden av den: THotPDF.GetLastDecodeBudgetInfo returnerer tilstanden til den siste kjeden instansen dekodet
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;
Les de feltene sammen, og de skiller de to angrepsformene fra hverandre. Når PeakStageBytes er nær DecodedBytes, gjorde ett trinn all skaden, og du ser på et enkelt filter med høyt forhold. Når PeakStageBytes er en liten brøkdel av DecodedBytes og FilterCount er høy, var ikke noe enkelt trinn ekstremt, og kjeden akkumulerte seg forbi taket, som nøyaktig er tilfellet en grense per filter ikke kan se. Én advarsel verdt å skrive inn i handleren din: GetLastDecodeBudgetInfo returnerer False til instansen har dekodet minst ett filter, så en False fra den er ikke bevis på at dokumentet var rent
Hvor budsjettet nullstilles, og når null er det ærlige svaret
DecodeBudgetBytes avgrenser én strømkjede, ikke ett dokument, og den grensen er bevisst, men lett å mistolke. Hver innholdsstrøm, hver innebygde fil, hver kryssreferansestrøm og hver objektstrøm starter med et ferskt 256 MiB. Et dokument på 4000 sider har derfor 4000 uavhengige sjanser til å bruke opp hele taket, og objektstrømmer multipliserer antallet ytterligere fordi hver av dem selv er en komprimert container som holder mange objekter, som beskrevet i notatene om objektstrømmer og inkrementelle oppdateringer. Hvis ditt reelle krav er en grense på total prosessminne, er denne egenskapen én innsatsfaktor i det, ikke hele svaret, og den bør sitte bak et jobb-nivå- eller container-nivå-tak
Null betyr ubegrenset, og det er en legitim innstilling snarere enn en rømningsvei. Sett den når du eier inndataen: en arkiv-reprosesseringspipeline over dokumenter ditt eget system produserte, eller et rasteriseringssteg hvor en enkelt 600 dpi fargeskanningskjede reelt trenger mer enn noe tak du ville følt deg komfortabel med å hardkode. Negative verdier avvises umiddelbart med ERangeError, fordi et negativt budsjett ikke har noen sammenhengende mening, og å klemme det stille ville skjule en konfigurasjonsfeil
// 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;
Å velge tallet fortjener mer omhu enn det vanligvis får, fordi et budsjett satt for lavt er et selvpåført driftsavbrudd. Kjør det eksisterende korpuset ditt med standardverdien, registrer PeakStageBytes og DecodedBytes for hver kjede, og sett taket over det observerte maksimumet med reell margin. Et rundt tall valgt fordi det hørtes trygt ut, vil avvise en legitim stor skanning på det verst tenkelige tidspunktet, og feilen vil se nøyaktig ut som et angrep i loggene dine
Kopien som ikke lenger skjer
Å rute hvert trinn gjennom en budsjett-wrapper viste seg å gjøre kjeden billigere i stedet for dyrere. Når en strøm har filtre, leser det første trinnet nå kildestrømmen direkte i stedet for å kopiere de kodede bytene inn i en scratch-buffer først, og derfra er bare to buffere aktive samtidig: den gjeldende inndataen og trinnutdataen som skrives. Rå-kopien overlever i de to tilfellene som trenger den, nemlig en strøm uten filtre i det hele tatt og et bilde hvor den som kaller vil ha den siste kodingen bevart, fordi begge leverer tilbake en strøm den som kaller eier og kan søke uavhengig i. Den ubevoktede versjonen av denne koden allokerte mer og avgrenset mindre, som er det vanlige forholdet mellom de to. Verdt å si tydelig, likevel: ingenting av dette gjør en vilkårlig PDF trygg å laste inn. Det lukker én spesifikk og svært billig tjenestenektvektor (denial-of-service), den hvor en liten fil kjøper en stor allokering gjennom nøstede filtre. Heltallsoverflyt i byteregnskapet vaktes separat, og det bredere spørsmålet om å parse fiendtlige dokumenter uten å stole på deres interne offsets er en annen disiplin. Et dekodebudsjett er én grense blant flere, og verdien er at det er den du kan sette fra én enkelt egenskap før du rører filen
Budsjettet per kjede, diagnoseposten, og de innlastede dokumentstiene det beskytter leveres alle som del av komponenten selv, uten noen ekstern dekomprimeringsavhengighet å konfigurere eller lappe. Hvis du vurderer hvordan du avgrenser upålitelig PDF-inndata inne i en Delphi- eller C++Builder-tjeneste, lister siden HotPDF Delphi PDF-komponent opp verktøykassen for innlastede dokumenter disse grensene gjelder for