Repository navigation
tests/fuzz/cbor: a coverage-guided fuzz harness for std.cbor - #3497
Merged
Merged
Conversation
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>
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.
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-cborbuildstests/fuzz/cbor/with libFuzzer, ASan and UBSan, and runs it forFUZZ_SECONDS(default 60). UBSan is set to halt, so undefined behaviour fails the run. The fuzzer is not part ofmake testor CI.The oracle checks more than "doesn't crash, doesn't leak". For each input:
encode_deterministicthe value. That encoding must parse, and must re-encode to the same bytes.diagnose's output, because std.cbor exports no array accessor.Before fuzzing, the script checks that the input
41 00reaches the decoder as 2 bytes, so data isn't cut at a NUL byte.gen_corpus.pywrites the 126 seeds from hex. They cover:Findings (commit 1)
read_valuelong's sign)1b ff…,db 80…float32_from_bits(×2)fa c0…,f9 c4 00parse_floatlongfb c0 10…format_float_diag(bits << 1) == 0on a negative valueruntime/aether_slice.hNULL + 0when sub-slicing an empty slice40gcc 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.speccase can see these bugs, because the compiler happens to wrap. The seeds are the regression test.What changed from the draft
\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.cbor.dicthad the same fault:"\\x81"is 4 bytes of text.-fno-sanitize-recover=undefined.build/fuzz/cbor/.-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=8by default. A caller's ownASAN_OPTIONSwins.ae_sweep_prune.txtcame from an older main, and would have droppedtest_ipc_write.cand thestd_ipc_socketprune entry.fuzz-cbortarget is added. The prune entry is dropped, because the.aesweep never reachestests/fuzz/.afl-clang-fast -fsanitize=fuzzer. Untested: AFL++ isn't installed here..gitattributesmarks the corpus binary: a seed of one 0x0a byte would become0d 0aunder autocrlf.Proof that it finds bugs:
prove_mutants.shEach planted bug goes in a copy of the module, which a local
std/cbor/shadows. That shadowing was checked.malloc(2147483648)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
make testpasses, and so dotest_cborandtest_cbor_hardening.make test-ae: 2069 passed, 2 failed. The failures arehttp_server_h2(a 50-stream curl stress test) andwindows_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