Technical Article

Zabezpečenie pascalovského PDF parsera proti škodlivým súborom

PDF nie je len dokument, ktorý otvoríte. Je to malý program, ktorý spúšťate. Každé vložené písmo je zásobníkový interpret čakajúci na charstrings, každý obrázok je dekodér napájaný poliami šírky, výšky a bitovej hĺbky, ktoré si vybral súbor, a každý stream prichádza obalený vo filtroch, ktorých parametre nastavil súbor. Žiadne z týchto čísel nie sú vaše. Pochádzajú od toho, kto súbor vytvoril, čo je pri reálnom zaťažení faktúra zákazníka alebo príloha od neznámeho odosielateľa. Dekodéry, ktoré menia tieto bajty na pixely a glyfy, sú útočnou plochou a parser, ktorý v tomto bode dôveruje svojmu vstupu, delí od pádu alebo niečoho horšieho jediný poškodený súbor.

Knižnica PDFlibPas prešla fázou zabezpečenia, ktorá považovala celú dekódovaciu cestu za nepriateľskú, a to naprieč programami písiem (TrueType, Type1, CFF a tabuľky CMap), dekodérmi obrázkov (PNG, GIF, TIFF, JBIG2 a CCITT Group 3 a Group 4) a filtrami streamov (LZW, ASCII85 a Flate prediktory). Nasleduje päť tried chýb, ktoré boli odstránené, pričom každá z nich je založená na špecifickom správaní Delphi, ktoré ju umožnilo. Sú opravené v aktuálnych vydaniach a rovnaké tvary sa opakujú v akomkoľvek kóde Pascalu, ktorý analyzuje nedôveryhodný vstup.

Pretečenie celého čísla, ktoré vám odovzdá príliš malú vyrovnávaciu pamäť

Klasickou chybou pamäťovej bezpečnosti v dekodéri obrázkov je súčin rozmerov, ktorý pretečie. Dekodér prečíta šírku, výšku, počet komponentov a bitovú hĺbku, vynásobí ich, aby určil veľkosť svojho výstupu, alokuje príslušný počet bajtov a potom zapíše obrázok v jeho skutočných rozmeroch. Ak sa násobenie vykonáva v 32-bitovej aritmetike, súčin môže pretiecť na malú hodnotu, aj keď je každý jednotlivý faktor v rozumnom rozsahu, takže alokácia prebehne úspešne, vyjde však príliš malá a dekódovanie zapíše dáta mimo nej. Ide o CWE-190 (pretečenie celého čísla), ktoré o krok neskôr vedie k zápisu mimo vyhradenú pamäť haldy (CWE-787).

Zdieľaná cesta pre obrázky už ohraničovala každý rozmer na 65535; samostatné dekodéry toto ohraničenie neprevzali všetky. Výraz ako ByteCount * FHeight alebo výraz na pixel ako FWidth * Components * BitDepth je v Delphi 32-bitovým súčinom, ak sú oba operandy 32-bitové celé čísla, a to bez ohľadu na to, aká široká je premenná, do ktorej priraďujete výsledok. Šírka a výška 60000 sú obe prijateľné pre veľký sken, ale ich súčin v bajtoch pretečie znamienkový 32-bitový rozsah a dĺžka vyjde malá. Rovnaká pasca sa nachádzala v kroku prediktora ZLib, BitsPerComponent * Colors * Columns.

Opravou je urobiť aspoň jeden operand typu Int64, aby sa celý výraz vyhodnotil ako 64-bitový, potom ho porovnať s MaxInt a odmietnuť súbor pred tým, než sa hodnota zúži na volanie SetLength.

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

To, čo z toho robí špecifický problém pre Delphi a nie všeobecný, je tiché zúženie typu. Priradenie príliš širokého výrazu do 32-bitového cieľa je legálna konverzia, na ktorú kompilátor predvolene neupozorní, a kontrola rozsahu nezachytí pretečenie, ku ktorému dôjde pred použitím hodnoty ako indexu. Ak necháte súčin v 32 bitoch, jazyk vám ticho odovzdá dĺžku, ktorá klame o tom, koľko pamäte sa dekódovanie chystá použiť.

Typ poľa, ktorý znemožňuje spustenie ochrany

