20 KB PDF, kuris prikausto paslaugos procesą, kol OOM žudikas jo neišjungia, nėra jūsų kodo klaida — tai dekompresijos bomba. HotPDF, natyvus VCL PDF komponentas Delphi ir C++Builder platformoms, ją riboja su DecodeBudgetBytes, vienos filtrų grandinės riba, pagal nutylėjimą 268435456 baitų, kuri apmokestina kiekvieną dekodavimo etapą prieš vieną bendrą biudžetą
20 KB failas, sunaikinęs darbo procesą
Incidento forma visada ta pati. Eilės darbuotojas, generuojantis miniatiūras, paima įkėlimą, rezidentinė atmintis per mažiau nei dvi sekundes peršoka 12 GB, ir procesas dingsta be jokio steko pėdsako. Failas — 20 KB. Jame vienas puslapis, vienas turinio srautas ir /Filter masyvas su penkiais įrašais. Kiekvienas pavadinimas tame masyve yra filtras, kurį apibrėžia specifikacija, kiekvienas etapas dekoduojasi be klaidos, ir niekas faile nėra netaisyklingas. Kaip tik tai daro šią įvesties klasę nepatogia: nėra sugadinto baito, kurį būtų galima atmesti
Tai — ne ta pati problema kaip vieno filtro teisingas dekodavimas. Teisingai gauti LZWDecode ir /DecodeParms nuspėjimo tvarką — atskira tema, aprašyta straipsnyje apie LZW, nuspėjiklius ir DecodeParms įkeltuose dokumentuose. Čia kiekvienas dekoderis jau teisingas. Gedimas — tai, ką daro teisingi dekoderiai, kai paleidžiate penkis iš eilės, ir niekas neskaičiuoja sumos. ISO 32000-1 §7.4 aiškiai sako, kad /Filter gali būti vienas pavadinimas arba pavadinimų masyvas, ir kad masyvas taikomas nuosekliai, pirmas įrašas pirmas. Jame nieko nesakoma apie tai, kiek etapas gali išplėsti savo įvestį, ir nieko apie sumą per visą grandinę. ASCIIHexDecode etapas maždaug perpus sumažina savo įvestį, kas skamba nekenksmingai. FlateDecode etapas per eilę nulinių baitų pasiekia tūkstantines proporcijas. Sujunkite juos, ir aritmetika — daugybinė: 20 KB tampa 20 MB, tampa 20 GB, o kiekvienas atskiras žingsnis — atitinkantis teisėto srauto dekodavimas
Kodėl vieno filtro riba nesustabdo dekodavimo bombos?
Todėl, kad viena filtro riba iš naujo įjungiama kiekviename /Filter masyvo elemente. Penkių etapų grandinė su 256 MiB vieno etapo riba leidžia 1.25 GiB, o paskutinis etapas vis tiek pradeda su visiškai švieža leidžiama norma, nepriklausomai nuo to, ką sukūrė keturi ankstesni. Riba vykdoma sąžiningai ir neapriboja nieko, kas svarbu. HotPDF turėjo tiksliai tokią formą iki v2.447.0, ir šalia jos buvo antra spraga. LZW dekompresorius nešė MaxOutputBytes ribą, o vaizdo nuspėjiklio kelias skaičiavo savo eilutes, todėl šie du buvo apriboti vietoje. FlateDecode, ASCIIHexDecode, ASCII85Decode ir RunLengthDecode visai neturėjo ribos: kiekvienas rašė į TMemoryStream, kol baigėsi įvestis arba paskirstytuvas pasidavė. Taigi priešiška grandinė turėjo du kelius. Ji galėjo naudoti visiškai neapsaugotą filtrą arba naudoti apsaugotus filtrus ir tiesiog pridėti daugiau jų
Yra trečia detalė, kurios paprasta pataisa nepastebi. Skaičius, kuris jums svarbus, nėra galutinės dekoduotos išvesties dydis. Tai — piko taškas, ir piko taškas paprastai gyvena tarpiniame buferyje. Grandinė, kuri baigiasi kukliu 4 MB turinio srautu, gali trečiame etape paskirstyti 8 GB ir grąžinti kažką, kas atrodo visiškai pagrįstai. Rezultato ilgio tikrinimas po fakto nepasako jums nieko apie paskirstymą, kuris nužudė procesą
Vienas biudžeto sekiklis vienai filtrų grandinei
Pataisymas HotPDF v2.447.0 — priversti apskaitą apimti visą grandinę, ne vieną etapą. Kiekviena filtrų grandinė sukonstruoja vieną THPDFDecodeBudgetTracker, ir kiekvienas dekoderis rašo per THPDFBudgetWriteStream, apgaubiantį tikrąjį tikslą. Įvyniotojas iškviečia Budget.Consume(Count), prieš persiųsdamas nors vieną baitą, todėl atsisakymas įvyksta, kol tikslo srautas dar yra senojo dydžio. Ši tvarka — visas esmė: patikra, atliekama po to, kai buferis jau išaugo, yra diagnostika, ne gynyba
// 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;
Vietinės ribos niekur nedingo, jos tapo bendro biudžeto projekcijomis. LZW etapas dabar nustato Decoder.MaxOutputBytes := Budget.RemainingBytes, todėl jo privati riba yra tai, kas grandinei liko, o ne nepriklausoma leidžiama norma. Vaizdo nuspėjiklio etapas prasideda su BeginFilter ir apmokestina savo eilutės reikalavimą per Consume prieš paskirstant, o tai reiškia, kad nuspėjiklio išvestis apmokestinama tam pačiam biudžetui kaip ir bendrieji filtrai, kurie ją maitino. Tai ypač svarbu vaizdo kelyje, kur filtrų grandinė ir nuspėjiklis yra dvi vienos operacijos pusės, kaip aprašyta straipsnyje apie vaizdų ištraukimą iš įkeltų dokumentų per jų dekodavimo filtrus
Ką mato iškviečiantysis, kai biudžetas atsisako?
Pačiame steko apačioje atsisakymas iškelia EHPDFDecodeBudgetError. Virš to, atsakymas priklauso nuo kontrakto, kurį iškviečiama API jau turėjo. Aukšto lygio skaitymo metodai, kurie klaidą pranešdavo per False arba nil, taip ir toliau daro, nes dokumentuoto loginio rezultato pavertimas išimtimi sulaužytų iškviesiančiuosius, kurie jau teisingai tvarkė netaisyklingą įvestį. Įkelto puslapio turinio kelias — sąmoninga išimtis: jis iš naujo iškelia EHPDFDecodeBudgetError, vietoj to, kad leistų nupjautam turinio srautui atsivaizduoti kaip puslapiui, kuris tiesiog išėjo tuščias. Šis dizainas reiškia, kad grynas False pats savaime nevienareikšmis, todėl biudžetas šalia jo skelbia diagnostikos įrašą: THotPDF.GetLastDecodeBudgetInfo grąžina paskutinės grandinės, kurią instancija dekodavo, būseną
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;
Skaitykite šiuos laukus kartu, ir jie atskirs du atakos tipus. Kai PeakStageBytes artimas DecodedBytes, vienas etapas padarė visą žalą, ir jūs žiūrite į vieną didelio santykio filtrą. Kai PeakStageBytes yra maža DecodedBytes dalis, o FilterCount didelis, joks atskiras etapas nebuvo pasiutęs, o grandinė sukaupė kelią virš ribos, tai — kaip tik atvejis, kurio viena filtro riba negali matyti. Vienas įspėjimas, vertas įrašyti į jūsų apdorotuvą: GetLastDecodeBudgetInfo grąžina False, kol instancija nedekodavo bent vieno filtro, todėl False iš jos nėra įrodymas, kad dokumentas buvo švarus
Kur biudžetas atsistato, ir kada nulis yra sąžiningas atsakymas
DecodeBudgetBytes riboja vieną srauto grandinę, ne vieną dokumentą, ir ši riba — sąmoninga, bet lengvai neteisingai suprantama. Kiekvienas turinio srautas, kiekvienas įterptas failas, kiekvienas kryžminių nuorodų srautas ir kiekvienas objektų srautas pradeda su šviežiu 256 MiB. 4000 puslapių dokumentas todėl turi 4000 nepriklausomų galimybių išleisti visą ribą, o objektų srautai dar padidina šį skaičių, nes kiekvienas iš jų pats yra suglaudintas konteineris, laikantis daug objektų, kaip aprašyta straipsnyje apie objektų srautus ir priaugančius atnaujinimus. Jei jūsų tikras reikalavimas yra viso proceso atminties riba, ši savybė — vienas įvestis tam, ne visa jo dalis, ir ji turėtų slypėti už darbo lygio ar konteinerio lygio ribos
Nulis reiškia neribotą, ir tai — teisėtas nustatymas, ne išeitis. Nustatykite jį, kai valdote įvestį: archyvo perapdorojimo srautas per dokumentus, kuriuos sukūrė jūsų pačių sistema, arba rastrizavimo žingsnis, kur vieno 600 dpi spalvoto nuskaitymo grandinei iš tikrųjų reikia daugiau, nei bet kokia riba, kurią jaustumėtės patogiai griežtai užkoduoti. Neigiamos reikšmės iškart atmetamos su ERangeError, nes neigiamas biudžetas neturi jokios darnios prasmės, o tylus jo apkirpimas paslėptų konfigūracijos klaidą
// 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;
Skaičiaus pasirinkimas nusipelno daugiau atidumo, nei paprastai gauna, nes per žemai nustatytas biudžetas — savęs sukeltas prastovos šaltinis. Paleiskite savo esamą dokumentų rinkinį su numatytąja reikšme, užregistruokite PeakStageBytes ir DecodedBytes kiekvienai grandinei, ir nustatykite ribą virš stebimo maksimumo su realiu atsargu. Apvalus skaičius, pasirinktas todėl, kad skambėjo saugiai, atmes teisėtą didelį nuskaitymą pačiu blogiausiu metu, o gedimas jūsų žurnaluose atrodys lygiai kaip ataka
Kopija, kuri nebevyksta
Kiekvieno etapo nukreipimas per biudžeto įvyniotoją galiausiai padarė grandinę pigesnę, o ne brangesnę. Kai srautas turi filtrus, pirmasis etapas dabar skaito šaltinio srautą tiesiogiai, vietoj kopijavimo koduotų baitų į juodraštinį buferį pirmiausia, ir nuo tada gyvi tik du buferiai vienu metu: dabartinė įvestis ir rašomas etapo rezultatas. Žalia kopija išlieka dviem atvejais, kuriems jos reikia — sraute be jokių filtrų ir vaizde, kur iškviečiantysis nori išlaikyti paskutinį kodavimą, nes abu grąžina srautą, kurį iškviečiantysis nuosavai valdo ir gali savarankiškai pozicionuoti. Neapsaugota šio kodo versija paskirstydavo daugiau ir ribojo mažiau, o tai — įprastas santykis tarp šių dviejų. Verta aiškiai pasakyti: nė vienas iš šių dalykų nepadaro savavališko PDF saugaus įkelti. Tai uždaro vieną konkretų ir labai pigų paslaugos atsisakymo vektorių — tą, kuriame mažas failas nusiperka didelį paskirstymą per įdėtinius filtrus. Sveikaskaitinis perpildymas baitų apskaitoje saugomas atskirai, o platesnis klausimas apie priešiškų dokumentų analizę, nepasitikint jų vidiniais poslinkiais, — kita disciplina. Dekodavimo biudžetas — viena riba tarp kelių, ir jos vertė ta, kad tai — viena, kurią galite nustatyti iš vienos savybės, prieš paliesdami failą
Vienos grandinės biudžetas, jo diagnostikos įrašas ir įkelto dokumento dekodavimo keliai, kuriuos jis apsaugo, dalyvauja pačiame komponente, be jokios išorinės dekompresijos priklausomybės, kurią reikėtų konfigūruoti ar taisyti. Jei vertinate, kaip apriboti nepatikimą PDF įvestį Delphi ar C++Builder paslaugoje, puslapis HotPDF Delphi PDF komponentas pateikia įkelto dokumento įrankių rinkinio sąrašą, kuriam taikomos šios ribos