Technical Article

Zabezpečení parseru PDF v Pascalu proti škodlivým souborům

PDF není dokument, který pouze otevíráte. Je to malý program, který spouštíte. Každé vložené písmo je zásobníkový interpret čekající na charstrings, každý obrázek je dekodér plněný šířkou, výškou a barevnou hloubkou, které určil soubor, a každý stream přichází obalený filtry s parametry nastavenými souborem. Žádné z těchto parametrů nejsou vaše. Pocházejí od kohokoli, kdo soubor vytvořil, což v reálném provozu může být faktura od zákazníka nebo příloha od neznámého odesílatele. Dekodéry, které tyto bajty převádějí na pixely a glyfy, představují prostor pro útok (attack surface). Parser, který zde důvěřuje svému vstupu, dělí od pádu nebo něčeho horšího jediný poškozený soubor. Knihovna PDFlibPas prošla fází zabezpečení (hardening), která přistupovala k celé dekódovací cestě jako k nepřátelské – napříč programy písem (TrueType, Type1, CFF a tabulky CMap), dekodéry obrázků (PNG, GIF, TIFF, JBIG2 a CCITT Group 3 a Group 4) a filtry streamů (LZW, ASCII85 a prediktory Flate). Následuje pět tříd chyb, které byly odstraněny, přičemž každá z nich pramenila z konkrétního chování Delphi, jež ji umožnilo. V aktuálních verzích jsou opraveny a stejné vzorce se opakují v jakémkoli kódu v Pascalu, který analyzuje nedůvěryhodný vstup.

Přetečení celého čísla, které vám předá poddimenzovaný buffer

Klasickou chybou paměťové bezpečnosti v dekodéru obrázků je přetečení součinu rozměrů. Dekodér načte šířku, výšku, počet komponent a barevnou hloubku, vynásobí je pro určení velikosti výstupu, alokuje příslušný počet bajtů a poté zapíše obrázek v jeho skutečných rozměrech. Pokud se násobení provádí v 32bitové aritmetice, součin může přetéct na malou hodnotu, i když je každý jednotlivý faktor v rozumném rozsahu. Alokace pak uspěje, buffer je však příliš malý a dekódování zapisuje za jeho konec. Jedná se o CWE-190 (přetečení celého čísla), které o krok později vede k zápisu mimo meze na haldě (heap out-of-bounds write, CWE-787). Společná cesta pro obrázky již omezovala každý rozměr na 65535. Samostatné dekodéry toto omezení nezdědily všechny. Výraz typu šířka řádku krát výška, například ByteCount * FHeight, nebo výraz na pixel, jako je FWidth * Components * BitDepth, je v Delphi 32bitovým součinem, pokud jsou oba operandy 32bitová celá čísla – bez ohledu na to, jak široká je proměnná, do které výsledek přiřazujete. Šířka a výška 60000 jsou u velkého skenu uvěřitelné, ale jejich součin v bajtech překračuje rozsah 32bitového čísla se znaménkem a délka vyjde jako malá. Stejná past se nacházela v kroku prediktoru ZLib: BitsPerComponent * Colors * Columns.

Nápravou je převést alespoň jeden operand na Int64, aby se celý výraz vyhodnocoval v 64 bitech, poté jej porovnat s MaxInt a odmítnout soubor předtím, než se typ zúží zpět pro volání 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, co z toho dělá problém Delphi a nikoli obecný problém, je tiché zúžení typu (silent narrowing). Přiřazení příliš širokého výrazu do 32bitového cíle je legální konverze, na kterou překladač ve výchozím nastavení neupozorní, a kontrola rozsahu nezachytí přetečení, ke kterému dojde dříve, než se hodnota použije jako index. Ponecháte-li součin v 32 bitech, jazyk vám tiše poskytne délku, která neodpovídá tomu, kolik paměti se dekódování chystá přepsat.

Typ pole, který znemožňuje spuštění ochrany

