Odborný článok

Pamäťovo bezpečné parsovanie PDF: Obrana proti škodlivým dokumentom

Procesný kanál na prijímanie dokumentov prijíma súbory od neznámych ľudí. Faktúry, skeny, prílohy z webového formulára: každý o sebe tvrdí, že je PDF, a prináša so sebou stovky čísel, podľa ktorých by mal váš parser konať. Dĺžky prúdov (stream lengths), rozmery obrázkov, posuny v bajtoch (byte offsets), referencie na objekty — každé jedno z týchto čísel zvolil ten, kto súbor vyprodukoval, a neúplné nahrávanie (truncated upload) alebo úmyselne poškodený dokument nakoniec aj tak vloží jedno z týchto čísel na miesto, kde reálne napácha škody. Rozdiel medzi parserom, ktorý takýto súbor prežije, a tým, ktorý padne (crashes), alebo pokračuje s poškodenou pamäťou, spočíva v malom súbore rôznych zvyklostí, ktoré nezávisia od žiadnej konkrétnej PDF knižnice

Všetky tieto návyky majú spoločnú jednu premisu: hodnota prečítaná zo súboru je len akési tvrdenie, nie meranie. Stáva sa použiteľnou až po tom, čo sa overí voči niečomu, čo si zmeral samotný parser — voči reálnej veľkosti súboru, skutočnému počtu bajtov vyprodukovaných dekodérom, či skutočnej hĺbke v rámci rekurzie. To, čo nasleduje ďalej, je aplikovanie tohto predpokladu na miesta, na ktorých sa parsery dokumentov v realite skutočne lámu a rozbíjajú

Deklarovaná dĺžka je tvrdenie, nie meranie

Najjednoduchším nesúladom je dĺžka prúdu (stream length). Objekt s PDF prúdom deklaruje svoj počet bajtov v kľúči /Length, zatiaľ čo reálne dáta sedia medzi kľúčovými slovami stream a endstream. Nič však nenúti tieto dve hodnoty, aby navzájom súhlasili. Orezaný a poškodený súbor zvyčajne obsahuje oveľa menej reálnych bajtov, než uvádza jeho deklarovaný počet; naopak, súbor z poškodeného generátora môže deklarovať dĺžku, ktorá presahuje až za samotný koniec súboru alebo do susedného objektu. Pri alokácii pomocou deklarovanej hodnoty a následnom kopírovaní do okamihu zastavenia na endstream jednoducho len prekročíte (overrun) buffer; pri čítaní presne podľa deklarovaného počtu bez kontroly reálnej dostupnosti zase odkráčate preč z konca celého súboru. Nechajte deklarovanú hodnotu riadiť alokácie len po jej predchádzajúcom zviazaní voči nameranej vzdialenosti na koniec dát, a so samotným nesúladom zaobchádzajte ako s bodom na rozhodovanie — súbor buď opravte pomocou preskúmania až k endstream, alebo taký prúd zamietnite — v každom prípade sa však k nemu nesprávajte ako k niečomu, čomu stopercentne a potichu dôverujete

Parametre obrázka, ktoré popisujú väčší raster (raster), ako ste alokovali

Prúdy s obrázkami trochu dvíhajú stávky, pretože rovnaké pixely popisujú hneď dve úplne nezávislé sady čísel. Slovník obrázka nesie /Width a /Height, a rastrované buffre sa zvyčajne dimenzujú z nich. Dekódovací filter si však nesie aj svoju vlastnú geometriu: CCITTFaxDecode preberá /Columns, /Rows a /K zo svojho DecodeParms, kde /K vyberá schému Group 3 alebo Group 4 a dekodér emituje (Columns + 7) div 8 bajtov na jeden skenovací riadok (scanline). Ak súbor deklaruje /Width 100, ale odovzdá filtru /Columns 1728 — čo je predvolená hodnota — spôsobí to, že dekodér vyprodukuje na jeden riadok vyše šestnásťkrát viac bajtov ako buffer očakáva, a toto pretečenie (overflow) pristane postupne, skenovací riadok po skenovacom riadku, do čohokoľvek, čo sa nachádza hneď po alokácii. Keď položka /Rows chýba, dekodér beží až dovtedy, kým samotné dáta nepovedia stop, takže je potrebné počet riadkov zhora obmedziť. DCTDecode má ten istý problém (same seam): dáta pre JPEG si nesú svoju vlastnú šírku a výšku vo svojom SOF markeri, a vôbec nič ich nenúti k tomu, aby sa zhodovali s údajmi v slovníku

