Technischer Artikel

Härtung eines Pascal-PDF-Parsers gegen schädliche Dateien

Ein PDF ist kein Dokument, das Sie einfach öffnen. Es ist ein kleines Programm, das Sie ausführen. Jede eingebettete Schriftart ist ein stapelbasierter Interpreter, der auf Charstrings wartet. Jedes Bild ist ein Decoder, der mit Breiten-, Höhen- und Bittiefenfeldern gefüttert wird, die die Datei gewählt hat, und jeder Stream kommt in Filter verpackt an, deren Parameter die Datei festgelegt hat. Keine dieser Zahlen gehört Ihnen. Sie stammen von demjenigen, der die Datei erstellt hat - bei realer Arbeit beispielsweise die Rechnung eines Kunden oder ein Anhang von einem unbekannten Absender. Die Decoder, die diese Bytes in Pixel und Glyphen umwandeln, sind die Angriffsfläche, und ein Parser, der seinen Eingaben vertraut, ist nur eine fehlerhafte Datei von einem Absturz oder Schlimmerem entfernt

PDF Library for Delphi behandelt den gesamten Decodierungspfad als feindselig — über die Schriftprogramme (TrueType, Type1, CFF und die CMap-Tabellen), die Bilddecoder (PNG, GIF, TIFF, JBIG2 sowie CCITT Gruppe 3 und Gruppe 4) und die Stream-Filter (LZW, ASCII85 und die Flate-Prädiktoren). Was folgt, sind fünf Speicher-Sicherheitsfallen, die dieser Pfad schließen muss, jede verankert in dem spezifischen Delphi-Verhalten, das sie ermöglicht, und dieselben Muster wiederholen sich in jedem Pascal-Code, der nicht vertrauenswürdige Eingaben parst

PDF Library for Delphi: Angriffsflächenkarte eines Pascal-PDF-Parsers, der Font-Interpreter, Bild-Dekodierer und Stream-Filter speist, hin zu fünf abgesicherten Fallenklassen
Breiten, Tiefen, Offsets, Längen und Filter-Flags stammen alle vom Erzeuger, was Font-, Bild- und Filter-Dekoder zur Angriffsfläche macht, die ein Parser als feindselig behandeln muss

Ein Ganzzahlüberlauf, der Ihnen einen zu kleinen Puffer übergibt

Der klassische Speichersicherheitsfehler in einem Bilddecoder ist ein Produkt der Abmessungen, das überläuft. Ein Decoder liest Breite, Höhe, Komponentenanzahl und Bittiefe, multipliziert diese zur Dimensionierung seiner Ausgabe, weist entsprechend viele Bytes zu und schreibt dann das Bild in seinen tatsächlichen Abmessungen. Wenn die Multiplikation in 32-Bit-Arithmetik durchgeführt wird, kann das Produkt zu einem kleinen Wert überlaufen, selbst wenn jeder einzelne faktor in einem vernünftigen Bereich liegt. Die Zuweisung ist zwar erfolgreich, fällt aber viel zu klein aus, und die Decodierung läuft über das Ende hinaus. Dies ist CWE-190 (Ganzzahlüberlauf), was einen Schritt später zu einem Heap-Out-of-Bounds-Schreibvorgang (CWE-787) führt

Jede Dimension auf 65535 zu begrenzen reicht auf einem gemeinsamen Bildpfad nicht, wenn ein eigenständiger Decoder seine Größen selbst berechnet. Ein Zeilen-Bytes-mal-Höhe-Ausdruck wie ByteCount * FHeight oder ein Pro-Pixel-Ausdruck wie FWidth * Components * BitDepth ist in Delphi ein 32-Bit-Produkt, wenn beide Operanden 32-Bit-Ganzzahlen sind — unabhängig davon, wie breit die Variable ist, der Sie das Ergebnis zuweisen. Eine Breite und eine Höhe von 60000 sind für einen großen Scan jeweils plausibel, aber ihr Produkt in Bytes übersteigt den vorzeichenbehafteten 32-Bit-Bereich, und die Länge fällt klein aus. Dieselbe Falle steckt in einem Prädiktor-Stride wie BitsPerComponent * Colors * Columns

Die Verteidigung besteht darin, mindestens einen Operanden zu Int64 zu machen, sodass der gesamte Ausdruck in 64-Bit ausgewertet wird, dann gegen MaxInt zu vergleichen und die Datei abzulehnen, bevor man wieder verkleinert, um SetLength aufzurufen

PDF Library for Delphi: Nebeneinander-Vergleich: ein überlaufendes 32-Bit-Dimensionsprodukt verursacht eine zu kleine Allokation und einen Out-of-Bounds-Schreibzugriff, gegenüber einer Int64-Widening-Prüfung, die die Datei zurückweist
Das Byteanzahlprodukt in Int64 auszuwerten lässt den Parser gegen MaxInt vergleichen und feindliche Dimensionen verwerfen, bevor SetLength überhaupt einen lügenden Puffer zuschneidet
// Ablehnen, bevor alloziert wird, nicht erst nach dem Schreiben.
// Das Produkt in Int64 auswerten, damit es bei 32 Bit nicht überläuft.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
  Exit;  // feindliche oder nicht unterstützbare Abmessungen; das Bild ablehnen
SetLength(Buffer, RowBytes * FHeight);

Was dies zu einem Delphi-Problem und nicht zu einem allgemeinen Problem macht, ist das stille Verkleinern. Das Zuweisen eines zu breiten Ausdrucks an ein 32-Bit-Ziel ist eine legale Konvertierung, vor der der Compiler standardmäßig nicht warnt, und die Bereichsprüfung erfasst keinen Überlauf, der stattfindet, bevor der Wert überhaupt als Index verwendet wird. Belässt man das Produkt bei 32 Bit, liefert die Sprache stillschweigend eine Länge, die darüber hinwegtäuscht, wie viel Speicher die Decodierung gleich berühren wird

Ein Feldtyp, der das Auslösen einer Schutzbarriere unmöglich macht

Eine TIFF-Datei ist eine Kette von Bilddateiverzeichnissen (Image File Directories, IFDs), von denen jedes den Byte-Offset des nächsten trägt. Eine bösartige Datei kann diese Kette auf sich selbst zurückverweisen lassen, und ein Reader, der sie ohne Abbruchbedingung durchläuft, läuft ewig. Das ist CWE-835, eine unendliche Schleife, die durch vom Angreifer kontrollierte Eingaben gesteuert wird. Die Verteidigung dagegen ist ein Zähler, der stoppt, sobald er eine Grenze überschreitet, die keine legitime Datei erreichen würde

Deklariert man den Seitenzähler nun als Word, das in Delphi Werte von 0 bis 65535 aufnehmen kann, und gibt der Schleife eine Abbruchbedingung der Form „Stoppe, wenn die Seitenzahl 65535 überschreitet“, liest sich das korrekt, bis man bemerkt, dass Operand und Schwellenwert dieselbe Obergrenze teilen. Ein Word kann niemals größer als 65535 sein, also ist der Vergleich strukturell immer falsch: Erreicht der Zähler 65535, wickelt das nächste Inkrement ihn zurück auf 0, die Wache sieht nie einen Wert über der Obergrenze, und eine loopende IFD-Kette hält den Reader am Drehen

Die Abhilfe ist, das Feld zu verbreitern, damit die Schutzbarriere einen Wert ausdrücken kann, den der Zähler tatsächlich halten kann. TPDFTIFF.FPageCount ist als Integer deklariert, daher ist der Vergleich FPageCount > 65535 erreichbar und die Schleife terminiert. Wann immer eine Grenzprüfung die Form Value > MaxValueOfType(Value) hat und der Operand bereits genau auf dieses Maximum typisiert ist, ist die Bedingung ein konstantes Falsch: Verbreitern Sie den Typ oder prüfen Sie auf Gleichheit mit dem Maximum, damit sie auslösen kann

Bereichsprüfung auf einem Hot-Path deaktiviert

