Teknisk artikel

Säker tolkning av PDF-ordlistor i Delphi: Namntokens

Delsträngssökningar som Pos('/Length', Dict) är fel verktyg för att läsa en PDF-ordlista (dictionary), eftersom PDF-namnnycklar delar prefix: /Length är ett prefix till /Length1, och /Encrypt är ett prefix till /EncryptMetadata. ISO 32000-1 §7.3.5 definierar ett namn som en token som endast slutar vid en avgränsare (delimiter) eller ett blanksteg (whitespace), så en nyckel räknas som hittad endast när byten efter den är ett av dessa tecken. En ordlisteläsare som hoppar över denna enda kontroll kommer förr eller senare att läsa fel värde från en helt giltig fil

Buggen som lärde oss detta såg inte alls ut som ett lexer-problem. En efterlevnadsskanning började rapportera en FontFile-ström som skadad: det dekomprimerade typsnittsprogrammet var ett fragment, avklippt mitt i en tabell. Filen öppnades utan problem i alla visare. Strömdata på disken var intakta. Den bakomliggande orsaken fanns på en enda rad i vår delade ordlisteläsare: Pos('/Length', ...) hade matchat /Length1, en standardnyckel som FontFile-strömordlistor bär på enligt ISO 32000-1 tabell 127, och läsaren tog heltalet efter /Length1 som strömlängd. Skrivaren av just den filen råkade serialisera /Length1 före /Length, vilket den har full frihet att göra eftersom ordlisteposter är osorterade enligt §7.3.7. Strömmen trunkerades till ett felaktigt byteantal, och varje efterföljande kontroll som använde den blev tyst blind

Varför förstör delsträngsmatchning tolkningen av PDF-ordlistor?

Delsträngsmatchning misslyckas eftersom PDF-namnrymden är full av medvetna prefixfamiljer, och eftersom ordningen på ordlisteposter är ospecificerad. Tabell 127 i ISO 32000-1 definierar /Length1, /Length2 och /Length3 för inbäddade typsnittsströmmar, vilka alla ligger bredvid en /Length i samma ordlista. Krypteringsordlistan parar ihop /Encrypt i trailern med /EncryptMetadata inuti den. Korta nycklar är värre: en sökning uppbyggd som Pos('/' + Key, ...) med Key = 'N' landar gärna på /Name eller /Nums. Ingen av dessa kollisioner kräver en felaktig fil. En skrivare som ordnar /Length1 före /Length är helt kompatibel, vilket innebär att delsträngsbuggen inte är en robusthetsbrist mot trasiga indata — det är en korrekthetsbrist mot giltiga indata

Detta felsymptom är dessutom av det tysta slaget. En felaktig /Length genererar inget undantag; den ger dig bara ett kortare eller längre bytesegment än vad strömden faktiskt upptar. Om det segmentet skickas till en typsnittsdelmängdskontroll, en CMap-tolkning eller en metadataskanning, ser mottagaren bara skräp och rapporterar vanligtvis ingenting alls, eftersom en halv zlib-ström helt enkelt inte kan packas upp och koden går vidare. Vi levererade just den här klassen av defekter och åtgärdade den i version 2.14.3 av vår delade läsare, efter att en granskning klausul för klausul av ISO 32000-1 §7.2–§7.3 flaggat varje nyckelsökning i Pos-stil som misstänkt

Vad ISO 32000-1 §7.3.5 faktiskt definierar att ett namn är

Avsnitt 7.3.5 är kort och exakt: ett namnobjekt är ett snedstreck (solidus) följt av en sekvens av vanliga tecken, och denna token avslutas av det första avgränsar- eller blankstegstecknet. Avgränsarna är de åtta parentestecknen plus snedstrecket och procenttecknet — ( ) < > [ ] { } / % — och blanksteg är null, tabulator, radmatning, sidmatning, vagnretur och mellanslag (§7.2.2–§7.2.3). Denna avslutningsregel är hela historien. /Length1 är inte "/Length följt av en 1:a"; det är en enda, odelbar token, precis som LengthOne och Length är olika identifierare i Pascal. Varje läsare som hittar nycklar genom rå bytesökning återimplementerar lexern med avslutningsregeln raderad

Här är defektens form, reducerad till dess väsentligheter. Denna version kompilerar, klarar tester mot filer vars skrivare placerar /Length först, men korrumperar strömmar för skrivare som inte gör det

// 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 av hela tokens: kontrollera byten efter nyckeln

Det korrekta predikatet följer direkt av §7.3.5: en matchningskandidat är en riktig nyckel endast om tecknet omedelbart efter den är en avgränsare, ett blanksteg eller slutet av bufferten. Allt annat är ett längre namn som bara råkar dela ett prefix, så sökningen måste fortsätta förbi det istället för att ge upp. Lösningen i vår läsare ersatte varje rå Pos-sökning med en enda delad rutin baserad på denna 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;

