A document intake pipeline accepts files written by strangers. Invoices, scans, attachments from a web form: each claims to be a PDF and carries hundreds of numbers your parser is expected to act on. Stream lengths, image dimensions, byte offsets, object references — every one was chosen by whoever produced the file, and a truncated upload or a deliberately malformed document will eventually put one of those numbers where it does damage. The difference between a parser that survives that file and one that crashes, or keeps running with corrupted memory, is a small set of habits that do not depend on any particular PDF library
The habits share one premise: a value read from the file is a claim, not a measurement. It becomes usable only after being checked against something the parser measured itself — the real size of the file, the real number of bytes a decoder produced, the real depth of a recursion. What follows is that premise applied to the places where document parsers actually break
A declared length is a claim, not a measurement
The simplest mismatch is the stream length. A PDF stream object declares its byte count in the /Length key, and the actual data sits between the stream and endstream keywords. Nothing forces the two to agree. A truncated file holds fewer real bytes than the declared count; a file from a broken generator can declare a length that reaches past the end of the file or into a neighboring object. Allocate from the declared value and copy until endstream and you overrun the buffer; read exactly the declared count without checking availability and you walk off the end of the file. Let the declared value drive allocation only after clamping it against the measured distance to the end of the data, and treat a disagreement as a decision point — repair by scanning for endstream, or reject the stream — never as something to silently believe
Image parameters that describe a bigger raster than you allocated
Image streams raise the stakes because two independent sets of numbers describe the same pixels. The image dictionary carries /Width and /Height, and raster buffers are usually sized from those. The decode filter carries its own geometry: CCITTFaxDecode takes /Columns, /Rows, and /K from its DecodeParms, where /K selects the Group 3 or Group 4 scheme and the decoder emits (Columns + 7) div 8 bytes per scanline. A file that declares /Width 100 but hands the filter /Columns 1728 — the default — makes the decoder produce over sixteen times the bytes per row the buffer expects, and the overflow lands one scanline at a time in whatever sits after the allocation. When /Rows is absent the decoder runs until the data says stop, so bound the row count too. DCTDecode has the same seam: the JPEG data carries its own width and height in its SOF marker, and nothing obliges them to match the dictionary
The defensive rule is mechanical: compute the expected raster size from the validated decode parameters — the filter's own /Columns and /Rows for CCITT, the SOF dimensions for DCT — check it against your limits, allocate from it, and verify during decode that output never runs past the allocation. When dictionary and filter disagree about geometry, reconcile them or reject the image. What a parser must never do is size the buffer from one set of numbers and let the decoder run at the other
Delphi arithmetic and allocation pitfalls
Three Delphi behaviors undermine even a parser that intends to validate. The first is 32-bit multiplication: Delphi evaluates the product of two Integer operands at 32 bits regardless of the destination's width, so Width * Height * BytesPerPixel can wrap even when every factor passes its own sanity check. A 30000 by 30000 scan at three bytes per pixel is 2.7 billion bytes, which wraps negative in signed 32-bit arithmetic; slightly different factors wrap to a small positive length that allocates and undersizes the buffer. Force the whole expression wide by casting the first operand — Size := Int64(Width) * Height * BytesPerPixel — then compare against an explicit cap before anything reaches SetLength
The second is range checking. Delphi's default release configuration ships with it off, so an out-of-range index computed from file data does not raise — it reads or writes memory adjacent to the array. Turn it back on with {$R+} (and {$Q+} for arithmetic overflow) at the top of every unit that indexes with file-derived values. The cost is unmeasurable next to the I/O a parser does anyway, and it converts silent corruption into a catchable ERangeError
The third is TMemoryStream.SetSize with a file-supplied Int64. On a current RTL it allocates whatever the file asked for, so a single stream claiming four gigabytes becomes an out-of-memory failure mid-intake. On older RTLs, where SetSize takes a Longint, the value is silently narrowed first: a declared $100000010 becomes 16, the allocation succeeds, and the write of the real data runs far past it. Validate every size against the measured source size and a hard cap before any allocation call sees it
Offsets that point outside the file
The cross-reference table maps object numbers to absolute byte offsets, and the parser seeks wherever it points. In a damaged or hostile file those offsets land past the end of the file or inside unrelated structures. TStream makes the failure quiet: setting Position beyond Size is not an error, and a plain Read past the end simply returns fewer bytes than requested, so code that skips the count check keeps parsing stale bytes from the previous object. The defense is a chokepoint — one helper through which every file-driven seek and read passes, validating offset and count against the measured file size before the stream moves
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;
Route cross-reference offsets, stream extents, and embedded-file reads through it, and a bad offset becomes a clean rejection that names the numbers instead of an access violation three calls later
Cycles and depth in the object graph
A PDF is a graph, not a tree. Any value may be an indirect reference, a reference may resolve to another reference — /Length 12 0 R, where object 12 holds 13 0 R — and nothing prevents a chain from closing back on itself. A resolver that follows references naively recurses until the native stack is exhausted, and stack exhaustion is not something you catch; it ends the process. Deeply nested arrays and dictionaries reach the same end without any cycle at all
Use two guards together: an explicit depth counter bounds the honest-but-deep case at a limit no legitimate file approaches, and a visited set catches a genuine cycle on its second visit, turning it into a precise, reportable error instead of a limit trip
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;
Decompression is an amplifier
A few kilobytes of FlateDecode input can inflate to gigabytes; general-purpose compression rewards repetitive plaintext, and an attacker can make it maximally repetitive. Cap the inflated size of each stream at what its consumer can plausibly need, and keep a second per-document budget: five hundred streams each just under the per-stream cap exhaust memory as surely as one giant stream. The check belongs inside the inflation loop, counting output bytes as produced and aborting on breach, not after the loop when the memory is already spent. A document budget expressed as a multiple of the compressed file size works well, since legitimate documents cluster far below the ratios a crafted stream reaches
Defense in depth beyond your own units
The same defect classes live inside libraries. Two case studies on this blog walk through real instances: the integer wraps, unbounded recursion, and uninitialized buffers closed in a native Pascal engine in Hardening a Pascal PDF Parser Against Malicious Files, and the calling-convention, integer-width, and ownership hazards of binding a C engine in Hardening a PDFium Component Binding. For genuinely untrusted intake — a public upload form, an unauthenticated mailbox — also run the parse and decode work in a separate low-privilege process, so the file that defeats every in-process guard costs a failed job instead of a downed service
A preflight checklist
Before the next build ships, walk the parser against this list: every stream buffer sized from a clamped length rather than the declared one; every raster sized from validated decoder parameters and checked against decoder output; every dimension product evaluated in Int64 and compared to an explicit cap; {$R+} active in every unit that indexes with file-derived values; every seek bounds-checked against the measured file size; every reference resolution depth-limited and cycle-checked; every inflation loop counting output against per-stream and per-document budgets. None of these checks costs measurable time on a legitimate document, and each converts memory corruption into a clean, loggable rejection
Note: the losLab HotPDF Component, PDFlibPas Delphi PDF Library, and PDFium Component apply these bounds checks, depth limits, and expansion caps internally, so an intake pipeline built on them starts from a hardened baseline