En pipeline for dokumentinntak aksepterer filer skrevet av fremmede. Fakturaer, skanninger, vedlegg fra et webskjema: alle påstår å være en PDF, og bærer med seg hundrevis av tall din parser forventes å handle ut fra. Strømlengder, bildedimensjoner, byte-forskyvninger (offsets), objektreferanser — hver og en av disse ble valgt av den som produserte filen, og en avkortet opplasting (truncated upload) eller et bevisst misdannet dokument vil til slutt plassere et av disse tallene der det gjør skade. Forskjellen mellom en parser som overlever den filen, og en som krasjer eller fortsetter å kjøre med korrumpert minne, er et lite sett av vaner som ikke er avhengig av noe spesifikt PDF-bibliotek
Vanene deler én forutsetning: en verdi lest fra filen er en påstand, ikke en måling. Den blir kun brukbar etter å ha blitt sjekket mot noe parseren selv har målt — den virkelige størrelsen på filen, det virkelige antall byte en dekoder produserte, den virkelige dybden på en rekursjon. Det som følger er denne forutsetningen anvendt på stedene hvor dokumentparsere faktisk bryter sammen
En deklarert lengde er en påstand, ikke en måling
Det enkleste avviket er strømlengden (stream length). Et PDF-strømobjekt deklarerer sitt antall bytes i /Length-nøkkelen, og selve dataene ligger mellom nøkkelordene stream og endstream. Ingenting tvinger disse to til å stemme overens. En avkortet fil inneholder færre virkelige byte enn det deklarerte antallet; en fil fra en ødelagt generator kan deklarere en lengde som strekker seg forbi slutten av filen, eller inn i et tilstøtende objekt. Hvis du allokerer ut fra den deklarerte verdien og kopierer inntil endstream, overkjører du bufferen; hvis du leser nøyaktig det deklarerte antallet uten å sjekke tilgjengelighet, går du forbi slutten av filen. La den deklarerte verdien styre allokering bare etter at du har klemt den (clamped it) mot den målte avstanden til slutten av dataene, og behandle et avvik som et beslutningspunkt — reparer ved å skanne etter endstream, eller avvis strømmen — aldri som noe man i stillhet bør tro på
Bilde-parametere som beskriver et større raster enn du allokerte
Bildestrømmer øker innsatsen fordi to uavhengige sett med tall beskriver de samme pikslene. Bildeordboken inneholder /Width og /Height, og rasterbuffere dimensjoneres vanligvis ut fra disse. Dekod-filteret har sin egen geometri: CCITTFaxDecode tar /Columns, /Rows og /K fra sin DecodeParms, der /K velger Group 3- eller Group 4-ordningen, og dekoderen sender ut (Columns + 7) div 8 byte per scanline. En fil som deklarerer /Width 100, men gir filteret /Columns 1728 — standardverdien — får dekoderen til å produsere over seksten ganger så mange byte per rad som bufferen forventer, og overflyten lander én scanline om gangen i hva enn som befinner seg etter allokeringen. Når /Rows mangler, kjører dekoderen til dataene sier stopp, så begrens radantallet også. DCTDecode har den samme sømmen: JPEG-dataene bærer sin egen bredde og høyde i sin SOF-markør, og ingenting forplikter dem til å samsvare med ordboken
Den defensive regelen er mekanisk: beregn forventet rasterstørrelse fra de validerte dekoderparametrene — filterets egne /Columns og /Rows for CCITT, SOF-dimensjonene for DCT — sjekk det mot grensene dine, alloker fra det, og verifiser under dekoding at utdata aldri passerer allokeringen. Når ordboken og filteret er uenige om geometri, avstem dem eller avvis bildet. Hva en parser aldri må gjøre, er å dimensjonere bufferen ut fra det ene settet med tall, og la dekoderen kjøre på det andre
Delphi aritmetikk- og allokeringsfeller
Tre Delphi-atferder undergraver selv en parser som har til hensikt å validere. Den første er 32-bits multiplikasjon: Delphi evaluerer produktet av to Integer-operander til 32 bit, uavhengig av destinasjonens bredde, slik at Width * Height * BytesPerPixel kan brytes (wrap) selv når hver enkelt faktor passerer sin egen sunnhetssjekk. Et 30000 ganger 30000 skann til tre byte per piksel er 2,7 milliarder byte, som brytes til et negativt tall i signert 32-bit aritmetikk; litt andre faktorer brytes til en liten positiv lengde som allokerer og underdimensjonerer bufferen. Tving hele uttrykket bredt ved å typekaste den første operanden — Size := Int64(Width) * Height * BytesPerPixel — sammenlign det deretter med et eksplisitt tak, før noe som helst når SetLength
Den andre er rekkeviddesjekking (range checking). Delphis standard release-konfigurasjon leveres med dette avslått, så en indeks utenfor grensene, beregnet fra fildata, reiser ikke et unntak — den leser eller skriver minne ved siden av matrisen (arrayen). Slå det på igjen med {$R+} (og {$Q+} for aritmetisk overflyt) i toppen av hver unit som indekserer med fil-avledede verdier. Kostnaden er ikke målbar ved siden av den I/O-en en parser uansett utfører, og det konverterer stille korrupsjon til en fangbar ERangeError
Den tredje er TMemoryStream.SetSize med en fil-levert Int64. På et nåværende RTL allokerer den hva enn filen ba om, så en enkelt strøm som krever fire gigabyte blir til en out-of-memory-feil midt under inntaket. På eldre RTl-er, der SetSize tar en Longint, blir verdien snevret inn i det stille først: en deklarert $100000010 blir til 16, allokeringen lykkes, og skrivingen av de virkelige dataene går langt forbi det. Valider hver størrelse mot den målte kildestørrelsen og et hardt tak før noe allokeringskall får se det
Forskyvninger som peker utenfor filen
Kryssreferansetabellen tilordner objektnumre til absolutte byte-forskyvninger (offsets), og parseren søker uansett hvor den peker. I en skadet eller fiendtlig fil havner disse forskyvningene forbi slutten av filen, eller inne i ubeslektede strukturer. TStream gjør feilen stille: å sette Position forbi Size er ingen feil, og et vanlig Read forbi slutten returnerer ganske enkelt færre byte enn forespurt, så kode som hopper over telle-sjekken fortsetter å parse gamle byte fra forrige objekt. Forsvaret er et flaskehals-punkt (chokepoint) — en enkelt hjelper, der ethvert fil-drevet søk og lesing passerer, som validerer forskyvning og antall mot den målte filstørrelsen før strømmen flyttes
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;
Rut kryssreferanseforskyvninger, strømutstrekninger og lesing av innbygde filer gjennom den, og en dårlig forskyvning blir en ren avvisning som navngir tallene, i stedet for et tilgangsbrudd (access violation) tre kall senere
Sykluser og dybde i objektgrafen
En PDF er en graf, ikke et tre. Hvilken som helst verdi kan være en indirekte referanse, en referanse kan løses opp til en annen referanse — /Length 12 0 R, der objekt 12 holder 13 0 R — og ingenting hindrer en kjede i å lukke seg tilbake på seg selv. En resolver (oppløser) som følger referanser naivt, rekurserer til den native stakken er utmattet, og stakkutmattelse er ikke noe du fanger opp; det avslutter prosessen. Dypt nøstede arrays (matriser) og ordbøker (dictionaries) når samme slutt uten noen syklus i det hele tatt
Bruk to vakter sammen: en eksplisitt dybdeteller begrenser det ærlige-men-dype tilfellet til en grense ingen legitim fil nærmer seg, og et besøkt-sett (visited set) fanger en ekte syklus på dens andre besøk, og gjør det om til en presis, rapporterbar feil i stedet for å utløse grensen
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 forsterker
Noen få kilobyte med FlateDecode-inndata kan blåses opp (inflate) til gigabyte; generell komprimering belønner repeterende klartekst, og en angriper kan gjøre den maksimalt repeterende. Sett et tak på den oppblåste (inflated) størrelsen for hver strøm til det dens konsument sannsynligvis kan ha behov for, og hold et sekundært budsjett per dokument: fem hundre strømmer, hver rett under taket per strøm, vil tømme minnet like sikkert som én gigantisk strøm. Sjekken hører hjemme inne i inflasjonsløkken, hvor den teller utdata-byte etter hvert som de produseres og avbryter ved brudd, ikke etter at løkken er ferdig og minnet allerede er oppbrukt. Et dokumentbudsjett uttrykt som et multiplum av den komprimerte filstørrelsen fungerer bra, ettersom legitime dokumenter samler seg langt under forholdstallene (ratios) som en utspekulert strøm (crafted stream) kan oppnå
Dybdeforsvar utover dine egne enheter
De samme feilklassene lever inne i biblioteker. To casestudier på denne bloggen går gjennom virkelige instanser: heltalls-wraps, ubegrenset rekursjon, og uinitialiserte buffere som ble lukket i en nativ Pascal-motor, omtalt i Herding av en Pascal PDF-parser mot ondsinnede filer, og risikoen knyttet til calling-konvensjoner, heltallsbredde og eierskap (ownership) ved å binde (bind) en C-motor i Herding av en PDFium Component Binding. For et genuint upålitelig inntak — et offentlig opplastingsskjema, en uautentisert innboks — bør du også kjøre parse- og dekode-arbeidet i en separat lav-privilegium-prosess (low-privilege process), slik at filen som beseirer enhver in-process vakt, koster deg en feilet jobb (failed job) i stedet for en nede-tjeneste (downed service)
En preflight-sjekkliste
Før neste build leveres, kjør parseren mot denne listen: hver strømbuffer er dimensjonert fra en klemt (clamped) lengde, i stedet for den deklarerte; hvert raster er dimensjonert fra validerte dekoderparametere og sjekket mot dekoder-utdata; hvert dimensjonsprodukt er evaluert i Int64 og sammenlignet med et eksplisitt tak; {$R+} er aktiv i hver enhet som indekserer med fil-avledede verdier; hvert søk (seek) er grensekontrollert mot den målte filstørrelsen; hver referanse-oppløsning (reference resolution) er dybdebegrenset og syklus-sjekket; hver inflasjonsløkke teller utdata mot per-strøm og per-dokument budsjetter. Ingen av disse sjekkene koster målbar tid på et legitimt dokument, og hver av dem konverterer minnekorrupsjon til en ren og loggbar avvisning
Merk: losLab HotPDF Component, PDFlibPas Delphi PDF Library, og PDFium Component anvender disse grensesjekkene, dybdegrensene, og utvidelsestakene internt, slik at en inntakspipeline bygd på dem starter fra et herdet utgangspunkt