Műszaki cikk

PDF dekódolási bombák Delphiben: HotPDF szűrőlánc-keretek

Egy 20 KB-os PDF, amely leszorít egy szolgáltatásfolyamatot addig, amíg az OOM killer le nem kapja, nem a kódunk hibája, hanem egy tömörítés-visszafejtési bomba. A HotPDF, a Delphi és C++Builder natív VCL PDF-komponense, ezt a DecodeBudgetBytes segítségével korlátozza, ami egy szűrőláncra vonatkozó felső korlát, alapértelmezés szerint 268435456 bájt, és minden dekódolási szakaszt egyetlen, közösen megosztott keretre terhel

A 20 KB-os fájl, amely felfalt egy worker processzt

Az incidens alakja mindig ugyanaz. Egy miniatűröket renderelő sorworker felvesz egy feltöltést, a rezidens memória két másodpercen belül 12 GB fölé emelkedik, a folyamat pedig eltűnik veremkiíratás nélkül. A fájl 20 KB. Egy oldala van, egy tartalomfolyama, és egy öt bejegyzésből álló /Filter tömbje. A tömbben minden név egy olyan szűrő, amelyet a specifikáció definiál, minden szakasz hiba nélkül dekódol, és a fájlban semmi nem hibás. Ez teszi ezt a bemenetosztályt kényelmetlenné: nincs sérült bájt, amelyet elutasíthatnánk

Ez nem ugyanaz a probléma, mint egyetlen szűrő helyes dekódolása. Az LZWDecode és a /DecodeParms predikátor helyes kezelése önálló téma, amelyet a LZW-ről, predikátorokról és a DecodeParms-ról betöltött dokumentumokon szóló bemutató tárgyal. Itt minden dekóder már helyes. A hiba az, amit a helyes dekóderek tesznek, amikor ötöt futtatunk belőlük egymás után, és senki nem számolja az összeget. Az ISO 32000-1 §7.4 kifejezetten kimondja, hogy a /Filter lehet egyetlen név vagy nevek tömbje, és hogy egy tömb sorrendben kerül alkalmazásra, elsőként az első bejegyzés. Semmit nem mond arról, mennyire bővítheti egy szakasz a bemenetét, és semmit az egész láncra vonatkozó összegről. Egy ASCIIHexDecode szakasz nagyjából felezi a bemenetét, ami ártalmatlannak hangzik. Egy FlateDecode szakasz nulla bájtok sorozatán ezres nagyságrendű arányokat ér el. Ha összeláncoljuk ezeket, az aritmetika szorzatos: 20 KB-ból 20 MB, abból 20 GB lesz, és minden egyes lépés egy legális folyam szabványos dekódolása

Miért nem állít meg egy dekódolási bombát a szűrőnkénti korlát?

Mert egy szűrőnkénti korlát a /Filter tömb minden elemén újra fel van fegyverezve. Egy öt szakaszból álló lánc egy 256 MiB-es szakaszonkénti felső korlát mellett 1,25 GiB-ot engedélyez, és az utolsó szakasz még mindig teljesen friss kerettel indul, függetlenül attól, mit termelt az előtte lévő négy. A korlát becsületesen kerül érvényesítésre, és semmi olyat nem korlátoz, ami számít. A HotPDF pontosan ilyen alakú volt a v2.447.0 előtt, és mellette egy második rés is volt. Az LZW-kitömörítő rendelkezett egy MaxOutputBytes felső korláttal, a kép-predikátor útvonal pedig elszámolta a saját sorait, így ez a kettő lokálisan korlátozott volt. A FlateDecode, az ASCIIHexDecode, az ASCII85Decode és a RunLengthDecode egyáltalán nem rendelkezett korláttal: mindegyik egy TMemoryStream-be írt, amíg el nem fogyott a bemenet, vagy fel nem adta az allokátor. Így egy ellenséges lánc két úton is átjuthatott. Használhatott egy teljesen őrizetlen szűrőt, vagy használhatott őrzötteket, és egyszerűen többet adhatott hozzájuk

