Testing No. 131

Fuzz Testing skill for Claude Code

Throw hostile bytes at parsers before attackers do. Claude picks the input boundaries worth fuzzing, writes fast deterministic harnesses with real invariants such as round-trip equality, and seeds them with real inputs and format dictionaries. Crashes get reproduced, minimized, classified by sanitizer report and deduplicated by stack, then committed as regression inputs, and CI runs short fuzz jobs with a cached corpus.

  • namefuzz-testing
  • size259 lines · ≈2,600 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Adds coverage-guided fuzzing to code that parses, decodes, or validates untrusted input — Go native fuzzing, cargo-fuzz with libFuzzer and arbitrary, Atheris for Python, Jazzer for JVM, and libFuzzer for C/C++ — with harness design, seed corpora and dictionaries, sanitizers, crash reproduction and minimization, turning crashes into regression tests, and short CI fuzz runs via ClusterFuzzLite or time-boxed jobs. Use when writing a fuzz target, fuzzing a parser, decoder, protocol handler or deserializer, triaging a fuzzer crash, or adding fuzzing to CI.

Try asking

  • Write a Go fuzz test for our binary message decoder and run it for five minutes
  • cargo fuzz found a crash in the frame parser. Triage it and add a regression test
  • Add fuzzing to CI for the config parser without slowing every PR

Use it when

  • Code parses, decodes or deserializes untrusted input
  • Writing a fuzz target or harness in Go, Rust, Python, Java or C/C++
  • A fuzzer reported crashes, timeouts or OOMs that need triage
  • Adding ClusterFuzzLite or time-boxed fuzz jobs to CI

Skip it when

How to check the result

A deliberately planted bounds bug is found from the seed corpus within minutes, each crash reproduces from its minimized file, and regression inputs replay in the normal test command.

The common mistake it prevents

Catching every exception or panic inside the harness, which turns real crashes into silent passes and leaves the fuzzer blind.

~/.claude/skills/fuzz-testing/SKILL.md

--- name: fuzz-testing description: Adds coverage-guided fuzzing to code that parses, decodes, or validates untrusted input — Go native fuzzing, cargo-fuzz with libFuzzer and… license: MIT ---

A good fuzz target is a fast, deterministic function that feeds fuzzer bytes into one input boundary and checks invariants that must hold for any input. Done well means: the target runs thousands of executions per second, starts from a real seed corpus, every crash becomes a committed regression input, and CI fuzzes changed code for minutes, not never.

When fuzzing pays off

TargetFuzz?Why
Parsers, decoders, deserializers (JSON, binary formats, images, protocols)Yes, firstUntrusted bytes, complex branching
Input validation, sanitizers, URL/path handlingYesSecurity boundaries
Round-trip pairs (encode/decode, compress/decompress)YesStrong built-in oracle
Two implementations of the same thing (old vs new)Yes, differentialOracle for free
Business logic over typed domain objectsPrefer property-based testsStructured generators beat raw bytes
UI, network-bound or slow codeNoToo slow, nondeterministic

Property-based testing (see property-based-testing) and fuzzing overlap; fuzzing wins when inputs are bytes and coverage guidance matters, PBT wins on structured domain data and fast shrinking in the unit suite.

Ground rules

  • One target per input boundary. Keep it pure: no network, no global state carried between iterations, no randomness, no wall-clock time. Nondeterminism makes crashes unreproducible.
  • Speed matters more than cleverness: aim for 1,000+ exec/s. Strip logging, cap input size, avoid filesystem writes in the target.
  • Assert invariants, not just “does not crash”: round-trip equality, output length bounds, decode(encode(x)) == x, no panic on invalid input (must return an error).
  • Always run native code with sanitizers (ASan + UBSan). Memory bugs often do not crash without them.
  • Commit the seed corpus and every crash reproducer; do not commit the full generated corpus unless it is small (store it as a CI cache or artifact instead).
  • Fuzz only code you own or are authorized to test. Report crashes found in third-party libraries privately to the maintainers when they look exploitable.

Workflow

- [ ] 1. Pick the boundary and its oracle (crash-free, round-trip, differential)
- [ ] 2. Write the harness for the language (templates below)
- [ ] 3. Build a seed corpus from real inputs + a dictionary of format tokens
- [ ] 4. Run locally with sanitizers; watch exec/s and coverage growth
- [ ] 5. Triage every crash: reproduce, minimize, dedupe, fix
- [ ] 6. Add each minimized crash as a regression test
- [ ] 7. Wire a time-boxed CI run and corpus caching

