Tehnički članak

Ojačavanje Pascal PDF parsera protiv zlonamernih datoteka

PDF nije dokument koji samo otvarate. To je mali program koji pokrećete. Svaki ugrađeni font je interpreter zasnovan na steku (stack-based interpreter) koji čeka nizove znakova (charstrings), svaka slika je dekoder koji se napaja poljima širine, visine i dubine bitova koje je datoteka sama odabrala, a svaki tok podataka (stream) stiže umotan u filtere čije je parametre odredio sam fajl. Nijedan od tih brojeva nije vaš. Došli su od onoga ko je napravio datoteku, što je u realnom radu faktura klijenta ili prilog od nepoznatog pošiljaoca. Dekoderi koji te bajtove pretvaraju u piksele i glifove čine napadačku površinu (attack surface), a parser koji veruje svom ulazu je na korak od pada sistema ili nečeg goreg nakon učitavanja samo jedne neispravne datoteke

Biblioteka PDFlibPas je prošla kroz proces ojačavanja (hardening pass) koji je tretirao ceo dekodirajući put kao neprijateljski, uključujući programe fontova (TrueType, Type1, CFF i CMap tabele), dekodere slika (PNG, GIF, TIFF, JBIG2, i CCITT Group 3 i Group 4), i filtere tokova (LZW, ASCII85, i Flate prediktore). U nastavku je opisano pet klasa grešaka koje su otklonjene, od kojih je svaka utemeljena na specifičnom ponašanju Delphi-ju koje ih je omogućilo. One su ispravljene u trenutnim izdanjima, a iste situacije se ponavljaju u bilo kom Pascal kodu koji parsira nepouzdane ulazne podatke

Prekoračenje celobrojnih vrednosti koje rezultuje premalim baferom

Klasična greška u memorijskoj bezbednosti unutar dekodera slika jeste umnožak dimenzija koji se prekorači (wrap). Dekoder čita širinu, visinu, broj komponenti i dubinu bitova, množi ih da bi odredio veličinu svog izlaza, alocira toliko bajtova, a zatim upisuje sliku u njenim stvarnim dimenzijama. Ako se množenje vrši u 32-bitnoj aritmetici, proizvod se može svesti na malu vrednost čak i kada je svaki pojedinačni faktor u razumnom opsegu. Zbog toga alokacija uspeva, ali biva daleko premala, a proces dekodiranja izlazi van njenih granica. Ovo je CWE-190, prekoračenje celobrojne vrednosti (integer overflow), koje korak kasnije dovodi do upisivanja van granica hipa (heap out-of-bounds write - CWE-787)

Zajednički put slika je već ograničavao svaku dimenziju na 65535. Samostalni dekoderi nisu svi nasledili to ograničenje. Izraz tipa bajtovi-reda-puta-visina, kao što je ByteCount * FHeight, ili izraz po pikselu, kao što je FWidth * Components * BitDepth, jeste 32-bitni proizvod u Delphi-ju kada su oba operanda 32-bitni celi brojevi, bez obzira na to kolika je širina promenljive kojoj dodeljujete rezultat. Širina i visina od 60000 su sasvim razumne za veliki skenirani dokument, ali njihov proizvod u bajtovima prekoračuje označeni 32-bitni opseg i dužina ispada mala. Isti problem je postojao i u ZLib prediktor koraku (predictor stride), BitsPerComponent * Colors * Columns

Rešenje je da se bar jedan operand postavi kao Int64 kako bi se ceo izraz procenio u 64-bitnom formatu, a zatim da se uporedi sa MaxInt i datoteka odbije pre nego što se tip ponovo suzi za poziv SetLength:

// Reject before allocating, not after writing.
// Evaluate the product in Int64 so it cannot wrap at 32 bits.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
  Exit;  // hostile or unsupportable dimensions; refuse the image
SetLength(Buffer, RowBytes * FHeight);

Ono što ovo čini problemom Delphi-ja, a ne opštim problemom, jeste tiho sužavanje tipa (silent narrowing). Dodeljivanje preširokog izraza 32-bitnom odredištu je dozvoljena konverzija za koju prevodilac podrazumevano neće izdati upozorenje, a provera opsega ne hvata prekoračenje koje se desi pre nego što se vrednost uopšte upotrebi kao indeks. Ako ostavite proizvod na 32 bita, jezik vam tiho daje dužinu koja laže o tome koliko memorije proces dekodiranja treba da dotakne

Tip polja koji onemogućava aktiviranje zaštitnog uslova

