20 KB velik PDF, ki drži delovni proces, dokler ga ne pobere OOM killer, ni hrošč v vaši kodi, temveč dekompresijska bomba. HotPDF, izvorna VCL PDF komponenta za Delphi in C++Builder, tako bombo omeji z DecodeBudgetBytes, stropom na verigo filtrov, ki je privzeto 268435456 bajtov in zaračuna vsako stopnjo dekodiranja na en sam skupen proračun
20 KB datoteka, ki je pojedla delovni proces
Oblika incidenta je vedno enaka. Delovni proces v čakalni vrsti, ki izrisuje sličice, pobere nalaganje, rezidenčni pomnilnik zraste čez 12 GB v manj kot dveh sekundah, proces pa izgine brez sledi sklada. Datoteka je velika 20 KB. Ima eno stran, en vsebinski tok in polje /Filter s petimi vnosi. Vsako ime v tem polju je filter, ki ga specifikacija opredeljuje, vsaka stopnja se dekodira brez napake in nič v datoteki ni nepravilno oblikovano. Prav to ta razred vhoda naredi nerodnega: ni pokvarjenega bajta, ki bi ga bilo mogoče zavrniti
To ni isti problem kot pravilno dekodiranje enega filtra. Pravilna izvedba LZWDecode in prediktorja /DecodeParms je svoja tema, obravnavana v vodiču po LZW, prediktorjih in DecodeParms na naloženih dokumentih. Tukaj je vsak dekoder že pravilen. Napaka je v tem, kaj pravilni dekoderji naredijo, ko jih poženete pet zaporedoma in nihče ne šteje skupnega. ISO 32000-1 §7.4 je izrecen, da je /Filter lahko eno samo ime ali polje imen in da se polje uporabi zaporedoma, prvi vnos prvi. Nič ne pove o tem, koliko sme stopnja razširiti svoj vhod, in nič o skupni vrednosti čez celotno verigo. Stopnja ASCIIHexDecode svoj vhod približno prepolovi, kar zveni neškodljivo. Stopnja FlateDecode nad zaporedjem ničelnih bajtov doseže razmerja v tisočih. Veriženje naredi aritmetiko množilno: 20 KB postane 20 MB postane 20 GB, vsak posamezen korak pa je skladno dekodiranje zakonitega toka
Zakaj omejitev na posamezen filter ne ustavi dekodirne bombe?
Ker se omejitev na posamezen filter ponovno napolni pri vsakem elementu polja /Filter. Veriga petih stopenj pod stropom 256 MiB na stopnjo pooblasti 1,25 GiB, zadnja stopnja pa se še vedno začne s povsem svežim dovoljenjem ne glede na to, kaj so proizvedle štiri pred njo. Omejitev je uveljavljena pošteno in ne omejuje ničesar pomembnega. HotPDF je imel natanko to obliko pred v2.447.0, poleg pa je imel še eno vrzel. Dekompresor LZW je nosil strop MaxOutputBytes, pot prediktorja slike pa je obračunavala svoje lastne vrstice, zato sta bila ta dva lokalno omejena. FlateDecode, ASCIIHexDecode, ASCII85Decode in RunLengthDecode niso imeli nobene omejitve: vsak je pisal v TMemoryStream, dokler mu ni zmanjkalo vhoda ali dokler alokator ni odnehal. Sovražna veriga je tako imela dve poti skozi. Uporabila je lahko povsem nezaščiten filter ali pa zaščitene in jih preprosto dodala še več
Obstaja tretja podrobnost, ki jo naiven popravek zgreši. Številka, za katero vam je mar, ni velikost končnega dekodiranega izhoda. Gre za vrh, vrh pa se navadno nahaja v vmesnem medpomnilniku. Veriga, ki se konča s skromnim 4 MB vsebinskim tokom, lahko na tretji stopnji dodeli 8 GB in vrne nekaj, kar izgleda popolnoma razumno. Preverjanje dolžine rezultata za nazaj vam ne pove ničesar o alokaciji, ki je usmrtila proces
En sledilnik proračuna na verigo filtrov
Popravek v HotPDF v2.447.0 razteza obračunavanje čez celotno verigo namesto le stopnjo. Vsaka veriga filtrov zgradi en THPDFDecodeBudgetTracker, vsak dekoder pa piše skozi THPDFBudgetWriteStream, ki ovija dejanski cilj. Ovoj pokliče Budget.Consume(Count), preden posreduje en sam bajt, tako da se zavrnitev zgodi, medtem ko je ciljni tok še vedno svoje stare velikosti. Ta vrstni red je celotno bistvo: preverjanje, izvedeno po tem, ko je medpomnilnik že zrasel, je diagnostika, ne obramba
// 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;
Lokalni stropovi niso izginili, postali so projekcije skupnega proračuna. Stopnja LZW zdaj nastavi Decoder.MaxOutputBytes := Budget.RemainingBytes, tako da je njen zasebni strop tisto, kar je verigi še ostalo, ne pa neodvisno dovoljenje. Stopnja prediktorja slike se odpre z BeginFilter in svojo zahtevo po vrsticah zaračuna prek Consume pred dodeljevanjem, kar pomeni, da je izhod prediktorja obračunan na isti proračun kot generični filtri, ki so ga hranili. To je še posebej pomembno na poti slike, kjer sta veriga filtrov in prediktor dve polovici ene operacije, kot je opisano v ekstrahiranju slik iz naloženih dokumentov prek njihovih dekodirnih filtrov
Kaj vidi klicatelj, ko proračun zavrne?
Na dnu sklada zavrnitev sproži EHPDFDecodeBudgetError. Nad tem je odgovor odvisen od pogodbe, ki jo je klicna API že imela. API-ji za branje na visoki ravni, ki so odpoved sporočali prek False ali nil, to počnejo naprej natanko tako, ker bi sprememba dokumentiranega logičnega rezultata v izjemo pokvarila klicatelje, ki so že pravilno obravnavali nepravilno oblikovan vhod. Pot vsebine naložene strani je namerna izjema: znova sproži EHPDFDecodeBudgetError, namesto da bi dovolila, da se prekinjen vsebinski tok izriše kot stran, ki je le prišla prazna. Ta zasnova pomeni, da je golo False samo po sebi dvoumno, zato proračun poleg tega objavi diagnostičen zapis: THotPDF.GetLastDecodeBudgetInfo vrne stanje zadnje verige, ki jo je instanca dekodirala
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;
Preberite ta polja skupaj in ločita dve obliki napada. Kadar je PeakStageBytes blizu DecodedBytes, je vso škodo naredila ena stopnja in gledate en sam filter z visokim razmerjem. Kadar je PeakStageBytes majhen delež DecodedBytes in je FilterCount visok, nobena posamezna stopnja ni bila pretirana, veriga pa se je nakopičila mimo stropa, kar je natanko primer, ki ga omejitev na posamezen filter ne vidi. Ena opozorilo, ki ga velja zapisati v vaš obravnavalnik: GetLastDecodeBudgetInfo vrne False, dokler instanca ni dekodirala vsaj enega filtra, zato False iz njega ni dokaz, da je bil dokument čist
Kje se proračun ponastavi in kdaj je nič pošten odgovor
DecodeBudgetBytes omeji eno verigo tokov, ne enega dokumenta, ta meja pa je namerna, a jo je lahko napačno razumeti. Vsak vsebinski tok, vsaka vgrajena datoteka, vsak tok navzkrižnih referenc in vsak tok objektov se začne s svežih 256 MiB. Dokument s 4.000 stranmi ima tako 4.000 neodvisnih priložnosti, da porabi celoten strop, tokovi objektov pa število še naprej množijo, ker je vsak sam po sebi stisnjen vsebnik, ki drži veliko objektov, kot je opisano v opombah o tokovih objektov in postopnih posodobitvah. Če je vaša resnična zahteva omejitev na skupni pomnilnik procesa, je ta lastnost en vhod v to, ne celota, sedeti pa mora za omejitvijo na ravni opravila ali vsebnika
Nič pomeni neomejeno, in je legitimna nastavitev, ne izhod v sili. Nastavite jo, kadar ste lastnik vhoda: cevovod za ponovno obdelavo arhiva nad dokumenti, ki jih je ustvaril vaš lasten sistem, ali korak rasterizacije, kjer ena sama veriga skeniranja v barvi pri 600 dpi resnično potrebuje več, kot bi vam bilo udobno trdo kodirati. Negativne vrednosti so vnaprej zavrnjene z ERangeError, saj negativen proračun nima koherentnega pomena, tiho omejevanje pa bi skrilo napako v konfiguraciji
// 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;
Izbira številke si zasluži več skrbi, kot jo ponavadi dobi, saj je prenizko nastavljen proračun samopovzročen izpad. Poženite svoj obstoječi korpus s privzeto vrednostjo, zabeležite PeakStageBytes in DecodedBytes za vsako verigo in nastavite strop nad opaženim maksimumom z resnično rezervo. Okrogla številka, izbrana zato, ker je zvenela varno, bo zavrnila zakonit velik sken ob najslabšem možnem trenutku, napaka pa bo v vaših dnevnikih izgledala natanko kot napad
Kopiranje, ki se ne zgodi več
Usmerjanje vsake stopnje skozi ovoj proračuna se je izkazalo, da naredi verigo cenejšo namesto dražje. Kadar ima tok filtre, prva stopnja zdaj bere izvorni tok neposredno namesto da bi najprej kopirala kodirane bajte v pomožen medpomnilnik, od tam pa sta hkrati živa le dva medpomnilnika: trenutni vhod in izhod stopnje, ki se piše. Surovo kopiranje preživi v dveh primerih, ki ga potrebujeta, namreč tok brez filtrov in slika, kjer klicatelj želi ohraniti zadnje kodiranje, saj oba vrneta tok, ki ga klicatelj lasti in po katerem lahko neodvisno išče. Nezaščitena različica te kode je dodeljevala več in omejevala manj, kar je običajen odnos med obema. Vredno je jasno povedati: nič od tega ne naredi poljubnega PDF-ja varnega za nalaganje. Zapre en specifičen in zelo poceni vektor zavrnitve storitve, tistega, kjer majhna datoteka kupi veliko alokacijo prek gnezdenih filtrov. Prekoračitev celega števila v obračunu bajtov je zaščitena ločeno, širše vprašanje razčlenjevanja sovražnih dokumentov brez zaupanja njihovim notranjim odmikom pa je druga disciplina. Proračun dekodiranja je ena od več omejitev, njegova vrednost pa je v tem, da je edina, ki jo lahko nastavite iz ene same lastnosti, preden se dotaknete datoteke
Proračun na verigo, njegov diagnostični zapis in poti dekodiranja naloženih dokumentov, ki jih ščiti, so vsi del same komponente, brez zunanje odvisnosti od dekompresije, ki bi jo bilo treba konfigurirati ali popravljati. Če ocenjujete, kako omejiti nezaupanja vreden vhod PDF znotraj storitve v Delphiju ali C++Builderju, stran komponente HotPDF Delphi PDF navaja nabor orodij za naložene dokumente, za katere te omejitve veljajo