Två detaljer i den loopen är viktiga. För det första fortsätter den att söka istället för att returnera misslyckande vid den första prefixkollisionen, eftersom /Length1 120 /Length 4076 är en tillåten ordning och den verkliga nyckeln fortfarande ligger framför oss. För det andra räknas slutet-på-bufferten som en avslutare, eftersom ett ordlistifragment legitimt kan sluta direkt efter ett namn. En mer subtil punkt värd att granska i din egen kod: samma regel gäller på vänster sida av matchningen om din söksträng inte inkluderar snedstrecket, annars kan Pos('Length', ...) landa inuti /PieceLength. Att förankra söksträngen med det inledande /, som ovan, hanterar den vänstra kanten eftersom / i sig är en avgränsare som avslutar den föregående token

Hur kan en fientlig PDF förvandla en tolkbugg till en gigabyte-allokering?

En felaktig eller skadlig fil eskalerar dessa lexikaliska misstag till resursutmatning, eftersom heltal i ordlistor ofta styr allokeringsstorlekar. Vår granskning hittade en kedja med exakt denna form i expansionen av objektströmmar. Posten /N i en ObjStm-ordlista anger hur många komprimerade objekt strömmen innehåller, och expansionskoden anropade SetLength på en array dimensionerad efter den. Heltalstolkaren lämnade dock sin ut-parameter orörd vid misslyckande men returnerade den ändå — så en icke-numerisk /N gav SetLength ett oinitialiserat stackvärde. Ett felaktigt positivt heltal där innebär en allokeringsbegäran i gigabyte-klassen, utlöst av några få byte av skadade indata, under tiden du bara skannar ett dokument som du inte ens har gått med på att lita på än

Åtgärden hade två oberoende delar, och båda är generella. Tolken returnerar nu ett explicit 0 vid misslyckande, aldrig oinitialiserat minne. Och konsumenten litar inte längre blint på /N: ObjStm-huvudområdet före /First lagrar ett par heltal — objektnummer och förskjutning — för varje komprimerat objekt, och varje par upptar minst fyra byte inklusive avgränsare. Alla /N över FirstVal div 4 + 1 är därför fysiskt omöjliga för den deklarerade storleken på huvudet och avvisas innan någon allokering sker. Begränsningen kostar en enda jämförelse och härleds från data som redan finns till hands, vilket är det mönster man bör söka efter: ett tak som filen själv bevisar, inte en godtycklig konstant

// /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

Ytterligare två tak kompletterar försvarslinjen i vår läsare, båda levererade i version 2.12.0. Strömläsaren avvisar alla /Length som är större än hela filen innan den allokerar resultatbufferten — en ström kan inte vara större än behållaren den lever i, så den kontrollen är fri från falska positiva resultat. Och inflate-banan begränsar dekomprimerade utdata till 256 MiB, vilket sätter stopp för den klassiska zlib-bomben där några få kilobyte indata expanderar gränslöst; taket är generöst för alla verkliga PDF-strömmar men håller det värsta scenariot överlevnadsbart. Temat för alla tre är detsamma: varje storlek en fil deklarerar är ett påstående, och tolken verifierar varje påstående mot något den kan mäta innan den allokerar minne för det. Samma granskningsattityd gäller ett steg längre ned vid bindningsgränsen, vilket beskrivs i artikeln om härdning av PDFium ABI och minnessäkerhet i Delphi

Där regeln om hela tokens inte räcker till

Ärliga gränser, så att du inte litar för mycket på rutinen ovan. Matchning av hela tokens åtgärdar nyckelidentifiering, men en platt bytesökning över ett ordlisteområde kan fortfarande inte avgöra om en matchning sits inuti en nästlad ordlista, en literal sträng eller en kommentar — FindDictKey på ett sidobjekt kan landa på en nyckel inuti dess /Resources-underordlista om du ger den ett för brett sökområde. Vår läsare begränsar sökområdet till en enskild objektkropp först och behandlar sträng- och kommentarskontexter som ett separat, stilla öppet granskningsobjekt. Delsträngssäkerhet är ett steg på en stege, inte hela stegen: korsreferenskonsekvens är sin egen disciplin, vilket beskrivs i validering av objekt- och xref-strömmar, och den bredare hotkatalogen för dokument du inte själv har skapat finns i granskning av säkerhetsrisker i PDF

Om du underhåller en handskriven ordlisteläsare i Delphi eller Lazarus är checklistan från denna incident kort. Sök efter alla Pos('/ i kodbasen och led träffarna genom en och samma hjälpare för hela tokens. Lista de prefixfamiljer som dina nycklar ingår i — /Length, /Encrypt, /N, /Type mot /Type1 förekommer alla i verkliga filer. Gå sedan igenom varje heltal som når SetLength, GetMem eller en kopieringsloop och fråga vad som begränsar det: filstorleken, ett tak härlett från huvudet eller ingenting alls. Det tolkningsskikt som beskrivs här är grunden i vår PDFium Component, där läsaren på bytenivå och renderingsmotorn korskontrollerar varandra på varje dokument de rör