Technischer Artikel

Sicheres Parsen von PDF-Dictionaries in Delphi: Name-Tokens

Substring-Suchen wie Pos('/Length', Dict) sind das falsche Werkzeug zum Lesen eines PDF-Dictionarys, da PDF-Namen-Schlüssel Präfixe teilen: /Length ist ein Präfix von /Length1 und /Encrypt ist ein Präfix von /EncryptMetadata. ISO 32000-1 §7.3.5 definiert einen Namen als ein Token, das nur an einem Trennzeichen (Delimiter) oder Whitespace-Zeichen endet. Daher gilt ein Schlüssel nur dann als gefunden, wenn das Byte danach eines dieser Zeichen ist. Ein Dictionary-Reader, der diese eine Prüfung auslässt, liest schließlich den falschen Wert aus einer vollkommen gültigen Datei

Der Fehler, der uns dies lehrte, sah keineswegs wie ein Lexer-Problem aus. Ein Konformitäts-Scan meldete plötzlich einen FontFile-Stream als beschädigt: Das dekomprimierte Schriftprogramm war ein Fragment, das mitten in der Tabelle abgeschnitten war. Die Datei ließ sich in jedem Viewer problemlos öffnen. Die Stream-Daten auf der Festplatte waren intakt. Die Ursache lag in einer einzigen Zeile unseres gemeinsamen Dictionary-Readers: Pos('/Length', ...) hatte auf /Length1 gematcht, einen Standardschlüssel, den FontFile-Stream-Dictionaries gemäß ISO 32000-1 Tabelle 127 enthalten, und der Reader nahm die Ganzzahl nach /Length1 als die Stream-Länge. Der Writer dieser speziellen Datei hatte zufällig /Length1 vor /Length serialisiert, was ihm völlig freisteht, da Dictionary-Einträge gemäß §7.3.7 ungeordnet sind. Der Stream wurde auf eine ungültige Byte-Anzahl gekürzt, und jede nachfolgende Prüfung, die ihn verarbeitete, schlug stillschweigend fehl

Warum zerstört der Substring-Abgleich das Parsen von PDF-Dictionaries?

Der Substring-Abgleich schlägt fehl, weil der PDF-Namensraum voller bewusster Präfix-Familien ist und die Reihenfolge der Dictionary-Einträge nicht spezifiziert ist. Tabelle 127 von ISO 32000-1 definiert /Length1, /Length2 und /Length3 für eingebettete Schriftart-Streams, die alle neben einem /Length im selben Dictionary stehen. Das Verschlüsselungs-Dictionary verbindet /Encrypt im Trailer mit /EncryptMetadata darin. Kurze Schlüssel sind noch schlimmer: Eine Suche, die als Pos('/' + Key, ...) mit Key = 'N' implementiert ist, landet bereitwillig bei /Name oder /Nums. Keine dieser Kollisionen erfordert eine fehlerhafte Datei. Ein Writer, der /Length1 vor /Length anordnet, ist völlig konform, was bedeutet, dass der Substring-Fehler keine Lücke in der Robustheit gegenüber fehlerhaften Eingaben ist — es ist eine Lücke in der Korrektheit gegenüber gültigen Eingaben

Das Fehlverhalten ist zudem stiller Natur. Ein falsches /Length löst keine Ausnahme aus; es liefert Ihnen ein kürzeres oder längeres Byte-Segment, als der Stream tatsächlich belegt. Wenn dieses Segment in eine Font-Subset-Prüfung, einen CMap-Parser oder einen Metadaten-Scan einfließt, sieht der Consumer Datenmüll und meldet in der Regel überhaupt nichts, da die Dekomprimierung eines halben zlib-Streams einfach fehlschlägt und der Code fortfährt. Wir haben genau diese Fehlerklasse ausgeliefert und sie in Version 2.14.3 unseres gemeinsamen Readers behoben, nachdem eine abschnittsweise Überprüfung von ISO 32000-1 §7.2–§7.3 jede Schlüsselsuche im Pos-Stil als verdächtig markiert hatte

Was ISO 32000-1 §7.3.5 tatsächlich als Namen definiert

