Linka na príjem dokumentov prijíma súbory napísané cudzími ľuďmi. Faktúry, skeny, prílohy z webového formulára: každý o sebe tvrdí, že je PDF, a nesie stovky čísel, podľa ktorých má váš analyzátor konať. Dĺžky prúdov, rozmery obrázkov, bajtové offsety, odkazy na objekty — každé z nich vybral ten, kto súbor vyrobil, a useknuté nahranie alebo zámerne poškodený dokument raz jedno z tých čísel položí tam, kde napácha škodu. Rozdiel medzi analyzátorom, ktorý taký súbor prežije, a tým, ktorý spadne alebo beží ďalej s poškodenou pamäťou, je malá sada návykov nezávislých od konkrétnej knižnice pre PDF
Tieto návyky zdieľajú jeden predpoklad: hodnota prečítaná zo súboru je tvrdenie, nie meranie. Použiteľnou sa stáva až po overení oproti niečomu, čo si analyzátor odmeral sám — oproti skutočnej veľkosti súboru, skutočnému počtu bajtov, ktoré dekodér vyprodukoval, skutočnej hĺbke rekurzie. Nasleduje tento predpoklad aplikovaný na miesta, kde sa analyzátory dokumentov naozaj lámu
Deklarovaná dĺžka je tvrdenie, nie meranie
Najjednoduchším nesúladom je dĺžka prúdu. Objekt prúdu v PDF deklaruje svoj počet bajtov v kľúči /Length a skutočné dáta ležia medzi kľúčovými slovami stream a endstream. Nič nenúti tieto dve hodnoty, aby si odpovedali. Useknutý súbor drží menej skutočných bajtov, než hovorí deklarovaný počet; súbor z pokazeného generátora môže deklarovať dĺžku siahajúcu za koniec súboru alebo do susedného objektu. Alokujte podľa deklarovanej hodnoty a kopírujte až po endstream a pretečiete buffer; prečítajte presne deklarovaný počet bez kontroly dostupnosti a vykročíte za koniec súboru. Nechajte deklarovanú hodnotu riadiť alokáciu až po jej orezaní oproti odmeranej vzdialenosti ku koncu dát a nesúlad berte ako rozhodovací bod — buď oprava skenovaním za endstream, alebo odmietnutie prúdu — nikdy nie ako niečo, čomu sa potichu verí
Parametre obrázka, ktoré opisujú väčší raster, než ste alokovali
Obrázkové prúdy stávku zvyšujú, pretože tie isté pixely opisujú dve nezávislé sady čísel. Slovník obrázka nesie /Width a /Height a rastrové buffery sa zvyčajne dimenzujú podľa nich. Dekódovací filter nesie vlastnú geometriu: CCITTFaxDecode berie /Columns, /Rows a /K zo svojho DecodeParms, kde /K vyberá schému Group 3 alebo Group 4 a dekodér vydá (Columns + 7) div 8 bajtov na riadok. Súbor, ktorý deklaruje /Width 100, ale filtru podá /Columns 1728 — teda predvolenú hodnotu — donúti dekodér vyprodukovať viac než šestnásťnásobok bajtov na riadok, než buffer očakáva, a pretečenie dopadne po jednom riadku do toho, čo za alokáciou leží. Keď /Rows chýba, dekodér beží, kým dáta nepovedia dosť, takže ohraničte aj počet riadkov. DCTDecode má ten istý šev: dáta JPEG nesú vlastnú šírku a výšku vo svojej značke SOF a nič ich nezaväzuje zhodovať sa so slovníkom
Obranné pravidlo je mechanické: vypočítajte očakávanú veľkosť rastra z overených dekódovacích parametrov — z vlastných /Columns a /Rows filtra pre CCITT, z rozmerov SOF pre DCT — porovnajte ju so svojimi limitmi, alokujte podľa nej a počas dekódovania overujte, že výstup nikdy nepresiahne alokáciu. Keď sa slovník a filter o geometrii nezhodnú, zosúlaďte ich alebo obrázok odmietnite. Čo analyzátor nikdy nesmie urobiť, je nadimenzovať buffer podľa jednej sady čísel a nechať dekodér bežať podľa druhej
Aritmetické a alokačné pasce v Delphi
Tri správania Delphi podkopávajú aj analyzátor, ktorý má úmysel overovať. Prvým je 32-bitové násobenie: Delphi vyhodnotí súčin dvoch operandov typu Integer na 32 bitoch bez ohľadu na šírku cieľa, takže Width * Height * BytesPerPixel môže pretiecť aj vtedy, keď každý činiteľ prejde vlastnou kontrolou zdravého rozumu. Sken 30000 krát 30000 pri troch bajtoch na pixel má 2,7 miliardy bajtov, čo v znamienkovej 32-bitovej aritmetike pretečie do zápornej hodnoty; mierne odlišné činitele pretečú na malú kladnú dĺžku, ktorá sa alokuje a buffer poddimenzuje. Vynúťte si široké vyhodnotenie celého výrazu pretypovaním prvého operandu — Size := Int64(Width) * Height * BytesPerPixel — a potom porovnajte s explicitným stropom skôr, než sa čokoľvek dostane k SetLength
Druhým je kontrola rozsahov. Predvolená release konfigurácia Delphi ju má vypnutú, takže index mimo rozsahu vypočítaný z dát súboru nevyvolá výnimku — číta alebo zapisuje pamäť susediacu s poľom. Zapnite ju späť cez {$R+} (a {$Q+} pre pretečenie aritmetiky) na začiatku každej jednotky, ktorá indexuje hodnotami odvodenými zo súboru. Cena je nemerateľná vedľa I/O, ktoré analyzátor aj tak robí, a mení tiché poškodenie pamäte na zachytiteľnú ERangeError
Tretím je TMemoryStream.SetSize s hodnotou Int64 dodanou súborom. Na aktuálnom RTL alokuje presne to, čo si súbor vypýtal, takže jediný prúd tvrdiaci štyri gigabajty sa uprostred príjmu zmení na zlyhanie z nedostatku pamäte. Na starších RTL, kde SetSize berie Longint, sa hodnota najprv potichu zúži: deklarovaných $100000010 sa zmení na 16, alokácia uspeje a zápis skutočných dát prebehne ďaleko za ňu. Overte každú veľkosť oproti odmeranej veľkosti zdroja a tvrdému stropu skôr, než ju uvidí akékoľvek volanie alokácie
Offsety, ktoré ukazujú mimo súbor
Tabuľka krížových odkazov mapuje čísla objektov na absolútne bajtové offsety a analyzátor sa presunie tam, kam ukazujú. V poškodenom alebo nepriateľskom súbore tieto offsety dopadnú za koniec súboru alebo do nesúvisiacich štruktúr. TStream robí toto zlyhanie tichým: nastavenie Position za Size nie je chyba a obyčajné Read za koncom jednoducho vráti menej bajtov, než sa žiadalo, takže kód, ktorý kontrolu počtu vynechá, analyzuje ďalej staré bajty z predchádzajúceho objektu. Obranou je úzke hrdlo — jediná pomocná rutina, cez ktorú prejde každé nastavenie pozície a čítanie riadené súborom a ktorá overí offset aj počet oproti odmeranej veľkosti súboru ešte pred posunom prúdu
uses
System.SysUtils, System.Classes;
const
MAX_OBJECT_BYTES = 64 * 1024 * 1024; // žiadny objekt nesmie prekročiť 64 MB
type
EPdfBoundsError = class(Exception);
// Každé čítanie a presun riadený súborom ide cez toto. Offset a Count sú
// tvrdenia zo súboru; Source.Size je meranie, do ktorého sa musia zmestiť.
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;
Nasmerujte cez ňu offsety krížových odkazov, rozsahy prúdov aj čítania vložených súborov a zlý offset sa zmení na čisté odmietnutie, ktoré pomenuje konkrétne čísla, namiesto porušenia prístupu o tri volania neskôr
Cykly a hĺbka v grafe objektov
PDF je graf, nie strom. Ktorákoľvek hodnota môže byť nepriamym odkazom, odkaz sa môže vyriešiť na ďalší odkaz — /Length 12 0 R, kde objekt 12 drží 13 0 R — a nič nebráni reťazi, aby sa uzavrela do seba. Rezolver, ktorý odkazy sleduje naivne, rekurzívne zostupuje, kým sa nevyčerpá natívny zásobník, a vyčerpanie zásobníka sa nedá zachytiť; ukončí proces. Hlboko vnorené polia a slovníky dospejú k rovnakému koncu aj úplne bez cyklu
Použite dve poistky spolu: explicitné počítadlo hĺbky ohraničí poctivý, ale hlboký prípad limitom, ku ktorému sa žiadny legitímny súbor nepribližuje, a množina navštívených objektov zachytí skutočný cyklus pri jeho druhej návšteve, čím ho zmení na presnú, ohlásiteľnú chybu namiesto naďabenia na limit
uses
System.SysUtils, System.Generics.Collections;
const
MAX_RESOLVE_DEPTH = 32; // oveľa hlbšie než akákoľvek legitímna reťaz odkazov
type
EPdfStructureError = class(Exception);
TPdfValueKind = (pvNull, pvNumber, pvName, pvString, pvArray,
pvDictionary, pvStream, pvReference);
TPdfValue = record
Kind: TPdfValueKind;
RefNumber: Integer; // zmysluplné, keď Kind = pvReference
// ... polia s obsahom pre ostatné druhy
end;
// LoadObject je vaša vlastná rutina: nájde offset v xref pre
// ObjNumber, prečíta objekt cez ReadBounded a rozanalyzuje ho.
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 // napr. /Length 12 0 R
Result := ResolveObject(Result.RefNumber, Depth + 1, Visited);
finally
Visited.Remove(ObjNumber); // súrodenci môžu tento objekt legálne zdieľať
end;
end;
Dekompresia je zosilňovač
Niekoľko kilobajtov vstupu pre FlateDecode sa môže nafúknuť na gigabajty; univerzálna kompresia odmeňuje opakujúci sa otvorený text a útočník ho vie urobiť maximálne opakujúcim sa. Ohraničte nafúknutú veľkosť každého prúdu tým, čo jeho konzument môže vierohodne potrebovať, a držte druhý rozpočet na celý dokument: päťsto prúdov, každý tesne pod stropom na prúd, vyčerpá pamäť rovnako spoľahlivo ako jeden obrí prúd. Kontrola patrí dovnútra dekompresného cyklu, kde sa výstupné bajty počítajú tak, ako vznikajú, a beh sa pri prekročení preruší, nie za cyklus, keď je pamäť už minutá. Rozpočet dokumentu vyjadrený ako násobok veľkosti komprimovaného súboru funguje dobre, keďže legitímne dokumenty sa zhlukujú ďaleko pod pomermi, ku ktorým sa vyrobený prúd dostane
Obrana do hĺbky za hranicami vlastných jednotiek
Tie isté triedy defektov žijú aj vnútri knižníc. Dve prípadové štúdie na tomto blogu prechádzajú skutočnými prípadmi: pretečenia celých čísel, neohraničenú rekurziu a neinicializované buffery uzavreté v natívnom enginu v Pascale opisuje spevnenie analyzátora PDF v Pascale proti škodlivým súborom a riziká volacích konvencií, šírky celých čísel a vlastníctva pri naviazaní enginu v C rozoberá spevnenie väzby na PDFium Component. Pri naozaj nedôveryhodnom príjme — verejný formulár na nahrávanie, neautentifikovaná schránka — spúšťajte analýzu a dekódovanie navyše v samostatnom procese s nízkymi oprávneniami, aby vás súbor, ktorý porazí všetky poistky vnútri procesu, stál zlyhanú úlohu a nie odstavenú službu
Kontrolný zoznam pred vydaním
Skôr než odíde ďalší build, prejdite analyzátor podľa tohto zoznamu: každý buffer prúdu dimenzovaný z orezanej dĺžky, nie z deklarovanej; každý raster dimenzovaný z overených parametrov dekodéra a porovnávaný s jeho výstupom; každý súčin rozmerov vyhodnotený v Int64 a porovnaný s explicitným stropom; {$R+} aktívne v každej jednotke, ktorá indexuje hodnotami odvodenými zo súboru; každý presun v prúde overený oproti odmeranej veľkosti súboru; každé vyriešenie odkazu ohraničené hĺbkou a kontrolované na cykly; každý dekompresný cyklus počítajúci výstup oproti rozpočtu na prúd aj na dokument. Žiadna z týchto kontrol nestojí na legitímnom dokumente merateľný čas a každá mení poškodenie pamäte na čisté, zalogovateľné odmietnutie
Poznámka: komponenty HotPDF Delphi Component, PDF Library for Delphi Delphi PDF Library a PDFium Component od losLab uplatňujú tieto kontroly hraníc, limity hĺbky aj stropy expanzie interne, takže linka na príjem postavená na nich vychádza zo spevneného základu