Technický článek

Bezpečné analyzování PDF slovníků v Delphi: Tokeny názvů

Hledání podřetězců, jako je například Pos('/Length', Dict), je nesprávným nástrojem pro čtení slovníku PDF, protože klíče názvů v PDF často sdílejí stejné předpony: /Length je předponou klíče /Length1 a /Encrypt je předponou pro /EncryptMetadata. Norma ISO 32000-1 §7.3.5 definuje název jako token, který končí pouze oddělovačem nebo prázdným znakem (whitespace). Klíč se proto považuje za nalezený pouze tehdy, když bajt následující bezprostředně za ním odpovídá jednomu z těchto znaků. Čtečka slovníků, která tuto kontrolu vynechá, dříve či později načte nesprávnou hodnotu z jinak naprosto platného souboru

Chyba, která nás tomu naučila, na první pohled vůbec nevypadala jako problém lexikálního analyzátoru. Kontrola kompatibility začala hlásit poškození datového toku (streamu) FontFile: dekomprimovaný program písma byl neúplný a uprostřed tabulky se uřízl. Soubor se přitom bez problémů otevřel v jakémkoli prohlížeči a data toku na disku byla neporušená. Hlavní příčina spočívala v jediném řádku naší sdílené čtečky slovníků: Pos('/Length', ...) se shodoval s /Length1 — standardním klíčem, který slovníky toků FontFile nesou podle tabulky 127 normy ISO 32000-1 —, a čtečka vzala celé číslo následující za /Length1 jako délku toku. Tvůrce tohoto konkrétního souboru náhodou serializoval /Length1 před /Length, což je plně dovoleno, protože položky slovníku jsou podle §7.3.7 neuspořádané. Tok byl zkrácen na nesmyslný počet bajtů a každá další kontrola, která jej zpracovávala, selhala bez jakéhokoli varování

Proč vyhledávání podřetězců narušuje analyzování slovníků PDF?

Hledání podřetězců selhává, protože jmenný prostor názvů v PDF je plný záměrně navržených rodin předpon a pořadí položek ve slovníku není nijak určeno. Tabulka 127 normy ISO 32000-1 definuje klíče /Length1, /Length2 a /Length3 pro vložené toky písem, které se nacházejí hned vedle /Length ve stejném slovníku. Slovník šifrování zase páruje klíč /Encrypt v traileru s /EncryptMetadata uvnitř něj. Krátké klíče jsou ještě horší: vyhledávání sestavené jako Pos('/' + Key, ...) s hodnotou Key = 'N' snadno narazí na /Name nebo /Nums. Žádná z těchto kolizí přitom nevyžaduje poškozený soubor. Zapisovač, který seřadí /Length1 před /Length, je plně v souladu s normou. Chyba vyhledávání podřetězců tedy není nedostatkem robustnosti vůči chybnému vstupu, ale chybou správnosti proti zcela platnému vstupu

Tento typ selhání se navíc projevuje tiše. Nesprávná hodnota /Length nevyvolá výjimku; pouze vám předá kratší nebo delší blok bajtů, než jaký tok ve skutečnosti zabírá. Pokud je tento blok předán kontrole podsad písem (font-subset), analýze CMap nebo skenování metadat, zpracovatelský kód vidí nečitelná data a obvykle nic neohlásí, protože polovina toku zlib se jednoduše nepodaří dekomprimovat a kód pokračuje dál. Přesně tento typ chyby se objevil i u nás a opravili jsme jej ve verzi v2.14.3 naší sdílené čtečky poté, co podrobný audit bodů §7.2–§7.3 normy ISO 32000-1 označil jakékoli vyhledávání klíčů pomocí funkce Pos za rizikové

Co přesně definuje norma ISO 32000-1 §7.3.5 jako název

Sekce 7.3.5 je krátká a přesná: objekt názvu je lomítko (solidus) následované sekvencí běžných znaků a token končí prvním oddělovačem nebo prázdným znakem (whitespace). Oddělovači jsou osm znaků závorek plus lomítko a procento — ( ) < > [ ] { } / % — a prázdnými znaky jsou null, tabulátor, odřádkování (LF), posun formuláře (FF), návrat vozíku (CR) a mezera (§7.2.2–§7.2.3). Toto pravidlo ukončení vysvětluje vše. /Length1 není „/Length následované jedničkou“; je to jediný, nedělitelný token, stejně jako jsou LengthOne and Length odlišnými identifikátory v Pascalu. Jakákoli čtečka, která hledá klíče pomocí prostého vyhledávání bajtů, pouze implementuje lexikální analyzátor bez pravidla ukončení

Zde je ukázka této chyby zjednodušená na to nejpodstatnější. Tato verze se zkompiluje, projde testy u souborů, kde tvůrci řadí /Length jako první, ale poškodí toky u těch, kteří tak nečiní

// WRONG: matches /Length1, /Length2, /Length3 as well
function ReadStreamLength(const Dict: AnsiString): Integer;
var
  P: Integer;
begin
  Result := -1;
  P := Pos('/Length', Dict);
  if P > 0 then
    Result := ReadIntAt(Dict, P + Length('/Length'));
end;

Porovnávání celých tokenů: kontrola bajtu za klíčem

Správný postup vyplývá přímo z §7.3.5: nalezený kandidát je skutečným klíčem pouze tehdy, pokud je znak bezprostředně za ním oddělovačem, prázdným znakem nebo koncem vyrovnávací paměti (bufferu). Cokoli jiného je delší název, který pouze sdílí stejnou předponu. Vyhledávání proto musí pokračovat dál, nikoli skončit. Oprava v naší čtečce nahradila každé přímé hledání pomocí Pos jednou sdílenou funkcí postavenou na tomto pravidle

function IsPdfDelimOrWs(C: AnsiChar): Boolean;
begin
  Result := C in [#0, #9, #10, #12, #13, ' ',
    '(', ')', '<', '>', '[', ']', '{', '}', '/', '%'];
end;

// Correct: whole-token match per ISO 32000-1 §7.3.5
function FindDictKey(const Dict, Key: AnsiString): Integer;
var
  P, After: Integer;
begin
  Result := 0;
  P := Pos(Key, Dict);
  while P > 0 do
  begin
    After := P + Length(Key);
    if (After > Length(Dict)) or IsPdfDelimOrWs(Dict[After]) then
      Exit(P);                       // token ends here: genuine key
    P := PosEx(Key, Dict, P + 1);    // prefix of a longer name: keep looking
  end;
end;

Dva detaily v tomto cyklu jsou klíčové. Zaprvé, cyklus pokračuje v hledání, místo aby vrátil chybu při první kolizi předpony, protože /Length1 120 /Length 4076 je zcela legální uspořání a skutečný klíč se nachází dále. Zadruhé, případ konce bufferu se počítá jako oddělovač, protože fragment slovníku může legitimně končit hned za názvem. Detail, který stojí za to ve vašem kódu prověřit: stejné pravidlo platí i na levé straně shody, pokud váš hledaný řetězec neobsahuje lomítko — jinak by Pos('Length', ...) mohl skončit uvnitř /PieceLength. Ukotvení hledaného řetězce s počátečním lomítkem /, jak ukazuje příklad výše, ošetřuje levou stranu, protože lomítko je samo o sobě oddělovačem, který ukončuje předchozí token

Jak může nepřátelské PDF proměnit chybu parseru v gigabajtovou alokaci?

Poškozený nebo úmyslně upravený soubor dokáže tyto lexikální chyby zneužít k vyčerpání systémových prostředků, protože celá čísla ze slovníků často určují velikost alokované paměti. Náš audit odhalil přesně takový řetězec chyb při rozbalování toku objektů (object-stream). Položka /N ve slovníku ObjStm určuje, kolik komprimovaných objektů tok obsahuje, a rozšiřující kód volal SetLength pro pole o této velikosti. Analyzátor celých čísel však při selhání ponechal výstupní parametr beze změny, ačkoli jej stále vracel. Nečíselná hodnota /N tak předala metodě SetLength neinicializovanou hodnotu ze zásobníku. Nesmyslné kladné celé číslo pak znamená požadavek na alokaci v řádu gigabajtů, spuštěný pouhými několika bajty poškozeného vstupu — a to při pouhém skenování dokumentu, který jste ještě ani neotevřeli

Naši obranu doplňují dva další limity (stropy), oba zavedené ve verzi v2.12.0. Čtečka toků odmítne jakoukoli hodnotu /Length větší, než je velikost celého souboru, ještě před alokací výsledného bufferu — tok totiž nemůže být větší než kontejner, ve kterém se nachází, takže tato kontrola nemá falešné poplachy. Dekompresní cesta pak omezuje výstup na 256 MiB, což zabraňuje klasickým útokům typu dekompresní bomba (zlib bomb), kdy se několik kilobajtů vstupu rozbalí do nekonečna. Tento limit je plně dostačující pro jakýkoli reálný PDF tok a zároveň zajišťuje bezpečné zvládnutí nejhoršího možného scénáře. Myšlenka všech tří opatření je stejná: jakákoli velikost deklarovaná souborem je pouze tvrzením a analyzátor toto tvrzení nejprve ověří proti měřitelným údajům předtím, než alokuje paměť. Stejný přístup k auditu platí také o úroveň níže na hranici API vazeb, což popisuje článek o zabezpečení PDFium ABI a paměťové bezpečnosti v Delphi

// /N is attacker-controlled; bound it by what /First can hold
if not TryReadDictInt(Dict, '/N', NVal) then
  NVal := 0;                          // explicit zero, never stack garbage
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
  Exit;                               // header cannot contain that many pairs

// /Length can never exceed the file that contains the stream
if (LenVal < 0) or (LenVal > SourceSize) then
  Exit;                               // refuse before allocating the buffer

Kde samotné pravidlo celých tokenů nestačí

Abychom byli upřímní k limitům a nespoléhali se na výše uvedenou funkci absolutně: porovnávání celých tokenů sice řeší identifikaci klíčů, ale prosté vyhledávání bajtů ve slovníku stále nedokáže rozlišit, zda se shoda nachází uvnitř vnořeného slovníku, textového řetězce nebo komentáře. Metoda FindDictKey u objektu stránky může narazit na klíč uvnitř jeho podslovníku /Resources, pokud jí předáte příliš široký rozsah. Naše čtečka proto nejprve omezuje rozsah na tělo jediného objektu a kontexty řetězců i komentářů zpracovává odděleně. Bezpečnost vyhledávání podřetězců je pouze jedním z kroků, nikoli celým řešením: konzistence křížových odkazů (xref) je samostatnou disciplínou (popisuje ji validace objektů a xref toků) a širší seznam hrozeb pro cizí dokumenty naleznete v článku o auditu bezpečnostních rizik PDF

Pokud spravujete ručně psanou čtečku slovníků v Delphi nebo Lazarusu, kontrolní seznam z tohoto incidentu je krátký. Najděte veškerá volání Pos('/ v kódu a nasměrujte je přes pomocnou funkci pro kontrolu celých tokenů. Vypište rodiny předpon, kterých se vaše klíče účastní — /Length, /Encrypt, /N, /Type proti /Type1 se v reálných souborech běžně objevují. Poté projděte každé celé číslo, které vstupuje do SetLength, GetMem nebo smyčky kopírování, a definujte jeho limity: velikost souboru, strop odvozený z hlavičky nebo cokoli jiného. Zde popsaná vrstva analýzy tvoří základ našeho produktu PDFium Component, kde bajtová čtečka a vykreslovací jádro vzájemně kontrolují každý zpracovávaný dokument