Tehnički članak

Memorijski sigurno parsiranje PDF-a: Obrana od zlonamjernih dokumenata

Cjevovod za prihvat dokumenata prihvaća datoteke koje su napisali neznanci. Računi, skenovi, privitci s web obrasca: svaki od njih tvrdi da je PDF i nosi stotine brojeva prema kojima bi vaš parser trebao postupati. Duljine tokova, dimenzije slika, pomaci u bajtovima, reference na objekte — svaki od njih odabrala je osoba koja je izradila datoteku, a prekinuti prijenos ili namjerno loše oblikovan dokument će s vremenom jedan od tih brojeva staviti na mjesto gdje će nanijeti štetu. Razlika između parsera koji preživi takvu datoteku i onoga koji se sruši, ili nastavi raditi s oštećenom memorijom, predstavlja mali skup navika koje ne ovise o nekoj određenoj PDF biblioteci

Te navike dijele jednu premisu: vrijednost očitana iz datoteke jest tvrdnja, a ne mjerenje. Postaje upotrebljiva tek nakon što se provjeri u odnosu na nešto što je sam parser izmjerio — stvarnu veličinu datoteke, stvarni broj bajtova koje je dekoder proizveo, stvarnu dubinu rekurzije. Ono što slijedi je ta premisa primijenjena na mjesta gdje se parseri dokumenata zapravo lome

Deklarirana duljina je tvrdnja, a ne mjerenje

Najjednostavnije nepodudaranje jest duljina toka. Objekt PDF toka deklarira broj bajtova u ključu /Length, dok stvarni podaci leže između ključnih riječi stream i endstream. Ništa ih ne prisiljava da se slažu. Skraćena datoteka sadrži manje stvarnih bajtova od deklariranog broja; datoteka iz pokvarenog generatora može deklarirati duljinu koja seže izvan kraja datoteke ili zadire u susjedni objekt. Alocirajte prema deklariranoj vrijednosti i kopirajte dok ne naiđete na endstream pa ćete preplaviti međuspremnik; čitajte točno deklarirani broj bez provjere dostupnosti i ispast ćete s kraja datoteke. Neka deklarirana vrijednost upravlja alokacijom tek nakon što je ograničite prema izmjerenoj udaljenosti do kraja podataka, a neslaganje tretirajte kao točku odluke — popravite traženjem endstream ili odbacite tok — a nikada kao nešto u što ćete tiho povjerovati

Parametri slike koji opisuju veći raster od onoga što ste alocirali

Tokovi slika podižu uloge jer dva neovisna skupa brojeva opisuju iste piksele. Rječnik slika nosi /Width i /Height, a međuspremnici rastera se obično dimenzioniraju prema njima. Filtar dekodiranja nosi vlastitu geometriju: CCITTFaxDecode uzima /Columns, /Rows i /K iz svog DecodeParms, pri čemu /K odabire shemu Group 3 ili Group 4, a dekoder emitira (Columns + 7) div 8 bajtova po liniji skeniranja. Datoteka koja deklarira /Width 100, ali filtru preda /Columns 1728 — što je zadano — uzrokuje da dekoder proizvede više od šesnaest puta više bajtova po retku od onoga što međuspremnik očekuje, a taj prelijevani dio slijeće red po red skeniranja na ono što god se nalazilo nakon alokacije. Kada nedostaje /Rows, dekoder radi dok podaci ne kažu da stane, pa stoga ograničite i broj redaka. DCTDecode ima isti šav: JPEG podaci nose vlastitu širinu i visinu u svom SOF markeru, i ništa ih ne obvezuje da se podudaraju s rječnikom

Obrambeno pravilo je mehaničko: izračunajte očekivanu veličinu rastera iz potvrđenih parametara dekodiranja — filtarovih vlastitih /Columns i /Rows za CCITT, SOF dimenzija za DCT — provjerite u odnosu na vaša ograničenja, alocirajte na temelju toga te tijekom dekodiranja potvrdite da izlaz nikada ne premašuje alokaciju. Kada se rječnik i filtar ne slažu u vezi s geometrijom, uskladite ih ili odbacite sliku. Ono što parser nikada ne smije učiniti je dimenzionirati međuspremnik na temelju jednog skupa brojeva, a dopustiti da dekoder radi prema drugom

