Skip to content

tests/fuzz/cbor: a coverage-guided fuzz harness for std.cbor - #3497

Merged
paul-hammant merged 2 commits into
mainfrom
feat/fuzz-cbor
Nov 1, 2026
Merged

paul-hammant merged 2 commits into
mainfrom
feat/fuzz-cbor

Conversation

@paul-hammant

Copy link
Copy Markdown
Collaborator

This adds the repo's first fuzzer: a coverage-guided harness for std.cbor's decoder, which will decode every message a sandboxed child sends its parent. It is shaped from Jules's draft (paul-hammant/aether-fork#48). The fuzzer found six undefined behaviours in C. They are fixed in their own commit.

What it does

make fuzz-cbor builds tests/fuzz/cbor/ with libFuzzer, ASan and UBSan, and runs it for FUZZ_SECONDS (default 60). UBSan is set to halt, so undefined behaviour fails the run. The fuzzer is not part of make test or CI.

The oracle checks more than "doesn't crash, doesn't leak". For each input:

  1. Parse it with the default options. An error here is a pass.
  2. If it parsed, encode_deterministic the value. That encoding must parse, and must re-encode to the same bytes.
  3. Parse again with small limits taken from the first byte. Whatever these stricter options accept, the defaults must accept as the same value.
  4. What the stricter options accept must keep to them: depth, array length, map size, and indefinite lengths when those are refused. This is measured from diagnose's output, because std.cbor exports no array accessor.

Before fuzzing, the script checks that the input 41 00 reaches the decoder as 2 bytes, so data isn't cut at a NUL byte.

gen_corpus.py writes the 126 seeds from hex. They cover:

  • RFC 8949 Appendix A, and some of Appendix F;
  • the byte-level inputs from the hardening tests;
  • deep nesting, duplicate keys, length bombs, and the sign-bit cases below.

Findings (commit 1)