TIFF datoteka je lanac direktorijuma slikovnih datoteka (image file directories), od kojih svaki nosi bajt-ofset sledećeg. Zlonamerna datoteka može usmeriti taj lanac nazad na samog sebe, a čitač koji prolazi kroz njega bez uslova za zaustavljanje radi zauvek. To je CWE-835, beskonačna petlja vođena ulazom pod kontrolom napadača, a odbrana se sastoji od brojača koji se zaustavlja čim pređe granicu koju nijedan legitiman fajl ne bi dostigao

Brojač stranica je bio deklarisan kao Word, što u Delphi-ju čuva vrednosti od 0 do 65535. Petlja je imala zaštitu za prekid u obliku "zaustavi kada broj stranica pređe 65535", što izgleda ispravno dok ne primetite da operand i prag dele istu gornju granicu. Word nikada ne može biti veći od 65535, tako da je poređenje strukturno uvek netačno: kada brojač dostigne 65535, sledeće uvećanje ga vraća na 0, zaštitni uslov nikada ne vidi vrednost iznad limita, i kružni IFD lanac drži čitač u beskonačnom radu

Rešenje je bilo proširenje polja kako bi zaštitni uslov mogao da izrazi vrednost koju brojač stvarno može da zadrži. Sa poljem TPDFTIFF.FPageCount deklarisanim kao Integer, isto poređenje FPageCount > 65535 postaje dostižno, petlja se prekida, a javno svojstvo PageCount je promenilo tip kako bi odgovaralo, a da pritom nije narušilo pozive klijenata. Kad god provera granica ima oblik Value > MaxValueOfType(Value), a operand je već tipiziran tačno na taj maksimum, uslov je konstantno netačan: proširite tip ili testirajte jednakost sa maksimumom kako bi se uslov mogao aktivirati

Isključena provera opsega na kritičnoj putanji (hot path)

Sa uključenom proverom opsega (range checking), Delphi ubacuje proveru granica na svaki indeks niza i stringa, što čini razliku između toga da indeks van opsega podiže uhvatljivu grešku ERangeError i toga da isti indeks čita ili piše po memoriji koja ne pripada toj strukturi. Kritične putanje (hot paths) je ponekad isključuju lokalnom direktivom {$R-}, što je opravdano sve dok indeksi ne prestanu da budu pouzdani

Pristupnik liste (list accessor) na koji se oslanjaju interpreteri fontova, TPDFlibStringList.Get, jeste upravo takva putanja. Na Windows-u se on kompajlira sa isključenom proverom opsega i direktno indeksira svoju memoriju za skladištenje, tako da indeks opsega nije greška već sirov pristup memoriji. To je u redu kada je indeks uvek validan, ali prestaje da bude u redu unutar CFF ili Type2 charstring interpretera, gde indeks može doći iz samog fajla. Charstring koji uklanja operand sa praznog steka proizvodi indeks minus jedan. Identifikator glifa koji je pomeren za jedan u odnosu na broj glifova indeksira jedno mesto iznad kraja niza. Sa isključenom proverom opsega, oba postaju stvarni pristupi van granica umesto uhvatljivog izuzetka, a pošto ta mesta sadrže AnsiString vrednosti sa brojanjem referenci, zalutalo čitanje može oštetiti i brojač referenci samog stringa

Proces ojačavanja nije ponovo uključio proveru opsega na kritičnoj putanji. On je prvo učinio indekse dokazivo ispravnim: pre uzimanja vrha steka operanda, interpreter proverava da stek nije prazan, a svaka zaštita indeksa napisana je kao strogo manje-od u odnosu na ukupan broj, a ne manje-ili-jednako što bi dozvolilo grešku odstupanja za jedan (off-by-one). Direktiva prebacuje odgovornost za granice sa kompajlera na vas, a validacija koju je uklonila mora se ručno vratiti na svakoj ulaznoj tački

Neograničena rekurzija u charstring interpreteru

Type2 charstring može pozvati potprogram (subroutine), a sam potprogram je charstring koji može pozvati drugi, tako da lokalni i globalni operatori poziva potprograma dozvoljavaju datoteci da odluči koliko duboko ide. Potprogram koji poziva sam sebe, direktno ili kroz petlju, rekurzivno se izvršava bez kraja dok se nativni stek ne iscrpi i proces ne padne. To je CWE-674, nekontrolisana rekurzija (uncontrolled recursion)

