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 hat einen Härtungsprozess durchlaufen, bei dem der gesamte Decodierungspfad als feindselig behandelt wurde - ü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). Im Folgenden werden fünf geschlossene Schwachstellenklassen beschrieben, die jeweils in dem spezifischen Delphi-Verhalten begründet liegen, das sie ermöglichte. Sie sind in aktuellen Versionen behoben, und dieselben Muster treten in jedem Pascal-Code auf, der nicht vertrauenswürdige Eingaben parst

PDF Library for Delphi: Angriffsflächenkarte eines Pascal-PDF-Parsers, der Schriftinterpretierer, Bild-Dekodierer und Stream-Filter speist, hin zu fünf gehärteten Defektklassen
Breiten, Tiefen, Offsets, Längen und Filter-Flags stammen alle vom Erzeuger, was Font-, Bild- und Filter-Dekoder zur Angriffsfläche macht, die die Härtungsstufe als feindlich behandelt

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

Der gemeinsame Bildpfad hat bereits jede Dimension auf 65535 begrenzt; die eigenständigen Decoder haben diese Begrenzung nicht alle übernommen. Ein Ausdruck für die Zeilen-Bytes mal Höhe 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 steckte im ZLib-Prädiktor-Stride: BitsPerComponent * Colors * Columns

Die Behebung besteht darin, mindestens einen Operanden zu Int64 zu machen, sodass der gesamte Ausdruck in 64-Bit ausgewertet wird. Anschließend wird das Ergebnis mit MaxInt verglichen und die Datei abgelehnt, bevor wieder verkleinert wird, um SetLength aufzurufen

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

Der Seitenzähler war als Word deklariert, das in Delphi Werte von 0 bis 65535 aufnehmen kann. Die Schleife trug eine Abbruchbedingung der Form „Stoppe, wenn die Seitenzahl 65535 überschreitet". Dies liest sich als korrekt, bis man bemerkt, dass der Operand und der Schwellenwert dieselbe Obergrenze teilen. Ein Word kann niemals größer als 65535 sein, sodass der Vergleich strukturell immer falsch ist: Wenn der Zähler 65535 erreicht, setzt der nächste Inkrementierungsschritt ihn auf 0 zurück. Der Schutz sieht nie einen Wert über der Obergrenze, und eine kreisende IFD-Kette hält den Reader in einer Endlosschleife

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

Die Behebung bestand darin, das Feld zu erweitern, damit die Schutzbarriere einen Wert ausdrücken kann, den der Zähler tatsächlich halten kann. Da TPDFTIFF.FPageCount als Integer deklariert wurde, wird derselbe Vergleich FPageCount > 65535 erreichbar, die Schleife bricht ab, und die öffentliche Eigenschaft PageCount änderte ihren Typ passend, ohne einen Aufrufer zu beeinträchtigen. Wann immer eine Grenzprüfung die Form Value > MaxValueOfType(Value) hat und der Operand bereits genau auf dieses Maximum typisiert ist, die Bedingung ist ein konstantes Falsch: Erweitern 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 Schriftarten-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 ungültiger Index kein Fehler, sondern ein direkter Speicherzugriff ist. Das ist in Ordnung, wenn der Index immer gültig ist. Es ist nicht mehr in Ordnung 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. Bei ausgeschalteter Bereichsprüfung werden beide zu einem echten Out-of-Bounds-Zugriff anstelle einer abfangbaren Ausnahme. Da die Plätze referenzgezählte AnsiString-Werte halten, kann ein Fehlzugriff auch den Referenzzähler eines Strings beschädigen

Die Härtung hat die Bereichsprüfung für den Hot-Path nicht wieder eingeschaltet. Sie hat die Indizes stattdessen vorab nachweisbar gültig gemacht: Bevor der Interpreter das oberste Element des Operanden-Stacks nimmt, prüft er, ob der Stack nicht leer ist, und jeder Indexschutz wurde als striktes „kleiner als" gegenüber der Anzahl geschrieben statt als ein „kleiner oder gleich", das den Off-by-One-Fehler zulässt. Die Direktive verlagert die Verantwortung für Grenzwerte 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 schützte sich bereits davor. Er trug einen Aufruftiefenzähler und eine Obergrenze, PLType1MaxCallDepth, und weigerte sich, tiefer abzusteigen, was das Tiefenlimit widerspiegelt, das die Type1-Spezifikation selbst nennt. Der später hinzugefügte und strukturell ähnliche Type2-Interpreter trug diesen Schutz nicht, und eine handgefertigte Schriftart mit einer Subroutine, die ihre eigene Nummer aufruft, läuft direkt durch die fehlende Prüfung in einen Stack-Überlauf

