Technikai cikk

PDF szótárak biztonságos elemzése Delphiben: Név-tokenek

Az alkarakterlánc-keresések, mint például a Pos('/Length', Dict), rossz eszközök a PDF szótárak beolvasásához, mivel a PDF név-kulcsok (name keys) közös előtagokon osztoznak: a /Length a /Length1 előtagja, az /Encrypt pedig az /EncryptMetadata előtagja. Az ISO 32000-1 §7.3.5 definíciója szerint a név egy olyan token, amely csak elválasztó jelnél (delimiter) vagy szóköz/whitespace karakternél végződik, így a kulcs csak akkor minősül megtaláltnak, ha az utána következő bájt ezen karakterek egyike. Az a szótárolvasó, amely kihagyja ezt az egyetlen ellenőrzést, előbb-utóbb hibás értéket fog beolvasni egy teljesen érvényes fájlból.

A hiba, amely erre megtanított minket, egyáltalán nem tűnt lexikai elemzői (lexer) problémának. A megfelelőségi vizsgálat elkezdte hibásnak jelenteni az egyik FontFile adatfolyamot: a kicsomagolt betűtípus-program csak egy töredék volt, a táblázat közepén félbevágva. A fájl minden megjelenítőben jól nyílt meg. A lemezen lévő stream adatok épek voltak. A kiváltó ok a megosztott szótárolvasónk egyik sorában rejlett: a Pos('/Length', ...) megegyezett a /Length1 kulccsal, amely egy szabványos kulcs, amelyet a FontFile stream szótárak hordoznak az ISO 32000-1 127. táblázata szerint, és az olvasó a /Length1 utáni egész számot vette a stream hosszának. Történetesen az adott fájl írója a /Length1 kulcsot a /Length előtt szerializálta, amit teljesen szabadon megtehet, mivel a szótárbejegyzések a §7.3.7 szerint nincsenek rendezve. A stream egy szemét bájtszámra lett csonkolva, és minden későbbi ellenőrzés, amely ezt felhasználta, csendben megvakult.

Miért rontja el az alkarakterlánc-egyeztetés a PDF szótárak elemzését?

Az alkarakterlánc-egyeztetés azért bukik el, mert a PDF névterület tele van szándékos előtag-családokkal, és mert a szótárbejegyzések sorrendje nincs meghatározva. Az ISO 32000-1 127. táblázata határozza meg a /Length1, /Length2 és /Length3 kulcsokat a beágyazott betűtípus-folyamokhoz, amelyek mind a /Length mellett helyezkednek el ugyanabban a szótárban. A titkosítási szótár az /Encrypt kulcsot párosítja a lezáróban (trailer) a rajta belüli /EncryptMetadata-val. A rövid kulcsok még rosszabbak: a Pos('/' + Key, ...) formában felépített keresés, ahol a Key = 'N', boldogan landol a /Name vagy /Nums kulcsokon. Ezen ütközések egyikéhez sem szükséges hibás fájl. Egy olyan író, amely a /Length1-et a /Length elé helyezi, teljesen szabványszerű, ami azt jelenti, hogy az alkarakterlánc-hiba nem egy robusztussági hiányosság a hibás bemenetekkel szemben — hanem helyességi hiányosság a teljesen érvényes bemenetekkel szemben.

A hibamód ráadásul a csendes fajta. A rossz /Length nem vált ki kivételt (exception); egyszerűen rövidebb vagy hosszabb bájtszeletet ad át, mint amit a stream ténylegesen elfoglal. Ha ez a szelet egy betűtípus-részhalmaz (font-subset) ellenőrzést, egy CMap elemzést vagy egy metaadat-vizsgálatot táplál, a felhasználó szemét adatokat lát, és jellemzően semmit sem jelent, mivel a félig lévő zlib stream kicsomagolása egyszerűen meghiúsul, és a kód továbblép. Pontosan ezt a hibafajtát szállítottuk le, és javítottuk ki a megosztott olvasónk v2.14.3 verziójában, miután az ISO 32000-1 §7.2–§7.3 szakaszonkénti átvizsgálása minden Pos-stílusú kulcskeresést gyanúsnak minősített.

Mit határoz meg valójában az ISO 32000-1 §7.3.5 névként?

