Technical Article

Sigurno raščlanjivanje PDF rječnika u Delphiju: tokeni naziva (name tokens)

Pretraživanje podnizova kao što je Pos('/Length', Dict) pogrešan je alat za čitanje PDF rječnika, jer ključevi naziva u PDF-u dijele prefikse: /Length je prefiks od /Length1, a /Encrypt je prefiks od /EncryptMetadata. ISO 32000-1 §7.3.5 definira naziv (name) kao token koji završava samo graničnikom (delimiter) ili praznim prostorom (whitespace), tako da se ključ smatra pronađenim samo kada je bajt iza njega jedan od tih znakova. Čitač rječnika koji preskoči tu jednu provjeru s vremenom će pročitati pogrešnu vrijednost iz savršeno valjane datoteke

Pogreška koja nas je tome naučila uopće nije izgledala kao problem leksičkog analizatora. Skeniranje sukladnosti počelo je prijavljivati da je struja (stream) FontFile oštećena: dekomprimirani program fonta bio je samo fragment, odsječen na sredini tablice. Datoteka se normalno otvarala u svakom pregledniku. Podaci struje na disku bili su netaknuti. Izvorni uzrok sjedio je u jednom retku našeg zajedničkog čitača rječnika: Pos('/Length', ...) se podudarao s /Length1, standardnim ključem koji nose rječnici struje FontFile prema tablici 127 standarda ISO 32000-1, a čitač je uzeo cijeli broj iza /Length1 kao duljinu struje. Autor te konkretne datoteke slučajno je serijalizirao /Length1 prije /Length, što mu je u potpunosti dopušteno jer su unosi u rječnik neuredni (unordered) prema §7.3.7. Struja je skraćena na pogrešan broj bajtova, a svaka provjera nakon toga koja ju je trošila tiho je zakazala

Zašto podudaranje podnizova kvari raščlanjivanje PDF rječnika?

Podudaranje podnizova propada jer je prostor naziva u PDF-u pun namjernih obitelji prefiksa, i zato što redoslijed unosa u rječnik nije specificiran. Tablica 127 standarda ISO 32000-1 definira /Length1, /Length2 i /Length3 za ugrađene struje fontova, a svi se nalaze pokraj /Length u istom rječniku. Rječnik šifriranja (encryption dictionary) uparuje /Encrypt u traileru s /EncryptMetadata unutar njega. Kratki ključevi su još gori: pretraživanje izgrađeno kao Pos('/' + Key, ...) s Key = 'N' sretno slijeće na /Name ili /Nums. Nijedan od ovih sudara ne zahtijeva loše oblikovanu datoteku. Autor koji poredi /Length1 prije /Length potpuno je sukladan, što znači da pogreška s podnizom nije praznina u robusnosti protiv pokvarenog ulaza — to je jaz u ispravnosti protiv valjanog ulaza

Način neuspjeha je također tih. Pogrešna duljina /Length ne izaziva iznimku (exception); ona vam predaje kraći ili duži odsječak bajtova nego što struja zapravo zauzima. Ako taj odsječak hrani provjeru podskupa fonta, raščlanjivanje CMap-a ili skeniranje metapodataka, potrošač vidi smeće i obično ne prijavljuje ništa, jer se pola zlib struje jednostavno ne može dekomprimirati (inflate) i kôd ide dalje. Isporučili smo točno ovu klasu kvara i popravili je u verziji v2.14.3 našeg zajedničkog čitača, nakon što je revizija klauzule po klauzulu standarda ISO 32000-1 §7.2–§7.3 označila svako pretraživanje ključeva u stilu Pos-a kao sumnjivo

Što ISO 32000-1 §7.3.5 zapravo definira kao naziv

Odjeljak 7.3.5 kratak je i precizan: objekt naziva (name object) je kosa crta (solidus) praćena nizom običnih znakova, a token završava na prvom graničniku (delimiter) ili znaku praznog prostora (whitespace). Graničnici su osam znakova zagrada plus kosa crta i znak postotka — ( ) < > [ ] { } / % — a prazan prostor je null, tabulator, prijelaz u novi red, form feed, carriage return i razmak (§7.2.2–§7.2.3). To pravilo završetka je cijela priča. /Length1 nije '/Length praćen znakom 1'; to je jedinstven, nedjeljiv token, baš kao što su LengthOne i Length različiti identifikatori u Pascalu. Svaki čitač koji pronalazi ključeve pretraživanjem sirovih bajtova ponovno implementira leksički analizator bez pravila o završetku

Evo oblika kvara, svedenog na bitno. Ova se verzija prevodi, prolazi testove protiv datoteka u kojima autori prvo stavljaju /Length, te kvari struje za one koji to ne čine

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

Podudaranje cijelog tokena: provjerite bajt nakon ključa

Ispravan predikat slijedi izravno iz §7.3.5: kandidat za podudaranje je stvarni ključ samo ako je znak neposredno nakon njega graničnik, prazan prostor ili kraj međuspremnika (buffer). Sve ostalo je duži naziv koji samo dijeli prefiks, pa se pretraživanje mora nastaviti dalje umjesto odustajanja. Ispravak u našem čitaču zamijenio je svako sirovo pretraživanje pomoću Pos-a jedinstvenom zajedničkom rutinom izgrađenom na ovom pravilu

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 detalja u toj petlji imaju težinu. Prvo, ona nastavlja pretraživati umjesto vraćanja neuspjeha pri prvom sudaru prefiksa, ove /Length1 120 /Length 4076 je legalan redoslijed i stvarni ključ je još uvijek ispred. Drugo, slučaj kraja međuspremnika računa se kao graničnik, budući da fragment rječnika može legitimno završiti odmah nakon naziva. Suptilnija točka koju vrijedi revidirati u vlastitom kodu: isto pravilo vrijedi i na lijevoj strani podudaranja ako vaš niz za pretraživanje ne uključuje kosu crtu, inače Pos('Length', ...) može sletjeti unutar /PieceLength. Sidrenje niza za pretraživanje s vodećim /, kao gore, rješava lijevi rub jer je / sam po sebi graničnik koji završava prethodni token

