Technical Article

Pascal PDF elemző megerősítése rosszindulatú fájlok ellen

A PDF nem csupán egy dokumentum, amit megnyit. Ez egy kis program, amit futtat. Minden beágyazott betűtípus egy verem-alapú értelmező (interpreter), amely a karakterláncokra (charstrings) vár, minden kép egy dekóder, amelyet a fájl által választott szélességi, magassági és bitmélység mezők táplálnak, és minden folyam olyan szűrőkbe csomagolva érkezik, amelyek paramétereit a fájl határozta meg. Ezen számok egyike sem az Öné. Attól származnak, aki a fájlt előállította, ami a valóságban egy ügyféltől kapott számla vagy egy ismeretlen feladótól származó melléklet. Azok a dekódolók, amelyek ezeket a bájtokat képpontokká és glifákká alakítják, alkotják a támadási felületet, és a bemenetében bízó elemző egyetlen hibásan formázott fájlra van az összeomlástól vagy valami még rosszabbtól.

A PDFlibPas átesett egy megerősítési folyamaton, amely a teljes dekódolási útvonalat ellenségesként kezelte, beleértve a betűtípus-programokat (TrueType, Type1, CFF és CMap táblák), a képdekódolókat (PNG, GIF, TIFF, JBIG2, valamint CCITT Group 3 és Group 4) és az adatfolyam-szűrőket (LZW, ASCII85 és Flate prediktorok). A következőkben öt olyan hibakategóriát mutatunk be, amelyeket bezártunk, mindegyiket a konkrét megvalósítást lehetővé tevő Delphi viselkedésre alapozva. Ezek a jelenlegi kiadásokban javítva vannak, és ugyanezek a formák visszaköszönnek minden olyan Pascal kódban, amely nem megbízható bemenetet elemez.

Egy egész-túlcsordulás, amely alulméretezett puffert ad át Önnek

A klasszikus memóriabiztonsági hiba egy képdekóderben a dimenziók szorzata, amely túlcsordul. A dekóder leolvassa a szélességet, magasságot, a komponensek számát és a bitmélységet, megszorozza őket a kimenet méretezéséhez, lefoglalja ezt a bájtszámot, majd a valódi méreteiben írja ki a képet. Ha a szorzás 32 bites aritmetikával történik, a szorzat kis értékre csordulhat túl még akkor is, ha minden egyes tényező az ésszerű tartományon belül van, így a foglalás sikeres, de túl kicsi lesz, a dekódolás pedig kisétál a végéről. Ez a CWE-190 (egész-túlcsordulás), amely egy lépéssel később a heap határontúli írásához (CWE-787) vezet.

A közös képútvonal már korlátozta a dimenziókat 65535-re; az önálló dekódolók nem mindegyike örökölte ezt a korlátozást. Egy sor-bájt-szorozva-magassággal kifejezés, mint a ByteCount * FHeight, vagy a képpontonkénti kifejezés, mint a FWidth * Components * BitDepth, 32 bites szorzat a Delphiben, ha mindkét operandus 32 bites egész, függetlenül attól, hogy milyen széles az a változó, amelyhez az eredményt hozzárendeli. A 60000-es szélesség és magasság egyaránt reális egy nagy szkennelésnél, de a bájtben kifejezett szorzatuk túlmutat az előjeles 32 bites tartományon, és a hossz kicsi lesz. Ugyanez a csapda élt a ZLib prediktor lépésközében (stride) is: BitsPerComponent * Colors * Columns.

A javítás az, hogy legalább az egyik operandust Int64-re változtatjuk, hogy a teljes kifejezés 64 biten értékelődjön ki, majd összehasonlítjuk a MaxInt-tel, és elutasítjuk a fájlt a SetLength meghívásához szükséges visszaszűkítés előtt.

// Reject before allocating, not after writing.
// Evaluate the product in Int64 so it cannot wrap at 32 bits.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
  Exit;  // hostile or unsupportable dimensions; refuse the image
SetLength(Buffer, RowBytes * FHeight);

Ami ezt Delphi-problémává teszi a generikus helyett, az a csendes szűkítés. A túl széles kifejezés hozzárendelése egy 32 bites célhoz olyan legális konverzió, amelyre a fordító alapértelmezés szerint nem figyelmeztet, és a tartományellenőrzés sem csípi el azt a túlcsordulást, amely azelőtt történik meg, hogy az értéket indexként használnánk. Hagyja a szorzatot 32 biten, és a nyelv csendben olyan hosszat ad Önnek, amely hazudik arról, hogy mennyi memóriához fog hozzáérni a dekódolás.

Mezőtípus, amely lehetetlenné teszi a védelem működését

