Repository navigation
Fuzz the parsers, and fix the two divergences it found - #59
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
pdfkithad no fuzz targets, and it has a parser:jpegInfowalkscaller-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 "
jpegInfoaccepts whatimage/jpegaccepts" — it accepts more, deliberately, because
DrawJPEGembeds withoutdecoding. 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
/Widththe reader'sown decoder contradicts and the picture comes out sheared with nothing to
blame.
What it found, in the first second
Both are files
jpegInforefused and every other decoder accepts."corrupt JPEG (bad segment length)"on a fileimage/jpegreads as 4×4"corrupt JPEG (expected marker)"on a fileimage/jpegreads as 12336×12336next_markerraisesJWRN_EXTRANEOUS_DATAand skips to the nextX'FF';image/jpegcopies that and says why (reader.go:542-556)The walk took the second
X'FF'for a marker byte and then read the realmarker as a segment length. Not a crash, not a hang — a legal file turned away,
which in
go-pdfkit/html2pdfmeans falling back to re-encoding a picturethat 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 "notequal to 0 or X'FF'" (§B.1.1.2).
X'FF01') stands alone and carries no segment (Table B.1), whichthe 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–CFlessC4(DHT),C8(JPG) andCC(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
FuzzDrawImageSamplesdrives the premultiply arithmetic insamples(). Itsproperty 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
it took to find both divergences above, and a new crasher lands in
testdata/fuzzand fails the step;govulncheck, in a lane of its own.pdfkitparses fonts and imageheaders 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