A parser hangs on a malformed PDF when some branch reads a byte, declines to handle it, and returns without moving the cursor. The caller loop sees no progress and no reason to stop, so it spins forever. HotPDF shipped exactly that defect in three independent parsers, all of them reachable from inputs under fifty bytes
This is a denial-of-service class worth naming separately, because it does not look like the one everybody already guards against. There is no decompression, no allocation growth, no memory pressure to trip a watchdog. The process just sits at one hundred percent CPU on a single core with a handful of bytes in a buffer. If you added a decode budget after reading about nested filter amplification and PDF bombs, that budget will not fire here: nothing is being decoded, and nothing is being allocated. Different failure, different fix
Why does a forty-byte file cost more than a decompression bomb?
Because the cost has no relationship to the input size at all. An amplification attack has a ratio you can bound: cap the output bytes, cap the recursion depth, and the worst case becomes finite. A zero-advance loop has no ratio to bound. The loop condition is a function of a cursor that never changes, so it is not slow, it is non-terminating, and no size-based limit upstream will catch it. That also makes it uniquely nasty operationally. A crash gives you a stack, an out-of-memory gives you a log line, and a stalled parser gives you a worker thread that never returns a result, a request that never times out because nothing downstream is aware it started, and a ticket that says the server got slow on Tuesday. The structural cause is always the same shape. A tokenizer or value parser has a chain of branches keyed on the current byte. Every branch that recognizes something consumes what it recognized. Then there is a final branch, or a guard before the chain, that returns for the bytes nobody claimed, and the author reasons about it as an error path rather than as a scanning step. Error paths return. Scanning steps advance. When one function has to be both, the advance is what gets forgotten
The object parser: two delimiters nobody consumed
In HPDFParser, the method THPDFSpanObjectParser.ParseValue begins by declining two byte sequences outright. Per ISO 32000-1 section 7.3, a value cannot start with ] or with >>, since those close a container rather than open a value, so ParseValue returns nil for both without consuming them. That is the correct contract, and it puts the entire burden of advancing on the caller. The array branch honored half of it: on a nil result it checked for ], left it in place for the next pass to close the array, and stepped over everything else — except that the check happened to be written so that a stray >> was also left in place. So [ >> ] hangs, because ParseArray asks for a value, gets nil, advances nothing, and asks again. The dictionary branch had the mirror-image hole, and << /A ] >> hangs the same way. Both inputs are valid enough to reach the parser and short enough to fit in a tweet
uses
HPDFObjs, HPDFParser;
procedure ProbeParserStalls;
var
Stats: THPDFParserViewStatistics;
Arr: THPDFArrayObject;
Dict: THPDFDictionaryObject;
begin
// Before the fix, neither call below ever returned
Arr := HPDFParserParseSimpleArrayWithStatistics('[ >> ]', Stats);
Arr.Free;
WriteLn('array errors=', Stats.ErrorCount, ' valid=', Stats.Valid);
Dict := HPDFParserParseSimpleDictionaryWithStatistics('<< /A ] >>', Stats);
Dict.Free;
WriteLn('dict errors=', Stats.ErrorCount, ' valid=', Stats.Valid);
end;
The fix is not clever, and that is the point: the nil path has to decide, explicitly, which of the two unconsumed sequences the current loop is able to act on, and step over the other one
Value := ParseValue(Depth + 1);
if Value = nil then
begin
RecordError;
// ParseValue returns nil for both ']' and '>>' without consuming
// them. The ']' ends the array on the next pass, but a stray '>>'
// inside an array has to be stepped over or the parse never advances
if (FPosition < FLength) and StartsWithPair('>', '>') then
Inc(FPosition, 2)
else if (FPosition < FLength) and (CharAt(FPosition) <> ']') then
Inc(FPosition);
Continue;
end;
One more thing deserves attention here. THPDFSpanObjectParser.RecordError only increments ErrorCount in THPDFParserViewStatistics; it caps nothing. The parser sets HPDFParserMaximumDepth to 64 to bound nesting, but has no equivalent ceiling on error count, and a counter nothing ever reads is a missed circuit breaker. Even with the advance restored, an input that produces one error per byte will chew through a large object at a rate that deserves an early exit rather than a full traversal. Check Valid on the statistics record after every parse and treat a large ErrorCount as a reason to stop trusting the object, not merely as a diagnostic
The same shape in the CMap tokenizer and the XFDF reader
HPDFCMapReader.NextToken carried the identical defect in its fallback branch. The function returns a TCMapTok and handles <, ( and / with dedicated branches; everything else falls through to "read until whitespace or delimiter". When the starting byte is itself a delimiter from the ISO 32000-1 section 7.2.2 set, that loop runs zero times, the token comes back empty, and the caller asks for the same token again. Of the six characters that can land there, only [ was intercepted earlier by the bfrange handling, so a stray ), >, ], { or } anywhere in a ToUnicode CMap was enough to stall text extraction
// keyword or number: read until whitespace or delimiter
StartP := P;
while (P <= Len) and (not IsCMapWhiteSpace(Text[P]))
and (not IsCMapDelimiter(Text[P])) do
Inc(P);
// A delimiter none of the branches above claim (a stray ')', '>',
// '[', ']', '{' or '}') would leave P where it was and return an
// empty keyword, so the caller would ask for the same token forever
if P = StartP then
Inc(P);
AKeyword := Copy(Text, StartP, P - StartP);
Result := ctKeyword;
The XFDF side had a variant with a different origin and the same effect. HPDFXFDFXml.ParseElement sets Result := P at the top, while P still points at the opening <, and only then increments its local cursor, so every early exit that follows returns the entry position unless it overwrites Result first. A doctype declaration per XML 1.0 section 2.8, or anything else without a parsable tag name such as <1a>, took an exit that never updated Result, and the child-element loop received the position it had passed in. The attribute loop had three more of these, since ReadName, the whitespace skip and the = handling all decline a byte that is neither a name character, nor whitespace, nor one of =, >, /, and none of them consumed it. The neighboring CDATA repair moved a responsibility rather than adding a guard: a CDATA section per XML 1.0 section 2.7 starts with <, so the child loop used to hand it to ParseElement, which then read a tag name out of the section content. Recognizing it in the child loop with IsCDATAStart and copying bytes into the text buffer until IsCDATAEnd is the correct division, and the old skip was also one byte long, since the marker is eight bytes counted from the !
Declared counts that outlive the data
A later release surfaced the same pattern from the opposite direction, and it is the variant most likely to exist in your own code. A cross-reference subsection header per ISO 32000-1 section 7.5.4 is a first count pair, and both HotPDF traditional-xref parsers looped for I := 0 to Count - 1 over it, using Continue rather than Break when an entry failed to parse. That reads as tolerance for one bad line. It is not: HPDFReadLine returns an empty string without advancing once the data is exhausted, and HPDFParseTraditionalXrefEntry rejects an empty entry immediately, so every remaining iteration of the declared count is pure spin. The arithmetic is unkind — the header xref\n0 2000000000\ntrailer plus a minimal wrapper is about forty bytes, and it buys tens of seconds of a busy core with no cancellation check anywhere in the loop. The tell was sitting in the same unit, because HPDFRevisionParseTraditionalXRef had always used Exit in that position. When two functions parse the same grammar and disagree about whether a failed entry ends the subsection, one of them is a fork that stopped getting the fixes, the same signature that shows up in hybrid cross-reference handling where a traditional table and a stream describe the same objects
if not HPDFParseIntegerPair(Line, First, Count) then
Exit;
for I := 0 to Count - 1 do
begin
Entry := HPDFReadLine(XrefData, P);
// A declared count larger than the table would otherwise spin
// here with no data left to read
if not HPDFParseTraditionalXrefEntry(Entry, EntryOffset, GenNum, InUse) then
Break;
// First comes from the subsection header, so the sum wraps for a
// declared start near the Integer limit, and the growth helper
// silently refuses a negative index
if (First < 0) or (Int64(First) + I > MaxInt) then
Break;
ObjNumber := First + I;
if (ObjNumber < 0) or (Int64(ObjNumber) > Stream.Size div 8) then
Break;
HPDFEnsureDirectXrefLength(State, ObjNumber);
// ... record the entry ...
end;
The two bounds after the Break belong to the same repair. HPDFEnsureDirectXrefLength declines to grow the array for a negative object number and says nothing about it, and First + I wraps for a declared start near the Integer limit, so a negative index reaches the writes below. The object-number ceiling of Stream.Size div 8 is the cheap general answer, since no traditional cross-reference table can describe more objects than the file has room to hold, and any header claiming otherwise is describing a file that does not exist. If you are hand-repairing a damaged table, the same reasoning applies from the other side, which is the territory covered in the notes on diagnosing and repairing hand-edited PDFs
How do you prove a hang fix actually works?
By running the old code, not by rereading it. A stall is one of the few defect classes where a fix can be verified with a binary signal and no instrumentation, because the process either returns or it does not, and reasoning about control flow is exactly the skill that produced the bug in the first place. The mechanics are worth copying. Extract the pre-fix revision of the changed units into a scratch directory, then compile one small probe with the unit search path putting that directory ahead of the real library: dcc32 -U<scratchdir>;<Lib> probe.dpr. The compiler takes the old sources for whatever it finds first and the current sources for everything else, so a single probe binary exercises the old parser against the rest of the library. Run it under a timeout — forty seconds is generous — and treat exit code 124 as the assertion. Then withdraw the units from the scratch directory one at a time and rebuild. All three stalls reproduced this way, each attributable to a specific unit, and all three returned once the fixed source was back in the path, at a cost of one compile per unit
Two traps are worth knowing before you try it. The unit search path is silently permissive: hand -U a Unix-style path from a shell, such as $PWD, and the compiler ignores that directory without a word, which means your probe compiled against the current sources and passed for the wrong reason. Convert the path to native Windows form first. The second is that the scratch directory does not carry the include files, so you also need -I<Lib> for HotPDF.inc or the build fails in a way that looks like a source problem rather than a path problem. And be honest about what the method does not give you: it confirms that a specific input stopped hanging on a specific build, and says nothing about the inputs you did not think of. Zero-advance defects are found by fuzzing or by reading every branch of every tokenizer, not by regression tests written after the fact
What to audit in your own parsers
The reusable rule is a contract, not a checklist item. Any function that reads from a shared cursor must, on every path including its failure paths, either consume at least one byte or return a value the caller cannot mistake for "try again". Mixing those two meanings into a single nil or a single empty token is what turns a decline into a stall. So audit for the shape rather than for the symptom. Find every loop whose termination depends on a cursor another function owns, and ask what happens when that function refuses the current byte. Find every fallback branch written as "read until X" and ask what it does when the cursor already sits on X. Find every loop bounded by a count that came out of the file rather than out of the data, and make a failed element end the loop instead of skipping an iteration. Where the same grammar is parsed in two places, diff them, because one will have received a fix the other did not. And put a cancellation check inside any loop that can run for a caller-supplied number of iterations, so an operator has something to pull when the next one of these turns up in production
The parsers described here ship in the current HotPDF Delphi Component for Delphi and C++Builder, with the statistics records and depth limits exposed so your own hardening can build on them