Skip to content

Fuzz the parsers, and fix the two divergences it found - #59

Merged
tannevaled merged 1 commit into
mainfrom
fuzz/parsers
Nov 4, 2026
Merged

tannevaled merged 1 commit into
mainfrom
fuzz/parsers

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

pdfkit had no fuzz targets, and it has a parser: jpegInfo walks
caller-supplied bytes by hand — markers, segment lengths, offsets, every one of
them a number out of the file.

⛔ The oracle is not the specification

It is what every consumer of our output will have used to read the same
bytes
. So the property is not "jpegInfo accepts what image/jpeg
accepts" — it accepts more, deliberately, because DrawJPEG embeds without
decoding. The property is that the two never agree to disagree: a file both
read must have the same dimensions in both, or we write a /Width the reader's
own decoder contradicts and the picture comes out sheared with nothing to
blame.

What it found, in the first second

Both are files jpegInfo refused and every other decoder accepts.

found the rule
Fill bytes on the first seed — "corrupt JPEG (bad segment length)" on a file image/jpeg reads as 4×4 "Any marker may optionally be preceded by any number of fill bytes, which are bytes assigned code X'FF'" — ISO/IEC 10918-1 / CCITT T.81 §B.1.1.2
Extraneous data between segments 15 572 execs in — "corrupt JPEG (expected marker)" on a file image/jpeg reads as 12336×12336 libjpeg's next_marker raises JWRN_EXTRANEOUS_DATA and skips to the next X'FF'; image/jpeg copies that and says why (reader.go:542-556)

The walk took the second X'FF' for a marker byte and then read the real
marker as a segment length. Not a crash, not a hang — a legal file turned away,
which in go-pdfkit/html2pdf means falling back to re-encoding a picture
that needed no re-encoding.

Two more came out of the same reading, before the fuzzer had to find them:

  • X'FF00' is byte stuffing, not a marker — a marker's second byte is "not
    equal to 0 or X'FF'"
    (§B.1.1.2).
  • TEM (X'FF01') stands alone and carries no segment (Table B.1), which
    the walk did not know.

And two bounds that were implicit in the old loop condition are now explicit
errors with their own names: a marker with nothing behind it to hold its
length, and a frame header too short to hold a frame.

⛔ The SOF selection is unchanged, and it was already right: C0–CF less
C4 (DHT), C8 (JPG) and CC (DAC) — exactly Table B.1. Saying so because
"the fuzzer found bugs in this function" reads as "this function was wrong",
and the part that decides which number to believe was not.

The second target

FuzzDrawImageSamples drives the premultiply arithmetic in samples(). Its
property is one no bug in the arithmetic can satisfy by accident: a fully
opaque pixel must come back exactly as it went in
. Half-transparent pixels
are bounded rather than pinned, because dividing by alpha and multiplying back
is lossy and an exact assertion there would test rounding rather than
correctness. 3 394 653 execs, clean.

CI gains the two gates this repository never had

  • a minute of fuzzing per target — not a formality: it is four times what
    it took to find both divergences above, and a new crasher lands in
    testdata/fuzz and fails the step;
  • govulncheck, in a lane of its own. pdfkit parses fonts and image
    headers out of files; a known vulnerability in anything it links is a defect
    in it. Clean locally today.

Corpus committed under testdata/fuzz/. Coverage stays at exactly 100% —
each new branch has a seed that reaches it.

No timings are quoted: this machine was at a 1-minute load of 19.2. Execution
counts are counts.

🤖 Generated with Claude Code

pdfkit had NO fuzz targets, and it has a parser: jpegInfo walks caller-supplied
bytes by hand -- markers, segment lengths, offsets, every one of them a number
out of the file. FuzzJPEGInfo drives it against image/jpeg as a differential
oracle; FuzzDrawImageSamples drives the premultiply arithmetic in samples().

⛔ THE ORACLE IS NOT THE SPECIFICATION, it is what every consumer of our output
will have used to read the same bytes. So the property is NOT "jpegInfo accepts
what image/jpeg accepts" -- it accepts more, deliberately, because DrawJPEG
embeds without decoding -- but that the two never AGREE TO DISAGREE: a file
both read must have the same dimensions in both, or we write a /Width the
reader's own decoder contradicts and the picture comes out sheared with nothing
to blame.

Two divergences, both within the first second, both files jpegInfo refused and
every other decoder accepts:

  1. FILL BYTES. "Any marker may optionally be preceded by any number of fill
     bytes, which are bytes assigned code X'FF'" (ISO/IEC 10918-1 / CCITT T.81
     §B.1.1.2). The walk took the second X'FF' for the marker byte and then
     read the real marker as a segment length: "corrupt JPEG (bad segment
     length)" on a file image/jpeg reads as 4x4. Found on the FIRST SEED.
  2. EXTRANEOUS DATA between segments. libjpeg's next_marker raises
     JWRN_EXTRANEOUS_DATA and skips to the next X'FF'; image/jpeg copies that
     and says why (reader.go:542-556). We said "corrupt JPEG (expected
     marker)" on a file image/jpeg reads as 12336x12336.

Also handled, from the same reading: X'FF00' is byte stuffing and not a marker
(§B.1.1.2 -- a marker's second byte is "not equal to 0 or X'FF'"), and TEM
(X'FF01') stands alone and carries no segment (Table B.1), neither of which
the walk knew. Two bounds that were implicit in the old loop condition are now
explicit errors: a marker with nothing behind it to hold its length, and a
frame header too short to hold a frame.

The SOF selection is unchanged and was already right: C0-CF less C4 (DHT), C8
(JPG) and CC (DAC), exactly Table B.1.

CI gains the two gates this repository never had: a minute of fuzzing per
target (which is not a formality -- it is four times what it took to find both
of the above), and govulncheck in a lane of its own.

Corpus committed under testdata/fuzz. Coverage stays at exactly 100%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 2ab0ede into main Nov 4, 2026
2 checks passed
@tannevaled
tannevaled deleted the fuzz/parsers branch November 4, 2026 16:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant