Teknisk artikel

Hukommelsessikker PDF-parsing: Forsvar mod ondsindede dokumenter

En dokument-indtagelsespipeline (document intake pipeline) accepterer filer skrevet af fremmede. Fakturaer, scanninger, vedhæftede filer fra en webformular: hver hævder at være en PDF og bærer hundredvis af numre, din parser forventes at handle på. Strømlængder, billeddimensioner, byte-offsets, objektreferencer — hver og en blev valgt af den, der producerede filen, og en trunkeret upload eller et bevidst forkert udformet dokument vil til sidst placere et af disse numre, hvor det gør skade. Forskellen mellem en parser, der overlever den fil, og en der går ned, eller fortsætter med at køre med korrumperet hukommelse, er et lille sæt vaner, der ikke afhænger af noget bestemt PDF-bibliotek

Vanerne deler én præmis: en værdi læst fra filen er en påstand, ikke en måling. Den bliver først brugbar, efter at være blevet tjekket mod noget, parseren selv har målt — den rigtige størrelse af filen, det rigtige antal bytes en dekoder producerede, den rigtige dybde af en rekursion. Hvad der følger er den præmis anvendt på de steder, hvor dokumentparsere faktisk går i stykker

En erklæret længde er en påstand, ikke en måling

Det enkleste misforhold er strømlængden (the stream length). Et PDF strøm-objekt erklærer sit byte-antal i /Length-nøglen, og de faktiske data sidder mellem stream og endstream nøgleordene. Intet tvinger de to til at være enige. En trunkeret fil rummer færre rigtige bytes end det erklærede antal; en fil fra en ødelagt generator kan erklære en længde, der når forbi slutningen af filen eller ind i et naboobjekt. Alloker fra den erklærede værdi og kopier indtil endstream, og du overskrider (overrun) bufferen; læs nøjagtigt det erklærede antal uden at kontrollere tilgængelighed, og du går ud over slutningen af filen. Lad kun den erklærede værdi drive allokering efter at have fastspændt (clamping) den mod den målte afstand til slutningen af dataene, og behandl en uenighed som et beslutningspunkt — reparer ved at scanne efter endstream, eller afvis strømmen — aldrig som noget, man i stilhed skal tro på

Billedparametre, der beskriver et større raster, end du allokerede

Billedstrømme hæver indsatsen, fordi to uafhængige sæt tal beskriver de samme pixels. Billed-ordbogen bærer /Width og /Height, og raster-buffere dimensioneres normalt ud fra dem. Afkodningsfiltret (the decode filter) bærer sin egen geometri: CCITTFaxDecode tager /Columns, /Rows og /K fra dets DecodeParms, hvor /K vælger Group 3 eller Group 4 skemaet, og dekoderen udsender (Columns + 7) div 8 bytes pr. scanlinje. En fil, der erklærer /Width 100, men giver filtret /Columns 1728 — standarden — får dekoderen til at producere over seksten gange så mange bytes pr. række, som bufferen forventer, og overløbet (the overflow) lander én scanlinje ad gangen i hvad end der sidder efter allokeringen. Når /Rows er fraværende, kører dekoderen, indtil dataene siger stop, så afgræns (bound) også rækketællingen. DCTDecode har den samme søm (seam): JPEG-dataene bærer sin egen bredde og højde i sin SOF-markør, og intet forpligter dem til at matche ordbogen

Den defensive regel er mekanisk: beregn den forventede raster-størrelse ud fra de validerede afkodningsparametre — filtrets egne /Columns og /Rows for CCITT, SOF-dimensionerne for DCT — tjek det mod dine grænser, alloker fra det, og bekræft under afkodning, at output aldrig kører forbi allokeringen. Når ordbog og filter er uenige om geometri, så forlig dem eller afvis billedet. Hvad en parser aldrig må gøre, er at dimensionere bufferen ud fra ét sæt tal og lade dekoderen køre på det andet

Delphi-aritmetik og allokerings-faldgruber

Tre Delphi-adfærd underminerer selv en parser, der har til hensigt at validere. Den første er 32-bit multiplikation: Delphi evaluerer produktet af to Integer operander ved 32 bit uanset destinationens bredde, så Width * Height * BytesPerPixel kan wrappe, selv når hver faktor består sit eget sanity-check. En 30000 gange 30000 scanning med tre bytes pr. pixel er 2,7 milliarder bytes, hvilket wrapper negativt i signeret 32-bit aritmetik; lidt forskellige faktorer wrapper til en lille positiv længde, der allokerer og underdimensionerer bufferen. Tving hele udtrykket bredt ved at caste den første operand — Size := Int64(Width) * Height * BytesPerPixel — og sammenlign derefter med et eksplicit loft (cap), før noget når SetLength

Den anden er intervalkontrol (range checking). Delphis standard release-konfiguration leveres med den slået fra, så et out-of-range indeks beregnet ud fra fil-data kaster (raise) ikke en fejl — det læser eller skriver hukommelse, der støder op til arrayet. Slå den til igen med {$R+} (og {$Q+} for aritmetisk overløb) i toppen af hver unit, der indekserer med fil-afledte værdier. Omkostningen er umålelig ved siden af den I/O, en parser alligevel udfører, og det konverterer tavs korruption til en catchable ERangeError

