If a finding is confirmed, hand the fix to remediate-web-vulnerabilities; this skill finds and reproduces, it does not patch app logic.
-
Pick the right tool for the target — do not write a fuzzer by hand. Coverage-guided engines mutate toward new code paths; random byte-spray finds nothing. Match the language:
| Target | Engine | Harness entry | Sanitizers |
|---|
| C/C++ | libFuzzer (clang -fsanitize=fuzzer) | LLVMFuzzerTestOneInput(const uint8_t*, size_t) | ASan + UBSan (+ MSan separately) |
| Rust | cargo-fuzz (libFuzzer under the hood) | fuzz_target!(|data: &[u8]| { ... }) | ASan on by default |
| Go | native go test -fuzz | func FuzzX(f *testing.F) + f.Fuzz(...) | race + built-in checks |
| Python | atheris (libFuzzer bindings) | atheris.Setup + TestOneInput(data) | native-ext ASan optional |
| JS/TS | Jazzer.js | module.exports.fuzz = (data) => {...} | n/a (catches throws) |
| Out-of-process C binary | AFL++ (afl-fuzz -i in -o out) | feed stdin/file | persistent mode + cmplog |
Default to the in-process libFuzzer-family engine for the language; reach for AFL++ only when you can't instrument the target (closed binary, weird build).
-
Fuzz the smallest deterministic boundary, structure-aware. Target one pure bytes → parsed value function — the deserializer, the codec/protocol decode, the template/expression parser — not the whole HTTP handler. Make it deterministic (no clock/network/RNG/global state). For structured formats, decode the byte buffer into typed inputs with arbitrary (Rust) / FuzzedDataProvider (C++/atheris) so mutations stay valid-ish and reach deep logic instead of dying at the length check. Rust example:
#![no_main]
use libfuzzer_sys::fuzz_target;
use arbitrary::Arbitrary;
#[derive(Arbitrary, Debug)]
struct Input { name: String, depth: u8, body: Vec<u8> }
fuzz_target!(|inp: Input| {
let _ = my_parser::parse(&inp.name, inp.depth, &inp.body);
});
-
Seed the corpus and add a dictionary — this multiplies coverage. Drop real, valid sample files into corpus/<target>/ (one input per file). Add a .dict of format tokens/magic bytes ("PDF", "\xFF\xD8", keywords) and pass -dict=tokens.dict. Without seeds the fuzzer wastes hours rediscovering the file header. Keep the corpus in the repo so CI starts warm.
-
Run, then minimize every crash before committing it. Run locally first (cargo fuzz run target -- -max_total_time=300 / go test -fuzz=FuzzX -fuzztime=5m). On a crash, minimize the input (cargo fuzz tmin, libFuzzer -minimize_crash=1 -runs=100000, AFL++ afl-tmin) so the repro is small and the root cause is obvious. Add -rss_limit_mb, -timeout=, and -max_len= so OOMs and hangs are reported as findings, not killed silently.
-
Commit each crash as a regression seed — this is the deliverable. Copy the minimized input to corpus/<target>/crash-<hash> (or Go's testdata/fuzz/FuzzX/). It now re-runs on every fuzz invocation, so the bug can't silently return. This is what turns a one-off crash into a permanent test. Open a finding (step 7) linking the seed.
-
For a running app, run DAST against staging — never prod. Stand up a disposable staging instance with seeded test data, then:
- nuclei for known-CVE/misconfig templates:
nuclei -u https://staging.app -severity medium,high,critical -rl 50.
- ZAP for app-aware crawling: baseline first, then authenticated active scan. Authenticate (ZAP context + auth script, or pass a session cookie/Bearer) so the scanner reaches logged-in routes — an unauthenticated scan misses ~80% of the surface.
- Baseline-suppress accepted findings instead of muting the whole rule: keep a
zap-baseline.conf / nuclei exclude list of triaged-and-accepted IDs so the gate only fails on new findings. Tune -rl/throttle so the active scan doesn't DoS staging.
-
Triage every finding: reproduce → severity → dedupe → file. Re-run the exact input/request to confirm it's real (drop scanner false positives — reflected param that's actually encoded, "missing header" on an internal-only route). Rate by realistic impact (RCE/memory-corruption/authn-bypass = Critical; reflected-but-encoded = noise). Dedupe by crash stack / vuln class, not by input bytes — 500 inputs hitting one parse() panic are one bug. File with the minimized repro and the committed seed path.
-
Wire into CI in two tiers + a DAST stage. Cheap on every PR, deep on a schedule:
pr-fuzz:
run: cargo fuzz run parser -- -runs=0 corpus/parser
nightly-fuzz:
run: cargo fuzz run parser -- -max_total_time=3600 -timeout=10 -rss_limit_mb=2048
dast:
run: nuclei -u $STAGING_URL -severity high,critical -ed <accepted.txt> -ni
PR job replays the corpus (deterministic, fast, catches regressions); nightly does the expensive mutation. Never put an unbounded mutation run on the PR critical path.
Done = the engine provably mutates toward new coverage, a planted bug or known-CVE pattern is caught and minimized, every crash is committed as a regression seed that fails-then-passes across the fix, and DAST runs authenticated against staging with scoped baseline suppression and a two-tier CI wiring.