A 7.3.5 szakasz rövid és pontos: a név-objektum egy törtvonal (solidus), amelyet szabályos karakterek sorozata követ, és a tokent az első elválasztó jel vagy szóköz/whitespace karakter zárja le. Az elválasztók a nyolc zárójelkarakter, plusz a törtvonal és a százalékjel — ( ) < > [ ] { } / % —, a szóközök/whitespace-ek pedig a null, tabulátor, soremelés, lapemelés, kocsivissza és a szóköz (§7.2.2–§7.2.3). Ez a lezárási szabály a teljes történet. A /Length1 nem egy '/Length, amelyet egy 1-es követ'; ez egyetlen, feloszthatatlan token, pontosan úgy, ahogyan a LengthOne and Length are different identifiers in Pascal. Any reader that finds keys by raw byte search is reimplementing the lexer with the termination rule deleted.

Teljes token-egyeztetés: ellenőrizze a kulcs utáni bájtot

A helyes predikátum közvetlenül a §7.3.5-ből következik: egy jelölt találat csak akkor valódi kulcs, ha a közvetlenül utána álló karakter egy elválasztó, egy szóköz/whitespace, vagy a puffer vége. Minden más egy hosszabb név, amely csupán az előtagon osztozik, így a keresést folytatni kell ahelyett, hogy feladnánk. Az olvasónkban lévő javítás minden nyers Pos-keresést egyetlen közös, ezen a szabályon alapuló rutinra cserélt.

function IsPdfDelimOrWs(C: AnsiChar): Boolean;
begin
  Result := C in [#0, #9, #10, #12, #13, ' ',
    '(', ')', '<', '>', '[', ']', '{', '}', '/', '%'];
end;

// Helyes: teljes token egyezés az ISO 32000-1 §7.3.5 szerint
function FindDictKey(const Dict, Key: AnsiString): Integer;
var
  P, After: Integer;
begin
  Result := 0;
  P := Pos(Key, Dict);
  while P > 0 do
  begin
    After := P + Length(Key);
    if (After > Length(Dict)) or IsPdfDelimOrWs(Dict[After]) then
      Exit(P);                       // a token itt ér véget: valódi kulcs
    P := PosEx(Key, Dict, P + 1);    // egy hosszabb név előtagja: keressük tovább
  end;
end;

Két részlet is súllyal bír abban a ciklusban. Először is, folytatja a keresést ahelyett, hogy az első előtag-ütközésnél hibával térne vissza, mivel a /Length1 120 /Length 4076 egy legális sorrend, és a valódi kulcs még hátrébb van. Másodszor, a puffer végi eset lezárónak számít, mivel egy szótártöredék jogosan végződhet közvetlenül egy név után. Egy finomabb pont, amelyet érdemes ellenőrizni a saját kódjában: ugyanez a szabály érvényes a találat bal oldalára is, ha a keresési karakterlánc nem tartalmazza a törtvonalat (solidus), különben a Pos('Length', ...) a /PieceLength belsejében landolhat. A keresett karakterlánc rögzítése a vezető / jellel, mint fentebb, kezeli a bal élt, mivel maga a / egy elválasztó jel, amely lezárja a megelőző tokent.

Hogyan változtathatja egy ellenséges PDF az elemző hibáját gigabájtos foglalássá?

Egy hibásan formázott vagy rosszindulatú fájl ezeket a lexikai hibákat erőforrás-kimerüléssé fokozhatja, mivel a szótár egész számai gyakran táplálják a memóriafoglalási méreteket. Az auditunk pontosan egy ilyen láncolatot talált az objektumfolyam (ObjStm) kibontásakor. Az ObjStm szótár /N bejegyzése határozza meg, hogy a stream hány tömörített objektumot tartalmaz, és a kibontó kód ez alapján állította be a SetLength-et egy tömbön. Az egész számot értelmező parser azonban hiba esetén érintetlenül hagyta a kimenő paraméterét (out-parameter), miközben továbbra is visszaadta azt — így egy nem numerikus /N egy inicializálatlan veremértéket adott át a SetLength-nek. Egy ott lévő szemét pozitív egész szám gigabájtos nagyságú memóriafoglalási kérelmet jelent, amelyet néhány bájtnyi hibás bemenet vált ki, miközben Ön még csak beolvas egy olyan dokumentumot, amelynek megbízhatóságát el sem fogadta.

A javítás két független részből állt, és mindkettő általánosítható. A parser most már kifejezett 0-val tér vissza hiba esetén, soha nem inicializálatlan memóriával. A fogadó kód pedig már nem bízik meg az /N-ben aritmetikai ellenőrzés nélkül: az /First előtti ObjStm fejléc régió egy egész szám párt tárol — objektumszámot és eltolást — minden tömörített objektumhoz, és minden pár legalább négy bájtot foglal el az elválasztókkal együtt. Bármely FirstVal div 4 + 1 feletti /N érték ezért fizikailag lehetetlen a deklarált fejlécmérethez képest, és elutasításra kerül, mielőtt bármilyen memóriafoglalás megtörténne. A korlát egyetlen összehasonlításba kerül, és a már meglévő adatokból származik, ami pontosan a keresendő minta: a fájl által bizonyított felső határ, nem pedig egy tetszőleges konstans.

// az /N-t a támadó irányítja; korlátozzuk az alapján, amit a /First képes tárolni
if not TryReadDictInt(Dict, '/N', NVal) then
  NVal := 0;                          // kifejezett nulla, soha nem veremszemét
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
  Exit;                               // a fejléc nem tartalmazhat ennyi párt

// a /Length soha nem haladhatja meg a streamet tartalmazó fájl méretét
if (LenVal < 0) or (LenVal > SourceSize) then
  Exit;                               // utasítsuk el a puffer lefoglalása előtt

Olvasónk védelmi vonalát további két felső határ egészíti ki, amelyek mindegyike a v2.12.0-s verzióval érkezett. A stream-olvasó elutasít minden olyan /Length-et, amely nagyobb a teljes fájlnál, még az eredmény-puffer lefoglalása előtt — a stream nem lehet nagyobb a tárolónál, amelyben él, így az ellenőrzés mentes a téves pozitív találatoktól. A kicsomagoló útvonal pedig 256 MiB-ban korlátozza a kicsomagolt kimenetet, ami leállítja a klasszikus zlib bombát, ahol néhány kilobájtnyi bemenet határtalanul tágul; ez a korlát bőséges bármilyen valós PDF streamhez, miközben a legrosszabb esetet túlélhetővé teszi. Mindhárom téma ugyanaz: minden méret, amelyet a fájl deklarál, csupán egy állítás, és az elemző minden állítást ellenőriz valami olyasmivel szemben, amit meg tud mérni, mielőtt memóriát rendelne hozzá. Ugyanez az ellenőrzési hozzáállás érvényes egy réteggel lejjebb, a kötések határán is, amivel a PDFium ABI megerősítése és a Delphi memóriabiztonsága című cikk foglalkozik.

Ahol a teljes token szabály nem elegendő

Őszinte korlátok, hogy ne bízzon meg túlzottan a fenti rutinban. A teljes token egyezés kijavítja a kulcs azonosítását, de a szótárterület feletti egyszerű bájtkeresés még mindig nem tudja megmondani, hogy a találat egy beágyazott szótáron, egy literális karakterláncon vagy egy megjegyzésen belül helyezkedik-e el — egy oldalspecifikus objektumon végzett FindDictKey landolhat a /Resources alszótárának kulcsán, ha túl széles tartományt ad át neki. Az olvasónk először egyetlen objektumtörzsre korlátozza a tartományt, és a karakterlánc- valamint megjegyzés-környezeteket különálló, még nyitott ellenőrzési tételként kezeli. Az alkarakterlánc-biztonság a létra egyik foka, nem a teljes létra: a kereszthivatkozások (xref) konzisztenciája saját diszciplína, amellyel a objektum- és xref-folyamok érvényesítése foglalkozik, és a nem Ön által készített dokumentumok szélesebb fenyegetési katalógusa a PDF biztonsági kockázatainak auditálása című cikkben található.

Ha saját kezűleg írt szótárolvasót tart fenn Delphiben vagy Lazarusban, az ebből az incidensből származó ellenőrzőlista rövid. Keressen rá a kódalapban minden Pos('/ előfordulásra, és irányítsa a találatokat egyetlen teljes token segédfüggvényen keresztül. Listázza ki azokat az előtag-családokat, amelyekben a kulcsok részt vesznek — a /Length, /Encrypt, /N, /Type a /Type1-gyel szemben mind megjelennek a valós fájlokban. Ezután menjen végig minden olyan egész számon, amely eléri a SetLength-et, a GetMem-et vagy egy másolási ciklust, és kérdezze meg, mi korlátozza azt: a fájlméret, egy fejlécből származtatott felső határ vagy semmi. Az itt leírt elemzési réteg az alapja a PDFium komponensünknek, ahol a bájtszintű olvasó és a renderelő motor kölcsönösen ellenőrzi egymást minden egyes dokumentumon, amelyhez hozzáér.