Kako zlonamjerni PDF može pretvoriti pogrešku parsera u alokaciju gigabajta?

Loše oblikovana ili zlonamjerna datoteka pretvara ove leksičke pogreške u iscrpljivanje resursa, jer cijeli brojevi rječnika često pune veličine alokacija. Naša revizija pronašla je lanac točno takvog oblika u proširenju struje objekata (object-stream expansion). Unos /N u ObjStm rječnik govori koliko komprimiranih objekata struja drži, a kôd za proširenje pozvao je SetLength na nizu čija je veličina time određena. Međutim, parser cijelih brojeva ostavio je svoj izlazni parametar netaknutim u slučaju neuspjeha iako ga je i dalje vraćao — tako da je ne-numerički /N predao funkciji SetLength neinicijaliziranu vrijednost sa stoga. Tamošnji pogrešan pozitivan cijeli broj znači zahtjev za alokaciju u gigabajtima, pokrenut s nekoliko bajtova oštećenog ulaza, i to tijekom samog skeniranja dokumenta kojem još niste ni pristali vjerovati

Popravak se sastojao od dva neovisna dijela, a oba se mogu generalizirati. Parser sada u slučaju neuspjeha vraća eksplicitnu 0, a nikada neinicijaliziranu memoriju. A potrošač više ne vjeruje vrijednosti /N bez aritmetičke provjere: područje zaglavlja ObjStm-a prije /First pohranjuje par cijelih brojeva — broj objekta i pomak — za svaki komprimirani objekt, a svaki par zauzima najmanje četiri bajta uključujući separatore. Bilo koji /N iznad FirstVal div 4 + 1 stoga je fizički nemoguć za deklariranu veličinu zaglavlja i odbija se prije nego što se dogodi bilo kakva alokacija. Ograničenje košta samo jednu usporedbu i izvedeno je iz podataka koje već imamo u rukama, što je obrazac koji treba tražiti: gornja granica koju sama datoteka dokazuje, a ne proizvoljna konstanta

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

Još dvije gornje granice upotpunjuju obrambeni perimetar u našem čitaču, obje isporučene u verziji v2.12.0. Čitač struje odbija bilo koji /Length veći od cijele datoteke prije nego što dodijeli međuspremnik za rezultat — struja ne može biti veća od spremnika u kojem živi, tako da je provjera bez lažnih pozitiva. A put dekompresije (inflate) ograničava dekomprimirani izlaz na 256 MiB, što zaustavlja klasičnu 'zlib bombu' gdje se nekoliko kilobajta ulaza širi bez ograničenja; granica je velikodušna za bilo koju stvarnu PDF struju, dok najgori slučaj održava preživim. Tema u sva tri slučaja je ista: svaka veličina koju datoteka deklarira je tvrdnja, a parser provjerava svaku tvrdnju u odnosu na nešto što može izmjeriti prije nego što joj dodijeli memoriju. Isti se stav primjenjuje i jedan sloj niže na granici povezivanja, što je pokriveno u očvršćivanju PDFium ABI-ja i sigurnosti memorije u Delphiju

Gdje pravilo cijelog tokena nije dovoljno

Iskrena ograničenja, kako ne biste previše vjerovali gornjoj rutini. Podudaranje cijelog tokena rješava identifikaciju ključa, ali ravno pretraživanje bajtova preko raspona rječnika i dalje ne može reći nalazi li se podudaranje unutar ugniježđenog rječnika, doslovnog niza (literal string) ili komentara — FindDictKey na objektu stranice može sletjeti na ključ unutar njegovog subrječnika /Resources ako mu predate preširok raspon. Naš čitač najprije ograničava raspon na tijelo jednog objekta i tretira kontekste nizova i komentara kao zasebnu, još uvijek otvorenu stavku revizije. Sigurnost podniza je samo jedna prečka na ljestvici, a ne cijele ljestvice: dosljednost unakrsnih referenci (cross-reference) je zasebna disciplina, pokrivena u validiranju objekata i unakrsnih referenci, a širi katalog prijetnji za dokumente koje niste sami izradili nalazi se u reviziji sigurnosnih rizika PDF-a

Ako održavate ručno napisani čitač rječnika u Delphiju ili Lazarusu, kontrolni popis iz ovog incidenta je kratak. Potražite (grep-om) svaki Pos('/ u bazi koda i usmjerite pogotke kroz jednog pomoćnika za cijeli token. Popišite obitelji prefiksa u kojima vaši ključevi sudjeluju — /Length, /Encrypt, /N, /Type naspram /Type1, svi se oni pojavljuju u stvarnim datotekama. Zanimljivo, tada prođite kroz svaki cijeli broj koji dosegne SetLength, GetMem ili petlju kopiranja i pitajte što ga ograničava: veličina datoteke, gornja granica izvedena iz zaglavlja ili ništa. Sloj raščlanjivanja opisan ovdje je temelj pod našom PDFium komponentom, gdje čitač na razini bajtova i mehanizam za iscrtavanje međusobno provjeravaju svaki dokument koji dodirnu