Pretrage podstringova kao što je Pos('/Length', Dict) pogrešan su alat za čitanje PDF rečnika (PDF dictionary) jer ključevi imena u PDF-u dele iste prefikse: /Length je prefiks za /Length1, a /Encrypt je prefiks za /EncryptMetadata. Standard ISO 32000-1 §7.3.5 definiše ime kao token koji se završava isključivo na graničniku (delimiter) ili belini (whitespace), tako da se ključ smatra pronađenim samo kada je bajt posle njega jedan od tih karaktera. Čitač rečnika koji preskoči ovu jednu proveru na kraju će pročitati pogrešnu vrednost iz potpuno ispravne datoteke
Greška koja nas je ovome naučila uopšte nije ličila na problem sa leksičkim analizatorom (lexer). Skeniranje usklađenosti počelo je da prijavljuje tok FontFile (FontFile stream) kao oštećen: dekompresovani program fonta bio je samo fragment, presečen na sredini tabele. Datoteka se normalno otvarala u svakom pregledaču. Podaci toka na disku bili su netaknuti. Koren problema ležao je u jednoj liniji našeg zajedničkog čitača rečnika: poziv Pos('/Length', ...) se poklopio sa /Length1, standardnim ključem koji rečnici toka FontFile nose prema tabeli 127 standarda ISO 32000-1, i čitač je uzeo ceo broj nakon /Length1 kao dužinu toka. Ispostavilo se da je pisač te konkretne datoteke serijalizovao /Length1 pre /Length, što je potpuno slobodan da uradi pošto su unosi u rečnik neuređeni prema §7.3.7. Tok je skraćen na pogrešan broj bajtova, a svaka naredna provera koja ga je koristila ostala je prećutno slepa
Zašto pretraga podstringova kvari parsiranje PDF rečnika
Pretraga podstringova ne uspeva jer je PDF imenski prostor pun namernih porodica prefiksa, i zato što redosled unosa u rečniku nije specificiran. Tabela 127 standarda ISO 32000-1 definiše /Length1, /Length2 i /Length3 za ugrađene tokove fontova, a svi oni stoje pored /Length u istom rečniku. Rečnik enkripcije uparuje /Encrypt u trejleru sa /EncryptMetadata unutar njega. Kratki ključevi su još gori: pretraga koncipirana kao Pos('/' + Key, ...) sa Key = 'N' rado pronalazi /Name ili /Nums. Nijedna od ovih kolizija ne zahteva neispravnu datoteku. Pisač koji postavi /Length1 pre /Length potpuno je usaglašen sa standardom, što znači da greška sa podstringom nije propust u robusnosti protiv neispravnog ulaza — to je propust u ispravnosti protiv validnog ulaza
Ovaj način otkazivanja je takođe tihe prirode. Pogrešna vrednost /Length ne izaziva izuzetak. Ona vam samo predaje kraći ili duži isečak bajtova nego što tok zapravo zauzima. Ako taj isečak hrani proveru podskupa fonta (font-subset), parsiranje CMap-a ili skeniranje metapodataka, primalac vidi neupotrebljive podatke i obično ne prijavljuje ništa jer polovina zlib toka jednostavno ne uspeva da se dekompresuje (inflate) i kod nastavlja dalje. Isporučili smo upravo ovu klasu defekta i popravili je u verziji v2.14.3 našeg zajedničkog čitača, nakon što je revizija standarda ISO 32000-1 §7.2–§7.3 član po član označila svaku pretragu ključeva u stilu Pos kao sumnjivu
Šta standard ISO 32000-1 §7.3.5 zapravo definiše kao ime
Odeljak 7.3.5 je kratak i precizan: objekat imena je kosa crta (solidus) praćena nizom običnih karaktera, a token se završava prvim graničnikom ili belinom. Graničnici su osam karaktera zagrada plus kosa crta i znak procenta — ( ) < > [ ] { } / % — a beline su null karakteri, tabulatori, prelazi u novi red, form feed, carriage return i razmak (§7.2.2–§7.2.3). To pravilo završetka je cela priča. /Length1 nije "/Length praćen brojem 1". To je jedinstven, nedeljiv token, baš kao što su LengthOne i Length različiti identifikatori u Pascal-u. Svaki čitač koji pronalazi ključeve pretragom sirovih bajtova ponovo implementira leksički analizator sa obbrisanim pravilom o završetku
Evo oblika defekta, svedenog na suštinu. Ova verzija se uspešno prevodi, prolazi testove nad datotekama čiji pisači postavljaju /Length na prvo mesto, ali kvari tokove podataka kod onih pisača koji to ne rade
// 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;
Poređenje celih tokena: proverite bajt posle ključa
Ispravan predikat sledi direktno iz §7.3.5: kandidat za preklapanje je stvarni ključ samo ako je karakter neposredno nakon njega graničnik, belina ili kraj bafera. Sve ostalo je duže ime koje samo deli prefiks, pa se pretraga mora nastaviti dalje umesto da se odustane. Ispravka u našem čitaču zamenila je svaku sirovu pretragu pomoću Pos jednom 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, pretraga se nastavlja umesto da se vrati neuspeh pri prvoj koliziji prefiksa, što je redosled /Length1 120 /Length 4076 legalan i pravi ključ je i dalje ispred. Drugo, slučaj kraja bafera se računa kao terminator, jer se fragment rečnika može legitimno završiti odmah nakon imena. Suptilnija tačka koju vredi proveriti u sopstvenom kodu: isto pravilo važi i na levoj strani preklapanja ako vaša pretraga ne uključuje kosu crtu, inače poziv Pos('Length', ...) može završiti unutar /PieceLength. Sidrenje pretrage sa vodećim /, kao što je prikazano iznad, rešava levu ivicu jer je sama kosa crta / graničnik koji završava prethodni token
Kako zlonamerni PDF može pretvoriti grešku parsera u alokaciju gigabajta
Neispravna ili zlonamerna datoteka eskalira ove leksičke greške u iscrpljivanje resursa, jer celi brojevi rečnika često utiču na veličine alokacije. Naša revizija je pronašla lanac upravo ovog oblika u ekspansiji toka objekata (object-stream expansion). Unos /N u rečniku ObjStm govori koliko kompresovanih objekata tok sadrži, a kod za ekspansiju je pozivao SetLength na nizu čija je veličina određena tim brojem. Međutim, parser celih brojeva je ostavio svoj izlazni parametar netaknutim u slučaju neuspeha iako ga je i dalje vraćao — tako da je ne-numerički /N predao SetLength-u neinicijalizovanu vrednost sa steka. Slučajni pozitivni ceo broj na tom mestu znači zahtev za alokaciju u gigabajtima, izazvan sa samo nekoliko bajtova neispravnog ulaza, i to tokom običnog skeniranja dokumenta kome još niste ni ukazali poverenje
Popravka je imala dva nezavisna dela, i oba se mogu generalizovati. Parser sada vraća eksplicitnu 0 u slučaju neuspeha, nikada neinicijalizovanu memoriju. Takođe, primalac više ne veruje vrednosti /N bez provere: zaglavlje ObjStm pre /First skladišti par celih brojeva — broj objekta i ofset — za svaki kompresovani objekat, a svaki par zauzima najmanje četiri bajta uključujući separatore. Bilo koji /N iznad FirstVal div 4 + 1 je stoga fizički nemoguć za deklarisanu veličinu zaglavlja i odbija se pre nego što se desi bilo kakva alokacija. Ova granica košta samo jedno poređenje i izvedena je iz podataka koji su već pri ruci, što je šablon koji treba tražiti: plafon koji 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š dva plafona kompletiraju odbrambeni perimetar u našem čitaču, oba isporučena u verziji v2.12.0. Čitač toka odbija bilo koji /Length veći od cele datoteke pre nego što alocira rezultujući bafer — tok ne može biti veći od kontejnera u kom se nalazi, tako da je ova provera bez lažno pozitivnih rezultata. Takođe, inflate putanja ograničava dekompresovani izlaz na 256 MiB, što zaustavlja klasičnu zlib bombu gde se nekoliko kilobajta ulaza širi bez ograničenja. Ovo ograničenje je velikodušno za bilo koji stvarni PDF tok, a istovremeno čini najgori scenario preživim. Tema kroz sva tri slučaja je ista: svaka veličina koju datoteka deklariše je tvrdnja, a parser verifikuje svaku tvrdnju u odnosu na nešto što može da izmeri pre nego što joj dodeli memoriju. Isti revizorski stav se primenjuje jedan sloj niže na granici povezivanja, što je pokriveno u članku o jačanju PDFium ABI-ja i memorijskoj bezbednosti u Delphi-ju
Gde pravilo celog tokena nije dovoljno
Iskrena ograničenja, kako ne biste previše verovali gornjoj rutini. Poređenje celih tokena rešava identifikaciju ključa, ali ravna pretraga bajtova preko opsega rečnika i dalje ne može reći da li se preklapanje nalazi unutar ugnježdenog rečnika, literalnog stringa ili komentara — FindDictKey na objektu stranice može sleteti na ključ unutar njegovog podređenog rečnika /Resources ako mu predate preširok opseg. Naš čitač prvo ograničava opseg na telo jednog objekta, a kontekste stringova i komentara tretira kao zasebnu, još uvek otvorenu stavku revizije. Sigurnost podstringa je samo jedna prečka na merdevinama, a ne cele merdevine: konzistentnost unakrsnih referenci (cross-reference) je sopstvena disciplina, pokrivena u članku o validaciji objekata i xref tokova, a širi katalog pretnji za dokumente čiji niste autor nalazi se u članku o reviziji bezbednosnih rizika PDF-a
Ako održavate ručno pisani čitač rečnika u Delphi-ju ili Lazarus-u, kontrolna lista nakon ovog incidenta je kratka. Potražite (grep) svaku instancu Pos('/ u bazi koda i usmerite te pogotke kroz jednu pomoćnu funkciju za cele tokene. Popišite porodice prefiksa u kojima vaši ključevi učestvuju — /Length, /Encrypt, /N, /Type naspram /Type1 se pojavljuju u realnim datotekama. Zatim prođite kroz svaki ceo broj koji stiže do SetLength, GetMem ili petlje za kopiranje i zapitajte se šta ga ograničava: veličina datoteke, plafon izveden iz zaglavlja ili ništa. Sloj za prasiranje opisan ovde je osnova naše PDFium komponente, gde se čitač na nivou bajtova i mehanizam za renderovanje međusobno proveravaju na svakom dokumentu koji dotaknu