Abschnitt 7.3.5 is kurz und präzise: Ein Namens-Objekt ist ein Schrägstrich (Solidus), gefolgt von einer Sequenz regulärer Zeichen, und das Token wird durch das erste Trennzeichen (Delimiter) oder Whitespace-Zeichen beendet. Die Trennzeichen sind die acht Klammerzeichen plus der Schrägstrich und das Prozentzeichen — ( ) < > [ ] { } / % — und Whitespace ist Null-Byte, Tabulator, Zeilenvorschub (Line Feed), Seitenvorschub (Form Feed), Wagenrücklauf (Carriage Return) und Leerzeichen (§7.2.2–§7.2.3). Diese Beendigungsregel ist die ganze Geschichte. /Length1 ist nicht „/Length gefolgt von einer 1“; es ist ein einzelnes, unteilbares Token, genau wie LengthOne und Length unterschiedliche Bezeichner in Pascal sind. Jeder Reader, der Schlüssel durch eine rohe Byte-Suche findet, implementiert den Lexer neu, wobei die Beendigungsregel weggelassen wurde

Hier ist die Form des Defekts, reduziert auf das Wesentliche. Diese Version kompiliert, besteht Tests mit Dateien, bei denen die Writer /Length zuerst anordnen, und beschädigt Streams bei Writern, die dies nicht tun

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

Ganzes-Token-Matching: Überprüfung des Bytes nach dem Schlüssel

Das korrekte Prädikat ergibt sich direkt aus §7.3.5: Ein Trefferkandidat ist nur dann ein echter Schlüssel, wenn das Zeichen direkt danach ein Trennzeichen, Whitespace oder das Ende des Puffers ist. Alles andere ist ein längerer Name, der lediglich ein Präfix teilt. Daher muss die Suche darüber hinaus fortgesetzt werden, anstatt aufzugeben. Der Fix in unserem Reader ersetzte jede rohe Pos-Suche durch eine einzige gemeinsame Routine, die auf dieser Regel basiert

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;

Zwei Details in dieser Schleife sind von Bedeutung. Erstens sucht sie weiter, anstatt beim ersten Präfix-Konflikt einen Fehler zurückzugeben, da /Length1 120 /Length 4076 eine zulässige Reihenfolge ist und der echte Schlüssel noch bevorsteht. Zweitens gilt der Fall des Puffer-Endes als Terminator, da ein Dictionary-Fragment legitimerweise direkt nach einem Namen enden kann. Ein subtilerer Punkt, den Sie in Ihrem eigenen Code überprüfen sollten: Dieselbe Regel gilt auf der linken Seite des Treffers, wenn Ihre Suchzeichenfolge nicht den Schrägstrich enthält. Andernfalls kann Pos('Length', ...) innerhalb von /PieceLength landen. Das Verankern der Suchzeichenfolge mit dem führenden /, wie oben gezeigt, deckt die linke Kante ab, da / selbst ein Trennzeichen ist, das das vorherige Token beendet

Wie kann ein manipuliertes PDF einen Parser-Fehler in eine Gigabyte-Allokation verwandeln?

Eine fehlerhafte oder bösartige Datei eskaliert diese lexikalischen Fehler zur Ressourcenerschöpfung, da Dictionary-Ganzzahlen häufig Allokationsgrößen bestimmen. Unsere Überprüfung ergab eine Kette genau dieser Art bei der Erweiterung von Objekt-Streams. Der Eintrag /N eines ObjStm-Dictionarys gibt an, wie viele komprimierte Objekte der Stream enthält, und the Code zur Erweiterung rief SetLength für ein Array auf, dessen Größe darauf basierte. Der Ganzzahl-Parser ließ jedoch im Fehlerfall seinen Out-Parameter unverändert, gab ihn aber dennoch zurück — so übergab ein nicht-numerisches /N an SetLength einen uninitialisierten Stack-Wert. Ein ungültiger positiver Ganzzahlwert dort bedeutet eine Allokationsanforderung im Gigabyte-Bereich, ausgelöst durch wenige Bytes fehlerhafter Eingaben, während Sie lediglich ein Dokument scannen, dem Sie noch nicht einmal vertraut haben

