Tehnični članak

Pomnilniško varno razčlenjevanje PDF v Delphiju

Cevovod za sprejem dokumentov sprejema datoteke, ki so jih napisali neznanci. Računi, skenirani dokumenti, priloge iz spletnega obrazca: vsaka trdi, da je PDF, in nosi na stotine števil, na katera naj bi se vaš razčlenjevalnik odzval. Dolžine tokov, mere slik, bajtni odmiki, reference na objekte, vsako od njih je izbral tisti, ki je datoteko izdelal, prisekano nalaganje ali namerno pokvarjen dokument pa bo eno od teh števil sčasoma postavil tja, kjer naredi škodo. Razlika med razčlenjevalnikom, ki tako datoteko preživi, in tistim, ki se sesuje ali teče naprej s pokvarjenim pomnilnikom, je majhen nabor navad, ki niso odvisne od nobene posamezne knjižnice za PDF

Te navade si delijo eno predpostavko: vrednost, prebrana iz datoteke, je trditev in ne meritev. Uporabna postane šele, ko je preverjena proti nečemu, kar je razčlenjevalnik izmeril sam, torej proti resnični velikosti datoteke, resničnemu številu bajtov, ki jih je dekodirnik ustvaril, in resnični globini rekurzije. Kar sledi, je ta predpostavka, uporabljena na mestih, kjer se razčlenjevalniki dokumentov dejansko lomijo

Zemljevid vrednosti PDF, ki jih nadzoruje napadalec, vključno z /Length, startxref in tokovi flate, ob izmerjenih mejah, ki ohranjajo razčlenjevanje PDF v Delphiju pomnilniško varno
Vsako število, ki ga poda sovražen PDF, je trditev, dokler razčlenjevalnik ne izmeri nečesa resničnega, od dolžin tokov do odmikov xref in razširjanja flate

Prijavljena dolžina je trditev, ne meritev

Najpreprostejše neujemanje je dolžina toka. Objekt toka PDF svoje število bajtov prijavi v ključu /Length, dejanski podatki pa ležijo med ključnima besedama stream in endstream. Nič ne sili obeh, da se ujemata. Prisekana datoteka nosi manj resničnih bajtov, kot jih prijavlja število; datoteka iz pokvarjenega generatorja lahko prijavi dolžino, ki sega čez konec datoteke ali v sosednji objekt. Če razporejate po prijavljeni vrednosti in kopirate do endstream, prekoračite medpomnilnik; če preberete natanko prijavljeno število brez preverjanja razpoložljivosti, odkorakate čez konec datoteke. Naj prijavljena vrednost poganja razporejanje šele po tem, ko jo pripnete na izmerjeno razdaljo do konca podatkov, neskladje pa obravnavajte kot točko odločanja, torej popravilo z iskanjem endstream ali zavrnitev toka, nikoli kot nekaj, čemur se tiho verjame

Parametri slike, ki opisujejo večji raster, kot ste ga razporedili

Tokovi slik zvišujejo vložek, ker iste slikovne pike opisujeta dva neodvisna nabora števil. Slovar slike nosi /Width in /Height, rastrski medpomnilniki pa so običajno odmerjeni po njiju. Filter za dekodiranje nosi svojo geometrijo: CCITTFaxDecode iz svojega DecodeParms vzame /Columns, /Rows in /K, kjer /K izbere shemo Group 3 ali Group 4, dekodirnik pa odda (Columns + 7) div 8 bajtov na vrstico. Datoteka, ki prijavi /Width 100, filtru pa izroči /Columns 1728, torej privzeto vrednost, doseže, da dekodirnik ustvari več kot šestnajstkrat toliko bajtov na vrstico, kot jih pričakuje medpomnilnik, prekoračitev pa po eno vrstico naenkrat pristane v tistem, kar leži za razporejenim prostorom. Kadar /Rows manjka, dekodirnik teče, dokler podatki ne rečejo stop, zato zamejite tudi število vrstic. DCTDecode ima isti šiv: podatki JPEG nosijo svojo širino in višino v svoji oznaki SOF, nič pa ju ne zavezuje, da se ujemata s slovarjem

Obrambno pravilo je strojno: pričakovano velikost rastra izračunajte iz preverjenih parametrov dekodiranja, torej iz filtrovih lastnih /Columns in /Rows pri CCITT ter iz mer SOF pri DCT, jo preverite proti svojim omejitvam, razporedite po njej in med dekodiranjem potrdite, da izhod nikoli ne steče čez razporejeni prostor. Kadar se slovar in filter o geometriji ne strinjata, ju uskladite ali sliko zavrnite. Česar razčlenjevalnik ne sme storiti nikoli, je odmeriti medpomnilnik po enem naboru števil in pustiti dekodirniku teči po drugem

