Tehnički članak

Memorijski bezbedno parsiranje PDF-a: Odbrana od zlonamernih dokumenata

Cevovod za prijem dokumenata prihvata datoteke koje su napisali stranci. Fakture, skeniranja, prilozi sa veb forme: svaka tvrdi da je PDF i nosi stotine brojeva po kojima se očekuje da vaš parser postupa. Dužine tokova, dimenzije slika, ofseti bajtova, reference na objekte — svaku je izabrao onaj ko je proizveo datoteku, a prekinut otpremanje (upload) ili namerno loše formiran dokument će na kraju staviti jedan od tih brojeva tamo gde će napraviti štetu. Razlika između parsera koji preživi tu datoteku i onog koji se sruši, ili nastavi da radi sa oštećenom memorijom, predstavlja mali skup navika koji ne zavisi od bilo koje određene PDF biblioteke

Sve navike dele jednu premisu: vrednost pročitana iz datoteke je tvrdnja, a ne merenje. Postaje upotrebljiva tek nakon provere protiv onoga što je sam parser izmerio — stvarne veličine datoteke, stvarnog broja bajtova koje je dekoder proizveo, stvarne dubine rekurzije. Ono što sledi jeste ta premisa primenjena na mesta na kojima se parseri dokumenata zapravo lome

Deklarisana dužina je tvrdnja, a ne merenje

Najjednostavnije neslaganje je dužina toka. Objekat PDF toka deklariše svoj broj bajtova u ključu /Length, a stvarni podaci nalaze se između ključnih reči stream i endstream. Ništa ih ne prisiljava da se slažu. Skraćena datoteka drži manje stvarnih bajtova nego što je deklarisano; datoteka iz pokvarenog generatora može deklarisati dužinu koja seže preko kraja datoteke ili u susedni objekat. Dodelite memoriju iz deklarisane vrednosti i kopirajte dok ne naiđete na endstream, i pregazićete bafer; pročitajte tačno onoliko koliko je deklarisano bez provere dostupnosti, i sići ćete sa kraja datoteke. Dozvolite da deklarisana vrednost vodi dodeljivanje tek nakon što je ograničite (clamp) na izmerenu udaljenost do kraja podataka, a neslaganje tretirajte kao tačku za odluku — popravite skeniranjem u potrazi za endstream, ili odbacite tok — a nikada kao nešto u šta ćete tiho poverovati

Parametri slike koji opisuju veći raster od onog koji ste dodelili

Tokovi slika podižu ulog jer dva nezavisna skupa brojeva opisuju iste piksele. Rečnik slika nosi /Width i /Height, i raster baferi se obično dimenzionišu na osnovu njih. Filter za dekodiranje nosi svoju sopstvenu geometriju: CCITTFaxDecode uzima /Columns, /Rows i /K iz svog DecodeParms, gde /K bira šemu Grupe 3 ili Grupe 4 i dekoder emituje (Columns + 7) div 8 bajtova po liniji skeniranja (scanline). Datoteka koja deklariše /Width 100 ali predaje filteru /Columns 1728 — podrazumevano — natera dekoder da proizvede više od šesnaest puta više bajtova po redu nego što bafer očekuje, a prelivanje sleti liniju po liniju skeniranja u ono što god da stoji posle dodeljivanja. Kada je /Rows odsutan dekoder radi dok podaci ne kažu stop, pa ograničite i broj redova. DCTDecode ima isti šav (seam): JPEG podaci nose sopstvenu širinu i visinu u svom SOF markeru, a ništa ih ne obavezuje da se podudaraju sa rečnikom

Odbrambeno pravilo je mehaničko: izračunajte očekivanu veličinu rastera iz validiranih parametara dekodiranja — iz /Columns i /Rows samog filtera za CCITT, iz SOF dimenzija za DCT — proverite ga u odnosu na vaše granice, iz te vrednosti alocirajte memoriju, i tokom dekodiranja proveravajte da izlaz nikada ne prođe granice alokacije. Kada se rečnik i filter ne slažu oko geometrije, pomirite ih ili odbacite sliku. Ono što parser nikada ne sme da uradi jeste da dimenzioniše bafer prema jednom setu brojeva i ostavi dekoder da radi na drugom

Delphi aritmetika i zamke prilikom dodeljivanja

