Egy dokumentumbeolvasó folyamat idegenek által írt fájlokat fogad. Számlák, szkennelt lapok, egy webes űrlapról érkező mellékletek: mindegyik PDF-nek vallja magát, és több száz olyan számot hordoz, amelyek alapján az elemzőnek cselekednie kell. Streamhosszok, képméretek, bájteltolások, objektumhivatkozások – mindegyiket az választotta, aki a fájlt előállította, és egy csonka feltöltés vagy egy szándékosan hibás dokumentum előbb-utóbb oda teszi egyiküket, ahol kárt okoz. A különbséget egy olyan elemző között, amely túléli ezt a fájlt, és egy olyan között, amely összeomlik vagy megrontott memóriával fut tovább, néhány szokás adja, amelyek egyetlen konkrét PDF-könyvtártól sem függenek
A szokások közös előfeltevése egy: a fájlból beolvasott érték állítás, nem mérés. Csak azután válik használhatóvá, hogy összevetették valamivel, amit maga az elemző mért meg – a fájl valódi méretével, azzal, hogy egy dekóder valójában hány bájtot állított elő, egy rekurzió valódi mélységével. Az alábbiakban ez az előfeltevés kerül alkalmazásra azokra a helyekre, ahol a dokumentumelemzők ténylegesen eltörnek
A deklarált hossz állítás, nem mérés
A legegyszerűbb eltérés a streamhossz. Egy PDF-streamobjektum a /Length kulcsban jelenti be a bájtszámát, a tényleges adat pedig a stream és az endstream kulcsszó között ül. Semmi nem kényszeríti ki, hogy a kettő egyezzen. Egy csonka fájlban kevesebb valódi bájt van a bejelentett számnál; egy hibás generátortól származó fájl olyan hosszt jelenthet be, amely túlnyúlik a fájl végén vagy belelóg egy szomszédos objektumba. Foglaljon a bejelentett érték alapján, és másoljon az endstream jelig, és túlírja a puffert; olvassa be pontosan a bejelentett számot a rendelkezésre állás ellenőrzése nélkül, és lesétál a fájl végéről. Hagyja, hogy a bejelentett érték vezérelje a foglalást, de csak azután, hogy megsapkázta az adat végéig mért távolsággal, az eltérést pedig döntési pontként kezelje – javítás az endstream keresésével, vagy a stream visszautasítása –, soha ne olyasmiként, amit csendben elhisz
Képparaméterek, amelyek nagyobb rasztert írnak le, mint amennyit lefoglalt
A képstreamek emelik a tétet, mert két független számhalmaz írja le ugyanazokat a képpontokat. A képszótár a /Width és a /Height kulcsot hordozza, és a raszterpuffereket rendszerint ezekből méretezik. A dekódszűrőnek saját geometriája van: a CCITTFaxDecode a DecodeParms értékből veszi a /Columns, /Rows és /K kulcsot, ahol a /K választja ki a Group 3 vagy Group 4 sémát, a dekóder pedig pásztasoronként (Columns + 7) div 8 bájtot bocsát ki. Az a fájl, amely /Width 100 értéket jelent be, de /Columns 1728 értéket – az alapértelmezést – adja a szűrőnek, több mint tizenhatszor annyi bájtot állíttat elő soronként, mint amennyire a puffer számít, a túlcsordulás pedig pásztasoronként landol abban, ami a foglalás után következik. Ha a /Rows hiányzik, a dekóder addig fut, amíg az adat meg nem állítja, ezért a sorszámot is korlátozza. A DCTDecode esetében ugyanez a varrat van jelen: a JPEG-adat a saját szélességét és magasságát hordozza az SOF-jelölőjében, és semmi nem kötelezi arra, hogy egyezzenek a szótárral
A védekező szabály gépies: számítsa ki az elvárt raszterméretet az ellenőrzött dekódolási paraméterekből – CCITT esetén a szűrő saját /Columns és /Rows értékéből, DCT esetén az SOF méreteiből –, vesse össze a korlátaival, ebből foglaljon, és dekódolás közben ellenőrizze, hogy a kimenet soha ne fusson túl a foglaláson. Amikor a szótár és a szűrő nem ért egyet a geometriában, egyeztesse őket, vagy utasítsa vissza a képet. Amit egy elemzőnek soha nem szabad tennie: az egyik számhalmazból méretezni a puffert, és a dekódert a másikkal futni hagyni
Delphi aritmetikai és foglalási csapdák
Három Delphi-viselkedés aknázza alá még azt az elemzőt is, amely ellenőrizni szándékozik. Az első a 32 bites szorzás: a Delphi két Integer operandus szorzatát 32 biten értékeli ki, függetlenül a célváltozó szélességétől, tehát a Width * Height * BytesPerPixel még akkor is körbefordulhat, ha minden tényező átmegy a saját józansági ellenőrzésén. Egy 30000-szer 30000-es, képpontonként három bájtos szkennelés 2,7 milliárd bájt, ami előjeles 32 bites aritmetikában negatívba fordul; kissé más tényezőknél kis pozitív hosszra fordul, amely lefoglalódik, és alulméretezi a puffert. Kényszerítse szélessé a teljes kifejezést az első operandus típuskényszerítésével – Size := Int64(Width) * Height * BytesPerPixel –, majd vesse össze egy kifejezett felső korláttal, mielőtt bármi elérné a SetLength hívást
A második a tartományellenőrzés. A Delphi alapértelmezett kiadási konfigurációjában ez ki van kapcsolva, tehát a fájladatból számított, tartományon kívüli index nem vált ki hibát – a tömb szomszédságában lévő memóriát olvassa vagy írja. Kapcsolja vissza a {$R+} direktívával (és a {$Q+} direktívával az aritmetikai túlcsordulásra) minden olyan unit tetején, amely fájlból származó értékekkel indexel. A költség mérhetetlen amellett az I/O mellett, amelyet egy elemző amúgy is végez, cserébe a néma memóriarontást elkapható ERangeError hibává alakítja
A harmadik a TMemoryStream.SetSize fájlból kapott Int64 értékkel. Egy mai RTL-en lefoglalja azt, amit a fájl kért, tehát egyetlen, négy gigabájtot állító stream memóriahiányos hibává válik a beolvasás közepén. Régebbi RTL-eken, ahol a SetSize Longint értéket vesz át, az érték előbb csendben szűkül: a bejelentett $100000010 értékből 16 lesz, a foglalás sikerül, a valódi adat kiírása pedig messze túlfut rajta. Minden méretet ellenőrizzen a mért forrásmérettel és egy kemény felső korláttal szemben, mielőtt bármelyik foglalási hívás meglátná
Eltolások, amelyek a fájlon kívülre mutatnak
A kereszthivatkozási tábla objektumszámokat képez le abszolút bájteltolásokra, az elemző pedig oda pozicionál, ahová az mutat. Sérült vagy ellenséges fájlban ezek az eltolások a fájl vége mögé vagy nem kapcsolódó szerkezetek belsejébe esnek. A TStream csendessé teszi a hibát: a Position beállítása a Size mögé nem hiba, a fájl vége mögötti sima Read pedig egyszerűen kevesebb bájtot ad vissza a kértnél, tehát az a kód, amely kihagyja a darabszám ellenőrzését, az előző objektum elavult bájtjait elemzi tovább. A védelem egy szűk keresztmetszet – egyetlen segédfüggvény, amelyen minden fájl által vezérelt pozicionálás és olvasás áthalad, és amely a mért fájlmérettel veti össze az eltolást és a darabszámot, mielőtt a stream elmozdulna
uses
System.SysUtils, System.Classes;
const
MAX_OBJECT_BYTES = 64 * 1024 * 1024; // egyetlen objektum sem lehet 64 MB-nál nagyobb
type
EPdfBoundsError = class(Exception);
// Minden fájl által vezérelt pozicionálás és olvasás itt halad át. Az Offset és a
// Count fájlból kapott állítás; a Source.Size az a mérés, amelybe bele kell férniük.
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;
Vezesse át rajta a kereszthivatkozási eltolásokat, a streamek kiterjedését és a beágyazott fájlok olvasásait, és egy rossz eltolásból tiszta visszautasítás lesz, amely megnevezi a számokat, ahelyett hogy három hívással később hozzáférési hibát okozna
Körök és mélység az objektumgráfban
A PDF gráf, nem fa. Bármelyik érték lehet közvetett hivatkozás, egy hivatkozás feloldódhat egy másik hivatkozásra – /Length 12 0 R, ahol a 12-es objektum a 13 0 R értéket tartalmazza –, és semmi nem akadályozza meg, hogy egy lánc önmagába záruljon. Az a feloldó, amely naivan követi a hivatkozásokat, addig rekurzál, amíg a natív verem el nem fogy, a veremkimerülés pedig nem olyasmi, amit el lehet kapni; véget vet a folyamatnak. A mélyen egymásba ágyazott tömbök és szótárak ugyanide jutnak, mindenféle kör nélkül
Két védelmet használjon együtt: egy kifejezett mélységszámláló olyan korláton fogja meg a becsületes, de mély esetet, amelyet egyetlen szabályos fájl sem közelít meg, egy meglátogatott halmaz pedig a második látogatáskor kapja el a valódi kört, és pontos, jelenthető hibává alakítja a korlát átlépése helyett
uses
System.SysUtils, System.Generics.Collections;
const
MAX_RESOLVE_DEPTH = 32; // sokkal mélyebb minden szabályos hivatkozási láncnál
type
EPdfStructureError = class(Exception);
TPdfValueKind = (pvNull, pvNumber, pvName, pvString, pvArray,
pvDictionary, pvStream, pvReference);
TPdfValue = record
Kind: TPdfValueKind;
RefNumber: Integer; // akkor értelmes, ha Kind = pvReference
// ... a többi fajtához tartozó adatmezők
end;
// A LoadObject az Ön saját rutinja: kikeresi az ObjNumber xref eltolását,
// beolvassa az objektumot a ReadBounded hívással, és elemzi.
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 // pl. /Length 12 0 R
Result := ResolveObject(Result.RefNumber, Depth + 1, Visited);
finally
Visited.Remove(ObjNumber); // a testvérek szabályosan osztozhatnak ezen az objektumon
end;
end;
A kicsomagolás erősítő
Néhány kilobájtnyi FlateDecode bemenet gigabájtokra fújódhat fel; az általános célú tömörítés jutalmazza az ismétlődő nyílt szöveget, egy támadó pedig maximálisan ismétlődővé teheti. Sapkázza meg minden stream felfújt méretét azzal, amennyire a fogyasztójának hihetően szüksége lehet, és tartson egy második, dokumentumonkénti keretet is: ötszáz stream, mindegyik éppen a streamenkénti korlát alatt, ugyanolyan biztosan meríti ki a memóriát, mint egyetlen óriási stream. Az ellenőrzés a felfújási cikluson belülre való, ahol a kimeneti bájtokat előállítás közben számolja, és a korlát átlépésekor megszakít – nem a ciklus után, amikor a memóriát már elköltötte. A tömörített fájlméret többszöröseként kifejezett dokumentumkeret jól működik, mivel a szabályos dokumentumok jóval azon arányok alatt csoportosulnak, amelyeket egy megszerkesztett stream elér
Mélységi védelem a saját unitjain túl
Ugyanezek a hibaosztályok a könyvtárakon belül is élnek. Ezen a blogon két esettanulmány jár végig valódi eseteket: az egész számok körbefordulását, a korlátlan rekurziót és az inicializálatlan puffereket egy natív Pascal motorban a Pascal PDF-elemző megerősítése rosszindulatú fájlok ellen című cikk zárja le, egy C motor bekötésének hívási konvenciós, egészszám-szélességi és tulajdonlási veszélyeit pedig a PDFium Component-kötés megerősítése című írás. Valóban megbízhatatlan beolvasásnál – nyilvános feltöltő űrlap, hitelesítés nélküli postaláda – az elemzési és dekódolási munkát külön, alacsony jogosultságú folyamatban is futtassa, hogy az a fájl, amely minden folyamaton belüli védelmet legyőz, egy elbukott feladatba kerüljön, ne egy leállt szolgáltatásba
Indulás előtti ellenőrzőlista
Mielőtt a következő build kimenne, járja végig az elemzőt ezen a listán: minden streampuffer megsapkázott hosszból méretezve, nem a bejelentettből; minden raszter ellenőrzött dekóderparaméterekből méretezve és a dekóder kimenetéhez mérve; minden méretszorzat Int64 típusban kiértékelve és kifejezett felső korláttal összevetve; {$R+} aktívan minden olyan unitban, amely fájlból származó értékekkel indexel; minden pozicionálás a mért fájlmérettel szemben határellenőrizve; minden hivatkozásfeloldás mélységkorlátozva és körre ellenőrizve; minden felfújási ciklus a kimenetet streamenkénti és dokumentumonkénti keretekhez mérve. Ezek közül egyik ellenőrzés sem kerül mérhető időbe egy szabályos dokumentumon, és mindegyik tiszta, naplózható visszautasítássá alakítja a memóriarontást
Megjegyzés: a losLab HotPDF Delphi Component, a PDF Library for Delphi Delphi PDF Library és a PDFium Component belsőleg alkalmazza ezeket a határellenőrzéseket, mélységkorlátokat és tágulási sapkákat, így a rájuk épülő beolvasó folyamat megerősített alapról indul