Diagram odločanja, ki uskladi /Width in /Height iz slovarja slike s /Columns in /Rows filtra CCITTFaxDecode, preden se v Delphiju razporedi rastrski medpomnilnik
Rastrski medpomnilniki morajo biti odmerjeni po preverjenih parametrih dekodiranja, nikoli po samih merah iz slovarja

Pasti aritmetike in razporejanja v Delphiju

Trije načini obnašanja Delphija spodkopljejo tudi razčlenjevalnik, ki namerava preverjati. Prvi je 32-bitno množenje: Delphi zmnožek dveh operandov tipa Integer ovrednoti pri 32 bitih ne glede na širino cilja, zato lahko Width * Height * BytesPerPixel prekipi, tudi če vsak faktor prestane lastno preverjanje razumnosti. Skeniran dokument 30000 krat 30000 pri treh bajtih na slikovno piko je 2,7 milijarde bajtov, kar v predznačeni 32-bitni aritmetiki prekipi v negativno; nekoliko drugačni faktorji prekipijo v majhno pozitivno dolžino, ki se razporedi in medpomnilnik odmeri premajhen. Celoten izraz prisilite v široko obliko s pretvorbo prvega operanda, torej Size := Int64(Width) * Height * BytesPerPixel, nato pa ga primerjajte z izrecno zgornjo mejo, preden karkoli doseže SetLength

Drugi je preverjanje obsega. Delphijeva privzeta izdajna konfiguracija ga dostavi izklopljenega, zato indeks zunaj obsega, izračunan iz podatkov datoteke, ne sproži ničesar, temveč bere ali piše v pomnilnik ob polju. Vklopite ga nazaj z {$R+} (in {$Q+} za aritmetično prekoračitev) na vrhu vsake enote, ki indeksira z vrednostmi iz datoteke. Strošek je neizmerljiv ob vhodno-izhodnem delu, ki ga razčlenjevalnik tako ali tako opravi, tiho pokvarjenost pa pretvori v ulovljivo napako ERangeError

Tretji je TMemoryStream.SetSize z vrednostjo Int64, ki jo poda datoteka. Na sodobnem RTL razporedi točno to, kar je datoteka zahtevala, zato en sam tok, ki trdi, da meri štiri gigabajte, sredi sprejema postane odpoved zaradi pomanjkanja pomnilnika. Na starejših RTL, kjer SetSize vzame Longint, se vrednost najprej tiho zoži: prijavljeni $100000010 postane 16, razporeditev uspe, zapis resničnih podatkov pa steče daleč čez njo. Vsako velikost preverite proti izmerjeni velikosti vira in trdi zgornji meji, preden jo vidi katerikoli klic za razporejanje

Odmiki, ki kažejo zunaj datoteke

Tabela navzkrižnih referenc preslika številke objektov v absolutne bajtne odmike, razčlenjevalnik pa se pomakne tja, kamor kaže. V poškodovani ali sovražni datoteki ti odmiki pristanejo čez konec datoteke ali znotraj nepovezanih struktur. TStream odpoved utiša: nastavitev lastnosti Position čez Size ni napaka, navadno branje Read čez konec pa preprosto vrne manj bajtov od zahtevanih, zato koda, ki preverjanja števila ne opravi, razčlenjuje naprej z zastarelimi bajti iz prejšnjega objekta. Obramba je grlo, torej en sam pomočnik, skozi katerega gre vsako iskanje in branje, ki ga vodi datoteka, in ki odmik ter število preveri proti izmerjeni velikosti datoteke, preden se tok premakne

uses
  System.SysUtils, System.Classes;

const
  MAX_OBJECT_BYTES = 64 * 1024 * 1024; // noben posamezen objekt ne sme preseči 64 MB

type
  EPdfBoundsError = class(Exception);

// Vsako iskanje in branje, ki ga vodi datoteka, gre skozi tukaj. Offset in Count sta
// trditvi iz datoteke; Source.Size je meritev, v katero se morata umestiti.
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;

Skoznjo usmerite odmike navzkrižnih referenc, obsege tokov in branja vdelanih datotek, in slab odmik postane čista zavrnitev, ki števila poimenuje, namesto kršitve dostopa tri klice pozneje

Cikli in globina v grafu objektov

PDF je graf, ne drevo. Vsaka vrednost je lahko posredna referenca, referenca se lahko razreši v drugo referenco, denimo /Length 12 0 R, kjer objekt 12 nosi 13 0 R, nič pa ne prepreči, da bi se veriga sklenila sama vase. Razreševalnik, ki referencam sledi naivno, se ponavlja, dokler ni izčrpan izvorni sklad, izčrpanja sklada pa ni mogoče ujeti; konča proces. Globoko gnezdena polja in slovarji pridejo do istega konca tudi brez vsakega cikla

Uporabite dve varovali skupaj: izrecni števec globine zameji pošten, a globok primer pri meji, ki se ji nobena legitimna datoteka ne približa, množica obiskanih pa resnični cikel ujame ob drugem obisku in ga spremeni v natančno, sporočljivo napako namesto v sprožitev omejitve

uses
  System.SysUtils, System.Generics.Collections;

const
  MAX_RESOLVE_DEPTH = 32; // veliko globlje od katere koli legitimne verige referenc

type
  EPdfStructureError = class(Exception);

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

  TPdfValue = record
    Kind: TPdfValueKind;
    RefNumber: Integer; // pomembno, kadar je Kind = pvReference
    // ... polja z vsebino za preostale vrste
  end;

// LoadObject je vaš lasten postopek: poišče odmik xref za
// ObjNumber, objekt prebere z 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('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 // npr. /Length 12 0 R
      Result := ResolveObject(Result.RefNumber, Depth + 1, Visited);
  finally
    Visited.Remove(ObjNumber); // sorojenci si ta objekt smejo zakonito deliti
  end;
end;

Dekompresija je ojačevalnik

Nekaj kilobajtov vhoda FlateDecode se lahko napihne v gigabajte; splošnonamensko stiskanje nagrajuje ponavljajoč se čistopis, napadalec pa ga lahko naredi kar najbolj ponavljajočega se. Napihnjeno velikost vsakega toka zamejite pri tem, kar njegov odjemalec verjetno potrebuje, in vodite še drugi proračun na dokument: petsto tokov, vsak tik pod mejo posameznega toka, pomnilnik izčrpa prav tako zanesljivo kot en sam orjaški tok. Preverjanje sodi znotraj zanke napihovanja, kjer izhodne bajte šteje sproti in ob kršitvi prekine, ne pa za zanko, ko je pomnilnik že porabljen. Proračun dokumenta, izražen kot večkratnik stisnjene velikosti datoteke, deluje dobro, saj se legitimni dokumenti gnetejo krepko pod razmerji, ki jih doseže prirejen tok

PDF: primerjava v dveh ploščah, kjer nepreverjeno napihovanje FlateDecode povzroči odpovedi zaradi pomanjkanja pomnilnika, na drugi strani pa izhodni proračuni, uveljavljeni znotraj zanke napihovanja
Dekompresija ojači majhne sovražne vhode, zato je treba izhodne proračune šteti znotraj zanke napihovanja in ne revidirati naknadno

Obramba v globino onkraj vaših lastnih enot

Isti razredi napak živijo znotraj knjižnic. Dve študiji primera na tem blogu se sprehodita skozi resnične primerke: celoštevilske prekoračitve, nezamejena rekurzija in neinicializirani medpomnilniki, zaprti v izvornem pogonu v Pascalu, v Utrjevanje razčlenjevalnika PDF v Pascalu proti zlonamernim datotekam, ter nevarnosti klicnega dogovora, širine celih števil in lastništva pri vezavi pogona v jeziku C v Utrjevanje vezave komponente PDFium. Za resnično nezaupanja vreden sprejem, torej za javni obrazec za nalaganje ali nepreverjen poštni predal, delo razčlenjevanja in dekodiranja poženite še v ločenem procesu z nizkimi pravicami, tako da datoteka, ki premaga vsa varovala v procesu, stane neuspelo opravilo namesto podrte storitve

Kontrolni seznam pred izdajo

Preden se odpremi naslednja gradnja, razčlenjevalnik prehodite po tem seznamu: vsak medpomnilnik toka odmerjen po pripeti in ne po prijavljeni dolžini; vsak raster odmerjen po preverjenih parametrih dekodirnika in preverjen proti izhodu dekodirnika; vsak zmnožek mer ovrednoten v Int64 in primerjan z izrecno zgornjo mejo; {$R+} dejaven v vsaki enoti, ki indeksira z vrednostmi iz datoteke; vsako iskanje preverjeno proti izmerjeni velikosti datoteke; vsako razreševanje referenc omejeno po globini in preverjeno na cikle; vsaka zanka napihovanja šteje izhod proti proračunu na tok in na dokument. Nobeno od teh preverjanj pri legitimnem dokumentu ne stane merljivega časa, vsako pa pokvarjenost pomnilnika pretvori v čisto, zabeležljivo zavrnitev

Opomba: losLabovi HotPDF Delphi Component, PDF Library for Delphi Delphi PDF Library in PDFium Component ta preverjanja meja, omejitve globine in zgornje meje razširjanja uporabljajo interno, tako da cevovod za sprejem, zgrajen na njih, začne z utrjenim izhodiščem