Eine Pipeline zur Dokumentenannahme akzeptiert Dateien, die von Fremden geschrieben wurden. Rechnungen, Scans, Anhänge aus einem Webformular: Jede gibt vor, ein PDF zu sein, und trägt Hunderte von Zahlen in sich, auf die Ihr Parser reagieren soll. Stream-Längen, Bilddimensionen, Byte-Offsets, Objektreferenzen — jede einzelne wurde von der Person (oder dem System) gewählt, die die Datei erstellt hat, und ein abgebrochener Upload oder ein absichtlich fehlerhaftes Dokument wird unweigerlich eine dieser Zahlen dort platzieren, wo sie Schaden anrichtet. Der Unterschied zwischen einem Parser, der diese Datei überlebt, und einem, der abstürzt oder mit korrumpiertem Speicher weiterläuft, besteht aus einer kleinen Reihe von Gewohnheiten, die von keiner bestimmten PDF-Bibliothek abhängen.
Die Gewohnheiten teilen eine Prämisse: Ein aus der Datei gelesener Wert ist eine Behauptung, keine Messung. Er wird erst nutzbar, nachdem er gegen etwas geprüft wurde, das der Parser selbst gemessen hat — die tatsächliche Größe der Datei, die tatsächliche Anzahl der Bytes, die ein Decoder erzeugt hat, die tatsächliche Tiefe einer Rekursion. Was folgt, ist diese Prämisse, angewendet auf die Stellen, an denen Dokumenten-Parser tatsächlich kaputtgehen.
Eine deklarierte Länge ist eine Behauptung, keine Messung
Die einfachste Nichtübereinstimmung ist die Stream-Länge. Ein PDF-Stream-Objekt deklariert seine Byteanzahl im Schlüssel /Length, und die tatsächlichen Daten befinden sich zwischen den Schlüsselwörtern stream und endstream. Nichts zwingt die beiden, übereinzustimmen. Eine abgeschnittene Datei enthält weniger tatsächliche Bytes als die deklarierte Anzahl; eine Datei von einem defekten Generator kann eine Länge deklarieren, die über das Ende der Datei hinaus oder in ein benachbartes Objekt hinein reicht. Wenn Sie den Speicher anhand des deklarierten Werts allokieren und bis endstream kopieren, verursachen Sie einen Pufferüberlauf; wenn Sie exakt die deklarierte Anzahl lesen, ohne die Verfügbarkeit zu prüfen, überschreiten Sie das Ende der Datei. Lassen Sie den deklarierten Wert erst dann die Allokation steuern, wenn er auf die gemessene Entfernung bis zum Ende der Daten begrenzt (geclampt) wurde, und betrachten Sie eine Abweichung als Entscheidungspunkt — reparieren Sie durch Scannen nach endstream, oder lehnen Sie den Stream ab — niemals als etwas, dem man stillschweigend glauben darf.
Bildparameter, die ein größeres Raster beschreiben, als Sie zugewiesen haben
Bild-Streams erhöhen den Einsatz, da zwei unabhängige Zahlensätze dieselben Pixel beschreiben. Das Bild-Wörterbuch enthält /Width und /Height, und Raster-Puffer werden normalerweise danach dimensioniert. Der Dekodierungsfilter bringt seine eigene Geometrie mit: CCITTFaxDecode nimmt /Columns, /Rows und /K aus seinen DecodeParms, wobei /K das Gruppe-3- oder Gruppe-4-Schema auswählt und der Decoder (Columns + 7) div 8 Bytes pro Scanline ausgibt. Eine Datei, die /Width 100 deklariert, aber dem Filter /Columns 1728 — den Standardwert — übergibt, lässt den Decoder mehr als sechzehnmal so viele Bytes pro Zeile erzeugen, wie der Puffer erwartet, und der Überlauf landet Scanline für Scanline in dem, was sich hinter der Allokation befindet. Wenn /Rows fehlt, läuft der Decoder so lange, bis die Daten den Stopp signalisieren; begrenzen Sie also auch die Zeilenanzahl. DCTDecode hat die gleiche Schwachstelle: Die JPEG-Daten tragen in ihrem SOF-Marker ihre eigene Breite und Höhe, und nichts zwingt sie, mit dem Wörterbuch übereinzustimmen.
Die defensive Regel ist mechanisch: Berechnen Sie die erwartete Rastergröße aus den validierten Dekodierungsparametern — /Columns und /Rows des Filters selbst bei CCITT, die SOF-Dimensionen bei DCT —, prüfen Sie diese gegen Ihre Limits, allokieren Sie danach, und verifizieren Sie während der Dekodierung, dass die Ausgabe niemals über die Allokation hinausläuft. Wenn Wörterbuch und Filter hinsichtlich der Geometrie nicht übereinstimmen, bringen Sie sie in Einklang oder lehnen Sie das Bild ab. Was ein Parser niemals tun darf, ist, den Puffer anhand des einen Zahlensatzes zu dimensionieren und den Decoder mit dem anderen laufen zu lassen.
Tücken der Arithmetik und Speicherallokation in Delphi
Drei Delphi-Verhaltensweisen untergraben selbst einen Parser, der eigentlich validieren will. Das erste ist die 32-Bit-Multiplikation: Delphi wertet das Produkt von zwei Integer-Operanden mit 32 Bit aus, unabhängig von der Breite des Ziels, sodass Width * Height * BytesPerPixel überlaufen kann, selbst wenn jeder Faktor seinen eigenen Plausibilitätscheck besteht. Ein Scan mit 30000 mal 30000 bei drei Bytes pro Pixel ergibt 2,7 Milliarden Bytes, was in der vorzeichenbehafteten 32-Bit-Arithmetik ins Negative überläuft (wrap); leicht abweichende Faktoren laufen auf eine kleine positive Länge über, die allokiert wird und den Puffer unterdimensioniert. Erzwingen Sie die Breite des gesamten Ausdrucks, indem Sie den ersten Operanden casten — Size := Int64(Width) * Height * BytesPerPixel — und vergleichen Sie dann gegen eine explizite Obergrenze, bevor irgendetwas SetLength erreicht.
Das zweite ist die Bereichsprüfung (Range Checking). Delphis Standard-Release-Konfiguration wird mit deaktivierter Bereichsprüfung ausgeliefert, sodass ein aus den Dateidaten berechneter, außerhalb des Bereichs liegender Index keine Ausnahme (Exception) auslöst — er liest oder schreibt einfach im Speicher neben dem Array. Schalten Sie sie mit {$R+} (und {$Q+} für arithmetischen Überlauf) am Anfang jeder Unit wieder ein, die mit aus Dateien abgeleiteten Werten indiziert. Die Kosten sind im Vergleich zur E/A, die ein Parser ohnehin durchführt, nicht messbar, und sie wandelt stille Korruption in einen abfangbaren ERangeError um.
Das dritte ist TMemoryStream.SetSize mit einem durch die Datei gelieferten Int64. In einer aktuellen RTL wird allokiert, was auch immer die Datei angefordert hat. Ein einzelner Stream, der vier Gigabyte beansprucht, wird somit mitten im Einlesevorgang zu einem Out-of-Memory-Fehler. Auf älteren RTLs, in denen SetSize einen Longint erwartet, wird der Wert zunächst stillschweigend beschnitten (narrowed): Ein deklariertes $100000010 wird zu 16, die Allokation ist erfolgreich, und das Schreiben der echten Daten läuft weit darüber hinaus. Validieren Sie jede Größe gegen die gemessene Quellgröße und ein hartes Limit, bevor ein Allokations-Aufruf sie zu sehen bekommt.
Offsets, die aus der Datei herauszeigen
Die Querverweistabelle ordnet Objektnummern absoluten Byte-Offsets zu, und der Parser springt (seek), wohin sie zeigt. In einer beschädigten oder feindseligen Datei landen diese Offsets hinter dem Dateiende oder in nicht zusammenhängenden Strukturen. TStream macht den Fehler geräuschlos: Das Setzen von Position über Size hinaus ist kein Fehler, und ein einfaches Read über das Ende hinaus liefert einfach weniger Bytes zurück als angefordert. Code, der die Prüfung der Anzahl überspringt, parst also weiterhin veraltete (stale) Bytes aus dem vorherigen Objekt. Die Verteidigung ist ein Knotenpunkt (Chokepoint) — ein einziger Helfer, den jedes dateigesteuerte seek und read passieren muss, und der Offset und Anzahl gegen die gemessene Dateigröße validiert, bevor sich der Stream bewegt:
uses
System.SysUtils, System.Classes;
const
MAX_OBJECT_BYTES = 64 * 1024 * 1024; // no single object may exceed 64 MB
type
EPdfBoundsError = class(Exception);
// Every file-driven seek and read goes through here. Offset and Count are
// file-supplied claims; Source.Size is the measurement they must fit.
procedure ReadBounded(Source: TStream; Offset, Count: Int64;
var Buffer: TBytes);
begin
if (Offset < 0) or (Count < 0) or (Count > MAX_OBJECT_BYTES) or
(Offset > Source.Size) or (Count > Source.Size - Offset) then
raise EPdfBoundsError.CreateFmt(
'object extent %d+%d exceeds file size %d',
[Offset, Count, Source.Size]);
SetLength(Buffer, Count);
if Count = 0 then
Exit;
Source.Position := Offset;
Source.ReadBuffer(Buffer[0], Count);
end;
Leiten Sie Querverweis-Offsets, Stream-Grenzen und das Lesen eingebetteter Dateien hierdurch, und ein fehlerhafter Offset wird zu einer sauberen Ablehnung, die die Zahlen benennt, anstatt drei Aufrufe später eine Zugriffsverletzung (Access Violation) zu verursachen.
Zyklen und Tiefe im Objektgraph
Ein PDF ist ein Graph, kein Baum. Jeder Wert kann eine indirekte Referenz sein, eine Referenz kann sich zu einer weiteren Referenz auflösen — /Length 12 0 R, wobei Objekt 12 13 0 R enthält — und nichts hindert eine Kette daran, sich zu einem Kreis zu schließen. Ein Resolver, der Referenzen naiv folgt, rekursiert, bis der native Stack erschöpft ist, und eine Stack-Erschöpfung ist nichts, was man abfangen kann; sie beendet den Prozess. Tief verschachtelte Arrays und Wörterbücher erreichen dasselbe Ende, ganz ohne Zyklus.
Setzen Sie zwei Wächter (Guards) gemeinsam ein: Ein expliziter Tiefenzähler begrenzt den ehrlichen, aber tiefen Fall auf ein Limit, dem sich keine legitime Datei annähert, und ein Set der besuchten Objekte fängt einen echten Zyklus beim zweiten Besuch ab und verwandelt ihn in einen präzisen, meldbaren Fehler anstatt in ein Anstoßen ans Limit.
uses
System.SysUtils, System.Generics.Collections;
const
MAX_RESOLVE_DEPTH = 32; // far deeper than any legitimate reference chain
type
EPdfStructureError = class(Exception);
TPdfValueKind = (pvNull, pvNumber, pvName, pvString, pvArray,
pvDictionary, pvStream, pvReference);
TPdfValue = record
Kind: TPdfValueKind;
RefNumber: Integer; // meaningful when Kind = pvReference
// ... payload fields for the remaining kinds
end;
// LoadObject is your own routine: it looks up the xref offset for
// ObjNumber, reads the object with ReadBounded, and parses it.
function ResolveObject(ObjNumber, Depth: Integer;
Visited: TDictionary<Integer, Boolean>): TPdfValue;
begin
if Depth > MAX_RESOLVE_DEPTH then
raise EPdfStructureError.Create('reference chain exceeds depth limit');
if Visited.ContainsKey(ObjNumber) then
raise EPdfStructureError.CreateFmt(
'circular reference through object %d', [ObjNumber]);
Visited.Add(ObjNumber, True);
try
Result := LoadObject(ObjNumber);
if Result.Kind = pvReference then // e.g. /Length 12 0 R
Result := ResolveObject(Result.RefNumber, Depth + 1, Visited);
finally
Visited.Remove(ObjNumber); // siblings may legally share this object
end;
end;
Dekompression ist ein Verstärker
Einige Kilobyte FlateDecode-Eingabe können sich zu Gigabytes aufblähen; universelle Komprimierung belohnt repetitiven Klartext, und ein Angreifer kann ihn maximal repetitiv gestalten (Zip-Bombe). Deckeln Sie die aufgeblähte Größe jedes Streams auf das, was sein Konsument plausiblerweise benötigen kann, und führen Sie ein zweites Budget pro Dokument: Fünfhundert Streams, jeder knapp unter dem Pro-Stream-Limit, erschöpfen den Speicher genauso sicher wie ein einziger riesiger Stream. Die Prüfung gehört in die Entpackungsschleife, wobei die Ausgabe-Bytes bei der Erzeugung gezählt werden und bei einer Überschreitung abgebrochen wird, nicht erst nach der Schleife, wenn der Speicher bereits verbraucht ist. Ein Dokument-Budget, ausgedrückt als Vielfaches der komprimierten Dateigröße, funktioniert gut, da legitime Dokumente weit unterhalb der Raten bleiben, die ein präparierter Stream erreicht.
Tiefenverteidigung (Defense in Depth) über Ihre eigenen Units hinaus
Dieselben Fehlerklassen existieren innerhalb von Bibliotheken. Zwei Fallstudien auf diesem Blog gehen reale Beispiele durch: die Integer-Überläufe, unbegrenzte Rekursion und uninitialisierte Puffer, die in einer nativen Pascal-Engine geschlossen wurden, in Härten eines Pascal PDF-Parsers gegen bösartige Dateien, und die Gefahren durch Aufrufkonventionen (Calling Conventions), Integer-Breiten und Besitzverhältnisse bei der Anbindung einer C-Engine in Härten eines PDFium-Komponenten-Bindings. Bei wirklich nicht vertrauenswürdiger Annahme — einem öffentlichen Upload-Formular, einem nicht authentifizierten Postfach — sollten Sie die Parsing- und Dekodierungsarbeiten zusätzlich in einem separaten Prozess mit niedrigen Rechten ausführen, damit die Datei, die jeden In-Process-Schutz überwindet, lediglich einen fehlgeschlagenen Job anstatt eines ausgefallenen Dienstes kostet.
Eine Preflight-Checkliste
Bevor der nächste Build ausgeliefert wird, gehen Sie den Parser anhand dieser Liste durch: jeder Stream-Puffer dimensioniert anhand einer begrenzten Länge statt der deklarierten; jedes Raster dimensioniert anhand validierter Dekodierungsparameter und geprüft gegen die Dekodiererausgabe; jedes Dimensionsprodukt in Int64 ausgewertet und mit einer expliziten Obergrenze verglichen; {$R+} aktiv in jeder Unit, die mit von Dateien abgeleiteten Werten indiziert; jeder Seek-Vorgang (Springen in der Datei) auf seine Grenzen gegen die gemessene Dateigröße geprüft; jede Referenzauflösung tiefenbegrenzt und zyklusgeprüft; jede Entpackungsschleife zählt die Ausgabe gegen Pro-Stream- und Pro-Dokument-Budgets. Keine dieser Prüfungen kostet bei einem legitimen Dokument messbare Zeit, und jede wandelt Speicherbeschädigung in eine saubere, protokollierbare Ablehnung um.
Hinweis: Die losLab HotPDF-Komponente, die PDFlibPas Delphi PDF Library und die PDFium-Komponente wenden diese Grenzprüfungen, Tiefenlimits und Expansions-Obergrenzen intern an, sodass eine darauf aufgebaute Annahme-Pipeline (Intake) von einer gehärteten Basis (Baseline) aus startet.