Bei aktivierter Bereichsprüfung fügt Delphi bei jedem Array- und Stringindex eine Grenzprüfung ein. Das macht den Unterschied aus, ob ein außerhalb des Bereichs liegender Index einen abfangbaren ERangeError auslöst oder ob derselbe Index Speicher liest oder schreibt, der nicht zur Struktur gehört. Hot-Paths deaktivieren dies manchmal mit einer lokalen {$R-}-Direktive, was so lange vertretbar ist, wie die Indizes vertrauenswürdig sind

Der Listen-Accessor, auf den sich die Font-Interpreter stützen, TPDFlibStringList.Get, ist genau ein solcher Pfad. Unter Windows wird er mit deaktivierter Bereichsprüfung kompiliert und indiziert seinen Backing-Store direkt, sodass ein außerhalb des Bereichs liegender Index ein direkter Speicherzugriff statt eines Fehlers wäre. Das ist in Ordnung, solange der Index immer gültig ist, und es verlangt Sorgfalt innerhalb eines CFF- oder Type2-Charstring-Interpreters, wo der Index aus der Datei stammen kann. Ein Charstring, der einen Operanden von einem leeren Stack holt, erzeugt einen Index von minus eins; ein um eins verschobener Glyphenbezeichner gegenüber der Glyphenanzahl indiziert einen Platz hinter dem Ende. Ungeprüft wären beide ein echter Out-of-Bounds-Zugriff statt einer abfangbaren Ausnahme, und da die Plätze referenzgezählte AnsiString-Werte halten, kann ein Fehlzugriff auch den Referenzzähler eines Strings beschädigen

PDF Library for Delphi lässt die Bereichsprüfung auf diesem Hot-Path aus und macht die Indizes stattdessen nachweisbar gültig: Bevor der Interpreter das oberste Element des Operanden-Stacks nimmt, prüft er, ob der Stack nicht leer ist, und jede Indexwache ist ein striktes „kleiner als“ gegenüber der Anzahl statt eines „kleiner oder gleich“, das den Off-by-One zulässt. Die Direktive verlagert die Verantwortung für Grenzen vom Compiler auf Sie, und die entfernte Validierung muss an jedem Einstiegspunkt von Hand wieder eingeführt werden

Unbegrenzte Rekursion in einem Charstring-Interpreter

Ein Type2-Charstring kann eine Subroutine aufrufen, und eine Subroutine ist selbst ein Charstring, der eine andere aufrufen kann. So ermöglichen es die lokalen und globalen Subroutinen-Aufrufoperatoren der Datei zu entscheiden, wie tief es geht. Eine Subroutine, die sich selbst direkt oder über einen Zyklus aufruft, rekursiert endlos, bis der native Stack erschöpft ist und der Prozess stirbt. Das ist CWE-674, unkontrollierte Rekursion

Der Type1-Interpreter sichert dagegen mit einem Aufruftiefenzähler und einer Obergrenze ab, PLType1MaxCallDepth, und verweigert den tieferen Abstieg, was das Tiefenlimit widerspiegelt, das die Type1-Spezifikation selbst nennt. Die Falle ist, dass ein später geschriebener, strukturell ähnlicher Interpreter wie der Type2 diese Wache nicht von selbst erbt, und eine handgefertigte Schrift mit einer Subroutine, die ihre eigene Nummer aufruft, läuft direkt durch die fehlende Prüfung in einen Stack-Überlauf

// Die Form der Tiefenwache, die beide Charstring-Interpreter brauchen.
// Die Tiefe über verschachtelte Aufrufe hinweg mitführen und weitergehende Rekursion verweigern.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
  Exit;  // feindliche selbstreferenzielle Subroutine; Abstieg stoppen
// ... die Subroutine interpretieren, dann auf dem Rückweg Dec(CallDepth)

PDF Library for Delphi gibt dem Type2-Pfad dieselbe begrenzte Tiefe wie seinem Type1-Geschwister. Jeder rekursive Abstieg über eine angreiferkontrollierte Struktur — ob Font-Subroutinen, ein verschachteltes Array oder eine Kreuzreferenzkette — braucht eine Tiefenobergrenze, die die Eingabe nicht anheben kann

Nicht initialisierter Speicher, der in die Ausgabe sickert