Súbor TIFF je reťazec adresárov súborov obrázkov (image file directories), z ktorých každý nesie bajtový offset nasledujúceho. Škodlivý súbor môže tento reťazec nasmerovať späť na seba a čítačka, ktorá ním prechádza bez podmienky zastavenia, pobeží nekonečne. Ide o CWE-835 (nekonečná slučka riadená vstupom útočníka) a obranou je počítadlo, ktoré sa zastaví, akonáhle prekročí limit, ktorý by žiadny legitímny súbor nedosiahol.

Počítadlo stránok bolo deklarované ako Word, čo v Delphi uchováva hodnoty 0 až 65535. Slučka niesla ukončovaciu ochranu v tvare „zastaviť, keď počet stránok prekročí 65535“, čo vyzerá správne, kým si nevšimnete, že operand a prahová hodnota zdieľajú hornú hranicu. Typ Word nemôže byť nikdy väčší ako 65535, takže porovnanie je štrukturálne vždy nepravdivé: keď počítadlo dosiahne 65535, ďalšie zvýšenie ho otočí späť na 0, ochrana nikdy neuvidí hodnotu nad stropom a zacyklený reťazec IFD udržiava čítačku v nekonečnom behu.

Opravou bolo rozšírenie poľa tak, aby ochrana mohla vyjadriť hodnotu, ktorú počítadlo dokáže skutočne pojať. Po deklarovaní TPDFTIFF.FPageCount ako Integer sa porovnanie FPageCount > 65535 stane dosiahnuteľným, slučka sa ukončí a verejná vlastnosť PageCount zmenila typ tak, aby zodpovedala, bez toho, aby to poškodilo volajúceho. Kedykoľvek má kontrola hranice tvar Value > MaxValueOfType(Value) and the operand is already typed at exactly that maximum, the condition is a constant false: widen the type, or test equality against the maximum so it can trigger.

Vypnutá kontrola rozsahu na kritickej ceste

Pri zapnutej kontrole rozsahu Delphi vkladá kontrolu hraníc pre každý index poľa a reťazca, čo predstavuje rozdiel medzi indexom mimo rozsahu vyvolávajúcim zachytiteľnú chybu ERangeError a tým istým indexom čítajúcim alebo zapisujúcim pamäť, ktorá nepatrí danej štruktúre. Kritické cesty (hot paths) ju niekedy vypínajú pomocou lokálnej direktívy {$R-}, čo je obhájiteľné až do momentu, kedy indexy prestane byť dôveryhodné.

Prístup k zoznamu, o ktorý sa opierajú interprety písiem, TPDFlibStringList.Get, je presne takouto cestou. Na systéme Windows sa kompiluje s vypnutou kontrolou rozsahu a priamo indexuje svoje úložisko, takže index mimo rozsahu nie je chybou, ale surovým prístupom k pamäti. To je v poriadku, keď je index vždy platný, a prestáva to byť v poriadku vnútri interpretu charstring pre CFF alebo Type2, kde index môže pochádzať zo súboru. Charstring, ktorý vytiahne operand z prázdneho zásobníka, vyprodukuje index mínus jedna; identifikátor glyfu posunutý o jedna oproti počtu glyfov indexuje jeden slot za koncom. Pri vypnutej kontrole rozsahu sa obe stávajú skutočným prístupom mimo vymedzené hranice namiesto zachytiteľnej výnimky, a pretože sloty držia hodnoty AnsiString s počítaním referencií, náhodné čítanie môže tiež poškodiť počet referencií reťazca.

Zabezpečenie nezaplo kontrolu rozsahu späť pre kritickú cestu. Najprv urobilo indexy preukázateľne platnými: pred odobratím vrcholu zásobníka operandov interpret skontroluje, či zásobník nie je prázdny, a každá ochrana indexu bola zapísaná ako striktné „menší ako“ voči počtu, a nie „menší alebo rovný“, čo by pripustilo chybu o jednotku. Direktíva presúva zodpvojnosť za hranice z kompilátora na vás a validácia, ktorú odstránila, musí byť ručne vrátená na každom vstupnom bode.

Nekonečná rekurzia v interprete charstring

