Articol tehnic

Analiză (parsing) PDF în siguranță a memoriei: Apărarea împotriva documentelor rău intenționate

Un flux de preluare de documente acceptă fișiere scrise de străini. Facturi, scanări, atașamente de la un formular web: fiecare pretinde a fi un PDF și poartă sute de numere pe care analizatorul (parser-ul) dumneavoastră se așteaptă să acționeze. Lungimi de fluxuri, dimensiuni de imagini, decalaje de octeți (byte offsets), referințe la obiecte — fiecare a fost ales de oricine a produs fișierul, iar o încărcare trunchiată sau un document deliberat malformat va pune în cele din urmă unul dintre aceste numere acolo unde face daune. Diferența dintre un parser care supraviețuiește acelui fișier și unul care eșuează sau continuă să ruleze cu memoria coruptă, este un set mic de obiceiuri care nu depind de o bibliotecă PDF anume

Obiceiurile au o premisă comună: o valoare citită din fișier este o afirmație, nu o măsurătoare. Devine utilizabilă doar după ce a fost verificată cu ceva ce parser-ul a măsurat singur — dimensiunea reală a fișierului, numărul real de octeți pe care l-a produs un decodor, adâncimea reală a unei recursivități. Ceea ce urmează este acea premisă aplicată în locurile unde parser-urile de documente se rup de fapt

O lungime declarată este o afirmație, nu o măsurătoare

Cea mai simplă nepotrivire este lungimea fluxului. Un obiect flux PDF (stream) își declară numărul de octeți în cheia /Length, iar datele efective se află între cuvintele cheie stream și endstream. Nimic nu forțează cele două lucruri să fie de acord. Un fișier trunchiat reține mai puțini octeți reali decât numărul declarat; un fișier de la un generator stricat poate declara o lungime care trece de sfârșitul fișierului sau intră într-un obiect vecin. Alocați din valoarea declarată și copiați până la endstream și veți depăși buffer-ul (overrun); citiți exact numărul declarat fără a verifica disponibilitatea și veți părăsi sfârșitul fișierului. Lăsați valoarea declarată să conducă alocarea doar după fixarea acesteia în limitele (clamping) distanței măsurate până la sfârșitul datelor și tratați un dezacord ca pe un punct de decizie — reparați scanând pentru endstream, sau respingeți fluxul — niciodată ca ceva de crezut orbește

Parametri de imagine care descriu un raster mai mare decât ați alocat

Fluxurile de imagine cresc miza deoarece două seturi independente de numere descriu aceiași pixeli. Dicționarul imaginii poartă /Width și /Height, iar bufferele raster sunt de obicei dimensionate de la ele. Filtrul de decodare poartă propria geometrie: CCITTFaxDecode preia /Columns, /Rows, și /K din DecodeParms ale sale, unde /K selectează schema Group 3 sau Group 4 iar decodorul emite (Columns + 7) div 8 octeți per linie de scanare (scanline). Un fișier care declară /Width 100 dar predă filtrului /Columns 1728 — implicit — face ca decodorul să producă de peste șaisprezece ori mai mulți octeți pe rând decât se așteaptă buffer-ul, iar surplusul (overflow) aterizează o linie de scanare la un moment dat în orice se află după alocare. Când /Rows lipsește, decodorul rulează până când datele spun stop, așa că limitați și numărul de rânduri. DCTDecode are aceeași cusătură: datele JPEG poartă propriile lor înălțimi și lățimi în marcajul SOF, și nimic nu le obligă să se potrivească cu dicționarul

Regula defensivă este mecanică: calculați dimensiunea așteptată a rasterului din parametrii validați ai decodării — parametrii /Columns și /Rows proprii filtrului pentru CCITT, dimensiunile SOF pentru DCT — verificați-l cu limitele dvs., alocați de pe urma lui și asigurați-vă în timpul decodării că ieșirea nu depășește niciodată alocarea. Când dicționarul și filtrul nu se pun de acord în privința geometriei, conciliați-le sau respingeți imaginea. Ceea ce un parser nu trebuie să facă niciodată este să dimensioneze buffer-ul după un set de numere și să lase decodorul să funcționeze la celălalt

Capcane aritmetice și de alocare în Delphi

Trei comportamente Delphi subminează chiar și un parser care intenționează să valideze. Primul este înmulțirea pe 32 de biți: Delphi evaluează produsul a doi operanzi Integer la 32 de biți, indiferent de lățimea destinației, astfel încât Width * Height * BytesPerPixel se poate întoarce peste limită (wrap) chiar și atunci când fiecare factor trece propria verificare de rezonabilitate. O scanare de 30000 pe 30000 la trei octeți pe pixel înseamnă 2,7 miliarde de octeți, care la wrap devin negativi în aritmetica semnată pe 32 de biți; factori ușor diferiți la wrap revin la o lungime pozitivă mică ce alocă și subdimensionează buffer-ul. Forțați întreaga expresie să fie mare, convertind primul operand — Size := Int64(Width) * Height * BytesPerPixel — apoi comparați cu un plafon explicit înainte ca orice să ajungă la SetLength

Al doilea este verificarea intervalului (range checking). Configurația implicită de eliberare (release) Delphi se livrează cu ea dezactivată, astfel încât un index în afara intervalului calculat din datele fișierului nu ridică o excepție — citește sau scrie memorie adiacentă matricei (array). Activați-o din nou cu {$R+} (și {$Q+} pentru depășirea capacității aritmetice) în partea de sus a fiecărei unități care indexează cu valori derivate din fișier. Costul este nemăsurabil alături de operațiunile I/O pe care le face oricum un parser, și transformă corupția tăcută într-un ERangeError care poate fi prins