Type1 interpreter je već imao zaštitu od ovoga. On je imao brojač dubine poziva i gornju granicu, PLType1MaxCallDepth, i odbijao je da ide dublje od toga, što odražava limit dubine koji sam standard Type1 navodi. Type2 interpreter, dodat kasnije i strukturno sličan, nije imao istu zaštitu, i ručno konstruisan font sa potprogramom koji poziva sopstveni broj prolazi direktno kroz nedostajuću proveru pravo u prepunjavanje steka (stack overflow)

// The shape of the Type1 guard the Type2 path was missing.
// Track depth across nested calls and refuse to recurse past it.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
  Exit;  // hostile self-referential subroutine; stop descending
// ... interpret the subroutine, then Dec(CallDepth) on the way out

Rešenje je bilo da se Type2 putanji dodeli ista ograničena dubina koju je njen Type1 blizanac već imao. Svako rekurzivno spuštanje preko strukture pod kontrolom napadača, bilo da su to potprogrami fonta, ugnežđeni niz ili lanac unakrsnih referenci (cross-reference chain), zahteva limit dubine koji ulazni podaci ne mogu da zaobiđu

Neinicijalizovana memorija koja curi u izlazne podatke

Najsuptilnija greška je propuštala sadržaj hipa u dešifrovani izlaz, a uzrok je osobina funkcije SetLength koju je lako zaboraviti. Kada proširite AnsiString pomoću SetLength, Delphi alocira bajtove ali ih ne postavlja na nulu, tako da nova regija zadržava ono što je prethodno bilo u toj memoriji hipa. Ako se nakon toga upiše svaki bajt, ovo nikada nije problem. Ako neka putanja ostavi deo bafera neupisanim i zatim ga vrati kao podatke, ti zastareli bajtovi izlaze sa rezultatom. To je CWE-457, upotreba neinicijalizovane memorije (use of uninitialized memory), i kada rezultat pređe granicu poverenja, pretvara se u curenje informacija

Putanja AES-CBC dešifrovanja je naišla upravo na ovo. Izlazni bafer je dimenzionisan pomoću SetLength, a dešifrator je obrađivao šifrovani tekst (ciphertext) po 16-bajtni blok u određenom trenutku. Kada dužina šifrovanog teksta nije bila umnožak broja 16 — što je dužina koju napadač može odabrati — završni delimični blok nikada nije bio upisan, pa su ti poslednji bajtovi zadržali sadržaj hipa koji je SetLength ostavio iza sebe, i bafer je vraćen kao dešifrovani čisti tekst (plaintext) objekta dokumenta. Rešenje se sastoji od dve zaštite, i nijedna sama po sebi nije dovoljna: ulazna tačka dešifrovanja sada odbija svaki šifrovani tekst čija dužina nije umnožak veličine bloka, a kao dodatna zaštita, izlaz se pre upotrebe čisti pomoću FillChar tako da bilo koja putanja koja ne uspe da upiše regiju vraća nule umesto ostataka hipa

Čime vas ovaj proces ostavlja

Pet pomenutih defekata su različite greške, ali su slične. Širina celog broja koja dovodi do prekoračenja proizvoda, tip polja koji fiksira zaštitni uslov na konstantno netačnu vrednost, isključena provera opsega tamo gde su indeksi prestali da budu sigurni, rekurzija bez limita dubine i bafer koji jezik nije očistio na nulu. U svakom od njih Delphi je uradio tačno ono što definiše, jer vam jezik daje aritmetiku sa prekoračenjem, prećutno sužavanje tipa, provere opsega koje možete isključiti, rekurziju bez ugrađenog limita i alokaciju koja ne inicijalizuje memoriju. To je ugovor, a Pascal parser ga ispunjava tako što ručno preuzima kontrolu nad četiri stvari na svakoj granici koju datoteka kontroliše: širinom celog broja, proverom opsega, dubinom rekurzije i inicijalizacijom bafera

Ove greške su otklonjene u trenutnim izdanjima biblioteke PDFlibPas, mehanizma za Delphi i C++Builder. Ako se vaš rad odnosi i na to kako datoteka tvrdi da je zaštićena, prateće beleške o reviziji enkripcije i dozvola i o preflight proveri za PDF/A i PDF/UA pokrivaju analitičku stranu istog parsera, a sve to se isporučuje unutar PDFlibPas Delphi PDF biblioteke zajedno sa API-jima za učitavanje, renderovanje i potpisivanje koji su pokriveni na drugim mestima na ovom blogu