Delphi aritmetika i zamke prilikom alokacije

Tri ponašanja Delphija potkopavaju čak i parser koji namjerava provoditi provjeru valjanosti. Prvo je 32-bitno množenje: Delphi evaluira umnožak dvaju Integer operanada na 32 bita, bez obzira na širinu odredišta, stoga Width * Height * BytesPerPixel može prekoračiti čak i kada svaki faktor samostalno prođe vlastitu provjeru ispravnosti. Skeniranje 30000 s 30000 pri tri bajta po pikselu iznosi 2.7 milijardi bajtova, što u predznačenoj 32-bitnoj aritmetici prekoračuje u minus; nešto drugačiji faktori prekoračuju na malu pozitivnu duljinu koja alocira preveliki i poddimenzionirani međuspremnik. Prisilite cijeli izraz na široko izvršavanje tako da castate prvi operand — Size := Int64(Width) * Height * BytesPerPixel — a zatim ga usporedite s eksplicitnim gornjim ograničenjem prije nego što išta dođe do SetLength

Drugo je provjera raspona. Delphijeva zadana konfiguracija izdanja isporučuje je isključenu, stoga se indeks izvan raspona izračunat iz podataka datoteke ne diže — već čita ili zapisuje u memoriju susjednu nizu. Uključite je natrag pomoću {$R+} (i {$Q+} za aritmetički preljev) na vrhu svake jedinice koja indeksira pomoću vrijednosti dobivenih iz datoteke. Trošak je nemjerljiv u usporedbi s I/O operacijama koje parser ionako obavlja, a pretvara tiho oštećenje memorije u uhvatljivu ERangeError pogrešku

Treće je ponašanje metode TMemoryStream.SetSize uz Int64 isporučen iz datoteke. U trenutnom RTL-u alocira se što god je datoteka tražila, pa jedan tok koji traži četiri gigabajta postaje greška manjka memorije usred prihvata. U starijim RTL-ovima, gdje SetSize prima Longint, vrijednost se prvo tiho sužava: deklariranih $100000010 postaje 16, alokacija uspijeva, a zapisivanje stvarnih podataka se nastavlja daleko izvan njezinih okvira. Potvrdite valjanost svake veličine u odnosu na izmjerenu izvornu veličinu i striktno ograničenje prije nego to vidi bilo koji poziv za alokaciju

Pomaci koji pokazuju izvan datoteke

Tablica unakrsnih referenci mapira brojeve objekata na apsolutne pomake u bajtovima, a parser skače tamo gdje god to pokazuje. U oštećenoj ili neprijateljskoj datoteci ti pomaci slete iza kraja datoteke ili unutar nepovezanih struktura. TStream čini ovaj kvar tihim: postavljanje Position iza Size nije pogreška, a običan poziv Read nakon kraja jednostavno vraća manje bajtova od zatraženih, pa kod koji preskače provjeru broja nastavlja parsirati zaostale bajtove iz prethodnog objekta. Obrana se sastoji od tzv. chokepointa — jedne pomoćne funkcije kroz koju prolazi svako pomicanje i čitanje vođeno datotekom, a koja provjerava valjanost pomaka i brojača u odnosu na izmjerenu veličinu datoteke prije nego što se tok uopće pomakne

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;

Usmjerite sve pomake unakrsnih referenci, raspona tokova i čitanja ugrađenih datoteka kroz njega, pa će loš pomak postati čisto odbacivanje koje navodi te brojeve umjesto kršenja prava pristupa koje bi se dogodilo tri poziva poslije

Ciklusi i dubina unutar grafa objekata