Defenzívne pravidlo je pritom len mechanické: vypočítajte očakávanú veľkosť rastra priamo zo zvalidovaných dekódovacích parametrov — z vlastných položiek /Columns a /Rows samotného filtra pre CCITT, prípadne z rozmerov SOF pre DCT — overte si ich voči vašim limitom, alokujte priamo z nich, a následne počas samotného dekódovania neustále overujte, že výstup nikdy nepretečie mimo vyčlenenú alokáciu. Ak sa slovník a filter nezhodujú v geometrii, buď ich zosúlaďte (reconcile), alebo obrázok odmietnite. To, čo však parser nikdy nesmie urobiť, je zdimenzovanie buffra pomocou jednej sady čísel, a ponechanie dekodéra bežať s tou druhou

Nástrahy pri aritmetike a alokácii v Delphi

Existujú tri druhy správania v Delphi, ktoré podkopávajú aj taký parser, ktorý to s validáciou myslí naozaj úprimne. Prvým z nich je 32-bitové násobenie: Delphi vyhodnocuje súčin dvoch operandov typu Integer ako 32-bitový bez ohľadu na šírku cieľa, takže výraz Width * Height * BytesPerPixel môže pretiecť (wrap) dokonca aj vtedy, ak všetky jeho činitele úspešne prejdú svojou vlastnou kontrolou správnosti (sanity check). Sken s rozlíšením 30 000 krát 30 000 pri troch bajtoch na pixel predstavuje 2,7 miliardy bajtov, čo pri signed 32-bitovej aritmetike pretečie do záporných hodnôt; mierne odlišné faktory zas pretečú do akejkoľvek malej kladnej dĺžky, ktorá následne alokuje a poddimenzuje buffer. Vynúťte pre celý tento výraz radšej šírku tak, že pretypujete jeho prvý operand — Size := Int64(Width) * Height * BytesPerPixel — a následne to celé ešte pred akýmkoľvek volaním SetLength porovnajte voči explicitnému limitu

Druhým bodom je kontrola rozsahu (range checking). Predvolená finálna konfigurácia (release configuration) v Delphi sa zvykne dodávať s vypnutou kontrolou, takže akýkoľvek index mimo rozsah, vypočítaný z dát uloženého súboru, zvyčajne nič neohlási a nevyvolá — jednoducho len prečíta alebo prepíše pamäť nachádzajúcu sa hneď vedľa priradeného poľa. Zapnite si ju pre istotu naspäť s použitím direktív {$R+} (a rovnako aj {$Q+} pre pretečenie aritmetiky) pridanými priamo na vrchol každej jednotky (unit), ktorá využíva indexovanie založené na hodnotách zo súborov. Náklady za tento krok sú v porovnaní s bežným V/V (I/O) prístupom neporovnateľné, a dokážu pretaviť tiché a postupné ničenie pamäte (silent corruption) na ľahko zachytiteľnú výnimku ERangeError

Tretím príkladom je volanie TMemoryStream.SetSize s argumentom Int64 dodaným samotným súborom. Na súčasnom RTL dochádza k alokácii čohokoľvek, o čo súbor požiada, takže sa aj jediný prúd nárokujúci si na štyri gigabajty rýchlo stane poruchou nedostatku pamäte (out-of-memory) už v polovici prijímania. Na starších knižniciach RTL, kde SetSize akceptuje hodnotu Longint, je takáto hodnota najskôr len v tichosti zúžená: a tak sa deklarovaná hodnota $100000010 stane hodnotou 16, alokácia uspeje, a samotný reálny zápis skutočných dát z nej s rezervou zabehne kamsi hlboko preč. Každú takúto veľkosť si preto vždy vopred zvalidujte proti aktuálnej nameranej zdrojovej veľkosti, a stanovte jej aj horný strop predtým, než ju nejaké alokačné volanie vôbec uvidí

Odsadenia (Offsets), ktoré smerujú (point) mimo súbor

Tabuľka krížových odkazov mapuje čísla objektov k absolútnym posunom v bajtoch, a parser následne nasleduje čokoľvek, na čo tieto posuny ukazujú. V poškodenom alebo úmyselne nepriateľskom súbore však tieto offsety pristávajú až kdesi za koncom samotného súboru, alebo prenikajú priamo zvnútra do štruktúr bez akéhokoľvek príslušného kontextu. Trieda TStream takúto poruchu udržuje v tichosti: nastavenie vlastnosti Position presahujúcej za limit Size samo o sebe nie je chybou, a jednoduché volanie Read nasmerované poza tento koniec súboru skrátka vráti o niečo menej bajtov, než sa požadovalo. Kód ignorujúci overenie pre množstvo (count check) tak iba pokračuje a ostáva uviaznutý v parsovaní zastaraných neplatných (stale) bajtov z predchádzajúceho objektu. Tou najlepšou obranou je už len akési úzke hrdlo (chokepoint) — jeden pomocník, cez ktorého bez výhrad prechádza akékoľvek posúvanie (seek) aj samotné čítanie (read), pričom zakaždým dôsledne verifikuje posun aj samotné dopytované množstvo proti zistenej meranej dĺžke súboru predtým, než sa daný prúd posunie (stream moves)