Van egy harmadik részlet, amit egy naiv javítás elhibáz. A szám, amely valóban számít, nem a végleges dekódolt kimenet mérete. A csúcs az, és a csúcs általában egy köztes pufferben él. Egy lánc, amely egy szerény 4 MB-os tartalomfolyammal végződik, allokálhat 8 GB-ot a harmadik szakaszban, és visszaadhat valamit, ami teljesen ésszerűnek tűnik. Az eredmény hosszának utólagos ellenőrzése semmit nem árul el arról az allokációról, amely megölte a folyamatot

Egy keretkövető szűrőlánconként

A HotPDF v2.447.0-ban a javítás az, hogy az elszámolás a láncot fedje le, ne a szakaszt. Minden szűrőlánc egyetlen THPDFDecodeBudgetTracker-t épít fel, és minden dekóder egy THPDFBudgetWriteStream-en keresztül ír, amely a valódi célt burkolja be. A burkoló meghívja a Budget.Consume(Count)-ot, mielőtt egyetlen bájtot is továbbítana, így az elutasítás akkor történik, amikor a célfolyam még a régi mérete. Ez a sorrend a lényeg: egy ellenőrzés, amelyet azután végzünk el, hogy a puffer már megnőtt, diagnosztika, nem védelem

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

A lokális felső korlátok nem tűntek el, a megosztott keret vetületeivé váltak. Az LZW szakasz most a Decoder.MaxOutputBytes := Budget.RemainingBytes-t állítja be, így a saját felső korlátja az, ami a láncból megmaradt, nem egy független allokáció. A kép-predikátor szakasz a BeginFilter-rel nyit, és a sorkövetelményét a Consume-on keresztül terheli, mielőtt allokálna, ami azt jelenti, hogy a predikátor kimenete ugyanabba a keretbe kerül elszámolásra, mint az azt tápláló általános szűrők. Ez különösen a képútvonalon számít, ahol a szűrőlánc és a predikátor egyetlen művelet két fele, ahogy azt a betöltött dokumentumokból a dekódolási szűrőiken keresztül képek kinyeréséről szóló cikk tárgyalja

Mit lát a hívó, amikor a keret elutasít?

A verem alján egy elutasítás EHPDFDecodeBudgetError-t dob. Efölött a válasz attól függ, milyen szerződéssel rendelkezett már a hívott API. Azok a magas szintű olvasási metódusok, amelyek a hibát False vagy nil értékkel jelentették, továbbra is pontosan ezt teszik, mert egy dokumentált boolean eredmény kivétellé alakítása olyan hívókat törne el, amelyek már helyesen kezelték a hibás bemenetet. A betöltött oldal tartalomútvonala szándékos kivétel: újradobja az EHPDFDecodeBudgetError-t ahelyett, hogy hagyná egy csonkolt tartalomfolyamot úgy renderelni, mintha egyszerűen üresen jött volna ki. Ez a tervezés azt jelenti, hogy egy önmagában álló False kétértelmű, ezért a keret egy diagnosztikai rekordot is közzétesz mellette: a THotPDF.GetLastDecodeBudgetInfo visszaadja a legutóbbi lánc állapotát, amelyet a példány dekódolt

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;

Ha ezeket a mezőket együtt olvassuk, szétválasztják a két támadási alakot. Amikor a PeakStageBytes közel van a DecodedBytes-hoz, egyetlen szakasz okozta az összes kárt, és egy magas arányú szűrővel állunk szemben. Amikor a PeakStageBytes a DecodedBytes kis törtrésze, a FilterCount pedig magas, egyetlen szakasz sem volt túlzó, és a lánc halmozottan haladta meg a felső korlátot, ez pedig pontosan az az eset, amelyet egy szűrőnkénti korlát nem lát. Egy figyelmeztetés, amelyet érdemes beírni a hibakezelőnkbe: a GetLastDecodeBudgetInfo False-szal tér vissza, amíg a példány legalább egy szűrőt nem dekódolt, így egy tőle kapott False nem bizonyíték arra, hogy a dokumentum tiszta volt

Hol áll vissza a keret, és mikor a nulla a becsületes válasz