Die subtilste Falle schleust Heap-Inhalte in entschlüsselte Ausgaben, und die Ursache ist eine Eigenschaft von SetLength, die man leicht vergisst. Wenn Sie einen AnsiString mit SetLength vergrößern, weist Delphi die Bytes zu, nullt sie jedoch nicht. Die neue Region enthält also das, was sich zuvor in diesem Heap-Speicher befand. Wird danach jedes Byte geschrieben, spielt das nie eine Rolle; lässt ein Pfad aber einen Teil des Puffers unbeschrieben und gibt ihn dann als Daten zurück, wandern diese veralteten Bytes mit dem Ergebnis ab. Das ist CWE-457, Verwendung von nicht initialisiertem Speicher, und wenn das Ergebnis eine Vertrauensgrenze überschreitet, wird es zum Informationsleck

Ein AES-CBC-Entschlüsselungspfad ist genau der Ort, an dem das beißt. Dimensioniert man den Ausgabepuffer mit SetLength und verarbeitet den Chiffretext blockweise mit jeweils 16 Byte, wird bei einer Chiffretext-Länge, die kein Vielfaches von 16 ist — eine Länge, die ein Angreifer wählen kann —, der abschließende Teilblock nie geschrieben. Diese letzten Bytes behalten also den Heap-Inhalt, den SetLength hinterlassen hatte, und der Puffer geht als entschlüsselter Klartext eines Dokumentobjekts zurück. Die Abhilfe sind zwei Wachen, und keine allein reicht: Der Entschlüsselungseinstiegspunkt der PDF Library for Delphi lehnt jeden Chiffretext ab, dessen Länge kein Vielfaches der Blockgröße ist, und als Rückfallebene wird die Ausgabe vor der Verwendung mit FillChar gelöscht, sodass jeder Pfad, der das Schreiben einer Region versäumt, Nullen statt Heap-Rückständen zurückgibt

PDF Library for Delphi-Diagramm der AES-CBC-Entschlüsselung: ein nicht terminierter Schlussblock hinterlässt uninitialisierte Heap-Bytes, außer Eingabelängenprüfungen und FillChar-Pufferlöschung sichern den Pfad
Zwei unabhängige Wachen schließen das Leck: Chiffretext verweigern, dessen Länge die 16-Byte-Grenze verfehlt, und die Ausgabe vorab nullen, sodass selbst ein verpasster Schreibvorgang Nullen statt Heap-Resten liefert

Was die fünf Fallen gemeinsam haben

Die fünf Fallen sind unterschiedliche Fehler, aber sie ähneln sich. Eine Ganzzahlbreite, die ein Produkt überlaufen lässt, ein Feldtyp, der eine Wache auf ein konstantes Falsch festnagelt, eine deaktivierte Bereichsprüfung dort, wo die Indizes nicht mehr sicher waren, eine Rekursion ohne Boden und ein Puffer, den die Sprache nicht nullen wollte. In jedem einzelnen Fall tut Delphi genau das, was es definiert, denn die Sprache bietet Ihnen Arithmetik, die überläuft, stilles Verkleinern, abschaltbare Bereichsprüfungen, Rekursion ohne eingebautes Limit und Speicherzuweisungen, die nicht initialisieren. Das ist der Vertrag, und ein Pascal-Parser erfüllt ihn, indem er vier Dinge an jeder von der Datei kontrollierten Grenze von Hand verwaltet: Ganzzahlbreite, Bereichsprüfung, Rekursionstiefe und Pufferinitialisierung

PDF Library for Delphi, die Engine für Delphi und C++Builder, sichert ihren Decodierungspfad gegen alle fünf ab. Wenn Ihre Arbeit auch die Frage berührt, wie eine Datei behauptet, geschützt zu sein, decken die begleitenden Notizen zur Prüfung von Verschlüsselung und Berechtigungen und zum PDF/A- und PDF/UA-Preflight die Analyseseite desselben Parsers ab, und all das wird innerhalb der PDF Library for Delphi Delphi PDF Library zusammen mit den an anderer Stelle auf diesem Blog behandelten APIs zum Laden, Rendern und Signieren ausgeliefert