uses
  System.SysUtils, System.Classes;

const
  MAX_OBJECT_BYTES = 64 * 1024 * 1024; // žiaden samostatný objekt nesmie prekročiť 64 MB

type
  EPdfBoundsError = class(Exception);

// Akékoľvek súborom riadené posúvanie a čítanie prechádza priamo tadiaľto.
// Offset a Count sú len súborom udané tvrdenia; Source.Size je meranie, ktorému sa musia prispôsobiť.
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(
      'rozsah objektu %d+%d presahuje veľkosť súboru %d',
      [Offset, Count, Source.Size]);
  SetLength(Buffer, Count);
  if Count = 0 then
    Exit;
  Source.Position := Offset;
  Source.ReadBuffer(Buffer[0], Count);
end;

Presmerujte cez neho posuny pre krížové odkazy, rozmery prúdov, ako aj čítania z vnorených (embedded) súborov, a ten jeden zlý offset sa rázom zmení na čisté odmietnutie uvádzajúce konkrétne problematické čísla, namiesto zbytočnej chybovej výnimky narušenia prístupu (access violation), ktorá by vznikla až niekde o ďalšie tri volania neskôr

Cykly a hĺbka v objektovom grafe

Štruktúra dokumentov PDF je graf, nie strom. Každá jedna hodnota môže byť nepriamym odkazom, a nejaký odkaz sa môže pokojne spájať s úplne iným odkazom — /Length 12 0 R, kde samotný objekt 12 ukrýva hodnotu pre 13 0 R — a rovnako absolútne nič nebráni tomu, aby sa taká reťaz len tak uzavrela a zreťazila sama na seba. Analyzátor a spracovateľ, ktorý za takýmito odkazmi putuje až príliš naivne, len rekurzívne postupuje dovtedy, kým v natívnom prostredí úplne nevyčerpá pamäť, pričom pre vyčerpanie pamäte (stack exhaustion) platí, že sa nejedná o niečo, čo tak ľahko "chytíte"; tento jav priamo definitívne ukončuje bežiaci proces. Hlboko zanorené (nested) polia a slovníky bez pochýb končia podobným spôsobom aj bez akejkoľvek prítomnosti aspoň jedného takéhoto cyklu

Používajte tieto dva ochranné prostriedky vzájomne a spoločne: jedno explicitné počítadlo hĺbky pre ohraničenie nejakého naoko bezúhonného, no hlboko previazaného prípadu v rámci presne definovaného limitu, k akému by žiaden legitímny dokument ani zďaleka nepristúpil, ako aj zoznam zaznamenávajúci už zrealizované prístupy k jednotlivým prvkom dokumentu, ktorý pri každom druhom takomto prístupe narazí práve na nejaký ozajstný (genuine) cyklus, čo tento proces zastaví a namiesto bezmyšlienkového narážania na limit ho premení na presnú, ľahko zachytiteľnú a hlásiteľnú chybu (reportable error)

uses
  System.SysUtils, System.Generics.Collections;

const
  MAX_RESOLVE_DEPTH = 32; // hlbšie než akýkoľvek legitímny referenčný reťazec

type
  EPdfStructureError = class(Exception);

  TPdfValueKind = (pvNull, pvNumber, pvName, pvString, pvArray,
    pvDictionary, pvStream, pvReference);

  TPdfValue = record
    Kind: TPdfValueKind;
    RefNumber: Integer; // zmysluplné ak je Kind = pvReference
    // ... polia na načítanie úžitkovej hodnoty pre ostávajúce typy (kinds)
  end;

// LoadObject je vaša vlastná rutina: hľadá posun (xref offset) pre 
// ObjNumber, číta objekt pomocou ReadBounded a analyzuje (parsuje) ho.
function ResolveObject(ObjNumber, Depth: Integer;
  Visited: TDictionary<Integer, Boolean>): TPdfValue;