Go (native, 1.18+)

// parse_fuzz_test.go
func FuzzParseHeader(f *testing.F) {
    for _, s := range []string{"GET / HTTP/1.1", "", "\x00\xff"} {
        f.Add([]byte(s)) // seed corpus entries
    }
    f.Fuzz(func(t *testing.T, data []byte) {
        h, err := ParseHeader(data)
        if err != nil {
            return // invalid input must error, not panic
        }
        out := h.Marshal()
        h2, err := ParseHeader(out)
        if err != nil || !h.Equal(h2) {
            t.Fatalf("round trip mismatch: %q -> %q", data, out)
        }
    })
}
go test -run='^$' -fuzz='^FuzzParseHeader$' -fuzztime=5m ./internal/http/
go test -run='FuzzParseHeader/<hash>' ./internal/http/   # replay one failing input
  • Failing inputs are written to testdata/fuzz/FuzzParseHeader/<hash>; commit them. Plain go test replays all of them as regression cases.
  • -fuzz accepts one target in one package per invocation; loop over targets in CI.
  • Generated corpus lives in $(go env GOCACHE)/fuzz; cache that directory in CI.
  • Supported argument types are primitives, string and []byte; parse structure from bytes yourself for complex inputs.

Rust (cargo-fuzz, libFuzzer, nightly)

cargo install cargo-fuzz
cargo fuzz init                      # creates fuzz/ crate
cargo fuzz add parse_frame
cargo +nightly fuzz run parse_frame -- -max_total_time=300 -max_len=4096
cargo +nightly fuzz tmin parse_frame fuzz/artifacts/parse_frame/crash-<hash>
cargo +nightly fuzz cmin parse_frame                 # shrink corpus
cargo +nightly fuzz coverage parse_frame             # see what the corpus reaches
#![no_main]
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
    if let Ok(frame) = mycrate::Frame::decode(data) {
        let bytes = frame.encode();
        assert_eq!(mycrate::Frame::decode(&bytes).unwrap(), frame);
    }
});

For structured input derive arbitrary::Arbitrary on an input enum/struct and take it as the closure parameter (|input: MyInput|). ASan is on by default; integer overflow checks need --debug-assertions or a debug profile.

Python (Atheris)

import sys, atheris
with atheris.instrument_imports():
    import mylib.parser

def TestOneInput(data: bytes) -> None:
    fdp = atheris.FuzzedDataProvider(data)
    text = fdp.ConsumeUnicodeNoSurrogates(4096)
    try:
        mylib.parser.parse(text)
    except mylib.parser.ParseError:
        pass          # expected rejection; anything else is a finding

atheris.Setup(sys.argv, TestOneInput)
atheris.Fuzz()
python fuzz_parser.py corpus/ -max_total_time=300 -dict=formats.dict
python fuzz_parser.py crash-<hash>          # reproduce

Atheris ships wheels for a limited set of CPython versions and tends to lag new releases; check PyPI before choosing the interpreter. Catch only the exceptions the API documents. Catching Exception hides the bugs you are looking for. For native extensions, build them with ASan and preload it as described in the Atheris README.

JVM (Jazzer + JUnit 5)

import com.code_intelligence.jazzer.api.FuzzedDataProvider;
import com.code_intelligence.jazzer.junit.FuzzTest;

class ConfigParserFuzzTest {
    @FuzzTest(maxDuration = "5m")
    void parse(FuzzedDataProvider data) {
        try {
            ConfigParser.parse(data.consumeRemainingAsString());
        } catch (ConfigException expected) { }
    }
}

mvn test / gradle test runs it in regression mode (replays stored findings from the test resources directory); JAZZER_FUZZ=1 mvn test -Dtest=ConfigParserFuzzTest fuzzes. Jazzer also flags injection-style issues (deserialization, SSRF, path traversal) through its built-in sanitizers.

C/C++ (libFuzzer)

extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    parse_packet(data, size);
    return 0;
}
clang++ -g -O1 -fsanitize=fuzzer,address,undefined fuzz_packet.cc packet.cc -o fuzz_packet
./fuzz_packet corpus/ seeds/ -max_total_time=600 -jobs=8 -dict=packet.dict