Tri Delphi ponašanja podrivaju čak i parser koji namerava da validira. Prvo je 32-bitno množenje: Delphi računa proizvod dva Integer operanda na 32 bita bez obzira na širinu odredišta, tako da Width * Height * BytesPerPixel može da se preklopi (wrap) čak i kada svaki činilac prođe svoju sopstvenu proveru razuma (sanity check). Skeniranje 30000 sa 30000 na tri bajta po pikselu je 2.7 milijardi bajtova, što se preklopi u minus u potpisanoj 32-bitnoj aritmetici; malo drugačiji činioci će se preklopiti u malu pozitivnu dužinu koja će dodeliti bafer, ali prekratak (undersize). Forsirajte izraz da bude širok kastovanjem prvog operanda — Size := Int64(Width) * Height * BytesPerPixel — zatim to uporedite sa eksplicitnim ograničenjem pre nego što bilo šta stigne do SetLength

Drugo je provera opsega (range checking). Podrazumevana konfiguracija izdanja (release) Delphija se isporučuje sa isključenom proverom opsega, tako da indeks van opsega izračunat iz podataka iz datoteke ne podiže grešku — on čita ili piše u memoriju susednu nizu. Ponovo ga uključite koristeći {$R+} (i {$Q+} za aritmetičko prelivanje) na vrhu svake jedinice (unit) koja vrši indeksiranje vrednostima izvedenim iz datoteke. Cena u performansama je nemerljiva pored I/O operacija koje parser svakako vrši, i to pretvara tihu korupciju u ERangeError grešku koja može da se uhvati

Treće je TMemoryStream.SetSize sa Int64 snabdevenim iz datoteke. Na aktuelnom RTL-u on alocira koliko god da je datoteka tražila, tako da jedan tok (stream) koji tvrdi da ima četiri gigabajta postaje greška usled nedostatka memorije usred usvajanja. Na starijim RTL-ovima, gde SetSize uzima Longint, vrednost se najpre tiho sužava: deklarisano $100000010 postaje 16, alokacija uspreva, i zapisivanje stvarnih podataka istrči daleko van tih granica. Valididrajte svaku veličinu poredeći je s izmerenom veličinom izvora i tvrdom granicom pre nego što je vidi ijedan poziv za alokaciju memorije

Ofseti koji pokazuju van datoteke

Tabela unakrsnih referenci mapira brojeve objekata na apsolutne ofsete bajtova, a parser prelazi (seeks) gde god oni da pokazuju. U oštećenoj ili neprijateljskoj datoteci ti ofseti završavaju iza kraja datoteke ili unutar nepovezanih struktura. TStream drži taj kvar u tišini: postavljanje Position preko Size nije greška, i obično čitanje preko kraja prosto vraća manje bajtova nego što je zatraženo, tako da kod koji preskače proveru broja bajtova nastavlja da parsira bajate bajtove iz prethodnog objekta. Odbrana je stvrdnuti kontrolni punkt (chokepoint) — jedan pomoćnik kroz koji prolazi svaki poziv za skok i čitanje (seek and read) vođen datotekom, validirajući ofset i broj u odnosu na izmerenu veličinu datoteke pre nego što se tok pomeri

uses
  System.SysUtils, System.Classes;

const
  MAX_OBJECT_BYTES = 64 * 1024 * 1024; // no single object may exceed 64 MB

type
  EPdfBoundsError = class(Exception);

// Every file-driven seek and read goes through here. Offset and Count are
// file-supplied claims; Source.Size is the measurement they must fit.
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(
      'object extent %d+%d exceeds file size %d',
      [Offset, Count, Source.Size]);
  SetLength(Buffer, Count);
  if Count = 0 then
    Exit;
  Source.Position := Offset;
  Source.ReadBuffer(Buffer[0], Count);
end;

Usmerite ofsete unakrsnih referenci, obime tokova i čitanja ugrađenih datoteka (embedded-file) kroz to, i loš ofset postaje čisto odbijanje koje imenuje konkretne brojeve umesto access violation-a tri poziva kasnije

Ciklusi i dubina u grafu objekata

PDF je graf, a ne stablo. Bilo koja vrednost može biti indirektna referenca, referenca se može razrešiti u drugu referencu — /Length 12 0 R, gde objekat 12 sadrži 13 0 R — i ništa ne sprečava lanac da se zatvori sam na sebe. Razrešivač (resolver) koji prati reference se naivno rekurzivno izvršava dok se "native" stek ne iscrpi, a iscrpljivanje steka nije nešto što hvatate (catch); to završava proces (process). Duboko ugneždeni nizovi i rečnici dolaze do istog kraja i bez ikakvog ciklusa

Upotrebite dva stražara zajedno: eksplicitni brojač dubine ograničava pošten-ali-dubok slučaj (honest-but-deep) na granici kojoj se nijedna legitimna datoteka ne približava, a posećeni set hvata pravi ciklus prilikom njegove druge posete, pretvarajući ga u preciznu grešku za izveštaj, umesto okidača za ograničenje

uses
  System.SysUtils, System.Generics.Collections;