begin
  if Depth > MAX_RESOLVE_DEPTH then
    raise EPdfStructureError.Create('referenčný reťazec presahuje limit hĺbky');
  if Visited.ContainsKey(ObjNumber) then
    raise EPdfStructureError.CreateFmt(
      'kruhová referencia naprieč objektom %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 takýto objekt legitímne zdieľať
  end;
end;

Dekompresia je zosilňovač (amplifier)

Len zopár kilobajtov na vstupe pre FlateDecode sa dokáže rozbaliť (nafúknuť) až na gigabajty; všestranná (general-purpose) kompresia zvykne odmeňovať často sa opakujúci čistý text, a pre útočníka nie je žiadnym problémom urobiť takýto text plne maximálne sa opakujúcim. Stanovte vrchný limit pre rozbalenú (inflated) veľkosť každého prúdu presne na tú úroveň, ktorú by mohol jeho konzument uveriteľne a vierohodne potrebovať, a k tomu udržiavajte aj sekundárny rozpočet priradený na úrovni celého konkrétneho dokumentu: totiž, päťsto prúdov, kde pre každý jeden z nich by veľkosť siahala len tesne po stanovený hraničný limit pre jeden prúd, vám vyčerpá operačnú pamäť takisto spoľahlivo, ako jeden jediný obrovský prúd. Samotná previerka by sa mala diať priamo v útrobách dekompresného cyklu (inflation loop), pričom počíta výstupné bajty ihneď počas ich vzniku, a následne pri prekročení týchto stanovených limitov jednoducho preruší proces; nemalo by sa tak diať až po ukončení tohto cyklu v čase, kedy už pre celý proces chýba akákoľvek pamäť (memory is already spent). Rozpočet pre dokument vyjadrený ako nejaký príslušný násobok už stlačenej (compressed) veľkosti funguje taktiež vynikajúco, najmä keď uvážite skutočnosť, že všetky legitímne dokumenty sa zhlukujú (cluster) ďaleko pod pomermi (ratios), aké dokáže dosiahnuť úmyselne vyhotovený prúd (crafted stream)

Hĺbková obrana mimo vaše vlastné jednotky

Úplne rovnaké triedy chýb a defektov prežívajú aj v samotných knižniciach. Dve prípadové štúdie na tomto blogu vás prevedú skutočnými reálnymi príkladmi: pretekaním celočíselných hodnôt (integer wraps), nekonečnou rekurziou a neinicializovanými bufframi zapuzdrenými v natívnom jadre pre Pascal v článku Hardening a Pascal PDF Parser Against Malicious Files, ako aj rizikami vyplývajúcimi z volacej konvencie (calling-convention), šírky celých čísel (integer-width) a nebezpečenstvom s vlastníctvom (ownership) pri prepájaní C enginu v článku Hardening a PDFium Component Binding. V prípade akéhokoľvek skutočne nedôveryhodného príjmu dokumentov (intake) — verejný formulár pre upload, neoverená poštová schránka — spúšťajte prácu okolo parsovania a dekódovania rovno v samostatnom procese s nízkymi oprávneniami, čím sa zaistí, že súbor, ktorý nakoniec aj tak porazí akúkoľvek ochrannú stráž bežiacu vo vnútorných procesoch, vás síce vyjde na zlyhanú úlohu, no zabráni položeniu vašej dôležitej a nasadenej služby (downed service)

Kontrolný zoznam pri preflightoch

Predtým než odošlete a uvoľníte (ships) váš ďalší build, prejdite si so svojim parserom nasledujúci zoznam: každý buffer pre prúd zdimenzovaný s ohľadom na uchopenú limitovanú (clamped) dĺžku, namiesto tej deklarovanej; každý raster prispôsobený a rozmerovaný voči už plne zvalidovaným dekódovacím parametrom a skontrolovaný proti dekódovanému výstupu; pri každom rozmerovom produkte (dimension product) vyhodnocovanom v rozsahu Int64 uplatňujte rovnako aj prirovnávanie s ohľadom na explicitne stanovený limit; aktívnu direktívu {$R+} využívajte na úrovni každej jednotky (unit), ktorá využíva indexovanie založené na hodnotách zo súborov; každý jeden posun (seek) kontrolujte na hranice a prirovnávajte s ohľadom na meranú dĺžku súboru; pri každom riešení odkazov a referencií udržujte pevnú kontrolu hĺbky (depth-limited) so sledovaním cyklov (cycle-checked); dekompresný cyklus musí zakaždým prevádzať počítanie pre vyprodukované výstupné (counting output) objekty proti prideleným rozpočtom a to ako na úrovni samotných prúdov, tak aj dokumentov vo všeobecnosti. Ani jedna z týchto uplatňovaných previerok vás na legitímnom dokumente nebude absolútne nikdy stáť žiadny merateľný čas, a pritom každá dokáže previesť rozvrat v pamäti (memory corruption) priamo v čisté odmietnutie a zamietnutie (rejection), ktoré si bez problémov môžete nechať zaznamenať do logu (loggable rejection)

Poznámka: Komponent HotPDF, knižnica PDFlibPas Delphi PDF Library, ako aj komponent PDFium od spoločnosti losLab aplikujú a uplatňujú všetky tieto kontroly hraníc, limity pre hĺbku a vrchné stropy expanzie (expansion caps) úplne interne, a procesný kanál pre príjem dokumentov vybudovaný na ich základe tak štartuje z predpripravenej spevnenej základne (hardened baseline)