Den tredje er TMemoryStream.SetSize med en fil-leveret Int64. På en nuværende RTL allokerer den, hvad filen bad om, så en enkelt strøm, der hævder at være fire gigabyte, bliver en ud-af-hukommelse-fejl midt i indtagelsen. På ældre RTL'er, hvor SetSize tager en Longint, bliver værdien stille indsnævret først: en erklæret $100000010 bliver til 16, allokeringen lykkes, og skrivningen af de rigtige data kører langt forbi det. Valider hver størrelse mod den målte kildestørrelse (source size) og et hårdt loft, før ethvert allokeringskald ser det

Offsets, der peger uden for filen

Krydsreference-tabellen mapper objektnumre til absolutte byte-offsets, og parseren søger (seeks) hen, hvor end den peger. I en beskadiget eller fjendtlig fil lander disse offsets forbi slutningen af filen eller inde i ikke-relaterede strukturer. TStream gør fejlen stille: at sætte Position ud over Size er ikke en fejl, og en almindelig Read forbi slutningen returnerer simpelthen færre bytes end anmodet om, så kode, der springer antalstjekket (the count check) over, fortsætter med at parse forældede bytes fra det forrige objekt. Forsvaret er et flaskepunkt (chokepoint) — én hjælper, som enhver fil-drevet søgning og læsning passerer igennem, der validerer offset og antal mod den målte filstørrelse, før strømmen flytter sig

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;

Diriger krydsreference-offsets, strømomfang og embedded-fil læsninger gennem det, og et dårligt offset bliver en ren afvisning, der navngiver numrene i stedet for en adgangskrænkelse (access violation) tre kald senere

Cykler og dybde i objektgrafen

En PDF er en graf, ikke et træ. Enhver værdi kan være en indirekte reference, en reference kan løses til en anden reference — /Length 12 0 R, hvor objekt 12 indeholder 13 0 R — og intet forhindrer en kæde i at lukke tilbage på sig selv. En resolver, der følger referencer naivt, rekursionerer indtil den native stak er udtømt (exhausted), og stakudtømning er ikke noget du fanger; det afslutter processen. Dybt indlejrede arrays og ordbøger når den samme ende uden nogen cyklus overhovedet

Brug to vagter (guards) sammen: en eksplicit dybdetæller afgrænser (bounds) det ærlige-men-dybe tilfælde ved en grænse, ingen legitim fil nærmer sig, og et besøgt-sæt (visited set) fanger en ægte cyklus ved dens andet besøg, hvilket gør det til en præcis, rapporterbar fejl i stedet for en grænse-udløsning (limit trip)

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;

Dekomprimering er en forstærker

Et par kilobytes FlateDecode input kan pustes op (inflate) til gigabytes; generel-formåls komprimering belønner repetitiv almindelig tekst, og en angriber kan gøre det maksimalt repetitivt. Sæt loft over den oppustede størrelse af hver strøm til, hvad dens forbruger plausibelt kan have brug for, og behold et andet pr.-dokument budget: fem hundrede strømme hver lige under pr.-strøm-loftet (the per-stream cap) udtømmer hukommelsen lige så sikkert som én kæmpe strøm. Tjekket hører hjemme inde i inflationsløkken, der tæller output-bytes efterhånden som de produceres og afbryder ved overskridelse, ikke efter løkken, når hukommelsen allerede er brugt. Et dokumentbudget udtrykt som et multiplum af den komprimerede filstørrelse fungerer godt, da legitime dokumenter klynger sig langt under de forhold, en manipuleret strøm (crafted stream) når

Forsvar i dybden ud over dine egne units

De samme defekt-klasser lever inde i biblioteker. To casestudier på denne blog gennemgår reelle tilfælde: integer wraps, ubegrænset rekursion og uinitialiserede buffere lukket i en native Pascal-motor i Hærdning af en Pascal PDF-parser mod ondsindede filer, og calling-convention-, integer-width- og ejerskabsfarerne ved at binde en C-motor i Hærdning af en PDFium Component Binding. For ægte upålidelig indtagelse — en offentlig upload-formular, en uautentificeret postkasse — skal du også køre parse- og afkodningsarbejdet i en separat lav-privilegium-proces (low-privilege process), så filen, der besejrer enhver in-process vagt, koster et mislykket job i stedet for en nede-tjeneste

En preflight-tjekliste

Før det næste build sendes ud, så gå igennem parseren med denne liste: hver strømbuffer dimensioneret ud fra en fastspændt længde snarere end den erklærede; hvert raster dimensioneret ud fra validerede dekoderparametre og tjekket mod dekoderoutput; hvert dimensionsprodukt evalueret i Int64 og sammenlignet med et eksplicit loft; {$R+} aktivt i hver unit, der indekserer med fil-afledte værdier; enhver søgning (seek) bounds-tjekket mod den målte filstørrelse; enhver reference-løsning dybdebegrænset og cyklus-tjekket; enhver inflationsløkke, der tæller output op mod pr.-strøm og pr.-dokument budgetter. Ingen af disse tjek koster målbar tid på et legitimt dokument, og hver konverterer hukommelseskorruption til en ren, logbar afvisning

Bemærk: losLabs HotPDF-komponent, PDFlibPas Delphi PDF-bibliotek og PDFium-komponent anvender disse grænsetjek (bounds checks), dybdebegrænsninger og ekspansionslofter internt, så en indtagelsespipeline bygget på dem starter fra en hærdet basislinje