Tehnični članek

Varno razčlenjevanje slovarjev PDF v Delphiju: žetoni imen

Iskanje podnizov, kot je Pos('/Length', Dict), je napačno orodje za branje slovarja PDF, saj si ključi imen v formatu PDF delijo predpone: /Length je predpona za /Length1, /Encrypt pa je predpona za /EncryptMetadata. Standard ISO 32000-1 §7.3.5 opredeljuje ime kot žeton (token), ki se konča le z ločilom ali presledkom (whitespace), zato se ključ šteje za najdenega le takrat, ko je bajt za njim eden od teh znakov. Bralnik slovarjev, ki izpusti to eno preverjanje, bo sčasoma prebral napačno vrednost iz povsem veljavne datoteke

Hrošč, ki nas je tega naučil, sploh ni bil podoben težavi z leksikalnim analizatorjem (lexer). Skladnostno skeniranje je začelo poročati o toku FontFile kot o poškodovanem: dekompresirani program pisave je bil le fragment, odrezan sredi tabele. Datoteka se je brez težav odprla v vsakem pregledovalniku. Podatki toka na disku so bili nedotaknjeni. Izvor težave je bil v eni vrstici našega skupnega bralnika slovarjev: klic Pos('/Length', ...) se je ujemal z /Length1, standardnim ključem, ki ga vsebujejo slovarji tokov FontFile po tabeli 127 standarda ISO 32000-1, bralnik pa je vzel celo število za /Length1 kot dolžino toka. Ustvarjalec tiste konkretne datoteke je naključno serializiral /Length1 pred /Length, kar lahko stori povsem svobodno, saj so vnosi v slovar po §7.3.7 neurejeni. Tok je bil skrajšan na napačno število bajtov, vsako nadaljnje preverjanje, ki ga je porabilo, pa je tiho oslepelo

Ujemanje podnizov se pokvari, ker je imenski prostor PDF poln namernih družin predpon in ker vrstni red vnosov v slovar ni določen. Tabela 127 standarda ISO 32000-1 opredeljuje /Length1, /Length2 in /Length3 za vgrajene tokove pisav, ki se vsi nahajajo poleg ključa /Length v istem slovarju. Šifrirni slovar (encryption dictionary) povezuje /Encrypt v napovedniku (trailer) z /EncryptMetadata znotraj njega. Kratki ključi so še slabši: iskanje, zgrajeno kot Pos('/' + Key, ...) s Key = 'N', veselo pristane na /Name ali /Nums. Nobeno od teh trčenj ne zahteva poškodovane datoteke. Ustvarjalec, ki razvrsti /Length1 pred /Length, popolnoma ustreza standardu, kar pomeni, da hrošč s podnizi ni pomanjkljivost robustnosti pred poškodovanim vhodom — je pomanjkljivost pravilnosti pri veljavnem vhodu

Način odpovedi je prav tako tihe vrste. Napačen /Length ne sproži izjeme; preprosto vam izroči krajši ali daljši kos bajtov, kot ga tok dejansko zaseda. Če ta kos napaja preverjanje podmnožice pisav (font-subset), razčlenjevanje CMap ali skeniranje metapodatkov, porabnik vidi smeti in običajno ne poroča o ničemer, saj se polovica toka zlib preprosto ne uspe razširiti (inflate) in koda nadaljuje delo. Poslali smo točno ta razred napak in ga odpravili v različici v2.14.3 našega skupnega bralnika po natančni reviziji poglavij §7.2–§7.3 standarda ISO 32000-1, ki je vsako iskanje ključev v slogu Pos označila za sumljivo

Kaj standard ISO 32000-1 §7.3.5 dejansko opredeljuje kot ime

Razdelek 7.3.5 je kratek in natančen: objekt imena je poševnica (solidus), ki ji sledi zaporedje običajnih znakov, žeton pa se konča s prvim ločilom ali znakom za presledek. Ločila so osem znakov oklepajev ter poševnica in znak za odstotek — ( ) < > [ ] { } / % — presledki pa so ničelni znak (null), tabulator, pomik vrstice (line feed), pomik strani (form feed), vrnitev vozička (carriage return) in presledek (§7.2.2–§7.2.3). To pravilo o zaključku je celotna zgodba. /Length1 ni "/Length, ki mu sledi 1"; je en sam, nedeljiv žeton, natanko tako kot sta LengthOne and Length različna identifikatorja v Pascalu. Vsak bralnik, ki išče ključe s surovim iskanjem bajtov, ponovno implementira leksikalni analizator z izbrisanim pravilom o zaključku

// NAPAČNO: ujema se tudi z /Length1, /Length2, /Length3
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;

Ujemanje celotnih žetonov: preverite bajt za ključem

Pravilni predikat sledi neposredno iz §7.3.5: kandidat za ujemanje je pravi ključ le, če je znak takoj za njim ločilo, presledek ali konec medpomnilnika (buffer). Vse ostalo je daljše ime, ki si le deli predpono, zato se mora iskanje nadaljevati mimo njega, namesto da bi odnehali. Popravek v našem bralniku je zamenjal vsako surovo iskanje Pos z eno samo skupno rutino, zgrajeno na tem pravilu

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

// Pravilno: ujemanje celotnega žetona po 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);                       // žeton se konča tukaj: pravi ključ
    P := PosEx(Key, Dict, P + 1);    // predpona daljšega imena: išči naprej
  end;
end;

