Egy dokumentumbeviteli (intake) folyamat ismeretlenek által írt fájlokat fogad. Számlák, beolvasások, weblap űrlapok mellékletei: mindegyikük azt állítja, hogy PDF, és több száz számot tartalmaz, amelyekre a feldolgozójának (parser) reagálnia kell. Az adatfolyamok (stream) hossza, a képek méretei, a bájteltolások, az objektumhivatkozások — mindegyiket az a személy választotta, aki a fájlt előállította, és egy csonka feltöltés vagy egy szándékosan hibásan formázott dokumentum végül az egyik ilyen számot olyan helyre teszi, ahol kárt okoz. A különbség a között az értelmező között, amelyik túléli ezt a fájlt, és a között, amelyik összeomlik, vagy tovább fut sérült memóriával, azoknak a szokásoknak a kis halmaza, amelyek nem függenek egyetlen adott PDF-könyvtártól sem
A szokások egyetlen feltevésen osztoznak: a fájlból kiolvasott érték csupán egy állítás, nem mérés. Csak azután válik használhatóvá, hogy ellenőrizték valami olyan dologgal szemben, amit a feldolgozó maga mért — a fájl valódi mérete, egy dekódoló által előállított bájtok tényleges száma, egy rekurzió valódi mélysége. Ami ezután következik, az ezt a feltevést alkalmazza azokra a helyekre, ahol a dokumentumfeldolgozók valójában elromlanak
A bejelentett hossz egy állítás, nem mérés
A legegyszerűbb eltérés az adatfolyam hossza. Egy PDF adatfolyam objektum a bájtok számát a /Length kulcsban deklarálja, és a tényleges adatok a stream és az endstream kulcsszavak között helyezkednek el. Semmi sem kényszeríti arra a kettőt, hogy megegyezzen. Egy csonka fájl kevesebb valódi bájtot tartalmaz, mint a deklarált szám; a hibás generátorból származó fájl pedig olyan hosszt deklarálhat, amely túlnyúlik a fájl végén, vagy egy szomszédos objektumba nyúlik. Ha a bejelentett érték alapján allokál és addig másol, amíg az endstream-hez nem ér, akkor túllépi a puffert; ha pontosan a bejelentett mennyiséget olvassa be anélkül, hogy ellenőrizné a rendelkezésre állást, akkor lelép a fájl végéről. Hagyja, hogy a deklarált érték irányítsa a memóriafoglalást, de csak azután, miután az adatok végéig mért távolsághoz igazította azt (clamped), és kezelje az eltérést egy döntési pontként — javítsa ki az endstream keresésével, vagy utasítsa el a streamet —, soha ne pedig úgy, mint valamit, amit csendben el kell hinni
Képparaméterek, amelyek nagyobb rasztert írnak le, mint amennyit lefoglalt
A kép adatfolyamok emelik a tétet, mert két független számhalmaz írja le ugyanazokat a képpontokat. A képszótár (image dictionary) hordozza a /Width és a /Height értékeket, és a raszterpufferek méretét általában ezekből határozzák meg. A dekódoló szűrő a saját geometriáját hordozza: A CCITTFaxDecode a /Columns, a /Rows és a /K értékeket a DecodeParms-ból veszi át, ahol a /K a Group 3 vagy Group 4 sémát választja ki, és a dekódoló (Columns + 7) div 8 bájtot bocsát ki letapogatási soronként (scanline). Egy fájl, amely /Width 100-at deklarál, de a szűrőnek /Columns 1728-at (az alapértelmezett érték) ad át, arra készteti a dekódolót, hogy soronként több mint tizenhatszor annyi bájtot termeljen, mint amennyit a puffer vár, és a túlcsordulás (overflow) soronként landol bármiben is, ami a lefoglalás (allocation) után következik. Ha a /Rows hiányzik, a dekódoló addig fut, amíg az adatok azt nem mondják, hogy álljon meg, tehát határolja a sorok számát is. A DCTDecode-nak ugyanez az illesztése (seam) van: a JPEG adat a saját szélességét és magasságát az SOF jelölőjében (marker) hordozza, és semmi sem kötelezi őket arra, hogy megegyezzenek a szótárral
A védekezési szabály mechanikus: számítsa ki a várható raszter méretét az érvényesített dekódolási paraméterekből — a szűrő saját /Columns és /Rows értéke a CCITT-hez, a SOF méretei a DCT-hez —, ellenőrizze a saját korlátaival szemben, abból foglaljon le memóriát (allocate), és a dekódolás során ellenőrizze, hogy a kimenet soha nem fut túl a foglaláson. Ha a szótár és a szűrő nem ért egyet a geometriában, egyeztesse őket, vagy utasítsa el a képet. Amit a feldolgozó (parser) soha nem tehet meg, hogy az egyik számhalmaz alapján méretezi a puffert, és hagyja a dekódolót a másik szerint futni
A Delphi aritmetikai és allokációs csapdái
Három Delphi viselkedés ássa alá még azt az értelmezőt (parser) is, amely validálni szándékozik. Az első a 32 bites szorzás: a Delphi két Integer (egész) típusú operandus szorzatát 32 biten értékeli ki, függetlenül a célpont szélességétől, így a Width * Height * BytesPerPixel körbefordulhat (wrap), még akkor is, ha minden tényező önmagában átmegy az épeszűségi ellenőrzésen (sanity check). Egy 30000 x 30000-es szkennelés három bájtonkénti pixellel 2,7 milliárd bájt, amely előjeles 32 bites aritmetikában negatívba fordul; kissé eltérő tényezők pedig egy kis pozitív hosszra fordulnak át, amely lefoglalja és alulméretezi a puffert. Kényszerítse a teljes kifejezést szélesebbé az első operandus átalakításával (cast) — Size := Int64(Width) * Height * BytesPerPixel —, majd hasonlítsa össze egy kifejezett maximummal, mielőtt bármi elérné a SetLength-et
A második a tartományellenőrzés (range checking). A Delphi alapértelmezett release (kiadási) konfigurációja kikapcsolt állapotban szállítja, így a fájladatokból kiszámított tartományon kívüli index nem dob hibát (raise) — kiolvassa vagy felülírja a tömbbel szomszédos memóriát. Kapcsolja vissza az {$R+}-szal (és a {$Q+}-szal az aritmetikai túlcsorduláshoz) minden olyan unit (egység) tetején, amely fájlból származó értékekkel indexel. A költség mérhetetlen ahhoz az I/O-hoz képest, amit a parser úgyis elvégez, és a csendes korrupciót egy elkapható ERangeError-rá alakítja
A harmadik a TMemoryStream.SetSize egy fájl által szolgáltatott Int64-gyel. Egy jelenlegi RTL-en (Run-Time Library) lefoglalja azt, amit a fájl kért, így egyetlen adatfolyam (stream), amely négy gigabájtot igényel, memóriahiányos (out-of-memory) hibává válik a bevitel (intake) közepén. Régebbi RTL-eken, ahol a SetSize egy Longint-et vesz be, az érték először csendben szűkül: a bejelentett $100000010 16-tá válik, a memóriafoglalás (allocation) sikerül, és a valódi adatok kiírása messze túlmutat rajta. Minden méretet validáljon a mért forrásméret és egy szigorú felső határ (hard cap) ellen, mielőtt bármilyen foglalási (allocation) hívás meglátná
A fájlon kívülre mutató eltolások
A kereszthivatkozási (cross-reference) tábla az objektumszámokat abszolút bájteltolásokra képezi le, és az értelmező (parser) odaugrik, ahová az mutat. Egy sérült vagy rosszindulatú fájlban ezek az eltolások a fájl végén túl vagy nem kapcsolódó struktúrák belsejében landolnak. A TStream ezt a hibát csendessé teszi: a Position beállítása a Size (méret) fölé nem hiba, és egy egyszerű Read a végén túl egyszerűen kevesebb bájtot ad vissza a kértnél, így az a kód, amelyik kihagyja a darabszám (count) ellenőrzését, folyamatosan feldolgozza az előző objektumból származó elavult (stale) bájtokat. A védelem egy fojtópont (chokepoint) — egyetlen segéd (helper), amelyen keresztül minden fájl-vezérelt keresés és olvasás áthalad, validálva az eltolást (offset) és a darabszámot (count) a mért fájlmérethez képest, még mielőtt a stream elmozdulna
uses
System.SysUtils, System.Classes;
const
MAX_OBJECT_BYTES = 64 * 1024 * 1024; // egyetlen objektum sem haladhatja meg a 64 MB-ot
type
EPdfBoundsError = class(Exception);
// Minden fájl-vezérelt keresés és olvasás ezen keresztül történik. Az Offset és a Count
// fájl által biztosított állítások; a Source.Size az a mérés, amelybe illeszkedniük kell.
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(
'az objektum kiterjedése %d+%d meghaladja a fájlméretet %d',
[Offset, Count, Source.Size]);
SetLength(Buffer, Count);
if Count = 0 then
Exit;
Source.Position := Offset;
Source.ReadBuffer(Buffer[0], Count);
end;
Ezen keresztül vezesse a kereszthivatkozási eltolásokat, a stream terjedelmeket (extents) és a beágyazott fájlok olvasását, és egy rossz eltolás egy tiszta elutasítássá válik, amely megnevezi a számokat, ahelyett, hogy három hívással később hozzáférési (access violation) hibát okozna
Ciklusok és mélység az objektumgráfon belül
Egy PDF egy gráf, nem egy fa. Bármilyen érték lehet közvetett hivatkozás, egy hivatkozás feloldódhat egy másik hivatkozássá — /Length 12 0 R, ahol a 12. objektum 13 0 R-t tárol —, és semmi sem akadályozza meg, hogy egy lánc önmagába záródjon. Egy feloldó (resolver), amelyik naivan követi a hivatkozásokat, addig rekurzál, amíg a natív verem (stack) ki nem merül, és a veremkimerülést (stack exhaustion) nem lehet elkapni; ez lezárja a folyamatot. A mélyen beágyazott tömbök és szótárak bármilyen ciklus nélkül is elérik ugyanezt a végét
Használjon két őrt együttesen: egy kifejezett mélységszámláló határolja a becsületes, de mély esetet olyan határnál, amelyet egyetlen legitim fájl sem közelít meg, egy meglátogatott halmaz (visited set) pedig elkap egy valódi ciklust a második látogatásakor, egy pontos, jelenthető hibává alakítva azt egy korlátozás-átlépés (limit trip) helyett
uses
System.SysUtils, System.Generics.Collections;
const
MAX_RESOLVE_DEPTH = 32; // sokkal mélyebb, mint bármilyen legitim hivatkozási lánc
type
EPdfStructureError = class(Exception);
TPdfValueKind = (pvNull, pvNumber, pvName, pvString, pvArray,
pvDictionary, pvStream, pvReference);
TPdfValue = record
Kind: TPdfValueKind;
RefNumber: Integer; // jelentőségteljes, amikor Kind = pvReference
// ... payload mezők a fennmaradó fajtákhoz
end;
// A LoadObject a saját rutinja: megkeresi az xref eltolást az ObjNumber-hez,
// beolvassa az objektumot a ReadBounded segítségével, és értelmezi (parses) azt.
function ResolveObject(ObjNumber, Depth: Integer;
Visited: TDictionary<Integer, Boolean>): TPdfValue;
begin
if Depth > MAX_RESOLVE_DEPTH then
raise EPdfStructureError.Create('a hivatkozási lánc meghaladja a mélységkorlátot');
if Visited.ContainsKey(ObjNumber) then
raise EPdfStructureError.CreateFmt(
'körkörös hivatkozás a(z) %d. objektumon keresztül', [ObjNumber]);
Visited.Add(ObjNumber, True);
try
Result := LoadObject(ObjNumber);
if Result.Kind = pvReference then // pl. /Length 12 0 R
Result := ResolveObject(Result.RefNumber, Depth + 1, Visited);
finally
Visited.Remove(ObjNumber); // a testvérek jogosan osztozhatnak ezen az objektumon
end;
end;
A kitömörítés egy erősítő
Néhány kilobájtnyi FlateDecode bemenet gigabájtokká tud felfújódni (inflate); az általános célú tömörítés jutalmazza a ismétlődő nyílt szöveget (plaintext), és egy támadó maximálisan ismétlődővé teheti azt. Korlátozza az egyes streamek felfújt (inflated) méretét arra, amire a fogyasztójának hihetően szüksége lehet, és tartson fenn egy második, dokumentumonkénti költségvetést: ötszáz stream, mindegyik épphogy a streamenkénti korlát alatt, éppolyan biztosan kimeríti a memóriát, mint egyetlen óriási stream. Az ellenőrzés a felfújási ciklus (inflation loop) belsejébe tartozik, számlálva a kimeneti bájtokat azok előállításakor, és megszakítva a folyamatot a korlát átlépésekor, nem pedig a ciklus után, amikor a memória már elfogyott. Egy tömörített fájlméret többszöröseként kifejezett dokumentum-költségvetés jól működik, mivel a legitim dokumentumok messze azok az arányok alatt csoportosulnak, amiket egy mesterségesen létrehozott (crafted) stream elér
Mélységi védelem (Defense in depth) a saját unitjain (egységein) túl
Ugyanezek a hibaosztályok megtalálhatóak a könyvtárakon belül is. Két esettanulmány ezen a blogon valós példákon megy keresztül: az egész szám körbefordulásokon (integer wraps), a korlátlan rekurzión és az inicializálatlan puffereken (amelyeket egy natív Pascal motorban zártak be) a Pascal PDF-feldolgozó megkeményítése rosszindulatú fájlokkal szemben című cikkben, valamint a hívási konvenció, az egész szám szélessége és egy C motor összekapcsolásának (binding) tulajdonjogi veszélyein a PDFium Komponens Binding megerősítése című cikkben. Egy valóban nem megbízható bevitelhez (intake) — egy nyilvános feltöltési űrlap, egy nem hitelesített postafiók — szintén futtassa az értelmezési (parse) és dekódolási munkát egy külön, alacsony jogosultságú (low-privilege) folyamatban, így az a fájl, amely legyőz minden folyamaton belüli (in-process) őrt, egy meghiúsult feladatba (failed job) fog kerülni a leállt szolgáltatás helyett
Előellenőrzési (preflight) ellenőrzőlista
Mielőtt a következő build (összeállítás) megjelenne, vizsgáltassa át a feldolgozóját (parser) ezen lista alapján: minden stream puffert egy rögzített (clamped) hossz alapján méreteztek a deklarált helyett; minden rasztert érvényesített dekódoló paraméterek alapján méreteztek és ellenőrizték a dekódoló kimenetével szemben; minden dimenziós szorzatot Int64-ben értékeltek ki és összehasonlították egy kifejezett maximummal; az {$R+} aktív minden olyan egységben (unit), amely a fájlból származó értékekkel indexel; minden keresést a mért fájlmérethez képest korlát-ellenőriztek (bounds-checked); minden hivatkozásfeloldás mélysége korlátozott és ciklus-ellenőrzött; minden felfújási (inflation) ciklus a kimenetet a streamenkénti és dokumentumonkénti költségvetésekhez képest számolja. Ezeknek az ellenőrzéseknek egyike sem kerül mérhető időbe egy legitim dokumentumon, és mindegyikük a memóriakorrupciót egy tiszta, naplózható (loggable) elutasítássá alakítja
Megjegyzés: a losLab HotPDF Komponens, a PDFlibPas Delphi PDF Könyvtár, és a PDFium Komponens alkalmazzák belsőleg ezeket a korlát-ellenőrzéseket (bounds checks), mélységkorlátokat és kiterjedési (expansion) maximumokat, így egy rájuk épülő beviteli (intake) folyamat egy megerősített alapállapotból indul