AFL++ is the alternative when you need its mutators or cannot use clang’s libFuzzer.

Corpus and dictionaries

  • Seeds: real files from tests, fixtures, bug reports, and minimal valid examples of each message type. Ten diverse small seeds beat one big file.
  • Dictionary: magic bytes, keywords, delimiters ("GET", "\r\n", "\x89PNG"), one per line in libFuzzer format (kw1="value"). Large gain on text and tag formats.
  • Cap input size (-max_len) near realistic maximums; huge inputs slow everything.
  • Minimize periodically (cmin, -merge=1) so CI restores a lean corpus.
  • Watch progress: if coverage (cov: / ft: in libFuzzer output, “new interesting” in Go) stops growing within minutes, add seeds or split the target.

Crash triage

Reproduce with the single input, minimize, then classify. Read references/crash-triage.md when a crash needs classification (sanitizer report types, timeouts vs OOM, dedupe by stack, severity) or when many crashes arrived at once.

Every fixed crash ends as a regression test:

  • Go: the file in testdata/fuzz/<Target>/ (already replayed by go test).
  • Rust: copy the minimized artifact into a tests/ case or a fuzz/regressions/ dir replayed by a unit test calling the same function.
  • Python/Java/C++: a unit test that loads the minimized bytes from a fixture.

CI integration

OptionUse when
Time-boxed job per target (-fuzztime=2m, -max_total_time=120)Simple, any CI; run on PRs touching the parser and nightly for longer
ClusterFuzzLite (GitHub Action)libFuzzer-compatible targets; code-change mode on PRs, batch mode on schedule, corpus pruning
OSS-FuzzOpen-source projects that qualify; continuous fuzzing on Google infra
# Nightly Go fuzz with cached corpus (GitHub Actions excerpt)
- uses: actions/cache@v4
  with:
    path: ~/.cache/go-build/fuzz
    key: fuzz-${{ github.sha }}
    restore-keys: fuzz-
- run: |
    for t in $(go test -list '^Fuzz' ./internal/http/ | grep ^Fuzz); do
      go test -run='^$' -fuzz="^$t\$" -fuzztime=10m ./internal/http/ || exit 1
    done
- if: failure()
  uses: actions/upload-artifact@v4
  with: { name: fuzz-crashers, path: '**/testdata/fuzz/**' }

The Go fuzz cache path is $(go env GOCACHE)/fuzz; print it in CI and adjust the cache path if the runner uses a different GOCACHE.

Gotchas

  • Fuzzing in a debug build without sanitizers misses most memory bugs; fuzzing a release build may optimize away the checks you care about. Use -O1 -g plus sanitizers.
  • Timeouts (-timeout, default 1200 s in libFuzzer, lower it to ~10 s) and OOMs (-rss_limit_mb) are real findings: algorithmic complexity bugs are DoS bugs.
  • A target that returns early on most inputs (checksum, magic header) stalls coverage; bypass checksums under a fuzzing cfg flag or compute them in the harness.
  • Global caches or singletons inside the target make crashes depend on earlier inputs; reset state per iteration or the reproducer will not reproduce.
  • assert-style invariants must be cheap; an O(n^2) check turns into timeouts.
  • Go: fuzz tests run their seed corpus under plain go test; a slow target slows the unit suite too.

Verification

  1. Plant a bug (e.g. off-by-one on a length field) in a scratch branch: the fuzzer must find it within minutes from the seed corpus. If not, improve seeds/dictionary.
  2. Every crash reproduces from its single file on a clean build.
  3. After the fix, the reproducer passes and a fresh run of the same duration finds no new crash in that code.
  4. Regression inputs run in the normal test suite (go test, cargo test, mvn test).

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/crash-triage.md 93 lines · 4.5 KB
# Fuzzer crash triage

Work one crash at a time, in this order. Never fix from the raw (unminimized) input.

## 1. Reproduce on a clean build

```bash
go test -run='FuzzX/<hash>' ./pkg/                       # Go
cargo +nightly fuzz run target fuzz/artifacts/target/crash-<hash>   # Rust
python fuzz_x.py crash-<hash>                            # Atheris
./fuzz_x crash-<hash>                                    # libFuzzer binary
mvn test -Dtest=XFuzzTest            # Jazzer: regression replay (JAZZER_FUZZ unset)
```

