Technisch artikel

PDF-woordenboeken veilig ontleden in Delphi: Name Tokens

Zoekacties naar sub-strings zoals Pos('/Length', Dict) zijn het verkeerde gereedschap om een PDF-woordenboek (dictionary) te lezen, omdat PDF-naamsleutels (name keys) gemeenschappelijke voorvoegsels delen: /Length is een voorvoegsel van /Length1 en /Encrypt is een voorvoegsel van /EncryptMetadata. ISO 32000-1 §7.3.5 definieert een naam als een token dat alleen eindigt bij een scheidingsteken of witruimte, dus een sleutel geldt alleen als gevonden als het byte erachter een van die tekens is. Een woordenboeklezer die die ene controle overslaat, zal uiteindelijk de verkeerde waarde lezen uit een volkomen geldig bestand

De bug die ons dit leerde, leek in niets op een lexer-probleem. Een nalevingsscan begon te melden dat een FontFile-stroom beschadigd was: het gedecomprimeerde lettertypeprogramma was een fragment, afgebroken in het midden van een tabel. Het bestand opende prima in elke viewer. De stroomgegevens op de schijf waren intact. De hoofdoorzaak lag in één regel van onze gedeelde woordenboeklezer: Pos('/Length', ...) had gematcht met /Length1, een standaardsleutel die FontFile-stroomwoordenboeken bevatten volgens ISO 32000-1 Tabel 127, en de lezer nam het gehele getal na /Length1 aan als de stroomlengte. De schrijver van dat specifieke bestand had toevallig /Length1 vóór /Length geserialiseerd, wat deze volledig vrij staat te doen, aangezien vermeldingen in woordenboeken ongesorteerd zijn volgens §7.3.7. De stroom werd afgekapt tot een ongeldig aantal bytes, en elke stroomafwaartse controle die deze verbruikte, ging geruisloos de mist in

Waarom breekt sub-string matching het ontleden van PDF-woordenboeken?

Sub-string matching breekt omdat de PDF-naamruimte vol zit met bewuste prefixfamilies, en omdat de volgorde van de vermeldingen in het woordenboek niet is gespecificeerd. Tabel 127 van ISO 32000-1 definieert /Length1, /Length2 en /Length3 voor ingebedde lettertypestromen, die allemaal naast een /Length in hetzelfde woordenboek staan. Het versleutelingswoordenboek (encryption dictionary) koppelt /Encrypt in de trailer aan /EncryptMetadata daarbinnen. Korte sleutels zijn nog erger: een zoekactie gebouwd als Pos('/' + Key, ...) met Key = 'N' landt vrolijk op /Name of /Nums. Geen van deze botsingen vereist een misvormd bestand. Een schrijver die /Length1 vóór /Length rangschikt, voldoet volledig aan de normen, wat betekent dat de sub-string bug geen robuustheidslek is tegen defecte invoer — het is een correctheidslek tegen geldige invoer

De foutmodus is bovendien van het stille soort. Een onjuiste /Length veroorzaakt geen uitzondering (exception); het overhandigt u een kortere of langere byte-slice dan de stroom feitelijk in beslag neemt. Als die slice een controle van de lettertypesubset, een CMap-ontleding of een metadata-scan voedt, ziet de verbruiker onzin en rapporteert doorgaans helemaal niets, omdat de helft van een zlib-stroom simpelweg niet kan worden uitgepakt (inflated) en de code gewoon verdergaat. We hebben exact deze klasse van defecten geleverd en deze opgelost in v2.14.3 van onze gedeelde lezer, nadat een controle clausule voor clausule van ISO 32000-1 §7.2–§7.3 elke zoekactie in de stijl van Pos als verdacht markeerde

Wat ISO 32000-1 §7.3.5 daadwerkelijk definieert dat een naam is