TIFF soubor je řetězec adresářů obrázků (image file directories), z nichž každý nese bajtový offset dalšího. Škodlivý soubor může tento řetězec nasměrovat zpět na sebe a čtečka, která jej prochází bez ukončovací podmínky, poběží do nekonečna. Jedná se o CWE-835 (nekonečná smyčka řízená vstupem od útočníka) a obranou je počítadlo, které se zastaví po překročení limitu, kterého by žádný legitimní soubor nedosáhl. Počítadlo stránek bylo deklarováno jako Word, což v Delphi pojme hodnoty 0 až 65535. Smyčka obsahovala ukončovací ochranu ve tvaru „zastav, pokud počet stránek překročí 65535“, což se zdá správné, dokud si nevšimnete, že operand a prahová hodnota sdílejí stejnou horní hranici. Typ Word nemůže být nikdy větší než 65535, so porovnání je strukturalně vždy nepravdivé: když počítadlo dosáhne 65535, další inkrementace jej vrátí na 0, ochrana nikdy neuvidí hodnotu nad stropem a zacyklený řetězec IFD udržuje čtečku v chodu. Nápravou bylo rozšíření typu pole, aby ochrana mohla vyjádřit hodnotu, kterou počítadlo skutečně dokáže udržet. Při deklaraci TPDFTIFF.FPageCount jako Integer se stejné porovnání FPageCount > 65535 stane dosažitelným, smyčka se ukončí a veřejná vlastnost PageCount změnila typ, aby tomu odpovídala bez narušení funkčnosti u volajících. Kdykoli má kontrola hranice tvar Value > MaxValueOfType(Value) a operand je již typu s tímto maximem, podmínka je trvale nepravdivá: rozšiřte typ nebo testujte rovnost s maximem, aby se mohla aktivovat.

Vypnutá kontrola rozsahu v kritické cestě

Při zapnuté kontrole rozsahu Delphi vkládá kontrolu mezí u každého indexu pole a řetězce, což představuje rozdíl mezi indexem mimo rozsah vyvolávajícím zachytitelnou výjimku ERangeError a tím, že stejný index čte nebo zapisuje paměť, která struktuře nepatří. Kritické cesty (hot paths) ji někdy vypínají pomocí lokální direktivy {$R-}, což je obhajitelné pouze do chvíle, než indexy přestanou být důvěryhodné. Přístupový prvek seznamu, o který se opírají interprety písem, TPDFlibStringList.Get, je přesně takovou cestou. Na Windows je kompilován s vypnutou kontrolou rozsahu a indexuje své úložiště přímo, takže index mimo rozsah není chybou, ale přímým přístupem do paměti. To je v pořádku, pokud je index vždy platný. Přestává to být v pořádku uvnitř interpretu charstringů CFF nebo Type2, kde index může pocházet ze souboru. Charstring, který vyjme operand z prázdného zásobníku, vytvoří index s hodnotou mínus jedna. Identifikátor glyfu posunutý o jedna oproti celkovému počtu indexuje jednu pozici za koncem. Při vypnuté kontrole rozsahu se obojí změní ve skutečný přístup mimo meze namísto zachytitelné výjimky. Vzhledem k tomu, že pozice obsahují hodnoty AnsiString s počítáním referencí, chybně směrované čtení může také poškodit počet referencí řetězce. Zabezpečení nezapnulo kontrolu rozsahu pro kritickou cestu zpět. Nejprve zajistilo, aby indexy byly prokazatelně platné: před odebráním prvku z vrcholu zásobníku operandů interpret zkontroluje, zda zásobník není prázdný, a každá ochrana indexu byla zapsána jako striktní porovnání „menší než“ vůči počtu, nikoli „menší nebo rovno“, které připouští chybu o jedna (off-by-one). Direktiva přesouvá odpovědnost za kontrolu mezí z překladače na vás a validace, kterou odstranila, musí být ručně doplněna na každém vstupním bodě.

Neomezená rekurze v interpretu charstringů