Dve podrobnosti v tej zanki sta pomembni. Prvič, iskanje se nadaljuje, namesto da bi vrnilo neuspeh ob prvem trčenju predpon, saj je zaporedje /Length1 120 /Length 4076 povsem zakonito in pravi ključ še sledi. Drugič, primer konca medpomnilnika šteje kot zaključni znak, saj se del slovarja lahko legitimno konča takoj za imenom. Subtilnejša točka, ki jo je vredno revidirati v lastni kodi: isto pravilo velja na levi strani ujemanja, če vaš iskalni niz ne vključuje poševnice, sicer lahko klic Pos('Length', ...) pristane znotraj /PieceLength. Sidranje iskalnega niza z vodilno poševnico /, kot je prikazano zgoraj, ureja levi rob, saj je / sam po sebi ločilo, ki zaključi predhodni žeton

Kako lahko zlonamerni PDF spremeni napako razčlenjevalnika v gigabajtno dodelitev pomnilnika?

Poškodovana ali zlonamerna datoteka lahko te leksikalne napake stopnjuje v izčrpanje virov, saj cela števila v slovarjih pogosto določajo velikosti dodelitev. Naša revizija je odkrila verigo točno takšne oblike pri razširjanju tokov objektov (object streams). Vnos /N v slovarju ObjStm določa, koliko stisnjenih objektov vsebuje tok, koda za razširitev pa je poklicala SetLength na matriki, ki je bila prilagojena tej velikosti. Vendar je razčlenjevalnik celih števil ob neuspehu pustil svoj izhodni parameter nespremenjen, a ga vseeno vrnil — tako je neštevilčni /N predal funkciji SetLength neinicializirano vrednost s sklada. Smetje (garbage) pozitivno celo število tam pomeni zahtevo po dodelitvi pomnilnika v gigabajtih, ki jo sproži le nekaj bajtov poškodovanega vhoda, in to med preprostim skeniranjem dokumenta, ki mu sploh še niste privolili zaupati

// /N je pod nadzorom napadalca; omejite ga s tem, kar lahko drži /First
if not TryReadDictInt(Dict, '/N', NVal) then
  NVal := 0;                          // eksplicitna ničla, nikoli smeti s sklada
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
  Exit;                               // glava ne more vsebovati toliko parov

// /Length nikoli ne more preseči datoteke, ki vsebuje tok
if (LenVal < 0) or (LenVal > SourceSize) then
  Exit;                               // zavrnite pred dodelitvijo medpomnilnika

Še dve omejitve dopolnjujeta obrambni perimeter v našem bralniku, obe uvedeni v različici v2.12.0. Bralnik tokov zavrne vsak /Length, ki je večji od celotne datoteke, preden dodeli ciljni medpomnilnik — tok ne more biti večji od vsebnika, v katerem živi, zato preverjanje nima lažnih pozitivnih rezultatov. In pot dekompresije omejuje razširjeni izhod na 256 MiB, kar prepreči klasično bombo zlib, kjer se nekaj kilobajtov vhoda razširi brez meja; meja je velikodušna za vsak dejanski tok PDF, hkrati pa ohranja najslabši možni scenarij preživet. Rdeča nit vseh treh omejitev je enaka: vsaka velikost, ki jo datoteka navaja, je le trditev, razčlenjevalnik pa vsako trditev preveri z nečim, kar lahko izmeri, preden zanjo dodeli pomnilnik. Enako revizijsko stališče velja eno plast nižje pri meji povezovanja, kar pokriva članek o utrjevanju PDFium ABI in pomnilniški varnosti v Delphiju

Kjer pravilo celotnega žetona ne zadošča

Iskrene omejitve, da ne boste preveč zaupali zgornji rutini. Ujemanje celotnih žetonov sicer popravi prepoznavanje ključev, vendar surovo iskanje bajtov po celotnem razponu slovarja še vedno ne more povedati, ali se ujemanje nahaja znotraj ugnezdenega slovarja, dobesednega niza ali komentarja — funkcija FindDictKey na objektu strani lahko pristane na ključu znotraj njegovega pod-slovarja /Resources, če ji posredujete preširok razpon. Naš bralnik najprej omeji razpon na telo posameznega objekta, kontekste nizov in komentarjev pa obravnava kot ločeno, še vedno odprto točko revizije. Varnost podnizov je le ena prečka na lestvi, ne pa celotna lestev: doslednost navzkrižnih sklicev je svoja lastna disciplina, ki jo pokriva članek o potrjevanju objektov in tokov navzkrižnih sklicev (xref streams), širši katalog groženj za dokumente, katerih avtor niste vi, pa je v članku o revidiranju varnostnih tveganj PDF

Če v Delphiju ali Lazarusu vzdržujete ročno napisan bralnik slovarjev, je kontrolni seznam iz tega incidenta kratek. Poiščite vsak Pos('/ v kodi in usmerite zadetke skozi enega pomočnika za celotne žetone. Naredite seznam družin predpon, v katerih sodelujejo vaši ključi — /Length, /Encrypt, /N, /Type proti /Type1 se vsi pojavljajo v resničnih datotekah. Nato prehodite vsako celo število, ki doseže SetLength, GetMem or kopirno zanko, in se vprašajte, kaj ga omejuje: velikost datoteke, meja izpeljana iz glave ali nič. Plast razčlenjevanja, opisana tukaj, je temelj pod našo komponento PDFium Component, kjer bralnik na ravni bajtov in pogon za upodabljanje navzkrižno preverjata drug drugega pri vsakem dokumentu, ki se ga dotaknejo