Die Behebung bestand aus zwei unabhängigen Teilen, die sich beide verallgemeinern lassen. Der Parser gibt nun im Fehlerfall explizit 0 zurück, niemals uninitialisierten Speicher. Und der Consumer vertraut /N nicht mehr ohne arithmetische Prüfung: Der ObjStm-Headerbereich vor /First speichert ein Paar Ganzzahlen — Objektnummer und Offset — für jedes komprimierte Objekt, und jedes Paar belegt einschließlich Trennzeichen mindestens vier Bytes. Jedes /N über FirstVal div 4 + 1 ist daher für die deklarierte Header-Größe physikalisch unmöglich und wird abgewiesen, bevor eine Allokation stattfindet. Die Grenze kostet einen Vergleich und wird von bereits vorliegenden Daten abgeleitet. Das ist das Muster, nach dem man suchen sollte: eine Obergrenze, die die Datei selbst beweist, keine willkürliche Konstante

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

Zwei weitere Obergrenzen vervollständigen den Schutzbereich in unserem Reader, beide ausgeliefert in Version 2.12.0. Der Stream-Reader verweigert jedes /Length, das größer als die gesamte Datei ist, bevor er den Ergebnis-Puffer allokiert — ein Stream kann nicht größer sein als der Container, in dem er sich befindet, sodass die Prüfung frei von Fehlalarmen ist. Und der Entkomprimierungspfad begrenzt die dekomprimierte Ausgabe auf 256 MiB, was die klassische zlib-Bombe entschärft, bei der sich wenige Kilobyte an Eingaben grenzenlos ausdehnen. Die Grenze ist für jeden realen PDF-Stream großzügig bemessen, während der schlimmste Fall überlebbar bleibt. Das Thema bei allen dreien ist dasselbe: Jede Größe, die eine Datei deklariert, ist eine Behauptung, und der Parser verifiziert jede Behauptung anhand von etwas, das er messen kann, bevor er Speicher dafür bereitstellt. Die gleiche Audit-Haltung gilt eine Ebene tiefer an der Bindungsgrenze, was in Absicherung der PDFium-ABI und Speichersicherheit in Delphi behandelt wird

Wo die Whole-Token-Regel nicht ausreicht

Ehrliche Grenzen, damit Sie der obigen Routine nicht zu sehr vertrauen. Der Ganzes-Token-Abgleich behebt zwar die Schlüsselidentifikation, aber eine flache Byte-Suche über einen Dictionary-Bereich kann immer noch nicht feststellen, ob sich ein Treffer in einem verschachtelten Dictionary, einer Zeichenfolgenkonstanten (Literal String) oder einem Kommentar befindet — FindDictKey auf einem Seitenobjekt kann auf einem Schlüssel in dessen /Resources-Subdictionary landen, wenn Sie einen zu großen Bereich übergeben. Unser Reader beschränkt den Bereich zuerst auf einen einzelnen Objektkörper und behandelt Zeichenfolgen- und Kommentar-Kontexte als separates, noch offenes Audit-Thema. Substring-Sicherheit ist nur eine Sprosse einer Leiter, nicht die ganze Leiter: Die Konsistenz von Querverweisen ist eine eigene Disziplin, die in Validierung von Objekt- und Xref-Streams behandelt wird, und der umfassendere Bedrohungskatalog für Dokumente, die Sie nicht selbst erstellt haben, findet sich in Prüfung von PDF-Sicherheitsrisiken

Wenn Sie einen selbstgeschriebenen Dictionary-Reader in Delphi oder Lazarus verwalten, ist die Checkliste aus diesem Vorfall kurz. Suchen Sie nach jedem Pos('/ in der Codebasis und leiten Sie die Treffer über einen Whole-Token-Helper um. Listen Sie die Präfix-Familien auf, an denen Ihre Schlüssel beteiligt sind — /Length, /Encrypt, /N, /Type im Vergleich zu /Type1 tauchen alle in echten Dateien auf. Gehen Sie dann jede Ganzzahl durch, die SetLength, GetMem oder eine Kopierschleife erreicht, und fragen Sie, was sie begrenzt: die Dateigröße, eine vom Header abgeleitete Obergrenze oder nichts. Die hier beschriebene Parsing-Ebene ist das Fundament unter unserer PDFium-Komponente, bei der der Reader auf Byte-Ebene und die Rendering-Engine sich bei jedem verarbeiteten Dokument gegenseitig überprüfen