A TIFF fájl képfájl-könyvtárak (image file directories) láncolata, amelyek mindegyike a következő bájteltolását hordozza. Egy rosszindulatú fájl visszamutathat a lánc önmagára, és az az olvasó, amely leállási feltétel nélkül járja be, örökké futni fog. Ez a CWE-835 (támadó által vezérelt végtelen ciklus), a védelem pedig egy számláló, amely leáll, ha átlép egy olyan korlátot, amelyet egyetlen törvényes fájl sem érne el.

Az oldalszámláló Word-ként lett deklarálva, amely a Delphiben a 0 és 65535 közötti értékeket tartja meg. A ciklus tartalmazott egy lezárási védelmet "állj le, ha az oldalszám meghaladja a 65535-öt" formában, ami helyesnek tűnik mindaddig, amíg észre nem veszi, hogy az operandus és a küszöbérték közös felső határon osztozik. Egy Word soha nem lehet nagyobb 65535-nél, így az összehasonlítás strukturálisan mindig hamis: amikor a számláló eléri a 65535-öt, a következő növelés visszaforgatja 0-ra, a védelem soha nem látja a plafon feletti értéket, és a végtelenített IFD lánc folyamatosan pörgeti az olvasót.

A javítás a mező kiszélesítése volt, hogy a védelem ki tudjon fejezni olyan értéket, amelyet a számláló ténylegesen el tud érni. A Integer-ként deklarált TPDFTIFF.FPageCount esetében ugyanaz a FPageCount > 65535 összehasonlítás elérhetővé válik, a ciklus leáll, és a nyilvános PageCount tulajdonság típusát is módosítottuk, hogy egyezzen, a hívók megtörése nélkül. Amikor egy határvizsgálat Value > MaxValueOfType(Value) formájú, és az operandus típusa már pontosan ez a maximum, a feltétel konstans hamis: szélesítse ki a típust, vagy ellenőrizze az egyenlőséget a maximummal szemben, hogy kioldódhasson.

Kikapcsolt tartományellenőrzés egy forró útvonalon

Bekapcsolt tartományellenőrzéssel (range checking) a Delphi beilleszt egy határellenőrzést minden tömb- és karakterlánc-indexhez, ami jelenti a különbséget a tartományon kívüli index által kiváltott elkapható ERangeError és aközött, hogy ugyanaz az index olyan memóriát olvas vagy ír, amely nem tartozik a struktúrához. A forró útvonalak (hot paths) időnként letiltják ezt a helyi {$R-} direktívával, ami védhető mindaddig, amíg az indexek megbízhatóak maradnak.

A font-értelmezők által használt listaelérő, a TPDFlibStringList.Get pontosan ilyen útvonal. Windows alatt kikapcsolt tartományellenőrzéssel fordul le, és közvetlenül indexeli a mögöttes tárolót, így a tartományon kívüli index nem hiba, hanem nyers memória-elérés. Ez rendben van, ha az index is érvényes, de nem az a CFF vagy Type2 karakterlánc-értelmezőben, ahol az index a fájlból érkezhet. Egy karakterlánc, amely leemel egy operandust az üres veremből, mínusz egyes indexet eredményez; a glifa-azonosító, amely eggyel eltér a glifaszámtól, egy hellyel a vége után indexel. Kikapcsolt tartományellenőrzéssel mindkettő valódi határontúli eléréssé válik az elkapható kivétel helyett, és mivel a helyek referencia-számlált AnsiString értékeket tartalmaznak, a kóbor olvasás a karakterlánc referencia-számlálóját is megrongálhatja.

A megerősítés nem kapcsolta vissza a tartományellenőrzést a forró útvonalon. Ehelyett először bizonyíthatóan érvényessé tette az indexeket: mielőtt elvenné az operandusverem tetejét, az értelmező ellenőrzi, hogy a verem nem üres-e, és minden indexvédelmet szigorú "kisebb, mint" összehasonlítással írtak meg a darabszámmal szemben, ahelyett, hogy megengednék az eggyel elcsúszott "kisebb vagy egyenlő" összehasonlítást. A direktíva a határokért való felelősséget a fordítóról Önre hárítja, és az általa eltávolított ellenőrzést kézzel kell visszatenni minden egyes belépési ponton.

Végtelen rekurzió a karakterlánc-értelmezőben

A Type2 karakterlánc meghívhat egy alprogramot (subroutine), az alprogram pedig maga is egy karakterlánc, amely meghívhat egy másikat, így a helyi és globális alprogram-hívó operátorok lehetővé teszik a fájl számára, hogy eldöntse, milyen mélyre megy. Az önmagát közvetlenül vagy egy cikluson keresztül meghívó alprogram végtelenül rekurzívvá válik, amíg a natív verem le nem merül, és a folyamat meg nem hal. Ez a CWE-674 (nem ellenőrzött rekurzió).

