Teknisk artikel

Sikker fortolkning af PDF-ordbøger i Delphi: Navnetokens

Understrengssøgninger som f.eks. Pos('/Length', Dict) er det forkerte værktøj til at læse en PDF-ordbog (dictionary), fordi PDF-navnenøgler deler præfikser: /Length er et præfiks af /Length1, og /Encrypt er et præfiks af /EncryptMetadata. ISO 32000-1 §7.3.5 definerer et navn som et token, der kun slutter ved en separator (delimiter) eller et hvidt felt (whitespace), så en nøgle tæller kun som fundet, når byten efter den er et af disse tegn. En ordbogslæser, der springer denne ene kontrol over, vil i sidste ende læse den forkerte værdi fra en fuldstændig gyldig fil

Fejlen, der lærte os dette, lignede slet ikke et problem med lexeren. En overensstemmelsesscanning begyndte at rapportere en FontFile-strøm som beskadiget: Det dekomprimerede skrifttypeprogram var et fragment, afskåret midt i en tabel. Filen åbnede fint i enhver fremviser. Strømdataene på disken var intakte. Den bagvedliggende årsag lå på én linje i vores fælles ordbogslæser: Pos('/Length', ...) havde matchet /Length1, en standardnøgle, som FontFile-strømordbøger bærer ifølge ISO 32000-1 tabel 127, og læseren tog heltallet efter /Length1 como strømlængden. Skriveren af netop den fil havde tilfældigvis serialiseret /Length1 før /Length, hvilket den er helt fri til at gøre, da ordbogsposter er uordnede ifølge §7.3.7. Strømmen blev afkortet til et forkert byte-antal, og enhver efterfølgende kontrol, der brugte den, blev lydløst blind

Hvorfor ødelægger understrengsmatchning fortolkning af PDF-ordbøger?

Understrengsmatchning fejler, fordi PDF-navnerummet er fuldt af bevidste præfiks-familier, og fordi rækkefølgen af ordbogsposter er uspecificeret. Tabel 127 i ISO 32000-1 definerer /Length1, /Length2 og /Length3 for indlejrede skrifttypestrømme, der alle sidder ved siden af /Length i den samme ordbog. Krypteringsordbogen parrer /Encrypt i traileren med /EncryptMetadata inde i den. Korte nøgler er værre: Et opslag opbygget som Pos('/' + Key, ...) med Key = 'N' lander gladeligt på /Name eller /Nums. Ingen af disse kollisioner kræver en misdannet fil. En skriver, der arrangerer /Length1 før /Length, er fuldt ud i overensstemmelse med reglerne, hvilket betyder, at understrengsfejlen ikke er et robusthedshul over for forkert input — det er en korrekthedsfejl over for gyldigt input

Fejltilstanden er også af den stille slags. En forkert /Length rejser ikke en undtagelse; den giver dig blot en kortere eller længere byte-andel, end strømmen faktisk optager. Hvis den andel føder en kontrol af skrifttype-undermængder, en CMap-fortolkning eller en metadata-scanning, ser forbrugeren vrøvl og rapporterer typisk slet ingenting, fordi en halv zlib-strøm simpelthen mislykkes med at dekomprimere (inflate), og koden fortsætter. Vi leverede netop denne klasse af fejl og rettede den i v2.14.3 af vores fælles læser, efter at en gennemgang af ISO 32000-1 §7.2-§7.3 paragraf for paragraf markerede enhver Pos-stil nøglesøgning som mistænkelig

Hvad ISO 32000-1 §7.3.5 faktisk definerer et navn til at være

Paragraf 7.3.5 er kort og præcis: Et navneobjekt er en skråstreg (solidus) efterfulgt af en sekvens af almindelige tegn, og tokenet afsluttes af det første separator- eller hvide tegn. Separatorerne (delimiters) er de otte parentestegn plus skråstregen og procenttegnet — ( ) < > [ ] { } / % — og hvide tegn (whitespace) er nul, tabulator, linjeskift, sideskift, vognretur og mellemrum (§7.2.2–§7.2.3). Den afslutningsregel er hele historien. /Length1 er ikke "/Length efterfulgt af et 1-tal"; det er et enkelt, udeleligt token, præcis som LengthOne og Length are forskellige identifikatorer i Pascal. Enhver læser, der finder nøgler ved hjælp af rå bytesøgning, genimplementerer lexeren med afslutningsreglen slettet

Her er formen af fejlen, reduceret til det væsentlige. Denne version kompilerer, består test mod filer, hvis skrivere placerer /Length først, og ødelægger strømme for skrivere, der ikke gør

// WRONG: matches /Length1, /Length2, /Length3 as well
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;

Matchning af hele tokens: Tjek byten efter nøglen

Det korrekte prædikat følger direkte af §7.3.5: Et kandidat-match er kun en rigtig nøgle, hvis tegnet umiddelbart efter det er en separator, et hvidt tegn eller slutningen af bufferen. Alt andet er et længere navn, der blot deler et præfiks, så søgningen skal fortsætte forbi det frem for at give op. Rettelsen i vores læser erstattede enhver rå Pos-søgning med en enkelt fælles rutine bygget på denne regel

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