PDF je graf, a ne stablo. Bilo koja vrijednost može biti neizravna referenca, referenca se može razriješiti u drugu referencu — /Length 12 0 R, gdje objekt 12 sadrži 13 0 R — i ništa ne sprječava da se lanac ponovno zatvori sam u sebe. Razrješavač koji prati reference naivno ponavlja rekurziju sve dok se ne iscrpi izvorni stog, a iscrpljivanje stoga nije nešto što jednostavno možete uloviti; to završava proces. Duboko ugniježđeni nizovi i rječnici dolaze do istog kraja čak i bez ikakvog ciklusa

Koristite dvije zaštite zajedno: eksplicitni brojač dubine ograničava pošten-ali-dubok slučaj do granice kojoj se ne približava nijedna legitimna datoteka, a skup posjećenih hvata istinski ciklus pri njegovom drugom posjetu, pretvarajući to u preciznu pogrešku koju je moguće prijaviti umjesto u jednostavni pad zbog ograničenja

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 služi kao pojačivač

Nekoliko kilobajta FlateDecode unosa može se napuhati do razine gigabajta; kompresija opće namjene nagrađuje ponavljajući čisti tekst, a napadač ga može učiniti maksimalno ponavljajućim. Ograničite napuhanu veličinu svakog toka na ono što njegovom potrošaču zapravo može trebati i vodite drugi proračun po dokumentu: pet stotina tokova od kojih je svaki tik ispod gornje granice za svaki pojedini tok iscrpljuje memoriju jednako sigurno kao i jedan divovski tok. Provjera pripada unutar same petlje inflacije, na način da broji izlazne bajtove kako se proizvode i prekida čim nastupi probijanje, a ne nakon petlje kada je memorija već potrošena. Proračun dokumenta izražen kao višekratnik veličine komprimirane datoteke dobro funkcionira, budući da se legitimni dokumenti grupiraju daleko ispod omjera koji postiže posebno kreiran namjerni tok

Dubinska obrana izvan vaših vlastitih jedinica

Iste klase kvarova žive unutar biblioteka. Dvije studije slučaja na ovom blogu prolaze kroz stvarne slučajeve: preklapanje cjelobrojnih vrijednosti, neograničena rekurzija i neinicijalizirani međuspremnici popravljeni su u izvornom Pascal engineu unutar članka Ojačavanje Pascal PDF parsera protiv zlonamjernih datoteka, kao i opasnosti konvencija pozivanja, širine cjelobrojnih vrijednosti te vlasništva kod povezivanja C enginea u članku Ojačavanje vezivanja PDFium komponente. Za doista nepouzdani unos — obrazac za javni prijenos ili neautentificirani poštanski sandučić — također pokrenite rad parsiranja i dekodiranja u zasebnom procesu niske razine privilegija, tako da datoteka koja pobijedi svaku in-process zaštitu u konačnici košta samo kao neuspjeli posao umjesto kao ugašen servis

Pripremna kontrolna lista

Prije nego sljedeći build krene u isporuku, prođite kroz ovaj popis provjera za parser: svaki međuspremnik toka dimenzioniran na temelju strogo ograničene duljine umjesto one deklarirane; svaki raster dimenzioniran prema potvrdanim parametrima dekodera i uspoređen s izlazom dekodera; svaki umnožak dimenzija procijenjen u Int64 te uspoređen s izričitim gornjim ograničenjem; aktivan {$R+} u svakoj jedinici koja se indeksira pomoću vrijednosti izvedenih iz datoteke; skokovi s provjerenim granicama u odnosu na izmjerenu veličinu datoteke; ograničena dubina za svako razrješavanje reference kao i obavljena provjera ciklusa; i to da svaka inflacijska petlja broji izlaze u odnosu na proračune po pojedinačnom toku te po cijelom dokumentu. Nijedna od ovih provjera ne troši mjerljivo vrijeme na legitimnom dokumentu, a svaka pretvara kvarenje memorije u čisto odbacivanje koje se lako može zabilježiti u log

Napomena: losLabove komponente HotPDF Component, PDFlibPas Delphi PDF Library te PDFium Component primjenjuju ove provjere granica, ograničenja dubine te ograničenja širenja na unutarnjoj razini, tako da prihvatni cjevovod izgrađen na njima započinje od početne točke koja je već snažno osigurana