A Type1 értelmező már védekezett ez ellen. Tartalmazott egy hívásmélység-számlálót és egy plafont (PLType1MaxCallDepth), és megtagadta a lejjebb lépést ezen túl, ami tükrözi azt a mélységkorlátot, amelyet maga a Type1 specifikáció megnevez. A később hozzáadott és szerkezetileg hasonló Type2 értelmező nem tartalmazta ugyanezt a védelmet, és egy kézzel épített betűtípus olyan alprogrammal, amely a saját számát hívja meg, egyenesen átgyalogolt a hiányzó ellenőrzésen a veremtúlcsordulásba (stack overflow).

// The shape of the Type1 guard the Type2 path was missing.
// Track depth across nested calls and refuse to recurse past it.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
  Exit;  // hostile self-referential subroutine; stop descending
// ... interpret the subroutine, then Dec(CallDepth) on the way out

A javítás az volt, hogy a Type2 útvonalnak ugyanazt a korlátozott mélységet adtuk, amellyel a Type1 testvére már rendelkezett. A támadó által irányított struktúra feletti minden rekurzív bejárásnak (legyen szó betűtípus-alprogramokról, egymásba ágyazott tömbökről vagy kereszthivatkozási láncról) szüksége van egy mélységi plafonra, amelyet a bemenet nem tud megemelni.

Inicializálatlan memória, amely kiszivárog a kimenetbe

A legszubtilisebb hiba heap tartalmakat szivárogtatott ki a visszafejtett kimenetbe, az ok pedig a SetLength egy könnyen elfelejthető tulajdonsága. Amikor a SetLength segítségével növeli az AnsiString méretét, a Delphi lefoglalja a bájtokat, de nem nullázza le őket, így az új tartomány mindent megtart, ami korábban az adott heap memóriában volt. Ha ezt követően minden bájt kiírásra kerül, ez soha nem számít; ha egy útvonal a puffer egy részét íratlanul hagyja, majd adatként adja vissza, ezek az elavult bájtok kikerülnek az eredménnyel együtt. Ez a CWE-457 (inicializálatlan memória használata), és amikor az eredmény átlép egy bizalmi határt, információszivárgássá válik.

Az AES-CBC visszafejtési útvonal pontosan ebbe ütközött bele. A kimeneti puffert a SetLength-tel méretezték, és a visszafejtő egyszerre egy 16 bájtos blokkot dolgozott fel a titkosított szövegből. Amikor a titkosított szöveg hossza nem volt a 16 többszöröse (ezt a hosszúságot a támadó megválaszthatja), a záró részleges blokk soha nem íródott ki, így a végső bájtok megőrizték a heap azon tartalmát, amelyet a SetLength hátrahagyott, és a puffert a dokumentumobjektum visszafejtett nyílt szövegeként adta vissza a rendszer. A megoldás két védelem, és egyik sem elegendő önmagában: a visszafejtési belépési pont most elutasít minden olyan titkosított szöveget, amelynek hossza nem a blokkméret többszöröse, háttérként pedig a kimenetet a használat előtt leoljuk a FillChar segítségével, így minden olyan útvonal, amely nem tud kiírni egy tartományt, nullákat ad vissza a heap maradéka helyett.

Amivel a folyamat végén marad

Az öt hiba különböző bug, de rímelnek egymásra. Túlcsorduló szorzatot adó egész-szélesség, a védelmet konstans hamisra állító mezőtípus, kikapcsolt tartományellenőrzés ott, ahol az indexek már nem biztonságosak, padló nélküli rekurzió és puffer, amelyet a nyelv nem volt hajlandó nullázni. Mindegyikben a Delphi pontosan azt tette, amit meghatároz, mert a nyelv túlcsorduló aritmetikát, csendes szűkítést, kikapcsolható tartományellenőrzést, beépített korlát nélküli rekurziót és inicializálatlan foglalást biztosít Önnek. Ez a szerződés, és a Pascal elemző azáltal tesz eleget neki, hogy kézzel kezel négy dolgot minden olyan határon, amelyet a fájl irányít: egész-szélesség, tartományellenőrzés, rekurziós mélység és puffer-inicializálás.

These defects are closed in current PDFlibPas releases, the engine for Delphi and C++Builder. If your work also reaches into how a file claims to be protected, the companion notes on auditing encryption and permissions and on PDF/A and PDF/UA preflight cover the analysis side of the same parser, and all of it ships inside the PDFlibPas Delphi PDF Library alongside the loading, rendering, and signing APIs covered elsewhere on this blog.