Where Undefined behaviour Seed that reaches it
read_value 64-bit argument assembled with signed shifts (top bit into a long's sign) 1b ff…, db 80…
float32_from_bits (×2) sign shifted into bit 63 (float32 and float16) fa c0…, f9 c4 00
parse_float float64 accumulated in a long fb c0 10…
format_float_diag (bits << 1) == 0 on a negative value (the same floats)
runtime/aether_slice.h NULL + 0 when sub-slicing an empty slice 40

gcc and clang wrap these shifts, so the values were already right; C just doesn't promise it. They are now assembled as uint64, AGENTS.md's idiom for bit patterns. The slice offset returns the pointer itself at offset 0.

To show each fix matters, I replayed the seeds against the unfixed module and the unfixed header. Each halts with its runtime error.

No std.spec case can see these bugs, because the compiler happens to wrap. The seeds are the regression test.

What changed from the draft

Problem in the draft Fix
The seeds were text, not CBOR: every byte from 0x80 up was written as the four characters \xNN, and the 0x0a and 0x20 seeds were empty. The depth 255/257 nests, duplicate keys, indefinite items, length bombs and most of Appendix A never reached the decoder as CBOR. All seeds regenerated from hex, readable in review.
cbor.dict had the same fault: "\\x81" is 4 bytes of text. Fixed.
UBSan printed its reports and carried on, so a 60 s run passed with six reports. -fno-sanitize-recover=undefined.
With the corpus fixed, M1 (depth check removed) went unfound for 300 s, because overflowing the stack needs tens of thousands of levels. The limit check in step 4 finds it at depth 2, in under a second.
Reproducers were written into a temp directory the exit trap deleted. The binary, working corpus and reproducers stay in build/fuzz/cbor/.
The first 30-minute run hit -rss_limit_mb (2 GB). The cause was ASan's per-allocation stack traces, not a leak: the decoder's recursion makes nearly every nesting shape a new trace. A plain build looping millions of mutated inputs stays flat. ASAN_OPTIONS=malloc_context_size=8 by default. A caller's own ASAN_OPTIONS wins.
The Makefile and ae_sweep_prune.txt came from an older main, and would have dropped test_ipc_write.c and the std_ipc_socket prune entry. Only the fuzz-cbor target is added. The prune entry is dropped, because the .ae sweep never reaches tests/fuzz/.
The README's AFL++ build linked no driver. Uses afl-clang-fast -fsanitize=fuzzer. Untested: AFL++ isn't installed here.
Not in the draft. .gitattributes marks the corpus binary: a seed of one 0x0a byte would become 0d 0a under autocrlf.

Proof that it finds bugs: prove_mutants.sh

Each planted bug goes in a copy of the module, which a local std/cbor/ shadows. That shadowing was checked.

Planted bug Found in How
M1: depth check removed 0 s limit check
M2: string length check removed 2 s out-of-memory, malloc(2147483648)
M3: definite array limit off by one 0 s limit check
M4: map pairs check removed 4 s limit check
M5: indefinite strings accepted when refused 0 s limit check

M1, M3 and M5 are found while the seeds replay, before mutation starts.

Unmutated run

30 minutes: 3,262,394 inputs, cov: 687 ft: 4753, corpus 1361 entries (196 KB), peak RSS 734 MB. No crash, leak, timeout or oracle failure.

Checks

  • Gates: fmt, check-docs, check-changelog, check-tests, check-warnings, check-raw-calls, check-extern-widths, check-standalone, check-crypto-wipe and check-archive-exports-nozlib all pass.
  • make test passes, and so do test_cbor and test_cbor_hardening.
  • make test-ae: 2069 passed, 2 failed. The failures are http_server_h2 (a 50-stream curl stress test) and windows_crt_symbols. Both fail the same way in a clean worktree of main.

Defaults

I didn't change any decode defaults. The fuzzer gave no reason to question them.

🤖 Generated with Claude Code

paul-hammant and others added 2 commits October 31, 2026 22:29
The std.cbor fuzz harness, run with UBSan halting, stopped on six
undefined behaviours, each reached by one seed:

- read_value assembled a 64-bit argument with signed shifts, so a top
  bit set (1b ff.., db 80..) shifted into a long's sign bit;
- float32_from_bits (twice) shifted a float32 or float16's sign into
  bit 63;
- parse_float accumulated a float64 byte by byte in a long, shifting
  set high bits out;
- format_float_diag tested zero as (bits << 1) == 0 on a negative.

gcc and clang wrap, so the values were right; C makes no promise. They
are assembled as uint64 now (AGENTS.md's idiom for bit patterns), and
the zero test masks the sign instead.

runtime/aether_slice.h sub-sliced an empty slice whose store is NULL
as NULL + 0, also undefined in C; decoding an empty byte string (40)
reached it. Offset 0 now returns the pointer itself.

Each was shown: the seeds replayed against the unfixed module (and the
unfixed header) halt with the runtime error; with the fixes, 1.06M
fuzz runs pass. test_cbor and test_cbor_hardening pass; make test
passes; make test-ae passes apart from http_server_h2 and
windows_crt_symbols, which fail the same way on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The repo's first fuzzer. `make fuzz-cbor` builds tests/fuzz/cbor with
libFuzzer, ASan and UBSan (halting) and runs it for FUZZ_SECONDS. The
oracle: a parsed value re-encodes deterministically to bytes that parse
and re-encode the same; what stricter options (from the first byte)
accept the defaults accept, as the same value; and what they accept
keeps to them, depth, array length, map size and indefinite lengths,
measured from diagnose's output.

Reworks Jules's draft (paul-hammant/aether-fork#48, Jules task
5601111562859236337). Fixed from the draft:

- The seed corpus held text, not CBOR: every byte from 0x80 up was
  written as the four characters `\xNN`, so the depth 255/257 nests,
  duplicate keys, indefinite items, length bombs and most of Appendix A
  were 28-byte byte strings; 0x0a and 0x20 seeds were empty. The seeds
  are now written from hex by gen_corpus.py, readable in review.
- cbor.dict had the same fault (`"\\x81"` is 4 bytes of text).
- UBSan printed and carried on, so the harness passed undefined
  behaviour: six reports in a 60s run. -fno-sanitize-recover makes it
  fail; the six are fixed in the previous commit.
- With the corpus fixed, M1 (depth check removed) went unfound for
  300s: overflowing the stack needs tens of thousands of nested levels.
  The new limit check finds it in under a second, at small depths.
- Reproducers were written into a temp dir the exit trap deleted. The
  binary, corpus and reproducers now stay in build/fuzz/cbor.
- The Makefile and tests/ae_sweep_prune.txt came from an older main and
  dropped test_ipc_write.c and the std_ipc_socket prune. The prune
  entry is gone: the sweep never reaches tests/fuzz.
- The README's AFL++ build linked no driver.

prove_mutants.sh plants five bugs; each is found: M1 depth check (0s),
M2 string length check (2s, out-of-memory), M3 array limit off by one
(0s), M4 map pairs check (4s), M5 indefinite strings when refused (0s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@paul-hammant
paul-hammant merged commit da113db into main Nov 1, 2026
45 checks passed
@paul-hammant
paul-hammant deleted the feat/fuzz-cbor branch November 1, 2026 06:02
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