Pipeline pro příjem dokumentů přijímá soubory vytvořené cizími lidmi. Faktury, skeny, přílohy z webového formuláře: každý tvrdí, že je PDF a nese stovky čísel, se kterými má váš parser pracovat. Délky proudů, rozměry obrázků, bajtové offsety, odkazy na objekty – každé z nich vybral ten, kdo soubor vytvořil, a neúplné nahrání nebo záměrně poškozený dokument nakonec vloží jedno z těchto čísel tam, kde způsobí škodu. Rozdíl mezi parserem, který tento soubor přežije, a tím, který spadne nebo běží dál s poškozenou pamětí, je malý soubor návyků, které nezávisí na žádné konkrétní knihovně PDF
Návyky sdílejí jeden předpoklad: hodnota načtená ze souboru je tvrzení, nikoli měření. Stává se použitelnou až po ověření vůči něčemu, co si parser změřil sám — skutečné velikosti souboru, skutečnému počtu bajtů vytvořených dekodérem, skutečné hloubce rekurze. Následuje aplikace tohoto předpokladu na místa, kde se parsery dokumentů skutečně lámou
Deklarovaná délka je tvrzení, nikoli měření
Nejjednodušším nesouladem je délka proudu. Objekt PDF streamu deklaruje svůj počet bajtů v klíči /Length a skutečná data se nacházejí mezi klíčovými slovy stream a endstream. Nic nenutí tato dvě čísla k souladu. Zkrácený soubor obsahuje méně skutečných bajtů, než je deklarovaný počet; soubor z rozbitého generátoru může deklarovat délku, která přesahuje konec souboru nebo zasahuje do sousedního objektu. Alokujte podle deklarované hodnoty a kopírujte až do endstream a přeteče vám vyrovnávací paměť; přečtěte přesně deklarovaný počet bez kontroly dostupnosti a ocitnete se za koncem souboru. Nechte deklarovanou hodnotu řídit alokaci až po jejím omezení podle změřené vzdálenosti ke konci dat, a neshodu berte jako bod rozhodnutí — oprava skenováním na endstream nebo odmítnutí proudu — nikdy ne jako něco, čemu byste měli tiše věřit
Parametry obrázku, které popisují větší rastr, než jste alokovali
Proudy obrázků zvyšují sázky, protože tytéž pixely popisují dvě nezávislé sady čísel. Slovník obrázku nese /Width a /Height, a vyrovnávací paměti rastru se obvykle dimenzují podle nich. Dekódovací filtr má svou vlastní geometrii: CCITTFaxDecode přebírá /Columns, /Rows a /K z jeho DecodeParms, kde /K vybírá schéma Group 3 nebo Group 4 a dekodér vysílá (Columns + 7) div 8 bajtů na řádek. Soubor, který deklaruje /Width 100, ale předá filtru /Columns 1728 — výchozí hodnota — způsobí, že dekodér vyprodukuje více než šestnáctkrát více bajtů na řádek, než buffer očekává, a přetečení skončí jeden řádek po druhém v čemkoli, co následuje po alokaci. Když chybí /Rows, dekodér běží, dokud data neřeknou stop, takže omezte i počet řádků. DCTDecode má stejný problém: data JPEG nesou svou vlastní šířku a výšku v markeru SOF a nic je nenutí shodovat se se slovníkem
Defenzivní pravidlo je mechanické: vypočítejte očekávanou velikost rastru z ověřených parametrů dekódování — vlastních /Columns a /Rows filtru pro CCITT, rozměrů SOF pro DCT — zkontrolujte ji s vašimi limity, alokujte z ní a během dekódování ověřte, že výstup nikdy nepřesáhne alokaci. Pokud se slovník a filtr neshodují v geometrii, slaďte je nebo obrázek odmítněte. Co parser nikdy nesmí udělat, je dimenzovat buffer z jedné sady čísel a nechat dekodér běžet podle druhé
Nástrahy aritmetiky a alokace v Delphi
Tři chování v Delphi podkopávají i parser, který má v úmyslu validovat. Prvním je 32bitové násobení: Delphi vyhodnocuje součin dvou operandů Integer na 32 bitů bez ohledu na šířku cíle, takže Width * Height * BytesPerPixel může přetéct, i když každý faktor projde vlastní kontrolou rozumnosti. Sken o rozměrech 30000 krát 30000 při třech bajtech na pixel je 2,7 miliardy bajtů, což ve znaménkové 32bitové aritmetice přeteče do záporných čísel; mírně odlišné faktory přetečou na malou kladnou délku, která buffer alokuje a poddimenzuje. Vynuťte rozšíření celého výrazu přetypováním prvního operandu — Size := Int64(Width) * Height * BytesPerPixel — pak ho porovnejte s explicitním limitem, než cokoli dosáhne SetLength
Druhým je kontrola rozsahu (range checking). Výchozí konfigurace release v Delphi se dodává s jejím vypnutím, takže index mimo rozsah vypočítaný z dat souboru nevyvolá výjimku — čte nebo zapisuje do paměti sousedící s polem. Zapněte ji zpět pomocí {$R+} (a {$Q+} pro aritmetické přetečení) na začátku každé unity, která indexuje s hodnotami odvozenými ze souboru. Náklady jsou neměřitelné vedle I/O operací, které parser stejně provádí, a tichou korupci to převádí na zachytitelnou chybu ERangeError
Třetím je TMemoryStream.SetSize s Int64 dodaným ze souboru. V aktuální RTL alokuje to, o co si soubor řekl, takže jediný stream požadující čtyři gigabajty se uprostřed příjmu změní v selhání nedostatku paměti (out-of-memory). Na starších RTL, kde SetSize přijímá Longint, je hodnota nejprve tiše zúžena: deklarovaných $100000010 se stane 16, alokace uspěje a zápis skutečných dat proběhne hluboko za ní. Ověřte každou velikost podle naměřené velikosti zdroje a pevného limitu dříve, než ji uvidí jakékoli volání alokace
Offsety, které ukazují mimo soubor
Tabulka křížových odkazů mapuje čísla objektů na absolutní bajtové offsety a parser hledá kdekoli, kam ukazuje. V poškozeném nebo nepřátelském souboru tyto offsety dopadnou za konec souboru nebo do nesouvisejících struktur. TStream činí toto selhání tichým: nastavení Position za Size není chybou a prosté Read za koncem jednoduše vrátí méně bajtů, než bylo požadováno, takže kód, který přeskočí kontrolu počtu, dál zpracovává neaktuální bajty z předchozího objektu. Obranou je zúžení — jeden pomocník, kterým projde každé hledání a čtení řízené souborem, ověřující offset a počet vůči naměřené velikosti souboru dříve, než se stream pohne
uses
System.SysUtils, System.Classes;
const
MAX_OBJECT_BYTES = 64 * 1024 * 1024; // žádný jednotlivý objekt nesmí přesáhnout 64 MB
type
EPdfBoundsError = class(Exception);
// Tudy projde každé vyhledávání a čtení řízené souborem. Offset a Count jsou
// tvrzení dodaná souborem; Source.Size je rozměr, do kterého musí zapadnout.
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;
Nasměrujte offsety křížových odkazů, velikosti streamů a čtení vložených souborů přes něj, a špatný offset se stane čistým odmítnutím, které pojmenuje čísla místo porušení přístupu o tři volání později
Cykly a hloubka v grafu objektů
PDF je graf, ne strom. Jakákoli hodnota může být nepřímým odkazem, odkaz se může přeložit na jiný odkaz — /Length 12 0 R, kde objekt 12 obsahuje 13 0 R — a nic nebrání tomu, aby se řetězec uzavřel sám na sebe. Resolver, který sleduje odkazy naivně, rekurzuje, dokud nedojde nativní zásobník, a vyčerpání zásobníku není něco, co zachytíte; ukončí to proces. Hluboce vnořená pole a slovníky dospějí ke stejnému konci i bez jakéhokoli cyklu
Použijte obě ochrany společně: explicitní počítadlo hloubky omezuje poctivé, ale hluboké případy na limit, ke kterému se žádný legitimní soubor nepřiblíží, a sada navštívených uzlů zachytí skutečný cyklus při jeho druhé návštěvě a změní jej na přesnou chybu, kterou lze nahlásit, místo zasažení limitu
uses
System.SysUtils, System.Generics.Collections;
const
MAX_RESOLVE_DEPTH = 32; // mnohem hlubší než jakýkoli legitimní řetězec odkazů
type
EPdfStructureError = class(Exception);
TPdfValueKind = (pvNull, pvNumber, pvName, pvString, pvArray,
pvDictionary, pvStream, pvReference);
TPdfValue = record
Kind: TPdfValueKind;
RefNumber: Integer; // má smysl, když je Kind = pvReference
// ... pole pro data ostatních typů
end;
// LoadObject je vaše vlastní rutina: vyhledá offset xref pro
// ObjNumber, přečte objekt pomocí ReadBounded a zpracuje jej.
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 // např. /Length 12 0 R
Result := ResolveObject(Result.RefNumber, Depth + 1, Visited);
finally
Visited.Remove(ObjNumber); // sourozenci mohou tento objekt legálně sdílet
end;
end;
Dekomprese je zesilovač
Několik kilobajtů vstupu FlateDecode se může nafouknout na gigabajty; obecná komprese odměňuje opakující se prostý text a útočník jej může udělat maximálně opakujícím se. Omezte nafouknutou velikost každého streamu na to, co jeho spotřebitel může reálně potřebovat, a udržujte druhý rozpočet na dokument: pět set streamů, každý těsně pod limitem na stream, vyčerpá paměť stejně spolehlivě jako jeden obří stream. Kontrola patří dovnitř smyčky nafukování, kde se výstupní bajty počítají, jak jsou produkovány, a v případě porušení limitu se přeruší, ne až po smyčce, kdy je paměť již vyčerpána. Rozpočet dokumentu vyjádřený jako násobek velikosti komprimovaného souboru funguje dobře, protože legitimní dokumenty se shlukují hluboko pod poměry, kterých dosahuje upravený stream
Hloubková obrana mimo vaše vlastní unity
Stejné třídy chyb žijí v knihovnách. Dvě případové studie na tomto blogu procházejí skutečnými příklady: přetečení celých čísel, neomezená rekurze a neinicializované buffery uzavřené v nativním enginu Pascal v článku Zabezpečení Pascal PDF parseru proti škodlivým souborům a rizika konvencí volání, šířky celých čísel a vlastnictví v bindingu C enginu v článku Zabezpečení komponenty PDFium Binding. U skutečně nedůvěryhodného příjmu — veřejný nahrávací formulář, neověřená e-mailová schránka — také spouštějte parsování a dekódování v samostatném procesu s nízkými oprávněními, takže soubor, který porazí každou in-process ochranu, bude stát neúspěšnou úlohu místo vypadku služby
Kontrolní seznam před nasazením
Než bude expedován další build, projděte parser podle tohoto seznamu: každý buffer streamu má velikost podle omezené délky namísto té deklarované; každý rastr má velikost na základě ověřených parametrů dekodéru a je kontrolován oproti výstupu dekodéru; každý součin rozměrů je vyhodnocen v Int64 a porovnán s explicitním limitem; {$R+} je aktivní v každé unitě, která indexuje hodnotami odvozenými ze souboru; každé vyhledávání je kontrolováno ohledně mezí s naměřenou velikostí souboru; každé rozlišení odkazů má omezenou hloubku a kontrolu cyklů; každá smyčka nafukování počítá výstup s rozpočty na stream a na dokument. Žádná z těchto kontrol nestojí u legitimního dokumentu měřitelný čas a každá převádí korupci paměti na čisté, zaznamenatelné zamítnutí
Poznámka: komponenty losLab HotPDF Component, PDFlibPas Delphi PDF Library a PDFium Component uplatňují tyto kontroly hranic, limity hloubky a omezení expanze interně, takže přijímací pipeline na nich postavená začíná na posíleném základu