Odborný článok

Bezpečné analyzovanie slovníkov PDF v Delphi: menné tokeny

Vyhľadávanie podreťazcov ako Pos('/Length', Dict) je nesprávnym nástrojom na čítanie slovníka PDF, pretože kľúče názvov v PDF zdieľajú rovnaké predpony (prefixes): /Length je predponou pre /Length1 a /Encrypt je predponou pre /EncryptMetadata. Norma ISO 32000-1 §7.3.5 definuje názov ako token, ktorý končí iba na oddeľovači (delimiter) alebo prázdnom znaku (whitespace), takže kľúč sa považuje za nájdený iba vtedy, ak je bajt za ním jedným z týchto znakov. Čítač slovníkov, ktorý túto jedinú kontrolu vynechá, časom prečíta nesprávnu hodnotu z úplne validného súboru

Chyba, ktorá nás to naučila, vôbec nevyzerala ako problém lexera. Test zhody začal hlásiť prúd FontFile ako poškodený: dekomprimovaný program písma bol len fragmentom, odseknutým uprostred tabuľky. Súbor sa bez problémov otvoril v každom prehliadači. Dáta prúdu na disku boli neporušené. Skutočná príčina ležala v jedinom riadku nášho zdieľaného čítača slovníkov: Pos('/Length', ...) sa zhodovalo s /Length1, čo je štandardný kľúč, ktorý slovníky prúdov FontFile nesú podľa normy ISO 32000-1 tabuľky 127, a čítač vzal celé číslo za /Length1 ako dĺžku prúdu. Tvorca tohto konkrétneho súboru náhodou zapísal /Length1 pred /Length, čo môže slobodne urobiť, keďže položky slovníka sú podľa §7.3.7 neusporiadané. Prúd bol orezaný na nezmyselný počet bajtov a každá nasledujúca kontrola, ktorá ho spracovávala, potichu zlyhala

Prečo vyhľadávanie podreťazcov narúša analýzu slovníkov PDF?

Vyhľadávanie podreťazcov zlyháva, pretože menný priestor PDF je plný zámerných skupín s rovnakými predponami a poradie položiek v slovníku nie je definované. Tabuľka 127 normy ISO 32000-1 definuje /Length1, /Length2 a /Length3 pre vložené prúdy písiem, pričom všetky stoja vedľa /Length v rovnakom slovníku. Šifrovací slovník páruje /Encrypt na konci dokumentu s /EncryptMetadata vo svojom vnútri. Krátke kľúče sú na tom ešte horšie: vyhľadávanie postavené ako Pos('/' + Key, ...) s hodnotou Key = 'N' veselo pristane na /Name alebo /Nums. Žiadna z týchto kolízií pritom nevyžaduje poškodený súbor. Tvorca súboru, ktorý zapíše /Length1 pred /Length, plne vyhovuje špecifikácii, čo znamená, že chyba s vyhľadávaním podreťazcov nie je problémom odolnosti voči chybnému vstupu — je to chyba správnosti voči validnému vstupu

Tento typ zlyhania navyše prebieha potichu. Nesprávna hodnota /Length nevyvolá výnimku; odovzdá vám len kratšiu alebo dlhšiu časť bajtov, než akú prúd v skutočnosti zaberá. Ak táto časť smeruje do kontroly subsetu písma, analýzy CMap alebo kontroly metadát, spracovateľ vidí nezmyselné dáta a zvyčajne nič nenahlási, pretože polovičný prúd zlib sa jednoducho nepodarí rozbaliť a kód pokračuje ďalej. Vydali sme presne tento typ chyby a opravili ho vo verzii v2.14.3 nášho zdieľaného čítača po tom, čo podrobný audit normy ISO 32000-1 §7.2–§7.3 označil každé vyhľadávanie kľúčov pomocou Pos za podozrivé

Čo norma ISO 32000-1 §7.3.5 v skutočnosti definuje ako názov