const
  MAX_RESOLVE_DEPTH = 32; // far deeper than any legitimate reference chain

type
  EPdfStructureError = class(Exception);

  TPdfValueKind = (pvNull, pvNumber, pvName, pvString, pvArray,
    pvDictionary, pvStream, pvReference);

  TPdfValue = record
    Kind: TPdfValueKind;
    RefNumber: Integer; // meaningful when Kind = pvReference
    // ... payload fields for the remaining kinds
  end;

// LoadObject is your own routine: it looks up the xref offset for
// ObjNumber, reads the object with ReadBounded, and parses it.
function ResolveObject(ObjNumber, Depth: Integer;
  Visited: TDictionary<Integer, Boolean>): TPdfValue;
begin
  if Depth > MAX_RESOLVE_DEPTH then
    raise EPdfStructureError.Create('reference chain exceeds depth limit');
  if Visited.ContainsKey(ObjNumber) then
    raise EPdfStructureError.CreateFmt(
      'circular reference through object %d', [ObjNumber]);
  Visited.Add(ObjNumber, True);
  try
    Result := LoadObject(ObjNumber);
    if Result.Kind = pvReference then // e.g. /Length 12 0 R
      Result := ResolveObject(Result.RefNumber, Depth + 1, Visited);
  finally
    Visited.Remove(ObjNumber); // siblings may legally share this object
  end;
end;

Dekompresija je pojačalo

Nekoliko kilobajta FlateDecode ulaza može da se naduva do gigabajta; kompresija opšte namene nagrađuje ponavljajući otvoreni tekst, a napadač ga može učiniti maksimalno ponavljajućim. Ograničite veličinu naduvavanja (inflated size) svakog toka na ono što njegovom potrošaču zapravo može biti potrebno, i čuvajte drugi budžet na nivou celog dokumenta (per-document budget): petsto tokova koji su svi tik ispod ograničenja pojedinačnog toka će istrošiti memoriju jednako sigurno kao jedan gigantski tok. Provera pripada unutar petlje naduvavanja, brojanjem izlaznih bajtova onako kako se proizvode i odustajanjem po probijanju mere, a ne nakon petlje kada je memorija već potrošena. Budžet dokumenta izražen kao višestrukost veličine komprimovane datoteke radi dobro, budući da se legitimni dokumenti grupišu daleko ispod razmera koje dostiže zlonamerni, fabrikovani tok

Odbrana po dubini izvan sopstvenih jedinica

Iste klase defekata žive i unutar biblioteka. Dve studije slučaja na ovom blogu šetaju vas kroz stvarne instance: preklapanje celih brojeva (integer wraps), bezgranična rekurzija, i neinicijalizovani baferi zatvoreni u matičnom Pascal motoru (engine-u) obrađeni su u Utvrđivanje Pascal PDF parsera protiv zlonamernih datoteka, a rizici u pozivnim konvencijama (calling-convention), širini integera i vlasništvu prilikom povezivanja sa C motorom nalaze se u Utvrđivanje poveza PDFium Component komponente. Za potpuno nepoverljiv unos — formu za javni upload, neautentifikovano sanduče za poruke — takođe pokrenite parsiranje i dekodiranje u zasebnom procesu niskog nivoa privilegija, tako da datoteka koja savlada sve ugrađene čuvare u procesu uzrokuje propali posao umesto srušenog servisa

Kontrolna lista (checklist) pre leta

Pre isporuke sledećeg izdanja (build-a), proverite parser prema ovoj listi: svaki bafer toka je dimenzionisan iz ograničene dužine radije nego iz one koja je deklarisana; svaki raster je dimenzionisan prema validiranim parametrima dekodera i prekontrolisan je izlaz dekodera; proizvod svih dimenzija obračunat unutar Int64 i naspram eksplicitne gornje mere (cap-a); {$R+} aktivan u svakom modulu (unit-u) koji indeksira po vrednostima izvedenim iz datoteke; svako traženje (seek) provereno za prekoračenje u odnosu na izmerenu veličinu datoteke; svaka rezolucija referenci ograničena na dubinu i testirana na ciklus; svaka petlja naduvavanja (inflation) broji izlaz spram definisanih per-stream i per-document budžeta. Ništa od ovih provera ne košta merljivog vremena na legitimnom dokumentu, a svaka pretvara memorijsku korupciju u čisto odbacivanje koje se lako zapiše u log (dnevnik)

Napomena: losLab-ove komponente HotPDF Component, PDFlibPas Delphi PDF Library i PDFium Component primenjuju ove provere granica, ograničenja dubine i granice ekspanzije interno, tako da cevovod za prijem dokumenata izgrađen na njima započinje posao na dobro utvrđenom terenu