A DecodeBudgetBytes egy folyamláncot korlátoz, nem egy dokumentumot, és ez a határ szándékos, de könnyen félreértelmezhető. Minden tartalomfolyam, minden beágyazott fájl, minden keresztreferencia-folyam és minden objektumfolyam friss 256 MiB-bal indul. Egy 4000 oldalas dokumentumnak ezért 4000 független esélye van a teljes felső korlát elköltésére, az objektumfolyamok pedig tovább szorozzák ezt a számot, mivel mindegyik önmagában is egy tömörített konténer, amely sok objektumot tartalmaz, ahogy azt az objektumfolyamokról és inkrementális frissítésekről szóló jegyzetek leírják. Ha a valós követelményünk a teljes folyamatmemória korlátozása, akkor ez a tulajdonság csak egy bemenet ehhez, nem az egész, és egy feladatszintű vagy konténerszintű felső korlát mögé kell helyezni

A nulla korlátlant jelent, és ez legitim beállítás, nem kiskapu. Akkor állítsuk be, ha mi magunk vagyunk a bemenet tulajdonosai: egy archívum-újrafeldolgozó pipeline a saját rendszerünk által előállított dokumentumokon, vagy egy rasterizációs lépés, ahol egy egyetlen 600 dpi-s színes szkennelt lánc valóban többre van szüksége, mint bármely felső korlát, amellyel jól éreznénk magunkat, ha kőbe vésnénk. A negatív értékek előre elutasításra kerülnek ERangeError-ral, mert egy negatív keretnek nincs koherens jelentése, és annak csendes korlátozása elrejtene egy konfigurációs hibát

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

A szám kiválasztása több gondosságot érdemel, mint amennyit általában kap, mert egy túl alacsonyra állított keret egy önokozta üzemzavar. Futtassuk a meglévő korpuszunkat az alapértelmezéssel, rögzítsük a PeakStageBytes-t és a DecodedBytes-t minden láncra, és állítsuk a felső korlátot a megfigyelt maximum fölé, valódi tartalékkal. Egy kerek szám, amelyet azért választottunk, mert biztonságosnak hangzott, a lehető legrosszabb pillanatban fog elutasítani egy legitim nagy szkennelt anyagot, és a hiba pontosan úgy fog kinézni a naplóinkban, mint egy támadás

A másolás, amely már nem történik meg

Minden szakasz keretburkolón keresztüli átvezetése végül olcsóbbá tette a láncot, nem drágábbá. Amikor egy folyamnak vannak szűrői, az első szakasz most közvetlenül a forrásfolyamot olvassa, ahelyett hogy előbb egy ideiglenes pufferbe másolná a kódolt bájtokat, és innentől egyszerre csak két puffer él: a jelenlegi bemenet és az éppen írt szakaszkimenet. A nyers másolás megmarad abban a két esetben, ahol szükség van rá, nevezetesen egy szűrő nélküli folyamnál és egy olyan képnél, ahol a hívó az utolsó kódolást szeretné megőrizni, mert mindkettő olyan folyamot ad vissza, amelynek a hívó a tulajdonosa, és amelyben függetlenül tud pozicionálni. E kód őrizetlen változata többet allokált és kevesebbet korlátozott, ami a kettő közötti szokásos viszony. Érdemes egyértelműen kimondani viszont: ettől semmi nem lesz biztonságos egy tetszőleges PDF betöltésére. Ez egy konkrét és nagyon olcsó szolgáltatásmegtagadási vektort zár be, azt, ahol egy kis fájl beágyazott szűrőkön keresztül nagy allokációt vásárol. Az egész szám túlcsordulása az elszámolásban külön van őrizve, a szélesebb kérdés pedig, hogy hogyan elemezzünk ellenséges dokumentumokat anélkül, hogy megbíznánk a belső eltolásaikban, egy másik diszciplína. Egy dekódolási keret egy korlát a több közül, és az értéke abban rejlik, hogy ez az, amelyet egyetlen tulajdonságból beállíthatunk, mielőtt hozzányúlnánk a fájlhoz

A láncszintű keret, a diagnosztikai rekordja, és a betöltött dokumentum dekódolási útvonalai, amelyeket véd, mind magának a komponensnek a részeként érkeznek, külső, konfigurálandó vagy javítandó kitömörítési függőség nélkül. Ha azt mérlegeljük, hogyan korlátozzuk a nem megbízható PDF-bemenetet egy Delphi vagy C++Builder szolgáltatáson belül, a HotPDF Delphi PDF-komponens oldala felsorolja a betöltött dokumentum eszközkészletét, amelyre ezek a korlátok vonatkoznak