Cevovod za sprejem dokumentov sprejema datoteke, ki so jih napisali neznanci. Računi, skeni, priloge iz spletnih obrazcev: vsak od njih trdi, da je PDF in vsebuje na stotine številk, na podlagi katerih naj bi vaš razčlenjevalnik (parser) deloval. Dolžine tokov, dimenzije slik, bajtni odmiki (byte offsets), reference na objekte — prav vsako od the številk je izbral tisti, ki je datoteko ustvaril, in prekinjen prenos ali namerno poškodovan dokument bo slej ko prej postavil katero izmed teh številk na mesto, kjer bo povzročila škodo. Razlika med razčlenjevalnikom, ki preživi srečanje s takšno datoteko in tistim, ki se sesuje ali pa nadaljuje z izvajanjem s poškodovanim pomnilnikom, tiči v majhnem naboru navad, ki niso odvisne od nobene specifične PDF knjižnice
Te navade imajo skupno eno predpostavko: vrednost, prebrana iz datoteke, je le trditev (claim) in ne meritev (measurement). Uporabna postane šele po tem, ko je preverjena glede na nekaj, kar je razčlenjevalnik izmeril sam — dejansko velikost datoteke, dejansko število bajtov, ki jih je proizvedel dekoder, ali dejansko globino rekurzije. V nadaljevanju je ta predpostavka aplicirana na tista mesta, kjer se razčlenjevalniki dokumentov dejansko zlomijo
Deklarirana dolžina je trditev, ne meritev
Najenostavnejše neskladje je dolžina toka. Objekt toka v PDF-ju (stream object) v ključu /Length deklarira svoje število bajtov, dejanski podatki pa se nahajajo med ključnima besedama stream in endstream. Nič ne sili obeh, da se morata ujemati. Odrezana datoteka vsebuje manj dejanskih bajtov, kot je deklarirano število; datoteka, ki prihaja iz pokvarjenega generatorja, pa lahko deklarira dolžino, ki sega prek konca datoteke ali pa v sosednji objekt. Če boste alocirali pomnilnik na podlagi deklarirane vrednosti in kopirali do besede endstream, boste presegli medpomnilnik; če boste prebrali točno takšno število, kot je deklarirano, ne da bi preverili razpoložljivost podatkov, boste stopili čez konec datoteke. Naj deklarirana vrednost narekuje alokacijo šele po tem, ko jo omejite glede na izmerjeno razdaljo do konca podatkov, neskladje pa obravnavajte kot točko odločitve — bodisi popravite s skeniranjem do endstream bodisi tok zavrnite —, nikoli pa ji ne smete slepo verjeti
Parametri slike, ki opisujejo večji raster, kot ste ga alocirali
Slikovni tokovi višajo vložek, saj dva neodvisna niza številk opisujeta iste piksle. Slovar slike vsebuje /Width in /Height in na podlagi teh dimenzij se običajno določi velikost rastrskih medpomnilnikov (raster buffers). Dekodirni filter vsebuje svojo lastno geometrijo: CCITTFaxDecode prevzame /Columns, /Rows in /K iz svojega DecodeParms, pri čemer /K izbere shemo Group 3 ali Group 4, dekoder pa izda (Columns + 7) div 8 bajtov na vsako vrstico skeniranja (scanline). Datoteka, ki deklarira /Width 100, a podaja filtru /Columns 1728 — kar je privzeto —, povzroči, da dekoder proizvede več kot šestnajstkrat več bajtov na vrstico, kot to pričakuje medpomnilnik, preliv (overflow) pa pristane po eno vrstico naenkrat v vsem tistem, kar se v pomnilniku nahaja za alokacijo. Ko /Rows ni prisoten, dekoder teče toliko časa, dokler mu podatki ne ukažejo, naj preneha, zato obvezno zamejite tudi število vrstic. DCTDecode ima isti precep: JPEG podatki nosijo svojo lastno širino in višino v svojem markerju SOF in prav nič ju ne zavezuje, da se morata ujemati s slovarjem
Defenzivno pravilo je mehansko: izračunajte pričakovano velikost rastra iz validiranih parametrov dekodiranja — filtrovih lastnih vrednosti /Columns in /Rows za CCITT ter SOF dimenzij za DCT —, jo preverite v primerjavi s svojimi omejitvami, iz nje izvedite alokacijo in med dekodiranjem preverjajte, da izhod nikoli ne seže prek alociranega prostora. Ko se slovar in filter ne ujemata glede geometrije, ju uskladite ali pa zavrnite sliko. Tisto, česar razčlenjevalnik nikoli ne sme storiti, je to, da določi velikost medpomnilnika na podlagi enega niza številk in hkrati dopusti dekoderju, da deluje na podlagi drugega
Pasti pri aritmetiki in alokaciji v Delphiju
Tri obnašanja v Delphiju ogrožajo celo razčlenjevalnik, ki ima namen vršiti validacijo. Prvo je 32-bitno množenje: Delphi izračuna produkt dveh Integer operandov na 32 bitih ne glede na širino cilja, zato se lahko izraz Width * Height * BytesPerPixel prelomi (wrap), tudi če vsak faktor prestane svoj lastni preizkus smiselnosti (sanity check). Sken velikosti 30000 krat 30000 pri treh bajtih na piksel znaša 2,7 milijarde bajtov, kar se v predznačeni 32-bitni aritmetiki prelomi v negativno vrednost; nekoliko drugačni faktorji pa se prelomijo v majhno pozitivno dolžino, ki povzroči alokacijo premajhnega medpomnilnika. Prisilite celoten izraz v uporabo večje širine tako, da pretvorite prvi operand — Size := Int64(Width) * Height * BytesPerPixel —, nato pa pred kakršnim klicem funkcije SetLength vrednost primerjajte z eksplicitno zgornjo mejo
Drugo je preverjanje obsega (range checking). Privzeta "release" konfiguracija v Delphiju ima to preverjanje izklopljeno, zato indeks izven dosega, izračunan na podlagi podatkov iz datoteke, ne sproži izjeme — namesto tega bere ali piše po pomnilniku, ki se nahaja v bližini tabele. Ponovno ga vklopite z uporabo {$R+} (in {$Q+} za aritmetični preliv) na vrhu vsake enote, ki indeksira z vrednostmi, pridobljenimi iz datoteke. Cena tega preverjanja je neizmerljiva v primerjavi z vhodno-izhodnimi (I/O) operacijami, ki jih razčlenjevalnik tako ali tako izvaja, prepreči pa tiho poškodovanje (silent corruption) in ga spremeni v prestrezljivo napako ERangeError
Tretje pa je TMemoryStream.SetSize z vneseno vrednostjo Int64, ki izvira iz datoteke. Na trenutni izvajalni knjižnici (RTL) ta funkcija alocira karkoli datoteka zahteva, zato lahko en sam tok, ki terja štiri gigabajte, med sprejemom podatkov povzroči napako zaradi pomanjkanja pomnilnika (out-of-memory failure). Na starejših RTL-jih, kjer funkcija SetSize sprejme Longint, pa se vrednost najprej tiho zoži: deklariranih $100000010 postane 16, alokacija uspe, zapisovanje pravih podatkov pa teče daleč prek njenih meja. Validirajte vsako velikost glede na izmerjeno velikost vira in absolutno zgornjo mejo (hard cap), še preden jo zagleda kakršen koli klic za alokacijo
Odmiki, ki kažejo zunaj datoteke
Tabela navzkrižnih referenc preslika številke objektov v absolutne bajtne odmike (absolute byte offsets), razčlenjevalnik pa išče (seek) natanko tam, kamor kažejo. V poškodovani ali sovražni datoteki pristanejo ti odmiki čez konec datoteke ali pa znotraj nepovezanih struktur. Razred TStream poskrbi, da je napaka neopazna: nastavljanje lastnosti Position onkraj lastnosti Size ni napaka, preprost klic metode Read prek konca pa enostavno vrne manj bajtov od zahtevanih, zato koda, ki preskoči preverjanje števila prebranih bajtov, nadaljuje z razčlenjevanjem preostalih zastarelih bajtov prejšnjega objekta. Obramba leži v uporabi ozkega grla (chokepoint) — ene same pomožne metode (helper), skozi katero prehaja vsako iskanje in branje, ki je vodeno iz datoteke, pri tem pa še pred premikanjem toka validira odmik in število bajtov glede na izmerjeno velikost datoteke
uses
System.SysUtils, System.Classes;
const
MAX_OBJECT_BYTES = 64 * 1024 * 1024; // no single object may exceed 64 MB
type
EPdfBoundsError = class(Exception);
// Vsako iskanje in branje, vodeno iz datoteke, prehaja tod. Offset in Count sta
// trditvi iz datoteke; Source.Size je meritev, kateri se morata prilegati.
procedure ReadBounded(Source: TStream; Offset, Count: Int64;
var Buffer: TBytes);
begin
if (Offset < 0) or (Count < 0) or (Count > MAX_OBJECT_BYTES) or
(Offset > Source.Size) or (Count > Source.Size - Offset) then
raise EPdfBoundsError.CreateFmt(
'obseg objekta %d+%d presega velikost datoteke %d',
[Offset, Count, Source.Size]);
SetLength(Buffer, Count);
if Count = 0 then
Exit;
Source.Position := Offset;
Source.ReadBuffer(Buffer[0], Count);
end;
Speljite odmike navzkrižnih referenc (cross-reference offsets), obsege tokov in branja vdelanih datotek skozi to metodo, in napačen odmik bo postal čista zavrnitev (rejection), ki bo neposredno navedla problematične številke, namesto da bi se manifestiral kot kršitev dostopa (access violation) tri klice kasneje
Cikli in globina v grafu objektov
PDF je graf, ne drevo. Katerakoli vrednost je lahko posredna referenca (indirect reference) in referenca se lahko razreši v drugo referenco — /Length 12 0 R, kjer objekt 12 hrani 13 0 R — in ničesar ni, kar bi preprečevalo, da bi se veriga zaprla sama vase. Komponenta za razreševanje (resolver), ki naivno sledi referencam, se rekurzivno kliče do točke, ko je domorodni sklad (native stack) izčrpan, izčrpanost sklada pa ni nekaj, kar bi lahko ujeli (catch); preprosto prekine proces. Globoko ugnezdeni nizi in slovarji pa pripeljejo do povsem enakega konca tudi brez vsakršnega cikla
Uporabljajte dva varovala hkrati: ekspliciten števec globine zameji "iskren, a globok" primer pri meji, ki se ji ne približa nobena legitimna datoteka, medtem ko množica obiskanih vozlišč (visited set) ujame pristni cikel (genuine cycle) ob njegovem drugem obisku in ga pretvori v natančno napako, o kateri se da poročati, namesto da bi zgolj sprožila prestop meje
uses
System.SysUtils, System.Generics.Collections;
const
MAX_RESOLVE_DEPTH = 32; // bistveno globlje kot katerakoli legitimna veriga referenc
type
EPdfStructureError = class(Exception);
TPdfValueKind = (pvNull, pvNumber, pvName, pvString, pvArray,
pvDictionary, pvStream, pvReference);
TPdfValue = record
Kind: TPdfValueKind;
RefNumber: Integer; // pomembno, ko je Kind = pvReference
// ... polja koristnega tovora (payload fields) za preostale vrste
end;
// LoadObject je vaša lastna rutina: v xref poišče odmik za
// ObjNumber, prebere objekt s pomočjo ReadBounded in ga razčleni.
function ResolveObject(ObjNumber, Depth: Integer;
Visited: TDictionary<Integer, Boolean>): TPdfValue;
begin
if Depth > MAX_RESOLVE_DEPTH then
raise EPdfStructureError.Create('veriga referenc presega omejitev globine');
if Visited.ContainsKey(ObjNumber) then
raise EPdfStructureError.CreateFmt(
'krožna referenca prek objekta %d', [ObjNumber]);
Visited.Add(ObjNumber, True);
try
Result := LoadObject(ObjNumber);
if Result.Kind = pvReference then // npr. /Length 12 0 R
Result := ResolveObject(Result.RefNumber, Depth + 1, Visited);
finally
Visited.Remove(ObjNumber); // sorodni objekti si lahko legitimno delijo ta objekt
end;
end;
Dekompresija deluje kot ojačevalec
Nekaj kilobajtov vhoda za FlateDecode se lahko napihne v gigabajte; splošnonamensko stiskanje (compression) nagrajuje ponavljajoče se golo besedilo (plaintext) in napadalec ga lahko naredi maksimalno ponavljajočega. Omejite napihnjeno velikost (inflated size) vsakega toka na tisto, kar njegov porabnik verjetno lahko potrebuje, in imejte na voljo še dodaten proračun na celoten dokument (per-document budget): petsto tokov, pri čemer je vsak le malce pod omejitvijo na tok, bo izčrpalo pomnilnik enako zanesljivo kot en sam orjaški tok. To preverjanje spada neposredno v zanko napihovanja (inflation loop) na način, da sproti šteje proizvedene izhodne bajte in v primeru prekoračitve prekine proces, ne pa šele po zanki, ko je pomnilnik že porabljen. Omejitev na dokument, izražena kot večkratnik stisnjene velikosti datoteke, deluje dobro, saj se legitimni dokumenti združujejo daleč pod razmerji, ki jih lahko doseže umetno izdelan (crafted) tok
Poglobljena obramba, ki sega onkraj vaših lastnih enot
Iste razrede napak (defect classes) najdemo znotraj knjižnic. Dve študiji primera na tem blogu obravnavata resnične primere: prelome celih števil (integer wraps), neomejeno rekurzijo (unbounded recursion) in neustrezno inicializirane medpomnilnike, ki smo jih zaprli v domorodnem Pascal pogonu, opisane v članku o utrjevanju Pascal PDF razčlenjevalnika pred zlonamernimi datotekami, ter nevarnosti, povezane s konvencijami klicev, širino celih števil in lastništvom pri povezovanju (binding) C pogona, opisane v članku o utrjevanju povezovanja PDFium komponente. Za resnično nezaupljiv sprejem — kot je denimo javen obrazec za nalaganje ali neavtenticiran poštni predal — poskrbite, da bosta razčlenjevanje in dekodiranje prav tako tekla v ločenem procesu z nizkimi privilegiji; tako bo datoteka, ki premaga vsako znotrajprocesno (in-process) varovalo, povzročila le neuspešno opravilo, ne pa izpada celotne storitve
Preveritveni seznam pred zagonom (preflight checklist)
Preden izdate naslednjo različico (build), preglejte razčlenjevalnik s tem seznamom: velikost vsakega medpomnilnika toka mora biti določena na podlagi omejene (clamped) in ne deklarirane dolžine; velikost vsakega rastra mora biti določena na podlagi validiranih parametrov dekoderja in preverjena ob izhodu dekoderja; vsak produkt dimenzij se mora izračunati v vrsti Int64 in primerjati z eksplicitno zgornjo mejo; koda {$R+} mora biti aktivna v vsaki enoti, ki indeksira z vrednostmi iz datoteke; vsako iskanje (seek) mora imeti preverjene meje glede na izmerjeno velikost datoteke; vsako razreševanje referenc mora imeti omejeno globino in preverjanje ciklov; vsaka zanka napihovanja mora šteti izhod glede na proračune na tok in na dokument. Nobeno od teh preverjanj ne terja merljivega časa pri legitimnem dokumentu, hkrati pa vsako od njih pretvori poškodovanje pomnilnika v čisto zavrnitev (rejection), ki jo je mogoče zabeležiti v dnevnik (log)
Opomba: komponente in knjižnice losLab, kot so HotPDF Component, PDFlibPas Delphi PDF Library ter PDFium Component, imajo ta preverjanja meja, omejitve globine in zgornje meje ekspanzije vgrajena interno, zato se vsak cevovod za sprejem dokumentov, zgrajen na njih, zažene z utrjenega izhodišča