// Die Form der Type1-Absicherung, die dem Type2-Pfad fehlte.
// Die Tiefe über verschachtelte Aufrufe hinweg mitführen und darüber hinaus die Rekursion verweigern.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
  Exit;  // feindliche selbstreferenzielle Subroutine; Abstieg stoppen
// ... die Subroutine interpretieren, dann auf dem Rückweg Dec(CallDepth)

Die Behebung bestand darin, dem Type2-Pfad dieselbe begrenzte Tiefe zu geben, die sein Type1-Geschwister bereits hatte. Jeder rekursive Abstieg über eine vom Angreifer kontrollierte Struktur (ob Schriftart-Subroutinen, ein verschachteltes Array oder eine Kreuzreferenzkette) benötigt eine Tiefenbegrenzung, die die Eingabe nicht aushebeln kann

Nicht initialisierter Speicher, der in die Ausgabe sickert

Die subtilste Schwachstelle schleuste Heap-Inhalte in entschlüsselte Ausgaben ein, und die Ursache ist eine Eigenschaft von SetLength, die man leicht vergisst. Wenn Sie einen AnsiString mit SetLength vergrößern, Delphi weist die Bytes zu, nullt sie jedoch nicht. Die neue Region enthält also das, was sich zuvor in diesem Heap-Speicher befand. Wenn jedes Byte anschließend geschrieben wird, spielt dies keine Rolle. Wenn ein Pfad jedoch einen Teil des Puffers unbeschrieben lässt und ihn dann als Daten zurückgibt, 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 zu einem Informationsleck

Der AES-CBC-Entschlüsselungspfad stieß genau auf dieses Problem. Der Ausgabepuffer wurde mit SetLength dimensioniert, und der Entschlüsseler verarbeitete den Chiffretext blockweise mit jeweils 16 Byte. Wenn die Länge des Chiffretexts kein Vielfaches von 16 war (eine Länge, die ein Angreifer wählen kann), wurde der abschließende Teilblock nie geschrieben. So behielten diese letzten Bytes den Heap-Inhalt, den SetLength hinterlassen hatte, und der Puffer wurde als entschlüsselter Klartext eines Dokumentobjekts zurückgegeben. Die Abhilfe besteht aus zwei Schutzmaßnahmen, von denen keine allein ausreicht: Der Einstiegspunkt für die Entschlüsselung lehnt nun jeden Chiffretext ab, dessen Länge kein Vielfaches der Blockgröße ist, und als zusätzliche Absicherung wird die Ausgabe vor der Verwendung mit FillChar gelöscht. So gibt jeder Pfad, der das Schreiben in einen Bereich versäumt, Nullen anstelle von Heap-Rückständen zurück

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 Ihnen nach dem Durchgang bleibt

Die fünf Schwachstellen sind unterschiedliche Fehler, aber sie ähneln sich. Eine Ganzzahlbreite, die ein produkt überlaufen lässt, ein Feldtyp, der eine Prüfung auf ein konstantes Falsch festlegt, eine deaktivierte Bereichsprüfung, bei der die Indizes nicht mehr sicher waren, eine Rekursion ohne Boden und ein Puffer, den die Sprache nicht nullen wollte. In jedem einzelner Fall tat Delphi genau das, was es definiert, weil die Sprache bietet Ihnen Arithmetik, die überläuft, stilles Verkleinern, abschaltbare Bereichsprüfungen, Rekursion ohne eingebautes Limit und Speicherzuweisungen, die nicht initialisiert werden. 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

Diese Schwachstellen sind in aktuellen PDF Library for Delphi-Versionen behoben, der Engine für Delphi und C++Builder. Wenn Ihre Arbeit auch die Frage betrifft, wie eine Datei geschützt zu sein beansprucht, 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. All dies wird innerhalb der PDF Library for Delphi Delphi PDF Library zusammen mit den APIs zum Laden, Rendern und Signieren ausgeliefert, die an anderer Stelle auf diesem Blog behandelt werden