Charstring Type2 môže volať podprogram (subroutine) a podprogram je sám osebe charstring, ktorý môže volať ďalší, takže operátory volania lokálnych a globálnych podprogramov umožňujú súboru rozhodnúť, ako hlboko pôjde. Podprogram, ktorý volá sám seba, priamo alebo cez cyklus, rekurzívne pokračuje bez konca, kým sa nevyčerpá natívny zásobník a proces nespadne. Ide o CWE-674 (nekontrolovaná rekurzia).

Interpret Type1 už pred týmto chránil. Obsahoval počítadlo hĺbky volania a strop PLType1MaxCallDepth a odmietal zostúpiť pod túto hodnotu, čo odráža limit hĺbky, ktorý menuje samotná špecifikácia Type1. Interpret Type2, pridaný neskôr a štrukturálne podobný, neobsahoval rovnakú ochranu a ručne vytvorené písmo s podprogramom, ktorý volá svoje vlastné číslo, prechádza priamo cez chýbajúcu kontrolu do pretečenia zásobníka.

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

Opravou bolo dať ceste Type2 rovnakú ohraničenú hĺbku, akú už mal jej súrodenec Type1. Akýkoľvek rekurzívny zostup nad útočníkom ovplyvnenou štruktúrou, či už ide o podprogramy písma, vnorené pole alebo reťazec krížových odkazov, potrebuje strop hĺbky, ktorý vstup nemôže zvýšiť.

Neinicializovaná pamäť, ktorá uniká do výstupu

Najsubtílnejšia chyba prepúšťala obsah haldy do dešifrovaného výstupu a príčinou je vlastnosť SetLength, na ktorú sa dá ľahko zabudnúť. Keď zväčšíte AnsiString pomocou SetLength, Delphi alokuje bajty, ale nevynuluje ich, takže nová oblasť obsahuje to, čo bolo predtým v tejto pamäti haldy. Ak sa následne zapíše každý bajt, na tomto nikdy nezáleží; ak cesta nechá časť vyrovnávacej pamäte nezapísanú a potom ju vráti ako dáta, tieto staré bajty odídu s výsledkom. Ide o CWE-457 (použitie neinicializovanej pamäte) a keď výsledok prekročí hranicu dôvery, stane sa únikom informácií.

Dešifrovacia cesta AES-CBC narazila presne na toto. Výstupná vyrovnávacia pamäť bola dimenzovaná pomocou SetLength and the decryptor processed the ciphertext one 16-byte block at a time. When the ciphertext length was not a multiple of 16, a length an attacker can choose, the trailing partial block was never written, so those final bytes kept the heap contents SetLength left behind and the buffer was handed back as the decrypted plaintext of a document object. The remedy is two guards, and neither alone is enough: the decryption entry point now rejects any ciphertext whose length is not a multiple of the block size, and as a backstop the output is cleared with FillChar before use so any path that fails to write a region returns zeros rather than heap residue.

Čo vám táto fáza prináša

Päť chýb sú rôzne nedostatky, ale rýmujú sa. Šírka celého čísla, ktorá spôsobí pretečenie súčinu, typ poľa, ktorý pripne ochranu k trvalej nepravde, vypnutá kontrola rozsahu tam, kde indexy prestali byť bezpečné, rekurzia bez dna a vyrovnávacia pamäť, ktorú jazyk odmietol vynulovať. V každom prípade Delphi urobilo presne to, čo definuje, pretože jazyk vám dáva aritmetiku, ktorá preteká, tiché zúženie typu, kontroly rozsahu, ktoré môžete vypnúť, rekurziu bez zabudovaného limitu a alokáciu, ktorá neinicializuje. Toto je kontrakt a pascalovský parser ho spĺňa tým, že na každej hranici riadenej súborom vlastní štyri veci ručne: šírku celého čísla, kontrolu rozsahu, hĺbku rekurzie a inicializáciu vyrovnávacej pamäte.

Tieto chyby sú zatvorené v aktuálnych verziách PDFlibPas, engine pre Delphi and C++Builder. Ak vaša práca siaha aj do toho, ako súbor tvrdí, že je chránený, sprievodné poznámky o audite šifrovania a oprávnení a o predletovej kontrole (preflight) PDF/A a PDF/UA pokrývajú analytickú stranu rovnakého parsera a všetko sa dodáva vnútri PDFlibPas Delphi PDF Library spolu s rozhraniami API na načítanie, vykresľovanie a podpisovanie popísanými inde na tomto blogu.