Sekcia 7.3.5 je krátka a presná: menný objekt (name object) je lomka (solidus) nasledovaná postupnosťou bežných znakov a token končí na prvom oddeľovači alebo prázdnom znaku. Oddeľovačmi sú osem znakov zátvoriek plus lomka a znak percenta — ( ) < > [ ] { } / % — a prázdnymi znakmi (whitespace) sú null, tabulátor, odriadkovanie (line feed), form feed, carriage return a medzera (§7.2.2–§7.2.3). Toto pravidlo ukončenia je všetko. /Length1 nie je „/Length nasledované znakom 1“; je to jediný, nedeliteľný token, presne tak, ako sú LengthOne a Length odlišné identifikátory v Pascale. Akýkoľvek čítač, ktorý vyhľadáva kľúče prostredníctvom čistého vyhľadávania bajtov, reimplementuje lexer s vynechaným pravidlom ukončenia

Tu je znázornená táto chyba zjednodušená na základy. Táto verzia sa kompiluje, prechádza testami pre súbory, ktorých generátory zapisujú /Length ako prvé, avšak poškodzuje prúdy pri generátoroch, ktoré tak nerobia

// 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ávanie celých tokenov: kontrola bajtu za kľúčom

Správny predikát priamo vyplýva z §7.3.5: kandidát na zhodu je skutočným kľúčom iba vtedy, ak znak bezprostredne za ním je oddeľovačom, prázdnym znakom alebo koncom buffra. Všetko ostatné je dlhší názov, ktorý iba zdieľa rovnakú predponu, takže vyhľadávanie musí pokračovať za ním a nie sa vzdať. Oprava v našom čítači nahradila každé čisté vyhľadávanie pomocou Pos jedinou zdieľanou procedúrou 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 cykle majú svoju váhu. Po prvé, vyhľadávanie pokračuje aj po prvej kolízii predpony, pretože /Length1 120 /Length 4076 is a legal ordering and the real key is still ahead. Po druhé, stav konca buffra sa počíta ako ukončenie, keďže fragment slovníka môže legitímne skončiť hneď za názvom. Jemnejší detail, ktorý stojí za to skontrolovať vo vašom kóde: rovnaké pravidlo platí pre ľavú stranu zhody, ak váš hľadaný reťazec neobsahuje lomku, inak by vyhľadávanie Pos('Length', ...) mohlo pristáť vo vnútri /PieceLength. Ukotvenie hľadaného reťazca s úvodným /, ako je uvedené vyššie, rieši ľavý okraj, pretože samotné / je oddeľovačom, ktorý ukončuje predchádzajúci token

Ako môže nebezpečné PDF premeniť chybu parsera na gigabajtovú alokáciu pamäte?

Poškodený alebo úmyselne škodlivý súbor dokáže tieto lexikálne chyby vystupňovať až do vyčerpania systémových prostriedkov, pretože celé čísla zo slovníkov často riadia veľkosti pamäťových alokácií. Náš audit odhalil reťazec presne tohto tvaru pri rozbaľovaní prúdov objektov (object streams). Položka /N slovníka ObjStm určuje, koľko komprimovaných objektov prúd nesie, a rozbaľovací kód volal SetLength na pole dimenzované podľa nej. Parser celých čísel však pri zlyhaní ponechal svoj výstupný parameter nezmenený, hoci ho stále vrátil — takže nečíselné /N odovzdalo funkcii SetLength neinicializovanú hodnotu zo zásobníka. Náhodné kladné celé číslo tu znamená žiadosť o alokáciu v poriadku gigabajtov, spustenú len niekoľkými bajtmi chybného vstupu, a to pri obyčajnom skenovaní dokumentu, ktorý ste ešte ani neodsúhlasili ako dôveryhodný