Does not reproduce → the target is nondeterministic (global state, time, randomness,
threads, uninitialized memory). Fix the harness first; rerun with `-runs=100` on the
single input to see if it is intermittent. MSan (C/C++) finds uninitialized reads that
cause this.

## 2. Minimize

```bash
cargo +nightly fuzz tmin target crash-<hash>
./fuzz_x -minimize_crash=1 -runs=100000 -exact_artifact_path=min.bin crash-<hash>
```

Go minimizes automatically when it records the failure. Read the minimized input in hex
(`xxd min.bin | head`) — it usually points straight at the offending field.

## 3. Classify

| Report | Meaning | Typical cause | Severity |
|---|---|---|---|
| ASan `heap-buffer-overflow` (READ) | Out-of-bounds read | Unchecked length field, off-by-one | High (info leak) |
| ASan `heap-buffer-overflow` (WRITE), `stack-buffer-overflow` | Memory corruption | Copy without bound | Critical |
| ASan `heap-use-after-free`, `double-free` | Lifetime bug | Freed on error path, then reused | Critical |
| UBSan `signed-integer-overflow`, `shift-exponent` | Undefined behavior | Arithmetic on sizes | Medium; can become overflow |
| `SEGV on unknown address 0x000...` | Null dereference | Missing error check | Medium (DoS) |
| `timeout` / `slow-unit` | Input takes too long | Quadratic loop, backtracking regex, unbounded recursion | Medium (DoS) |
| `out-of-memory` (`-rss_limit_mb`) | Allocation from untrusted size | `Vec::with_capacity(len_from_input)` | Medium (DoS) |
| Rust `panicked at` (index, unwrap, overflow) | Panic on bad input | `unwrap()` on parse result, slice index | Medium in services, low in CLIs |
| Go `panic: runtime error: index out of range` | Same as above | Slice without bounds check | Medium |
| `t.Fatalf` / assertion from your invariant | Logic bug | Round-trip mismatch, wrong canonicalization | Depends on data impact |
| Python uncaught non-documented exception | API contract bug | `KeyError`, `IndexError`, `RecursionError` leaking out | Low to medium |
| Jazzer `FuzzerSecurityIssue*` | Injection-style finding | Deserialization, path traversal, SSRF | High; verify reachability |

Stack overflow from deep recursion on nested input (JSON arrays, XML) is a DoS: add a
depth limit, not a bigger stack.

## 4. Deduplicate

Many crash files share one root cause. Group by the top 3–5 frames of the symbolized stack
inside your code (ignore allocator, sanitizer and libc frames). libFuzzer/ClusterFuzzLite
do this automatically; for a pile of local artifacts:

```bash
for f in fuzz/artifacts/target/crash-*; do
  cargo +nightly fuzz run target "$f" 2>&1 | grep -m3 -E ' in mycrate::' | sha1sum | cut -c1-8
done | sort | uniq -c
```

Fix the most frequent group first, then re-run all artifacts; many disappear together.

## 5. Fix at the right layer

- Validate the length/count field against the remaining buffer before use.
- Bound allocations by a configured maximum, not by a size read from the input.
- Replace `unwrap`/unchecked indexing on parsed data with error returns.
- Add explicit recursion depth and total-size limits to parsers.
- For timeouts, fix the algorithm (linear scan, memoization) rather than raising limits.

Do not "fix" by catching the panic/exception at the top of the harness. That hides the
bug from the fuzzer while production still crashes.

## 6. Lock it in

1. Add the minimized input to the regression corpus (see SKILL.md per language).
2. Re-run the full regression set and the unit suite.
3. Fuzz the same target again for at least the duration that originally found the crash.
4. If the crash is a memory-safety issue in code that ships to users, treat it as a
   security bug: private tracker, fix, then disclosure per the project's policy.

## Triage note template

```markdown
### Fuzz finding: <target> — <sanitizer/report type>
- Reproducer: <path to minimized input> (<n> bytes)
- Stack (top frames): <fn a> <- <fn b> <- <fn c>
- Root cause: <one sentence>
- Impact: <DoS / info leak / memory corruption / logic>, reachable from <entry point>
- Fix: <change>, regression test <path>
- Duplicates closed: <count or list>
```

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc