En dokumentintagspipeline accepterar filer skrivna av främlingar. Fakturor, inskanningar, bilagor från ett webbformulär: alla gör anspråk på att vara en PDF och bär hundratals siffror som din tolk förväntas agera på. Strömlängder, bilddimensioner, byteförskjutningar, objektreferenser — varenda en valdes av den som producerade filen, och en avkortad uppladdning eller ett medvetet missformat dokument kommer så småningom att placera en av dessa siffror där den gör skada. Skillnaden mellan en tolk som överlever den filen och en som kraschar, eller fortsätter köra med korrupt minne, är en liten uppsättning vanor som inte beror på något speciellt PDF-bibliotek
Vanorna delar en premiss: ett värde som läses från filen är ett påstående, inte ett mått. Det blir användbart först efter att ha kontrollerats mot något som tolken mätte själv — den verkliga storleken på filen, det verkliga antalet byte som en avkodare producerade, det verkliga djupet av en rekursion. Vad som följer är den premissen tillämpad på de platser där dokumenttolkar faktiskt går sönder
En deklarerad längd är ett påstående, inte ett mått
Den enklaste missmatchningen är strömlängden. Ett PDF-strömobjekt deklarerar sitt byteantal i /Length-nyckeln, och faktiska data sitter mellan stream och endstream-nyckelorden. Inget tvingar de två att stämma överens. En avkortad fil innehåller färre verkliga byte än det deklarerade antalet; en fil från en trasig generator kan deklarera en längd som sträcker sig förbi slutet av filen eller in i ett intilliggande objekt. Allokera från det deklarerade värdet och kopiera fram till endstream och du överskrider bufferten; läs exakt det deklarerade antalet utan att kontrollera tillgängligheten och du går av slutet av filen. Låt det deklarerade värdet styra allokeringen först efter att ha begränsat det mot det uppmätta avståndet till slutet av datan, och behandla en oenighet som en beslutspunkt — reparera genom att söka efter endstream, eller avvisa strömmen — aldrig som något att tyst tro på
Bildparametrar som beskriver ett större raster än du allokerade
Bildströmmar höjer insatserna eftersom två oberoende uppsättningar siffror beskriver samma pixlar. Bildordboken bär /Width och /Height, och rasterbuffertar dimensioneras vanligtvis utifrån dem. Avkodningsfiltret bär sin egen geometri: CCITTFaxDecode tar /Columns, /Rows, och /K från sina DecodeParms, där /K väljer Group 3- eller Group 4-schemat och avkodaren avger (Columns + 7) div 8 byte per skanningslinje. En fil som deklarerar /Width 100 men överlämnar filtret /Columns 1728 — standardvärdet — får avkodaren att producera över sexton gånger de byte per rad som bufferten förväntar sig, och överflödet landar en skanningslinje i taget i vad som än sitter efter allokeringen. När /Rows saknas körs avkodaren tills datan säger stopp, så begränsa även radantalet. DCTDecode har samma söm: JPEG-datan bär sin egen bredd och höjd i sin SOF-markör, och ingenting tvingar dem att matcha ordboken
Den defensiva regeln är mekanisk: beräkna den förväntade rasterstorleken från de validerade avkodningsparametrarna — filtrets egna /Columns och /Rows för CCITT, SOF-dimensionerna för DCT — kontrollera det mot dina gränser, allokera från det, och verifiera under avkodning att utdata aldrig löper förbi allokeringen. När ordbok och filter är oense om geometri, förena dem eller avvisa bilden. Vad en tolk aldrig får göra är att dimensionera bufferten från en uppsättning siffror och låta avkodaren köra med den andra
Delphi-aritmetik och allokeringsfallgropar
Tre Delphi-beteenden undergräver även en tolk som avser att validera. Det första är 32-bitars multiplikation: Delphi utvärderar produkten av två Integer-operander vid 32 bitar oavsett destinationens bredd, så Width * Height * BytesPerPixel kan slå runt (wrap) även när varje faktor klarar sin egen rimlighetskontroll. En skanning på 30 000 gånger 30 000 vid tre byte per pixel är 2,7 miljarder byte, vilket slår runt negativt i en tecknad 32-bitars aritmetik; något annorlunda faktorer slår runt till en liten positiv längd som allokerar och underdimensionerar bufferten. Tvinga hela uttrycket brett genom att omvandla (cast) den första operanden — Size := Int64(Width) * Height * BytesPerPixel — jämför sedan mot ett uttryckligt tak innan något når SetLength
Det andra är räckviddskontroll (range checking). Delphis standard release-konfiguration levereras med den avstängd, så ett index utanför räckvidden beräknat från fildata lyfter inget fel — det läser eller skriver minne i anslutning till arrayen. Slå på den igen med {$R+} (och {$Q+} för aritmetisk överspillning) i toppen av varje enhet som indexerar med filhärledda värden. Kostnaden är omätbar i jämförelse med den I/O en tolk ändå gör, och den omvandlar tyst korruption till ett fångbart ERangeError
Det tredje är TMemoryStream.SetSize med en fillevererad Int64. I en nuvarande RTL allokerar den vad filen än bad om, så en enda ström som gör anspråk på fyra gigabyte blir ett slut-på-minne-fel mitt i intaget. På äldre RTL:er, där SetSize tar en Longint, smalnas värdet tyst av först: en deklarerad $100000010 blir 16, allokeringen lyckas, och skrivningen av verklig data kör långt förbi det. Validera varje storlek mot den uppmätta källstorleken och ett hårt tak innan något allokeringsanrop ser den
Förskjutningar som pekar utanför filen
Korsreferenstabellen mappar objektnummer mot absoluta byte-förskjutningar (offsets), och tolken söker (seeks) vart den än pekar. I en skadad eller fientlig fil hamnar dessa förskjutningar förbi slutet av filen eller inuti orelaterade strukturer. TStream gör felet tyst: att ställa in Position bortom Size är inte ett fel, och en vanlig Read förbi slutet returnerar helt enkelt färre byte än begärt, så kod som hoppar över antalskontrollen fortsätter tolka inaktuella byte från det föregående objektet. Försvaret är en flaskhals (chokepoint) — en hjälpare genom vilken varje fil-driven sökning och läsning passerar, som validerar förskjutning och antal mot den uppmätta filstorleken innan strömmen flyttar 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;
Dra korsreferensförskjutningar, strömutsträckningar och inbäddade filläsningar genom den, och en dålig förskjutning blir ett rent avvisande som namnger siffrorna istället för en åtkomstöverträdelse (access violation) tre anrop senare
Cykler och djup i objektgrafen
En PDF är en graf, inte ett träd. Vilket värde som helst kan vara en indirekt referens, en referens kan lösas upp till en annan referens — /Length 12 0 R, där objekt 12 innehåller 13 0 R — och ingenting hindrar en kedja från att sluta sig tillbaka på sig själv. En upplösare (resolver) som följer referenser naivt rekurserar tills den inbyggda stacken är utmattad, och stack-utmattning är inte något du fångar; det avslutar processen. Djupt nästlade arrayer och ordböcker når samma slut utan någon cykel alls
Använd två vakter tillsammans: en uttrycklig djupräknare begränsar det ärliga men djupa fallet vid en gräns inget legitimt dokument närmar sig, och ett besökt-set (visited set) fångar en genuin cykel vid dess andra besök, och omvandlar det till ett exakt, rapporterbart fel istället för ett snubblat tak
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 är en förstärkare
Några kilobyte av FlateDecode-indata kan blåsas upp till gigabyte; generell komprimering belönar repetitiv klartext, och en angripare kan göra den maximalt repetitiv. Sätt ett tak för den uppblåsta storleken på varje ström till vad dess konsument rimligen kan behöva, och håll en andra budget per dokument: femhundra strömmar var och en strax under taket per ström uttömmer minnet lika säkert som en gigantisk ström. Kontrollen hör hemma inuti uppblåsningsloopen, räknande utdatabyte allt eftersom de produceras och avbryter vid överträdelse, inte efter loopen när minnet redan är förbrukat. En dokumentbudget uttryckt som en multipel av den komprimerade filstorleken fungerar väl, eftersom legitima dokument klustrar sig långt under de förhållanden en tillverkad ström når
Försvar på djupet bortom dina egna enheter
Samma defektklasser lever inuti bibliotek. Två fallstudier på den här bloggen går igenom verkliga instanser: integer wrap, obegränsad rekursion och oinitialiserade buffertar tillslutna i en native Pascal-motor i Att härda en Pascal PDF-tolk mot skadliga filer, och riskerna med anropskonventioner, integer-bredder och ägandeskap av att binda en C-motor i Att härda en PDFium Component bindning. För genuint opålitligt intag — ett offentligt uppladdningsformulär, en oautentiserad brevlåda — kör också tolkning- och avkodningsarbetet i en separat process med låg behörighet, så filen som besegrar varje vakt inom processen kostar ett misslyckat jobb istället för en nedstängd tjänst
En checklista före start
Innan nästa bygge (build) levereras, gå igenom tolken mot denna lista: varje strömbuffert dimensionerad från en begränsad längd snarare än den deklarerade; varje raster dimensionerad från validerade avkodningsparametrar och kontrollerad mot avkodarens utdata; varje dimensionsprodukt utvärderad i Int64 och jämförd med ett explicit tak; {$R+} aktivt i varje enhet som indexerar med filhärledda värden; varje sökning gränskontrollerad mot den uppmätta filstorleken; varje referensupplösning djupbegränsad och cykelkontrollerad; varje uppblåsningsloop räknande utdata mot budgetar per ström och per dokument. Inga av dessa kontroller kostar mätbar tid på ett legitimt dokument, och var och en konverterar minneskorruption till en ren, loggningsbar avvisning
Notera: losLab HotPDF Component, PDFlibPas Delphi PDF Library, och PDFium Component applicerar dessa gränskontroller, djupgränser och expansions-tak internt, så en intagspipeline byggd på dem startar från en härdad baslinje