Oprava pozostávala z dvoch nezávislých častí, pričom obe sa dajú zovšeobecniť. Parser teraz pri zlyhaní vracia explicitnú 0 a nikdy nie neinicializovanú pamäť. A spracovateľ už nedôveruje /N bez dodatočnej matematickej kontroly: hlavičková časť ObjStm pred hodnotou /First ukladá dvojicu celých čísel — číslo objektu a posun — pre každý komprimovaný objekt a každá dvojica zaberá najmenej štyri bajty vrátane oddeľovačov. Akákoľvek hodnota /N vyššia ako FirstVal div 4 + 1 je preto fyzicky nemožná pre deklarovanú veľkosť hlavičky a zamietne sa ešte pred akoukoľvek alokáciou. Táto kontrola stojí len jedno porovnanie a odvodzuje sa z údajov, ktoré už máme k dispozícii, čo je presne ten princíp, ktorý treba hľadať: limit, ktorý dokazuje samotný súbor a nie ľubovoľná konštanta

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

Dva ďalšie detaily uzatvárajú obranný obvod v našom čítači, oba zavedené vo verzii v2.12.0. Čítač prúdov odmietne akúkoľvek hodnotu /Length väčšiu ako samotný súbor ešte pred alokáciou výsledného buffra — prúd predsa nemôže byť väčší ako kontajner, v ktorom sa nachádza, takže táto kontrola neprodukuje falošné poplachy. A cesta rozbaľovania (inflate) obmedzuje dekomprimovaný výstup na 256 MiB, čo eliminuje klasické zlib bomby, kedy sa niekoľko kilobajtov vstupu rozbalí bez akýchkoľvek obmedzení; limit je dostatočne veľkorysý pre akýkoľvek reálny prúd PDF a zároveň zaručuje prežitie najhoršieho scenára. Téma všetkých troch kontrol je rovnaká: každá veľkosť, ktorú súbor deklaruje, je len tvrdenie a parser overuje každé tvrdenie voči niečomu, čo dokáže reálne odmerať ešte pred tým, ako na to vyčlení pamäť. Rovnaký prístup z pohľadu auditu platí aj o úroveň nižšie na hranici prepojenia, čo je popísané v zabezpečení rozhrania PDFium ABI a pamäťovej bezpečnosti v Delphi

Kde samotné pravidlo celých tokenov nestačí

Úprimné limity, aby ste vyššie uvedenej procedúre nedôverovali slepo. Porovnávanie celých tokenov rieši identifikáciu kľúčov, avšak ploché vyhľadávanie bajtov v rozsahu slovníka stále nedokáže určiť, či sa nájdená zhoda nachádza vo vnútri vnoreného slovníka, doslovného reťazca (literal string) alebo komentára — vyhľadávanie FindDictKey na objekte stránky môže pristáť na kľúči vo vnútri jeho subslovníka /Resources, ak mu odovzdáte príliš široký rozsah. Náš čítač najprv obmedzuje rozsah na telo jediného objektu a s kontextom reťazcov a komentárov zaobchádza ako so samostatnou, stále otvorenou témou auditu. Bezpečnosť podreťazcov je len jedným stupňom rebríka a nie celým rebríkom: konzistencia krížových odkazov je vlastná disciplína, popísaná v validácii objektov a xref prúdov, a širší katalóg hrozieb pre cudzie dokumenty nájdete v audite bezpečnostných rizík v PDF

Ak v Delphi alebo Lazaruse spravujete vlastnoručne napísaný čítač slovníkov, zoznam úloh z tohto incidentu je krátky. Vyhľadajte všetky výskyty Pos('/ v kóde a nasmerujte ich na jedného pomocníka pre porovnávanie celých tokenov. Zostavte zoznam skupín kľúčov so zhodnými predponami — /Length, /Encrypt, /N, /Type voči /Type1 sa v reálnych súboroch bežne vyskytujú. Potom prejdite každé celé číslo, ktoré vstupuje do SetLength, GetMem alebo cyklu kopírovania, a spýtajte sa, čo ho obmedzuje: veľkosť súboru, limit odvodený z hlavičky alebo vôbec nič. Analytická vrstva opísaná v tomto článku je základom nášho komponentu PDFium Component, kde čítač na úrovni bajtov a vykresľovací engine vzájomne kontrolujú každý dokument, ktorého sa dotknú