En 20 KB PDF, der fastholder en serviceproces, indtil OOM-killeren tager den, er ikke en fejl i din kode — det er en dekomprimeringsbombe. HotPDF, den native VCL PDF-komponent til Delphi og C++Builder, afgrænser sådan én med DecodeBudgetBytes, et loft pr. filterkæde, der som standard er 268435456 bytes og opkræver hvert afkodningstrin mod ét fælles budget
20 KB-filen, der spiste en arbejdsproces
Formen på hændelsen er altid den samme. En kø-worker, der gengiver thumbnails, henter en upload, resident hukommelse kravler forbi 12 GB på under to sekunder, og processen forsvinder uden stack trace. Filen er 20 KB. Den har én side, én indholdsstrøm og et /Filter-array med fem poster. Hvert navn i det array er et filter, specifikationen definerer, hvert trin afkoder uden fejl, og intet i filen er misdannet. Det er det, der gør denne klasse af input ubehagelig: der er ingen korrupt byte at afvise
Dette er ikke samme problem som at afkode ét filter korrekt. At få LZWDecode og /DecodeParms-prediktoren rigtig er sit eget emne, dækket i gennemgangen af LZW, prediktorer og DecodeParms på indlæste dokumenter. Her er hver dekoder allerede korrekt. Fejlen er, hvad korrekte dekodere gør, når man kører fem af dem efter hinanden, og ingen tæller totalen. ISO 32000-1 §7.4 er eksplicit om, at /Filter kan være ét enkelt navn eller et array af navne, og at et array anvendes i rækkefølge, første post først. Den siger intet om, hvor meget et trin må udvide sit input, og intet om det samlede for hele kæden. Et ASCIIHexDecode-trin cirka halverer sit input, hvilket lyder harmløst. Et FlateDecode-trin over en række nul-bytes når forhold i tusinderne. Kæd dem sammen, og aritmetikken er multiplikativ: 20 KB bliver 20 MB bliver 20 GB, og hvert enkelt trin er en overholdende afkodning af en lovlig strøm
Hvorfor mislykkes en grænse pr. filter med at stoppe en afkodningsbombe?
Fordi en grænse pr. filter genoprustes ved hvert element i /Filter-arrayet. En kæde på fem trin under et loft på 256 MiB pr. trin autoriserer 1,25 GiB, og det sidste trin begynder stadig med en helt frisk tilladelse uanset, hvad de fire foregående producerede. Grænsen håndhæves ærligt og begrænser intet af betydning. HotPDF havde netop den form før v2.447.0, og havde et andet hul ved siden af. LZW-dekomprimeringen bar et MaxOutputBytes-loft, og billedprediktor-stien afregnede sine egne rækker, så de to var lokalt afgrænsede. FlateDecode, ASCIIHexDecode, ASCII85Decode og RunLengthDecode havde intet loft overhovedet: hver skrev ind i en TMemoryStream, indtil den løb tør for input, eller allokatoren gav op. Så en fjendtlig kæde havde to veje igennem. Den kunne bruge et helt ubevogtet filter, eller den kunne bruge bevogtede og bare tilføje flere af dem
Der er en tredje detalje, en naiv rettelse overser. Tallet, man skal bekymre sig om, er ikke størrelsen af det endelige afkodede output. Det er toppen, og toppen bor som regel i en mellemliggende buffer. En kæde, der ender med en beskeden 4 MB indholdsstrøm, kan allokere 8 GB ved trin tre og aflevere noget, der ser helt rimeligt ud. At tjekke længden af resultatet bagefter fortæller intet om den allokering, der dræbte processen
Én budgettracker pr. filterkæde
Rettelsen i HotPDF v2.447.0 er at få regnskabet til at spænde over kæden frem for trinet. Hver filterkæde konstruerer én THPDFDecodeBudgetTracker, og hver dekoder skriver gennem en THPDFBudgetWriteStream, der pakker det reelle mål ind. Wrapperen kalder Budget.Consume(Count), før den videresender en eneste byte, så afvisningen sker, mens målstrømmen stadig har sin gamle størrelse. Den rækkefølge er hele pointen: et tjek udført efter bufferen allerede er vokset, 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 lofter forsvandt ikke, de blev projektioner af det fælles budget. LZW-trinnet sætter nu Decoder.MaxOutputBytes := Budget.RemainingBytes, så dets private loft er, hvad kæden har tilbage, frem for en uafhængig tilladelse. Billedprediktor-trinnet åbner med BeginFilter og opkræver sit rækkekrav gennem Consume, før det allokerer, hvilket betyder, at prediktor-output afregnes mod samme budget som de generiske filtre, der fodrede det. Det betyder noget specielt på billedstien, hvor filterkæden og prediktoren er to halvdele af én operation, som dækket i udtrækning af billeder fra indlæste dokumenter gennem deres afkodningsfiltre
Hvad ser kalderen, når budgettet afviser?
Nederst i stakken rejser en afvisning EHPDFDecodeBudgetError. Over det afhænger svaret af den kontrakt, den kaldende API allerede havde. High-level læsemetoder, der rapporterede fejl gennem False eller nil, bliver ved med præcis det, fordi at gøre et dokumenteret boolsk resultat om til en undtagelse ville ødelægge kaldere, der allerede håndterede misdannet input korrekt. Den indlæste sideindholdssti er den bevidste undtagelse: den rejser EHPDFDecodeBudgetError igen frem for at lade en afkortet indholdsstrøm gengive som en side, der blot kom ud tom. Det design betyder, at et rent False er tvetydigt i sig selv, så budgettet udgiver en diagnose-record ved siden af: THotPDF.GetLastDecodeBudgetInfo returnerer tilstanden for den seneste kæde, instansen afkodede
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 felter sammen, og de adskiller de to angrebsformer. Når PeakStageBytes er tæt på DecodedBytes, gjorde ét trin al skaden, og man kigger på et enkelt højforholds-filter. Når PeakStageBytes er en lille brøkdel af DecodedBytes, og FilterCount er højt, var intet enkelt trin uhyrligt, og kæden akkumulerede sig forbi loftet, hvilket er præcis det tilfælde, en grænse pr. filter ikke kan se. Ét forbehold værd at skrive ind i din handler: GetLastDecodeBudgetInfo returnerer False, indtil instansen har afkodet mindst ét filter, så et False derfra er ikke bevis for, at dokumentet var rent
Hvor budgettet nulstilles, og hvornår nul er det ærlige svar
DecodeBudgetBytes afgrænser én strømkæde, ikke ét dokument, og den grænse er bevidst, men let at læse forkert. Hver indholdsstrøm, hver indlejret fil, hver cross-reference-strøm og hver objektstrøm starter med en frisk 256 MiB. Et dokument på 4.000 sider har derfor 4.000 uafhængige chancer for at bruge hele loftet, og objektstrømme multiplicerer antallet yderligere, fordi hver af dem selv er en komprimeret container med mange objekter, som beskrevet i noterne om objektstrømme og inkrementelle opdateringer. Er ens reelle krav en grænse for den samlede procesomhukommelse, er denne egenskab ét input til det, ikke det hele, og den bør sidde bag et job-niveau- eller container-niveau-loft
Nul betyder ubegrænset, og det er en legitim indstilling frem for en bagdør. Sæt den, når man ejer input: en genforarbejdningspipeline for arkiver over dokumenter, ens eget system producerede, eller et rasteriseringstrin, hvor en enkelt 600 dpi-farvescanning-kæde genuint behøver mere end noget loft, man ville føle sig tryg ved at hardkode. Negative værdier afvises straks med ERangeError, fordi et negativt budget ikke har nogen sammenhængende mening, og stiltiende klemme det ville skjule en konfigurationsfejl
// 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;
At vælge tallet fortjener mere omhu, end det som regel får, fordi et budget sat for lavt er en selvforskyldt driftsforstyrrelse. Kør din eksisterende korpus med standarden, registrér PeakStageBytes og DecodedBytes for hver kæde, og sæt loftet over det observerede maksimum med reel margin. Et rundt tal valgt, fordi det lød sikkert, vil afvise en legitim stor scanning på det værst tænkelige tidspunkt, og fejlen vil se nøjagtig ud som et angreb i dine logs
Kopien, der ikke længere sker
At dirigere hvert trin gennem en budget-wrapper viste sig at gøre kæden billigere frem for dyrere. Når en strøm har filtre, læser det første trin nu kildestrømmen direkte i stedet for at kopiere de kodede bytes ind i en scratch-buffer først, og derfra er kun to buffere i live på samme tid: det aktuelle input og det udskrevne trin-output. Den rå kopi overlever i de to tilfælde, der behøver den, nemlig en strøm uden filtre overhovedet, og et billede, hvor kalderen ønsker den sidste kodning bevaret, fordi begge afleverer en strøm, kalderen ejer og kan søge uafhængigt i. Den ubevogtede version af denne kode allokerede mere og afgrænsede mindre, hvilket er det sædvanlige forhold mellem de to. Værd at sige ligeud, dog: intet af dette gør en vilkårlig PDF sikker at indlæse. Det lukker én specifik og meget billig denial-of-service-vektor, den hvor en lille fil køber en stor allokering gennem indlejrede filtre. Heltalsoverløb i byte-regnskabet er bevogtet separat, og det bredere spørgsmål om at parse fjendtlige dokumenter uden at stole på deres interne offsets er en anden disciplin. Et afkodningsbudget er én grænse blandt flere, og dets værdi er, at det er den, man kan sætte fra én enkelt egenskab, før man rører filen
Budgettet pr. kæde, dets diagnose-record og de indlæste dokumentstier, det beskytter, leveres alle som en del af selve komponenten, uden nogen ekstern dekomprimeringsafhængighed at konfigurere eller patche. Evaluerer man, hvordan man afgrænser upålideligt PDF-input inde i en Delphi- eller C++Builder-service, opfører siden for HotPDF Delphi PDF-komponent det indlæste dokument-værktøjssæt, disse grænser gælder for