Technical Article

PDF Decode Bombs in Delphi: HotPDF Filter Chain Budgets

A 20 KB PDF that pins a service process until the OOM killer takes it is not a bug in your code, it is a decompression bomb. HotPDF, the native VCL PDF component for Delphi and C++Builder, bounds one with DecodeBudgetBytes, a per-filter-chain ceiling that defaults to 268435456 bytes and charges every decode stage against a single shared budget

The 20 KB file that ate a worker process

The shape of the incident is always the same. A queue worker that renders thumbnails picks up an upload, resident memory climbs past 12 GB in under two seconds, and the process disappears without a stack trace. The file is 20 KB. It has one page, one content stream, and a /Filter array with five entries. Every name in that array is a filter the specification defines, every stage decodes without an error, and nothing in the file is malformed. That is what makes this class of input awkward: there is no corrupt byte to reject

This is not the same problem as decoding a single filter correctly. Getting LZWDecode and the /DecodeParms predictor right is its own subject, covered in the walkthrough of LZW, predictors, and DecodeParms on loaded documents. Here every decoder is already correct. The failure is what correct decoders do when you run five of them back to back and nobody is counting the total. ISO 32000-1 §7.4 is explicit that /Filter may be a single name or an array of names, and that an array is applied in sequence, first entry first. It says nothing about how much a stage may expand its input, and nothing about the aggregate across the chain. An ASCIIHexDecode stage roughly halves its input, which sounds harmless. A FlateDecode stage over a run of zero bytes reaches ratios in the thousands. Chain them and the arithmetic is multiplicative: 20 KB becomes 20 MB becomes 20 GB, and each individual step is a conforming decode of a legal stream

Why does a per-filter limit fail to stop a decode bomb?

Because a per-filter limit is re-armed at every element of the /Filter array. A chain of five stages under a 256 MiB per-stage cap authorizes 1.25 GiB, and the final stage still begins with a completely fresh allowance no matter what the four before it produced. The limit is enforced honestly and constrains nothing that matters. HotPDF had exactly that shape before v2.447.0, and it had a second gap alongside it. The LZW decompressor carried a MaxOutputBytes cap and the image predictor path accounted for its own rows, so those two were bounded locally. FlateDecode, ASCIIHexDecode, ASCII85Decode, and RunLengthDecode had no cap at all: each wrote into a TMemoryStream until it ran out of input or the allocator gave up. So a hostile chain had two ways through. It could use an entirely unguarded filter, or it could use guarded ones and simply add more of them

There is a third detail that a naive fix misses. The number you care about is not the size of the final decoded output. It is the peak, and the peak usually lives in an intermediate buffer. A chain that ends in a modest 4 MB content stream can allocate 8 GB at stage three and hand back something that looks entirely reasonable. Checking the length of the result after the fact tells you nothing about the allocation that killed the process

One budget tracker per filter chain

The fix in HotPDF v2.447.0 is to make the accounting span the chain rather than the stage. Each filter chain constructs one THPDFDecodeBudgetTracker, and every decoder writes through a THPDFBudgetWriteStream that wraps the real target. The wrapper calls Budget.Consume(Count) before it forwards a single byte, so the refusal happens while the target stream is still its old size. That ordering is the entire point: a check performed after the buffer has already grown is a diagnostic, not a defense

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

The local caps did not go away, they became projections of the shared budget. The LZW stage now sets Decoder.MaxOutputBytes := Budget.RemainingBytes, so its private ceiling is whatever the chain has left rather than an independent allowance. The image predictor stage opens with BeginFilter and charges its row requirement through Consume before allocating, which means predictor output is billed to the same budget as the generic filters that fed it. That matters on the image path in particular, where the filter chain and the predictor are two halves of one operation, as covered in extracting images from loaded documents through their decode filters

What does the caller see when the budget refuses?

At the bottom of the stack, a refusal raises EHPDFDecodeBudgetError. Above that, the answer depends on the contract the calling API already had. High-level read methods that reported failure through False or nil keep doing exactly that, because turning a documented boolean result into an exception would break callers who were already handling malformed input correctly. The loaded page content path is the deliberate exception: it re-raises EHPDFDecodeBudgetError rather than letting a truncated content stream render as a page that merely came out empty. That design means a bare False is ambiguous by itself, so the budget publishes a diagnostic record alongside it: THotPDF.GetLastDecodeBudgetInfo returns the state of the most recent chain the instance decoded

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;

Read those fields together and they separate the two attack shapes. When PeakStageBytes is close to DecodedBytes, one stage did all the damage and you are looking at a single high-ratio filter. When PeakStageBytes is a small fraction of DecodedBytes and FilterCount is high, no individual stage was outrageous and the chain accumulated its way past the ceiling, which is precisely the case a per-filter limit cannot see. One caveat worth writing into your handler: GetLastDecodeBudgetInfo returns False until the instance has decoded at least one filter, so a False from it is not evidence that the document was clean

Where the budget resets, and when zero is the honest answer

DecodeBudgetBytes bounds one stream chain, not one document, and that boundary is deliberate but easy to misread. Every content stream, every embedded file, every cross-reference stream and every object stream starts with a fresh 256 MiB. A 4,000-page document therefore has 4,000 independent chances to spend the full ceiling, and object streams multiply the count further because each one is itself a compressed container holding many objects, as described in the notes on object streams and incremental updates. If your real requirement is a bound on total process memory, this property is one input to that, not the whole of it, and it should sit behind a job-level or container-level cap

Zero means unlimited, and it is a legitimate setting rather than an escape hatch. Set it when you own the input: an archive re-processing pipeline over documents your own system produced, or a rasterization step where a single 600 dpi color scan chain genuinely needs more than any ceiling you would be comfortable hard-coding. Negative values are rejected up front with ERangeError, because a negative budget has no coherent meaning and silently clamping it would hide a configuration bug

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

Picking the number deserves more care than it usually gets, because a budget set too low is a self-inflicted outage. Run your existing corpus with the default, record PeakStageBytes and DecodedBytes for every chain, and set the ceiling above the observed maximum with real headroom. A round number chosen because it sounded safe will reject a legitimate large scan at the worst possible time, and the failure will look exactly like an attack in your logs

The copy that no longer happens

Routing every stage through a budget wrapper turned out to make the chain cheaper rather than more expensive. When a stream has filters, the first stage now reads the source stream directly instead of copying the encoded bytes into a scratch buffer first, and from there only two buffers are alive at once: the current input and the stage output being written. The raw copy survives in the two cases that need it, namely a stream with no filters at all and an image where the caller wants the last encoding preserved, because both hand back a stream the caller owns and can seek independently. The unguarded version of this code allocated more and bounded less, which is the usual relationship between the two. Worth stating plainly, though: none of this makes an arbitrary PDF safe to load. It closes one specific and very cheap denial-of-service vector, the one where a small file buys a large allocation through nested filters. Integer overflow in the byte accounting is guarded separately, and the broader question of parsing hostile documents without trusting their internal offsets is a different discipline. A decode budget is one bound among several, and its value is that it is the one you can set from a single property before you touch the file

The per-chain budget, its diagnostics record, and the loaded-document decode paths it protects all ship as part of the component itself, with no external decompression dependency to configure or patch. If you are evaluating how to bound untrusted PDF input inside a Delphi or C++Builder service, the HotPDF Delphi PDF component page lists the loaded-document toolkit these limits apply to