Technical Article

Utrjevanje razčlenjevalnika PDF v Pascalu pred zlonamernimi datotekami

PDF ni le dokument, ki ga odprete. Je majhen program, ki ga zaženete. Vsaka vgrajena pisava je interpreter na podlagi sklada, ki čaka na nize znakov (charstrings), vsaka slika je dekodirnik, ki se napaja s polji širine, višine in barvne globine, ki jih je izbrala datoteka, vsak tok pa prispe zavit v filtre, katerih parametre je nastavila datoteka. Nobena od teh številk ni vaša. Prišle so od tistega, ki je ustvaril datoteko, kar je v praksi račun stranke ali priloga neznanega pošiljatelja. Dekodirniki, ki te bajte spremenijo v slikovne pike in glife, so napadalna površina, razčlenjevalnik, ki tam zaupa svojemu vhodu, pa je le eno nepravilno oblikovano datoteko oddaljen od sesutja ali česa hujšega.

Knjižnica PDFlibPas je šla skozi cikel utrjevanja, ki je celotno pot dekodiranja obravnaval kot sovražno, tako pri programih pisav (TrueType, Type1, CFF in tabele CMap) kot pri dekodirnikih slik (PNG, GIF, TIFF, JBIG2 ter CCITT Group 3 in Group 4) in filtrih tokov (LZW, ASCII85 in Flate prediktorji). V nadaljevanju je opisanih pet razredov napak, ki jih je odpravil, pri čemer vsak temelji na specifičnem obnašanju Delphija, ki jih je omogočilo. Te napake so v trenutnih različicah odpravljene, enaki vzorci pa se ponavljajo v kateri koli kodi Pascal, ki razčlenjuje nezaupljiv vhod.

Prekoračitev celih števil, ki vam izroči premajhen medpomnilnik

Klasična napaka pomnilniške varnosti v dekodirniku slik je produkt dimenzij, ki se prekorači. Dekodirnik prebere širino, višino, število komponent in barvno globino, jih pomnoži za določitev velikosti svojega izhoda, dodeli toliko bajtov in nato zapiše sliko v njenih dejanskih dimenzijah. Če se množenje izvede v 32-bitni aritmetiki, se lahko produkt ovije v majhno vrednost, tudi če je vsak posamezen dejavnik znotraj razumnega obsega, zato dodelitev uspe, a je rezultat veliko premajhen, dekodiranje pa poteka izven meja dodeljenega prostora. To je CWE-190 (integer overflow), kar en korak kasneje vodi do pisanja izven meja kopice (CWE-787).

Skupna pot slik je že omejila vsako dimenzijo na 65535; samostojni dekodirniki pa niso vsi podedovali te omejitve. Izraz RowBytes-krat-višina, kot je ByteCount * FHeight, ali izraz na slikovno piko, kot je FWidth * Components * BitDepth, je v Delphiju 32-bitni produkt, ko sta oba operanda 32-bitna cela števila, ne glede na to, kako široka je spremenljivka, ki ji dodelite rezultat. Širina in višina 60000 sta obe verjetni za veliko skeniranje, vendar njun produkt v bajtih preseže predznačeno 32-bitno območje in dolžina se vrne kot majhna. Ista past je bila prisotna v koraku prediktorja ZLib: BitsPerComponent * Colors * Columns.

Popravek je v tem, da vsaj en operand spremenimo v Int64, tako da se celoten izraz ovrednoti v 64-bitni obliki, nato pa ga primerjamo z MaxInt in zavrnemo datoteko pred ponovnim zoženjem za klic 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);

Težava, ki jo dela specifično za Delphi in ne splošno, je tiho zoženje. Dodelitev preširokega izraza v 32-bitni cilj je zakonita pretvorba, o kateri prevajalnik privzeto ne bo opozoril, preverjanje obsega pa ne ujame prekoračitve, ki se zgodi, preden se vrednost sploh uporabi kot indeks. Pustite produkt pri 32 bitih in jezik vam bo potihoma dal dolžino, ki laže o tem, koliko pomnilnika se bo dekodiranje dotaknilo.

Tip polja, zaradi katerega se zaščita ne more sprožiti