Charstring Type2 může volat subrutinu a subrutina je sama o sobě charstringem, který může volat další. Volací operátory lokálních a globálních subrutin tak umožňují souboru rozhodnout, jak hluboko půjde. Subrutina, která volá sama sebe (přímo nebo přes cyklus), provádí nekonečnou rekurzi, dokud se nevyčerpá nativní zásobník a proces neskončí. Jedná se o CWE-674 (neřízená rekurze). Interpret Type1 se proti tomu již chránil. Obsahoval počítadlo hloubky volání a strop PLType1MaxCallDepth a odmítal sestoupit hlouběji, což odráží limit hloubky, který uvádí samotná specifikace Type1. Interpret Type2, přidaný později a strukturálně podobný, stejnou ochranu neobsahoval, a ručně vytvořené písmo se subrutinou volající vlastní číslo prošlo chybějící kontrolou přímo do přetečení zásobníku.

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

Nápravou bylo poskytnout cestě Type2 stejnou omezenou hloubku, jakou již měl její sourozenec Type1. Jakýkoli rekurzivní sestup nad strukturou kontrolovanou útočníkem, ať už jde o subrutiny písma, vnořené pole nebo řetězec křížových odkazů, vyžaduje strop hloubky, který vstup nemůže zvýšit.

Neinicializovaná paměť, která uniká do výstupu

Nejjemnější chyba propouštěla obsah haldy (heap) do dešifrovaného výstupu a příčinou je vlastnost SetLength, na kterou se snadno zapomíná. Když zvětšíte AnsiString pomocí SetLength, Delphi alokuje bajty, ale nevynuluje je, takže nová oblast obsahuje to, co se v této paměti haldy nacházelo dříve. Pokud se následně zapíše každý bajt, na tomto chování nezáleží. Pokud však cesta ponechá část bufferu nezapsanou a poté ji vrátí jako data, tyto neaktuální bajty odejdou s výsledkem. Jedná se o CWE-457 (použití neinicializované paměti) a pokud výsledek překročí hranici důvěry, stává se z něj únik informací. Dešifrovací cesta AES-CBC narazila přesně na tento problém. Výstupní buffer byl dimenzován pomocí SetLength a dešifrovací proces zpracovával šifrový text po 16bajtových blocích. Pokud délka šifrového textu nebyla násobkem 16 (což je délka, kterou útočník může zvolit), koncový částečný blok nebyl nikdy zapsán, takže tyto finální bajty si uchovaly obsah haldy, který po sobě zanechala operace SetLength, a buffer byl předán zpět jako dešifrovaný text objektu dokumentu. Nápravou jsou dvě ochrany a žádná sama o sobě nestačí: vstupní bod dešifrování nyní odmítá jakýkoli šifrový text, jehož délka není násobkem velikosti bloku, a jako pojistka se výstup před použitím vyčistí pomocí FillChar, takže jakákoli cesta, která selže při zápisu do některé oblasti, vrátí nuly namísto reziduí z haldy.

Co po sobě tato fáze zanechá

Těchto pět chyb jsou různé defekty, ale mají společné rysy. Šířka celého čísla, která způsobí přetečení součinu, typ pole, který staví ochranu do role trvale nepravdivé podmínky, vypnutá kontrola rozsahu tam, kde indexy přestaly být bezpečné, rekurze bez dna a buffer, který jazyk odmítl vynulovat. V každém případě Delphi dělalo přesně to, co definuje, protože jazyk vám poskytuje aritmetiku s přetékáním, tiché zužování typů, kontroly rozsahu, které lze vypnout, rekurzi bez vestavěného limitu a alokaci, která neprovádí inicializaci. To je kontrakt a parser v Pascalu jej plní tím, že na každé hranici ovládané souborem ručně hlídá čtyři věci: šířku celých čísel, kontrolu rozsahu, hloubku rekurze a inicializaci bufferu.

Tyto chyby jsou v aktuálních verzích PDFlibPas, což je engine pro Delphi a C++Builder, odstraněny. Pokud se vaše práce týká také toho, jak se soubor deklaruje jako chráněný, doplňující poznámky o auditu šifrování a oprávnění a o předletové kontrole (preflight) PDF/A and PDF/UA pokrývají analytickou stranu stejného parseru. Vše je dodáváno v rámci PDFlibPas Delphi PDF Library společně s rozhraními API pro načítání, vykreslování a podepisování popsanými jinde na tomto blogu.