Skip to content

Native fuzzing on Jazzer: hostile read + round trip, smoke, nightly, negative control - #17

Merged
gafferongames merged 2 commits into
mainfrom
rowan/native-fuzz
Oct 27, 2026
Merged

gafferongames merged 2 commits into
mainfrom
rowan/native-fuzz

Conversation

@gafferongames

Copy link
Copy Markdown
Contributor

Gap

The C++ reference runs a 60 s libFuzzer smoke on every push and a nightly extended fuzz; serialize.java had no fuzzing at all.

What this adds

A native coverage-guided harness on Jazzer 0.30.0 (standalone release, Apache-2.0), pinned and sha256-verified by a Makefile rule before anything is unpacked (hashes for linux-x86-64, linux-arm64 and macos recorded in tending/PINS.md). The harness has no compile-time Jazzer dependency: each target is a plain fuzzerTestOneInput( byte[] ) class compiled with the same --release 17 -Xlint:all -Werror against the library classes only. Runs with assertions on (-ea:serialize...), as the reference fuzzes its Debug build.

  • fuzz/serialize/fuzz/HostileReadFuzzer.java: the reference's FuzzRead. A 32-byte op program drives arbitrary bytes through every ReadStream primitive: bits (1..32), bits64, bool, uint8/16/32/64, uint128, align, int (three range shapes), int64 (full and ranged), int128 (ranged over 1 to 4 groups, and full), fixed Q16.16, Q48.16 and Q112.16, int_relative, float, double, compressed_float, bytes, string, end. Traps on any throw, any accepted value outside its contract (range, finiteness, string length, NUL, unpaired surrogates, an end that leaves a bit behind), and any refusal that breaks the refusal contract: the stream not latched, no reason or one outside the closed Refusal list, a scalar destination written, or a later read that is accepted, consumes bits or changes the reason. In place of the reference's InitializePadded pass, the same bytes are read twice, from an exact-size array and from one with 8 bytes of set bits past the end, and the two readers must agree on every value, where they stop and why.
  • fuzz/serialize/fuzz/RoundTripFuzzer.java: the reference's FuzzRoundTrip. Values generated from the input go through one serialize body against WriteStream, MeasureStream and ReadStream. Traps on any write/read asymmetry, on a measure that under-measures, on a read that doesn't end where the writer stopped (serializeEnd), and on a stream one byte longer whose end isn't refused as trailing_data.
  • Make targets: make fuzz (both), make fuzz-read, make fuzz-roundtrip (FUZZ_TIME, default 60), and make fuzz-control. Fuzzing is the functional tier and isn't part of make all.
  • CI job fuzz (ubuntu-24.04): the negative control, then 60 s of each target. Crash reproducers are uploaded on failure.
  • .github/workflows/nightly-fuzz.yml, shaped like the reference's: daily cron at 08:00 UTC, workflow_dispatch input duration (default 3600, per target), corpus restored and saved with actions/cache keyed by run id, crash reproducers uploaded on failure, ubuntu-24.04.

Negative control (reversed witness)

make fuzz-control (equivalently, any fuzz target with SABOTAGE=1) builds a scratch copy of src/ with one write/read asymmetry planted by fuzz/serialize/fuzz/Sabotage.java. WriteStream.serializeInt64 writes a value whose low 16 bits are 0x5A5A one higher than it is. If the needle stops matching, the tool fails; it never silently becomes a no-op. The round trip target then runs against that library from an empty corpus within the 60 s smoke budget. The control is green only if the fuzzer traps, and only on the planted int64 asymmetry. CI runs it on every push before the smoke.

Local evidence (studio, 4 runs, under nice -n 19):

== Java Exception: serialize.fuzz.FuzzCheck: int64 full range: asymmetric round trip
fuzz-control: green: the planted asymmetry was caught in 4 s (budget 60 s)
fuzz-control: green: the planted asymmetry was caught in 4 s (budget 60 s)
fuzz-control: green: the planted asymmetry was caught in 2 s (budget 60 s)
fuzz-control: green: the planted asymmetry was caught in 2 s (budget 60 s)

Clean-library smokes (20 s locally): hostile read ran 1,841,069 executions (about 88k/s) and round trip ran 272,547 (about 13k/s). Neither produced a finding. make all (test, test-release, controls) is unchanged and green.

🤖 Generated with Claude Code

gafferongames and others added 2 commits October 25, 2026 13:51
…ke, nightly, negative control

The reference runs a 60 s libFuzzer smoke per push and a nightly extended
fuzz; serialize.java had no fuzzing. This adds the Java counterpart of
fuzz.cpp on Jazzer 0.30.0 (standalone release, pinned and sha256 checked
by the Makefile, recorded in tending/PINS.md), compiled with
--release 17 -Xlint:all -Werror against the library classes only.

- fuzz/serialize/fuzz/HostileReadFuzzer: arbitrary bytes through every
  ReadStream primitive under an op program; traps on a throw, an out of
  contract value, or a refusal breaking the refusal contract (latched,
  reason from the closed Refusal list, destination unwritten, the next
  read refuses without consuming bits); exact-array vs slack-array
  readers must agree.
- fuzz/serialize/fuzz/RoundTripFuzzer: write -> measure -> read of
  generated values through one serialize body; traps on any asymmetry,
  an under-measure, a read not ending where the writer stopped, and a
  trailing byte not refused as trailing_data.
- make fuzz / fuzz-read / fuzz-roundtrip (FUZZ_TIME, default 60).
- make fuzz-control (and SABOTAGE=1): a scratch library with one
  int64 write asymmetry planted must be caught within the smoke budget.
- ci.yml job fuzz (ubuntu-24.04): control + 60 s per target.
- nightly-fuzz.yml: daily cron, workflow_dispatch duration (3600),
  cached cumulative corpus, crash reproducer upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gafferongames
gafferongames merged commit 96f1393 into main Oct 27, 2026
5 checks passed
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