Al treilea este TMemoryStream.SetSize cu un Int64 furnizat de fișier. Pe un RTL actual, acesta alocă orice i-a cerut fișierul, astfel încât un singur flux care pretinde patru gigabytes devine un eșec de tip lipsă de memorie în mijlocul preluării. Pe RTL-uri mai vechi, unde SetSize ia un Longint, valoarea este micșorată tacit la început: un $100000010 declarat devine 16, alocarea reușește, iar scrierea datelor reale rulează mult dincolo de ea. Validați fiecare dimensiune raportat la dimensiunea măsurată a sursei și un plafon fix (hard cap) înainte ca vreun apel de alocare să o vadă

Decalaje (offsets) care indică în afara fișierului

Tabelul de referințe încrucișate mapează numerele de obiect la decalaje absolute de octeți (byte offsets), și analizatorul caută (seeks) oriunde arată. Într-un fișier deteriorat sau ostil acele decalaje aterizează dincolo de sfârșitul fișierului sau în interiorul unor structuri necorelate. TStream face eșecul tăcut: setarea Position dincolo de Size nu este o eroare, și o simplă apelare Read pe lângă capăt pur și simplu returnează mai puțini octeți decât s-a solicitat, așa că acel cod ce omite verificarea numărătorului continuă să analizeze octeți învechiți din obiectul anterior. Apărarea este un punct de strangulare (chokepoint) — o singură funcție de asistență (helper) prin care trece fiecare căutare și citire condusă de fișier, validând decalajul și numărul în raport cu dimensiunea măsurată a fișierului înainte ca fluxul să se deplaseze

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;

Direcționați decalajele referințelor încrucișate, extinderile fluxurilor și citirile fișierelor încorporate prin intermediul acesteia, iar un decalaj greșit devine o respingere curată care denumește numerele în loc de o încălcare a accesului trei apeluri mai târziu

Cicluri și adâncime în graful de obiecte

Un PDF este un graf, nu un arbore. Orice valoare poate fi o referință indirectă, o referință se poate rezolva cu o altă referință — /Length 12 0 R, unde obiectul 12 deține 13 0 R — și nimic nu împiedică un lanț să se închidă pe el însuși. Un rezolvator care urmărește referințele recursiv în mod naiv o face până când stiva nativă este epuizată, și epuizarea stivei nu este ceva ce poți prinde; încheie procesul. Matricele și dicționarele profund imbricate ajung la același capăt fără niciun ciclu

Utilizați două gărzi împreună: un contor explicit de adâncime limitează cazul onest, dar profund, la o limită pe care niciun fișier legitim nu o abordează, și un set vizitat prinde un ciclu autentic la a doua sa vizită, transformându-l într-o eroare precisă, raportabilă, în loc de o declanșare de limită

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;

Decompresia este un amplificator

Câțiva kiloocteți de intrare FlateDecode se pot umfla la gigabytes; compresia de uz general recompensează textul simplu repetitiv, și un atacator îl poate face maxim de repetitiv. Plafonați dimensiunea umflată a fiecărui flux la ceea ce consumatorul său poate avea în mod plauzibil nevoie, și păstrați un al doilea buget per-document: cinci sute de fluxuri fiecare chiar sub plafonul per-flux epuizează memoria la fel de sigur ca un singur flux uriaș. Verificarea aparține interiorului buclei de inflație, numărând octeții de ieșire pe măsură ce sunt produși și anulând (abort) la încălcare, nu după buclă când memoria a fost deja cheltuită. Un buget pentru document exprimat ca multiplu al dimensiunii fișierului comprimat funcționează bine, deoarece documentele legitime se grupează mult sub rapoartele pe care le atinge un flux fabricat artizanal

Apărare în adâncime dincolo de propriile unități

Aceleași clase de defecte trăiesc în biblioteci. Două studii de caz de pe acest blog parcurg instanțe reale: erorile tip wrap la numere întregi, recursivitatea nemărginită și bufferele neinițializate închise într-un motor Pascal nativ în Întărirea unui analizator Pascal PDF împotriva fișierelor malițioase, și pericolele legate de convenția de apel, lățimea numerelor întregi, și pericolele de proprietate (ownership hazards) la legarea unui motor C în Întărirea unei legături a componentei PDFium. Pentru o preluare (intake) cu adevărat lipsită de încredere — un formular public de încărcare, o căsuță poștală neautentificată — rulați de asemenea munca de parsare și decodare într-un proces separat cu privilegii reduse, astfel încât fișierul ce învinge fiecare gardă din proces să coste o lucrare eșuată în loc de un serviciu căzut

O listă de verificare preflight (înainte de lansare)

Înainte ca următoarea versiune (build) să fie livrată, ghidați analizatorul (parser-ul) cu această listă: fiecare buffer de flux este dimensionat dintr-o lungime fixată la limite mai degrabă decât cea declarată; fiecare raster dimensionat din parametri de decodare validați și verificat cu producția decodorului; fiecare produs de dimensiune evaluat în Int64 și comparat cu un plafon explicit; {$R+} activ în fiecare unitate care indexează cu valori derivate din fișier; limitele pentru fiecare căutare verificate față de dimensiunea măsurată a fișierului; fiecare rezoluție de referință limitată în adâncime și verificată la ciclu; fiecare buclă de umflare contorizând ieșirea față de bugetele per-flux și per-document. Niciuna dintre aceste verificări nu costă timp măsurabil pe un document legitim și fiecare transformă coruperea memoriei într-o respingere curată, ce poate fi înregistrată

Notă: Componenta HotPDF losLab, Biblioteca Delphi PDF PDFlibPas, și Componenta PDFium aplică intern aceste verificări ale limitelor, limitări de adâncime și plafoane de expansiune, deci un flux de preluare construit pe baza acestora pornește de la o linie de bază solidificată (hardened)