Datoteka TIFF je veriga imenikov slikovnih datotek (image file directories - IFD), od katerih vsak prenaša bajtni odmik naslednjega. Zlonamerna datoteka lahko to verigo usmeri nazaj nase, bralnik, ki jo prehodi brez pogoja za zaustavitev, pa deluje neskončno. To je CWE-835, neskončna zanka pod vplivom napadalčevega vhoda, obramba pa je števec, ki se ustavi, ko preide mejo, ki je ne bi dosegla nobena legitimna datoteka.

Števec strani je bil deklariran kot Word, ki v Delphiju drži vrednosti od 0 do 65535. Zanka je vsebovala zaščito za ustavitev v obliki "ustavi se, ko število strani preseže 65535", kar se bere kot pravilno, dokler ne opazite, da si operand in prag delita zgornjo mejo. Tip Word ne more biti nikoli večji od 65535, zato je primerjava strukturno vedno napačna: ko števec doseže 65535, ga naslednji inkrement ponastavi na 0, zaščita nikoli ne zazna vrednosti nad zgornjo mejo, krožna veriga IFD pa bralnik drži v neskončnem vrtenju.

Rešitev je bila razširitev polja, tako da zaščita lahko izrazi vrednost, ki jo števec dejansko lahko zadrži. Ko je TPDFTIFF.FPageCount deklariran kot Integer, ista primerjava FPageCount > 65535 postane dosegljiva, zanka se zaključi, javna lastnost PageCount pa je spremenila svoj tip, da se ujema, ne da bi s tem zlomila klicatelje. Kadar koli ima preverjanje meja obliko Value > MaxValueOfType(Value) in je operand že tipiziran na natanko ta maksimum, je pogoj konstantno napačen: razširite tip ali preizkusite enakost z maksimumom, da se lahko sproži.

Izklopljeno preverjanje obsega na vroči poti

Ko je preverjanje obsega vklopljeno, Delphi vstavi preizkus meja pri vsakem indeksu polja in niza, kar predstavlja razliko med indeksom izven obsega, ki sproži ulovljiv ERangeError, in tem istim indeksu, ki bere ali piše pomnilnik, ki ne pripada tej strukturi. Vroče poti ga včasih onemogočijo z lokalno direktivo {$R-}, kar je opravičljivo le do trenutka, ko indeksi prenehajo biti vredni zaupanja.

Dostopnik seznama, na katerega se opirajo tolmači pisav, TPDFlibStringList.Get, je natanko takšna pot. Na sistemu Windows je preveden z izklopljenim preverjanjem obsega in neposredno indeksira svojo shrambo, zato indeks izven obsega ni napaka, temveč surov dostop do pomnilnika. To je v redu, ko je indeks vedno veljaven, preneha pa biti v redu znotraj tolmača charstring CFF ali Type2, kjer indeks lahko pride iz datoteke. Niz charstring, ki vzame operand s praznega sklada, ustvari indeks minus ena; identifikator glifa, zamaknjen za ena glede na število glifov, pa indeksira eno mesto čez konec. Z izklopljenim preverjanjem obsega oba postaneta dejanski dostop izven meja namesto ulovljive izjeme, in ker mesta vsebujejo referenčno štete vrednosti AnsiString, lahko naključno branje pokvari tudi števec referenc niza.

Utrjevanje ni ponovno vklopilo preverjanja obsega za vročo pot. Najprej je naredilo indekse dokazljivo veljavne: pred jemanjem vrha sklada operandov tolmač preveri, ali sklad ni prazen, vsaka zaščita indeksa pa je bila zapisana kot strogi manj-kot (less-than) glede na število, namesto manj-kot-ali-enako, kar bi dopuščalo zamik za ena. Direktiva prenese odgovornost za meje s prevajalnika na vas, validacijo, ki jo je odstranila, pa je treba ročno postaviti nazaj na vsaki vstopni točki.

Neomejena rekurzija v tolmaču charstring

Niz charstring Type2 lahko pokliče podprogram, podprogram pa je sam po sebi charstring, ki lahko pokliče drugega, zato lokalni in globalni operaterji klica podprograma pustijo datoteki, da odloči, kako globoko gre. Podprogram, ki pokliče samega sebe, neposredno ali prek kroga, se neskončno rekurzivno izvaja, dokler se lastni sklad ne izčrpa in proces ne umre. To je CWE-674, nenadzorovana rekurzija.