// Correct: whole-token match per 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 ends here: genuine key
    P := PosEx(Key, Dict, P + 1);    // prefix of a longer name: keep looking
  end;
end;

To detaljer i den løkke har vægt. For det første fortsætter den med at søge i stedet for at returnere fejl ved den første præfiks-kollision, fordi /Length1 120 /Length 4076 er en lovlig rækkefølge, og den rigtige nøgle stadig er forude. For det andet tæller tilfældet med bufferens slutning som en terminator, da et ordbogsfragment legitimt kan ende lige efter et navn. En mere subtil detalje, der er værd at revidere i din egen kode: Den samme regel gælder på venstre side af matchet, hvis din søgestreng ikke indeholder skråstregen, ellers kan Pos('Length', ...) lande inde i /PieceLength. At forankre søgestrengen med den foranstillede /, som ovenfor, håndterer den venstre kant, fordi / i sig selv er en separator, der afslutter det foregående token

Hvordan kan en fjendtlig PDF forvandle en parser-fejl til en gigabyte-allokering?

En misdannet eller ondsindet fil eskalerer disse leksikalske fejl til ressourceudmattelse, fordi heltal i ordbøger ofte føder allokeringsstørrelser. Vures revision fandt en kæde af netop denne form i objektstrøm-udvidelse. /N-posten i en ObjStm-ordbog angiver, hvor mange komprimerede objekter strømmen indeholder, og udvidelseskoden kaldte SetLength på et array dimensioneret efter den. Heltalsparseren efterlod dog sin ud-parameter uberørt ved fejl, mens den stadig returnerede den — så en ikke-numerisk /N overrakte SetLength en uinitialiseret stakværdi. Et forkert positivt heltal dér betyder en allokeringsanmodning i gigabyte-klassen, udløst af et par bytes forkert input, blot ved at scanne et dokument, du ikke engang har indvilget i at stole på endnu

// /N is attacker-controlled; bound it by what /First can hold
if not TryReadDictInt(Dict, '/N', NVal) then
  NVal := 0;                          // explicit zero, never stack garbage
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
  Exit;                               // header cannot contain that many pairs

// /Length can never exceed the file that contains the stream
if (LenVal < 0) or (LenVal > SourceSize) then
  Exit;                               // refuse before allocating the buffer

Yderligere to lofter fuldender den defensive omkreds i vores læser, begge leveret i v2.12.0. Strømlæseren afviser enhver /Length, der er større end hele filen, før den allokerer resultatbufferen — en strøm kan ikke være større end den beholder, den lever i, så kontrollen er fri for falske positive. Og dekomprimeringsstien (inflate) begrænser dekomprimeret output til 256 MiB, hvilket afværger den klassiske zlib-bombe, hvor et par kilobyte input udvides uden grænser; loftet er generøst for enhver reel PDF-strøm, mens det værste tilfælde forbliver til at overleve. Temaet på tværs af alle tre er det samme: Enhver størrelse, en fil erklærer, er en påstand, og parseren verificerer hver påstand mod noget, den kan måle, før den forpligter hukommelse til den. Den samme revisionsholdning gælder et lag længere nede ved bindingsgrænsen, hvilket er dækket i hærdning af PDFium-ABI'en og hukommelsessikkerhed i Delphi

Hvor hele-token-reglen ikke er nok

Ærlige grænser, så du ikke overvurderer rutinen ovenfor. Matchning af hele tokens retter nøgleidentifikation, men en flad bytesøgning over et ordbogsspand kan stadig ikke afgøre, om et match sidder inde i en indlejret ordbog, en bogstavelig streng eller en kommentar — FindDictKey on a page object can land on a key inside its /Resources subdictionary if you hand it too wide a span. Vores læser begrænser først spændet til en enkelt objektkrop og behandler streng- og kommentarkontekster som et separat, stadig åbent revisionspunkt. Understrengssikkerhed er ét trin på en stige, ikke hele stigen: Konsistens i krydsreferencer er sin egen disciplin, dækket i validering af objekt- og xref-strømme, og det bredere trusselskatalog for dokumenter, du ikke selv har skrevet, findes i revision af PDF-sikkerhedsrisici

Hvis du vedligeholder en håndskrevet ordbogslæser i Delphi eller Lazarus, er tjeklisten fra denne hændelse kort. Søg (grep) efter enhver Pos('/ i kodebasen, og led resultaterne gennem en fælles hjælper til hele tokens. Oplist de præfiks-familier, dine nøgler deltager i — /Length, /Encrypt, /N, /Type over for /Type1 optræder alle i rigtige filer. Gennemgå derefter ethvert heltal, der når SetLength, GetMem eller en kopieringsløkke, og spørg, hvad der begrænser det: Filstørrelsen, et loft afledt af headeren eller ingenting. Det fortolkningslag, der beskrives her, er fundamentet under vores PDFium Component, hvor læseren på byte-niveau og renderingsmotoren krydstjekker hinanden på ethvert dokument, de rører ved