Eine 20-KB-PDF-Datei, die einen Dienstprozess so lange festnagelt, bis der OOM-Killer eingreift, ist kein Fehler im eigenen Code, sondern eine Dekomprimierungsbombe. HotPDF, die native VCL-PDF-Komponente für Delphi und C++Builder, begrenzt so etwas mit DecodeBudgetBytes, einer Obergrenze pro Filterkette, die standardmäßig 268435456 Bytes beträgt und jede Dekodierstufe gegen ein einziges gemeinsames Budget verrechnet
Die 20-KB-Datei, die einen Worker-Prozess aufgefressen hat
Der Ablauf des Vorfalls ist immer derselbe. Ein Queue-Worker, der Vorschaubilder rendert, holt sich einen Upload, der belegte Speicher klettert in unter zwei Sekunden über 12 GB, und der Prozess verschwindet ohne Stack-Trace. Die Datei ist 20 KB groß. Sie hat eine Seite, einen Content-Stream und ein /Filter-Array mit fünf Einträgen. Jeder Name in diesem Array ist ein von der Spezifikation definierter Filter, jede Stufe dekodiert ohne Fehler, und nichts an der Datei ist fehlerhaft. Genau das macht diese Klasse von Eingaben unangenehm: Es gibt kein defektes Byte, das man zurückweisen könnte
Das ist nicht dasselbe Problem wie das korrekte Dekodieren eines einzelnen Filters. LZWDecode und den Prädiktor in /DecodeParms richtig zu behandeln, ist ein eigenes Thema, das in der Durchsicht von LZW, Prädiktoren und DecodeParms an geladenen Dokumenten behandelt wird. Hier ist jeder Dekoder bereits korrekt. Der Fehler liegt darin, was korrekte Dekoder tun, wenn man fünf von ihnen hintereinander laufen lässt und niemand die Summe zählt. ISO 32000-1 §7.4 stellt ausdrücklich klar, dass /Filter ein einzelner Name oder ein Array von Namen sein darf und dass ein Array der Reihe nach angewendet wird, erster Eintrag zuerst. Zur Frage, wie stark eine Stufe ihre Eingabe expandieren darf, und zur Summe über die gesamte Kette sagt sie nichts. Eine ASCIIHexDecode-Stufe halbiert ihre Eingabe grob, was harmlos klingt. Eine FlateDecode-Stufe über eine Folge von Nullbytes erreicht Verhältnisse im vierstelligen Bereich. Verkettet man das, ist die Rechnung multiplikativ: Aus 20 KB werden 20 MB, daraus werden 20 GB, und jeder einzelne Schritt ist ein konformes Dekodieren eines legalen Streams
Warum stoppt ein Per-Filter-Limit eine Dekomprimierungsbombe nicht?
Weil ein Per-Filter-Limit bei jedem Element des /Filter-Arrays neu scharfgestellt wird. Eine Kette aus fünf Stufen mit einer Obergrenze von 256 MiB pro Stufe erlaubt 1,25 GiB, und die letzte Stufe beginnt trotzdem wieder mit einem völlig frischen Kontingent, unabhängig davon, was die vier davor produziert haben. Das Limit wird korrekt durchgesetzt und begrenzt trotzdem nichts, worauf es ankommt. HotPDF hatte vor v2.447.0 genau diese Form, dazu noch eine zweite Lücke. Der LZW-Dekompressor trug eine Obergrenze MaxOutputBytes, und der Bildprädiktor-Pfad rechnete seine eigenen Zeilen ab, sodass diese beiden lokal begrenzt waren. FlateDecode, ASCIIHexDecode, ASCII85Decode und RunLengthDecode hatten überhaupt keine Obergrenze: Jeder schrieb in einen TMemoryStream, bis die Eingabe erschöpft war oder der Allocator aufgab. Eine feindliche Kette hatte also zwei Wege hindurch. Sie konnte einen völlig ungesicherten Filter nutzen, oder sie konnte gesicherte Filter nutzen und einfach mehr davon aneinanderreihen
Es gibt ein drittes Detail, das ein naiver Fix übersieht. Die entscheidende Zahl ist nicht die Größe der endgültigen dekodierten Ausgabe. Es ist das Maximum, und dieses Maximum liegt meist in einem Zwischenpuffer. Eine Kette, die in einem bescheidenen 4-MB-Content-Stream endet, kann in Stufe drei 8 GB allozieren und am Ende etwas völlig Vernünftiges zurückgeben. Die Länge des Ergebnisses im Nachhinein zu prüfen, verrät nichts über die Allokation, die den Prozess getötet hat
Ein Budget-Tracker pro Filterkette
Der Fix in HotPDF v2.447.0 besteht darin, die Buchführung über die gesamte Kette statt über die einzelne Stufe zu führen. Jede Filterkette erzeugt einen THPDFDecodeBudgetTracker, und jeder Dekoder schreibt über einen THPDFBudgetWriteStream, der das eigentliche Ziel umschließt. Der Wrapper ruft Budget.Consume(Count) auf, bevor er auch nur ein Byte weiterleitet, sodass die Ablehnung erfolgt, während der Zielstream noch seine alte Größe hat. Genau diese Reihenfolge ist der Kern der Sache: Eine Prüfung, die erst nach dem Wachstum des Puffers erfolgt, ist eine Diagnose, keine Verteidigung
// Simplified from the HotPDF chain decoder: one tracker for the whole
// /Filter array, one bounded wrapper stream per stage
Budget := THPDFDecodeBudgetTracker.Create(Doc.DecodeBudgetBytes);
try
for I := 0 to FilterCount - 1 do
begin
if I = 0 then
InputStream := StreamObj.Stream // read the source, do not copy it
else
InputStream := CurrentStream;
NextStream := TMemoryStream.Create;
InputStream.Position := 0;
// BeginFilter names the stage and bumps FilterCount; the wrapper
// stream calls Budget.Consume before writing into NextStream
Doc.LoadUnFlateLZW(InputStream, NextStream, Filters[I], True, Budget);
CurrentStream.Free;
CurrentStream := NextStream;
end;
finally
Budget.FinishFilter;
Budget.Free;
end;
Die lokalen Obergrenzen sind nicht verschwunden, sie wurden zu Projektionen des gemeinsamen Budgets. Die LZW-Stufe setzt jetzt Decoder.MaxOutputBytes := Budget.RemainingBytes, sodass ihre private Obergrenze das ist, was der Kette noch bleibt, statt ein unabhängiges Kontingent zu sein. Die Bildprädiktor-Stufe öffnet mit BeginFilter und rechnet ihren Zeilenbedarf über Consume ab, bevor sie alloziert, wodurch die Prädiktor-Ausgabe demselben Budget belastet wird wie die generischen Filter, die sie gespeist haben. Das spielt besonders auf dem Bildpfad eine Rolle, wo Filterkette und Prädiktor zwei Hälften einer Operation sind, wie in der Extraktion von Bildern aus geladenen Dokumenten über ihre Dekodierfilter beschrieben
Was sieht der Aufrufer, wenn das Budget verweigert?
Am unteren Ende des Stacks löst eine Ablehnung EHPDFDecodeBudgetError aus. Darüber hängt die Antwort vom bereits bestehenden Vertrag der aufrufenden API ab. High-Level-Lesemethoden, die Fehler bisher über False oder nil meldeten, tun genau das weiterhin, weil aus einem dokumentierten booleschen Ergebnis eine Exception zu machen, Aufrufer bricht, die fehlerhafte Eingaben bereits korrekt behandelt haben. Der Pfad für geladenen Seiteninhalt ist die bewusste Ausnahme: Er wirft EHPDFDecodeBudgetError erneut, statt einen abgeschnittenen Content-Stream als eine Seite darzustellen, die einfach nur leer herauskam. Das bedeutet, ein blankes False ist für sich genommen mehrdeutig, deshalb veröffentlicht das Budget zusätzlich einen Diagnosedatensatz: THotPDF.GetLastDecodeBudgetInfo liefert den Zustand der zuletzt von dieser Instanz dekodierten Kette
type
THPDFDecodeBudgetInfo = record
LimitBytes: Int64;
DecodedBytes: Int64;
PeakStageBytes: Int64;
FilterCount: Integer;
Exceeded: Boolean;
ExceededFilter: AnsiString;
end;
var
Pdf: THotPDF;
Info: THPDFDecodeBudgetInfo;
PageText: UnicodeString;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.DecodeBudgetBytes := 64 * 1024 * 1024; // tighter than the default
Pdf.LoadFromFile('untrusted.pdf');
if not Pdf.ExtractLoadedPageText(0, PageText) then
if Pdf.GetLastDecodeBudgetInfo(Info) and Info.Exceeded then
LogWarning(Format(
'decode refused in %s after %d bytes, peak stage %d, %d filters',
[String(Info.ExceededFilter), Info.DecodedBytes,
Info.PeakStageBytes, Info.FilterCount]));
finally
Pdf.Free;
end;
end;
Liest man diese Felder zusammen, trennen sie die beiden Angriffsformen. Liegt PeakStageBytes nah bei DecodedBytes, hat eine einzige Stufe den ganzen Schaden verursacht, und man hat es mit einem einzelnen hochgradigen Filter zu tun. Ist PeakStageBytes nur ein kleiner Bruchteil von DecodedBytes und FilterCount hoch, war keine einzelne Stufe für sich auffällig, und die Kette hat sich Stück für Stück über die Grenze gearbeitet – genau der Fall, den ein Per-Filter-Limit nicht erkennen kann. Ein Vorbehalt, der sich zu merken lohnt: GetLastDecodeBudgetInfo liefert False, solange die Instanz noch keinen einzigen Filter dekodiert hat, sodass ein False von dort kein Beleg dafür ist, dass das Dokument sauber war
Wo das Budget zurückgesetzt wird, und wann null die ehrliche Antwort ist
DecodeBudgetBytes begrenzt eine Stream-Kette, nicht ein Dokument, und diese Grenze ist bewusst gewählt, aber leicht misszuverstehen. Jeder Content-Stream, jede eingebettete Datei, jeder Querverweis-Stream und jeder Object Stream beginnt mit frischen 256 MiB. Ein Dokument mit 4.000 Seiten hat somit 4.000 unabhängige Gelegenheiten, die volle Obergrenze auszuschöpfen, und Object Streams vervielfachen die Zahl weiter, weil jeder für sich ein komprimierter Container mit vielen Objekten ist, wie in den Notizen zu Object Streams und inkrementellen Updates beschrieben. Wer eigentlich eine Grenze für den gesamten Prozessspeicher braucht, für den ist diese Eigenschaft ein Baustein davon, nicht das Ganze, und sie sollte hinter einer Job- oder Container-Obergrenze stehen
Null bedeutet unbegrenzt, und das ist eine legitime Einstellung, kein Hintertürchen. Man setzt sie, wenn man die Eingabe selbst kontrolliert: eine Pipeline zur erneuten Verarbeitung eines Archivs mit Dokumenten aus dem eigenen System, oder ein Rasterisierungsschritt, bei dem eine einzelne 600-dpi-Farbscankette wirklich mehr braucht, als man als fest codierte Obergrenze guten Gewissens akzeptieren würde. Negative Werte werden von vornherein mit ERangeError zurückgewiesen, weil ein negatives Budget keine schlüssige Bedeutung hat und ein stillschweigendes Begrenzen einen Konfigurationsfehler verschleiern würde
// Trusted archive pipeline: state the intent rather than guess a ceiling
ArchivePdf.DecodeBudgetBytes := 0; // explicit unlimited
// Untrusted upload: size the ceiling from what your corpus actually needs
IngestPdf.DecodeBudgetBytes := 96 * 1024 * 1024;
// Configuration mistakes fail loudly instead of clamping
try
IngestPdf.DecodeBudgetBytes := -1;
except
on E: ERangeError do
LogWarning('DecodeBudgetBytes cannot be negative');
end;
Die Wahl der Zahl verdient mehr Sorgfalt, als ihr üblicherweise zuteilwird, denn ein zu niedrig gesetztes Budget ist ein selbstverschuldeter Ausfall. Den vorhandenen Bestand mit dem Standardwert laufen lassen, PeakStageBytes und DecodedBytes für jede Kette protokollieren und die Obergrenze mit echtem Spielraum über dem beobachteten Maximum setzen. Eine runde Zahl, die man wählt, weil sie sicher klingt, wird ausgerechnet im ungünstigsten Moment einen legitimen großen Scan zurückweisen, und der Fehlschlag sieht in den Logs exakt wie ein Angriff aus
Die Kopie, die nicht mehr stattfindet
Jede Stufe über einen Budget-Wrapper zu leiten, hat die Kette am Ende günstiger statt teurer gemacht. Hat ein Stream Filter, liest die erste Stufe jetzt die Quelle direkt, statt die kodierten Bytes zunächst in einen Zwischenpuffer zu kopieren, und ab dann sind nur noch zwei Puffer gleichzeitig aktiv: die aktuelle Eingabe und die gerade geschriebene Ausgabe der Stufe. Die Rohkopie bleibt in den beiden Fällen erhalten, die sie brauchen, nämlich einem Stream ganz ohne Filter und einem Bild, bei dem der Aufrufer die zuletzt verwendete Kodierung erhalten will, weil beide einen Stream zurückgeben, den der Aufrufer besitzt und unabhängig durchsuchen kann. Die ungesicherte Version dieses Codes alloziierte mehr und begrenzte weniger, was das übliche Verhältnis zwischen beidem ist. Trotzdem sollte man es klar sagen: All das macht keine beliebige PDF-Datei sicher zu laden. Es schließt einen einzigen, sehr günstigen Denial-of-Service-Vektor, nämlich den, bei dem eine kleine Datei über verschachtelte Filter eine große Allokation erkauft. Integer-Überlauf in der Byte-Buchführung wird separat abgesichert, und die größere Frage, feindliche Dokumente zu parsen, ohne ihren internen Offsets zu vertrauen, ist eine andere Disziplin. Ein Decode-Budget ist eine Grenze unter mehreren, und sein Wert liegt darin, dass es diejenige ist, die man über eine einzelne Eigenschaft setzen kann, bevor man die Datei überhaupt anfasst
Das Budget pro Kette, sein Diagnosedatensatz und die Lesepfade für geladene Dokumente, die es schützt, sind alle Teil der Komponente selbst, ohne externe Dekomprimierungsabhängigkeit, die man konfigurieren oder patchen müsste. Wer bewertet, wie sich nicht vertrauenswürdige PDF-Eingaben in einem Delphi- oder C++Builder-Dienst begrenzen lassen, findet auf der Seite der HotPDF-Delphi-PDF-Komponente das Werkzeug für geladene Dokumente, auf das sich diese Grenzen beziehen