Tolmač Type1 je že imel zaščito pred tem. Imel je števec globine klicanja in zgornjo mejo PLType1MaxCallDepth ter je zavrnil spust pod to mejo, kar odraža mejo globine, ki jo določa specifikacija Type1. Tolmač Type2, dodan pozneje in strukturno podoben, ni vseboval enake zaščite, ročno zgrajena pisava s podprogramom, ki kliče svojo številko, pa se je sprehodila skozi manjkajoče preverjanje neposredno v prekoračitev sklada (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

Popravek je bil v tem, da je pot Type2 dobila enako omejeno globino, kot jo je že imela njena sestrska različica Type1. Vsak rekurzivni spust čez strukturo pod vplivom napadalca, pa naj gre za podprograme pisav, ugnezdeno polje ali verigo navzkrižnih referenc, potrebuje zgornjo mejo globine, ki je vhod ne more dvigniti.

Neinicializiran pomnilnik, ki izteka v izhod

Najbolj subtilna napaka je izpuščala vsebino kopice v dešifriran izhod, vzrok pa je lastnost metode SetLength, ki jo je lahko pozabiti. Ko povečate AnsiString z metodo SetLength, Delphi dodeli bajte, a jih ne ponastavi na nič, zato novo območje zadrži vse, kar je bilo prej v tem pomnilniku kopice. Če se kasneje vsak bajt zapiše, to ni pomembno; če pa pot pusti del medpomnilnika nezapisan in ga nato vrne kot podatke, se ti zastareli bajti vrnejo z rezultatom. To je CWE-457 (uporaba neinicializiranega pomnilnika), ko rezultat prečka mejo zaupanja, pa postane iztekanje informacij.

Dešifrirna pot AES-CBC je naletela prav na to. Izhodni medpomnilnik je določil svojo velikost s SetLength, dešifrirnik pa je obdelal šifrirano besedilo po en 16-bajtni blok naenkrat. Ko dolžina šifriranega besedila ni bila večkratnik števila 16, kar je dolžina, ki jo napadalec lahko izbere, se končni delni blok ni nikoli zapisal, zato so ti končni bajti obdržali vsebino kopice, ki jo je pustil SetLength, medpomnilnik pa se je vrnil kot dešifrirano besedilo dokumenta. Rešitev sta dve zaščiti in nobena sama ne zadošča: vstopna točka dešifriranja zdaj zavrne katero koli šifrirano besedilo, katerega dolžina ni večkratnik velikosti bloka, kot varovalo pa se izhod pred uporabo počisti s FillChar, tako da vsaka pot, ki ne zapiše območja, vrne ničle namesto ostankov kopice.

Kaj vam ta cikel zapusti

Pet napak so različni hrošči, a se ujemajo. Širina celega števila, ki prekorači produkt, tip polja, ki zaščito pripne na konstantno napačno vrednost, izklopljeno preverjanje obsega, kjer indeksi prenehajo biti varni, rekurzija brez dna in medpomnilnik, ki ga jezik ni ponastavil na nič. V vsaki od teh je Delphi storil natanko to, kar definira, saj vam jezik ponuja aritmetiko s prekoračitvijo, tiho oženje, možnost izklopa preverjanja obsega, rekurzijo brez vgrajene meje in dodelitev brez inicializacije. To je pogodba, razčlenjevalnik Pascal pa jo izpolni tako, da ročno upravlja štiri stvari na vsaki meji pod nadzorom datoteke: širino celega števila, preverjanje obsega, globino rekurzije in inicializacijo medpomnilnika.

Te napake so odpravljene v trenutnih različicah knjižnice PDFlibPas, pogona za Delphi in C++Builder. Če vaše delo sega tudi v to, kako datoteka trdi, da je zaščitena, spremljajoči zapisi o reviziji šifriranja in dovoljenj ter o predpripravi za PDF/A in PDF/UA pokrivajo analitično stran istega razčlenjevalnika, vse to pa se dostavlja znotraj knjižnice PDFlibPas Delphi PDF Library, skupaj z vmesniki API za nalaganje, upodabljanje in podpisovanje, ki so obravnavani drugje na tem blogu.