Sectie 7.3.5 is kort en nauwkeurig: een naamobject is een schuine streep (solidus) gevolgd door een reeks reguliere tekens, en het token wordt beëindigd door het eerste scheidingsteken of witruimteteken. De scheidingstekens zijn de acht haakjes plus de schuine streep en het procentteken — ( ) < > [ ] { } / % — en witruimte is null, tab, regelsprong, form feed, carriage return en spatie (§7.2.2–§7.2.3). Die beëindigingsregel is het hele verhaal. /Length1 is niet "/Length gevolgd door een 1"; het is een afzonderlijk, ondeelbaar token, net zoals LengthOne en Length verschillende identificatoren zijn in Pascal. Elke lezer die sleutels vindt door middel van een zoekactie naar ruwe bytes, implementeert de lexer opnieuw waarbij de beëindigingsregel is weggelaten

Hier is de vorm van het defect, teruggebracht tot de essentie. Deze versie compileert, slaagt voor tests met bestanden waarvan de schrijvers /Length als eerste rangschikken, maar corrumpeert stromen voor schrijvers die dat niet doen

// FOUT: matcht ook /Length1, /Length2, /Length3
function ReadStreamLength(const Dict: AnsiString): Integer;
var
  P: Integer;
begin
  Result := -1;
  P := Pos('/Length', Dict);
  if P > 0 then
    Result := ReadIntAt(Dict, P + Length('/Length'));
end;

Whole-token matching: controleer het byte na de sleutel

Het juiste predicaat volgt rechtstreeks uit §7.3.5: een kandidaat-match is alleen een echte sleutel als het teken direct daarna een scheidingsteken, witruimte of het einde van de buffer is. Al het andere is een langere naam die louter een voorvoegsel deelt, dus de zoekactie moet daarnaar worden voortgezet in plaats van te worden opgegeven. De oplossing in onze lezer verving elke zoekactie naar ruwe Pos door een enkele gedeelde routine die op deze regel is gebouwd

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

// Correct: whole-token match volgens ISO 32000-1 §7.3.5
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);                       // token eindigt hier: echte sleutel
    P := PosEx(Key, Dict, P + 1);    // voorvoegsel van een langere naam: blijf zoeken
  end;
end;

Twee details in die lus zijn belangrijk. Ten eerste blijft het zoeken in plaats van te falen bij de eerste prefixbotsing, omdat /Length1 120 /Length 4076 een legale volgorde is en de echte sleutel nog moet komen. Ten tweede telt de situatie van het einde-van-de-buffer als een beëindiging, aangezien een woordenboekfragment legitiem direct na een naam kan eindigen. Een subtieler punt dat u in uw eigen code moet controleren: dezelfde regel is van toepassing aan de linkerkant van de match als uw zoekstring de schuine streep (solidus) niet bevat, anders kan Pos('Length', ...) binnen /PieceLength landen. Het verankeren van de zoekstring met de leidende /, zoals hierboven, regelt de linkerrand omdat / zelf een scheidingsteken is dat het voorafgaande token beëindigt

Hoe kan een vijandige PDF een parser-bug veranderen in een gigabyte-allocatie?

Een misvormd of kwaadaardig bestand escaleert deze lexicale fouten in resource-uitputting, omdat integers in het woordenboek vaak allocatiegroottes voeden. Onze controle vond een keten van exact deze vorm in de uitbreiding van objectstromen (object streams). De vermelding /N van een ObjStm-woordenboek geeft aan hoeveel gecomprimeerde objecten de stroom bevat, en de uitbreidingscode riep SetLength aan op een array die daardoor werd gedimensioneerd. De integer-parser liet echter zijn out-parameter ongemoeid bij een fout, terwijl hij deze nog steeds retourneerde — zodat een niet-numerieke /N de methode SetLength voorzag van een niet-geinitialiseerde stapelwaarde (stack value). Een willekeurig positief geheel getal daar betekent een allocatieverzoek in gigabytes, geactiveerd door een paar bytes aan corrupte invoer, terwijl u louter een document scant dat u nog niet eens hebt vertrouwd

De reparatie bestond uit twee onafhankelijke delen, en beide zijn te generaliseren. De parser retourneert nu een expliciete 0 bij falen, nooit niet-geinitialiseerd geheugen. En de verbruiker vertrouwt /N niet langer zonder berekeningscontrole: het ObjStm-headergebied vóór /First slaat een paar gehele getallen op — objectnummer en offset — voor elk gecomprimeerd object, en elk paar beslaat minstens vier bytes inclusief scheidingstekens. Elke /N boven FirstVal div 4 + 1 is daarom fysiek onmogelijk voor de gedeclareerde headergrootte en wordt afgewezen voordat er een allocatie plaatsvindt. De grens kost één vergelijking en is afgeleid van gegevens die al voorhanden zijn, wat het patroon is om naar te zoeken: een limiet die het bestand zelf bewijst, geen willekeurige constante

// /N is door de aanvaller gecontroleerd; begrens het door wat /First kan bevatten
if not TryReadDictInt(Dict, '/N', NVal) then
  NVal := 0;                          // expliciete nul, nooit stapel-rommel
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
  Exit;                               // header kan niet zoveel paren bevatten

// /Length kan nooit groter zijn dan het bestand dat de stroom bevat
if (LenVal < 0) or (LenVal > SourceSize) then
  Exit;                               // weigeren voordat de buffer wordt gealloceerd

Nog twee limieten completeren de defensieve perimeter in onze lezer, beide geleverd in v2.12.0. De stroomlezer weigert elke /Length die groter is dan het hele bestand voordat de resultaatbuffer wordt gealloceerd — een stroom kan niet groter zijn dan de container waarin deze zich bevindt, dus de controle is vrij van fout-positieven. En het inflate-pad begrenst de gedecomprimeerde uitvoer op 256 MiB, wat de klassieke zlib-bom uitschakelt waarbij een paar kilobytes aan invoer onbeperkt uitdijt; de limiet is genereus voor elke echte PDF-stroom, terwijl het ergste geval overleefbaar blijft. Het thema bij alle drie is hetzelfde: elke grootte die een bestand declareert is een bewering, en de parser verifieert elke bewering aan de hand van iets dat hij kan meten voordat hij er geheugen aan committeert. Dezelfde controlestelling is van toepassing op een lager niveau bij de bindingsgrens, wat wordt behandeld in het harden van de PDFium-ABI en geheugenveiligheid in Delphi

Waar de whole-tokenregel niet genoeg is

Eerlijke grenzen, zodat u de bovenstaande routine niet te veel vertrouwt. Whole-token matching lost de sleutelidentificatie op, maar een platte byte-zoekactie over een woordenboekbereik kan nog steeds niet vertellen of een match zich in een genest woordenboek, een letterlijke string of een opmerking bevindt — FindDictKey op een pagina-object kan op een sleutel in haar subwoordenboek /Resources landen als u het een te groot bereik geeft. Onze lezer begrenst het bereik eerst tot het lichaam van een enkel object en behandelt string- en commentaarcontexten als een apart, nog openstaand controlepunt. Sub-stringveiligheid is een sport van een ladder, niet de hele ladder: consistentie van kruisverwijzingen is een eigen discipline, behandeld in het valideren van object- en xref-stromen, en de bredere catalogus van bedreigingen voor documenten die u niet zelf hebt geschreven staat in het auditeren van PDF-veiligheidsrisico's

Als u een handgeschreven woordenboeklezer in Delphi of Lazarus onderhoudt, is de checklist van dit incident kort. Zoek naar elke Pos('/ in de codebase en leid de resultaten door één whole-token-helper. Maak een lijst van de prefixfamilies waaraan uw sleutels deelnemen — /Length, /Encrypt, /N, /Type tegenover /Type1 verschijnen allemaal in echte bestanden. Loop vervolgens elk geheel getal na dat SetLength, GetMem of een kopieerlus bereikt, en vraag wat het begrenst: de bestandsgrootte, een van de header afgeleide limiet, of niets. De hier beschreven ontleedlaag is het fundament onder ons PDFium Component, waar de lezer op byte-niveau en de rendering-engine elkaar controleren bij elk document dat ze aanraken