A minimal, target-agnostic kit for running AFL++ fuzz rigs with the same conventions across many targets, driven from either the terminal or an AI coding agent (Claude Code / Codex).
Each target gets three concurrent AFL++ workers (fast + ASAN + explore),
a CMPLOG companion, a crash-triage loop that deduplicates by ASAN stack
hash, a workflow state machine for crashes, and a shared 5-minute
watchdog that respawns anything that dies. Two commands — check-in
("are we finding bugs?") and rig-check ("is the box healthy?") — tell
you everything you need in under a second.
fuzzah assumes a strict split between control-plane files and runtime state:
<control-root>/
fuzzah/ # reusable toolkit repo
shared/
target-template/ # blank per-target script pack
examples/ # filled-in illustrations of target-template
.agents/skills/
.claude/{commands,skills}/
<target>-setup/ # host-side, versioned target setup
SETUP.md
scripts/
$HOME/fuzzig-shared/ # installed shared scripts on the fuzz host
check-in.sh
rig-check.sh
fuzz-watchdog.sh
$HOME/fuzzing/ # live runtime state on the fuzz host
tools/AFLplusplus/
targets/<target>/
src/
build-afl/
build-afl-asan/
build-afl-cmplog/
seeds/
findings/
crashes-triaged/
scripts/
SETUP.md
The helpers under shared/ assume that shape. If you keep fuzzah/ nested
inside a larger control-plane repo, set FUZZAH_CONTROL_ROOT or rely on the
current parent-directory auto-detection.
Yes, if you want long-lived fuzz rigs that survive OOMs, reboots, and operator absences, across multiple targets, with deduplicated crashes organized for review.
No, if you want a one-shot afl-fuzz run on a single target — just
use AFL++ directly.
One picture: you drive the rig three ways, it runs on one host, and every target is a self-contained loop that finds crashes and files them away for you.
flowchart TB
subgraph drive["Drive the rig 3 ways"]
direction LR
cli["Terminal<br/>check-in · rig-check"]
agent["Claude Code / Codex<br/>/fuzz-status · /fuzz-review"]
web["Browser<br/>fuhq dashboard"]
end
subgraph host["Fuzz host — a Linux box, or a VM on your Mac"]
direction TB
subgraph rig["One self-contained rig per target · ~/fuzzing/targets/<name>"]
direction LR
w["3 AFL++ workers<br/>primary +cmplog · asan · explore"]
t["Triage loop<br/>dedupe by ASAN stack hash"]
c["crashes-triaged/<hash><br/>meta · trace · poc"]
w --> t --> c
end
wd["fuzz-watchdog.timer<br/>relaunches any dead worker every 5 min"]
wd -. respawn .-> w
end
drive --> host
- Drive it 3 ways — the same rig is reachable from the terminal, from an AI agent (Claude Code / Codex), or from the fuhq browser dashboard.
- One rig per target — three AFL++ workers feed a triage loop that
deduplicates crashes by ASAN stack hash and drops each unique one into
crashes-triaged/<hash>/. - Self-healing — a 5-minute watchdog relaunches any worker that dies (OOM, reboot, …), so the rig survives being left alone for weeks.
The on-disk layout under ~/fuzzing/ is the directory tree shown in
Expected layout above.
Two paths. Pick the one that matches your host.
Prereq: Orbstack installed (brew install --cask orbstack).
# 1. Clone. You can clone anywhere, but ~/fuzzig/fuzzah matches the
# operator layout used by the local docs and agent instructions.
mkdir -p ~/fuzzig
cd ~/fuzzig
git clone https://github.com/sandbornm/fuzzah.git
cd fuzzah
# 2. Create/reuse the OrbStack VM, install packages, build AFL++, install
# shared scripts, enable the watchdog, and run smoke checks.
bash shared/setup-macos-orb.sh
# 3. Health check.
bash shared/rig-check.shDone. rig-check will report "no targets yet" until you add one (next section).
For a brand-new node where you have not cloned the repo yet, the setup script can bootstrap the checkout first:
curl -fsSL https://raw.githubusercontent.com/sandbornm/fuzzah/main/shared/setup-macos-orb.sh \
-o /tmp/setup-macos-orb.sh
bash /tmp/setup-macos-orb.shThat clones fuzzah into ~/fuzzig/fuzzah by default, then re-execs the repo
copy of the script.
The script defaults to a VM named fuzzer, Ubuntu 24.04, 10 CPUs, 8 GB RAM,
and a 256 GB disk cap. Override these with flags:
bash shared/setup-macos-orb.sh \
--vm-name fuzzer \
--distro ubuntu:24.04 \
--cpus 10 \
--memory 8G \
--disk 256G \
--user "$USER"If you create the VM manually, use OrbStack's plural flag names:
orb create --cpus 10 --memory 8G --disk 256G --user "$USER" ubuntu:24.04 fuzzerFor any command that should run on the fuzz host, prefer:
bash shared/run-on-fuzz-host.sh '<command that should run on the fuzz host>'It auto-detects direct Linux vs Orb-on-macOS and makes sure $HOME expands on
the fuzz host.
If OrbStack is present but unhealthy, this wrapper now prints a diagnostic hint instead of silently assuming the proxy path works. For a focused host-side debug snapshot, run:
bash shared/orb-debug.shIf you keep target setup dirs outside the fuzzah/ repo itself, set
FUZZAH_CONTROL_ROOT=/path/to/control-plane. If fuzzah/ is nested under a
larger control-plane repo that has AGENTS.md / CLAUDE.md at the parent
level, the helpers auto-detect that parent as the control root.
SSH into the host, then:
git clone https://github.com/sandbornm/fuzzah.git && cd fuzzah
# Install AFL++ and deps
sudo apt-get update && sudo apt-get install -y \
build-essential clang llvm lld cmake git python3 python3-dev \
automake libtool pkg-config libglib2.0-dev bison flex gdb jq \
nftables tmux
mkdir -p $HOME/fuzzing/tools && cd $HOME/fuzzing/tools
git clone --depth 1 https://github.com/AFLplusplus/AFLplusplus.git
cd AFLplusplus && make distrib -j$(nproc)
cd -
# Install shared infrastructure
mkdir -p $HOME/fuzzig-shared $HOME/fuzzing/logs $HOME/.config/systemd/user
cp shared/{check-in,rig-check,fuzz-watchdog}.sh $HOME/fuzzig-shared/
chmod +x $HOME/fuzzig-shared/*.sh
cp shared/fuzz-watchdog.{service,timer} $HOME/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now fuzz-watchdog.timer
# Smoke test
bash shared/rig-check.shApple's container project is relevant as a possible future Linux execution
backend on Apple silicon, but it is not a replacement for this rig today.
It runs Linux containers as lightweight VMs on macOS and consumes/produces
OCI images. That makes it potentially useful for reproducible build/test
environments or short-lived isolated fuzz jobs.
It is not a way to fuzz macOS frameworks such as ImageIO inside a Darwin container. Those jobs need to run on the macOS host or a real macOS guest VM. For the existing AFL++ lane, keep OrbStack as the supported backend until there is a concrete reason to add a second Linux backend and maintain the extra host-routing surface.
- CPU: 3 cores per target (fast + asan + explore). 10 cores fits 3 targets with headroom.
- RAM: 8 GB minimum per target. ASAN shadow memory allocates virtual address space aggressively; 4 GB hosts OOM constantly.
- Disk: 5–15 GB per target (source + 3 build dirs + findings growth).
The fastest path: let an agent walk you through it.
- Claude Code: open this directory and say "add a fuzz target for
<name>". - Codex: same, or invoke
$fuzz-add-target.
The fuzz-add-target skill handles everything — copying the template,
wiring your build system, scraping seeds, and starting the rig.
Copy the per-target template and edit four files. Assume target name
mytool:
# Create a host-side editable setup dir.
bash shared/scaffold-target.sh mytool
# Sync that setup dir into the fuzz host.
bash shared/sync-target.sh mytoolBy default this creates:
<control-root>/mytool-setup/
SETUP.md
scripts/Now edit these files in the host-side mytool-setup/scripts/ dir
(every other script auto-derives the target name from its filesystem
location):
| file | what to set |
|---|---|
start-fuzz.sh |
HARNESS_SUBPATH (binary path inside each build dir), HARNESS_ARGS (@@ = input) |
build-afl-fast.sh |
Uncomment your build system (autoconf / cmake / meson), set SRC_GIT_URL; mirror into build-afl-asan.sh and build-afl-cmplog.sh |
fetch-seeds.sh |
Fill SOURCES=() with 2–5 seed repos: `label |
filter-seeds.sh |
Set VALID_MAGIC_HEX (e.g. "25504446" for PDF) and VALID_EXTENSIONS |
apt-packages.txt |
Optional extra Debian packages for this target's build (one per line) |
Then bootstrap and launch:
# One command for sync + seed prep + 3 builds + cmin + systemd start.
bash shared/bootstrap-target.sh mytool
# Wait ~30 s for calibration, then:
bash shared/check-in.sh
# mytool 3 <execs/s> 0 0 0 0 ← 3 fuzzers alive, zero crashes yetEvery step is idempotent; re-run the one that failed after fixing the edit.
Before you bootstrap, inspect the target summary:
bash shared/inspect-target.sh mytoolThis toolkit is intentionally opinionated around a baseline AFL++ workflow:
- fast build
- ASAN build
- CMPLOG build
- three long-lived workers (
primary,asan,explore) - dictionary support via
scripts/<target>.dict - seed scraping, filtering, and corpus minimization
- crash triage via ASAN/GDB-style post-processing
That baseline is what the current helpers, skills, watchdog, and review flow understand.
fuzzah does not currently automate grammar-aware or schema-aware target
types as a first-class workflow. In practice that means:
- no built-in custom mutator build/install flow
- no grammar/tree sidecar directory conventions
- no grammar-aware seed generation bootstrap
- no mutator-specific crash replay/triage assumptions
If you want to go there, use the current toolkit as the process shell and layer your mutator work in manually for now. Good starting points:
- AFL++ custom mutators: https://aflplus.plus/docs/custom_mutators/
- AFL++ Grammar-Mutator: https://github.com/AFLplusplus/Grammar-Mutator
- libprotobuf-mutator: https://github.com/google/libprotobuf-mutator
Two commands cover 90% of what you'll do:
bash shared/check-in.sh # fuzz domain: crashes, workflow state
bash shared/rig-check.sh # system domain: memory, OOM, systemd, diskThe fuzzers-alive count shown by check-in.sh is verified via fuzzer_pid +
kill -0 (not pgrep), so stale fuzzer_stats files left by a crashed worker
do not inflate the count.
Or from an agent session:
| task | Claude Code | Codex |
|---|---|---|
| check-in (all targets) | /check-in |
$check-in |
| system health | /rig-check |
$rig-check |
| one target's status | /fuzz-status mytool |
$fuzz-status mytool |
| list crashes | /fuzz-crashes mytool |
$fuzz-crashes mytool |
| triage one crash | /fuzz-review <hash> mytool |
$fuzz-review <hash> mytool |
| browser dashboard | /fuzz-dashboard |
bash shared/fuzz-dashboard/run.sh |
Every unique crash lands at ~/fuzzing/targets/<t>/crashes-triaged/<hash>/
with meta.json + trace.txt + poc.bin, and moves through states:
new → reviewed → repro-ok → reported (progress)
\→ dup (duplicate of another hash)
\→ ignore (false positive / noise)
Mark state:
bash shared/run-on-fuzz-host.sh \
'echo reviewed > "$HOME/fuzzing/targets/mytool/crashes-triaged/<hash>/.status"'The fuzz-crash-review skill (Claude + Codex) walks classification —
loads the trace, inspects source at the top frame, recommends action.
fuhq is a live web view of the whole rig — fuzzer health, per-target stats,
and per-crash drill-down with a report-aware priority score that tells you,
at a glance, which crashes are worth your time. It's Python stdlib only —
nothing to pip install.
1. Start it on the host that can reach your fuzz host (the Mac, or the Linux box itself):
bash shared/fuzz-dashboard/run.sh # foreground; Ctrl-C to stop
# from an agent session, just say: /fuzz-dashboardIt binds 127.0.0.1:8765 only — never exposed to the network.
2. Open it. On the same machine, browse to http://localhost:8765. From a different machine (e.g. the dashboard runs on a headless Linux box or your Mac mini), forward the port over SSH first, then browse to localhost:
ssh -L 8765:127.0.0.1:8765 <user>@<host-running-fuhq>
# then open http://localhost:8765That's the whole setup. Three pages:
| page | shows |
|---|---|
/ |
health banner, aggregate KPIs, target list |
/t/<target> |
per-worker stats + the crash table, filterable by status and priority (high / med / low / noise) |
/c/<target>/<hash> |
REPORT.md, POC.md, REPRO.md, optional REVIEW.md, NOTES.md, trace, meta.json, PoC hexdump + download |
The crash table sorts highest-priority-first, so the handful of crashes that
matter float to the top of a list that may be hundreds long. When REPORT.md
contains report_priority, that value drives the score and raw hits remain a
separate stability signal. Hover any priority tag to see why it scored that
way.
shared/crash-digest/ turns the dashboard into a phone-friendly email workflow.
The scheduled entry point is:
bash shared/crash-digest/send-digest.shIt runs a bounded raw-crash triage pass, promotes a small number of high-signal
clusters into reproducible REPORT.md/REPRO.md/POC.md artifacts, collects
live state, then sends a Resend email with direct dashboard links.
By default, the email top list is stricter than the dashboard: it requires
FUZZ_DIGEST_MIN_REPORT_PRIORITY=80 and FUZZ_DIGEST_ONLY_HIGH_VALUE=1, so
assertions, UBSan-only reports, JavaScript exceptions, and parser DoS stay
visible in the dashboard without being flagged for manual security triage.
Dry-run first:
bash shared/crash-digest/send-digest.sh --dry-runInstall on macOS with launchd jobs for both the dashboard and six-hour digest:
bash shared/crash-digest/install-macos.sh --dry-run
bash shared/crash-digest/install-macos.sh --tailscale-serveThe email intentionally links to crash pages instead of attaching raw PoCs. This keeps mail delivery predictable and preserves the highest-fidelity view: summary, reproducer code, hexdump/download, trace, metadata, and status controls. The HTML email uses a light high-contrast palette because mobile mail clients often rewrite dark-mode colors.
With --tailscale-serve, the dashboard is tailnet-only and read-only. It
proxies to 127.0.0.1:8765 via Tailscale Serve; do not enable Tailscale Funnel
for crash reports/PoCs.
On macOS, an OrbStack control-plane failure does not usually mean your fuzz state is gone. The backing disk image persists separately from the CLI state.
- Orb-backed commands fail but
vmgr.logstill showscontainer started: the backend likely booted, but the client/proxy path is wedged. orbctl statussaysStoppedwhile the helper is alive: treatorbctl statusas advisory only and probe with a realorb -m ...command before assuming the VM is absent.proxy dialer did not pass back a connectionormm_receive_fd: OrbStack is stuck in the proxy handoff path. Fully quit and relaunch the app; if it persists, reboot macOS.- very large
OrbStack Helper vmgrRSS on the Mac host: the helper is likely wedged; quit and relaunch OrbStack before trusting any status output.
Useful commands:
bash shared/orb-debug.sh
bash shared/rig-check.shOn macOS, the persisted OrbStack disk image is usually under:
$HOME/Library/Group Containers/*.dev.orbstack/data/data.img.rawThat image is where your ~/fuzzing/targets/*/{findings,crashes-triaged} data
lives, so access failures are usually a control-plane problem rather than a
data-loss event.
Safe to commit/push from a target setup dir like <control-root>/poppler-setup/
(or one of the illustration examples under fuzzah/examples/, e.g.
fuzzah/examples/jq/):
SETUP.mdscripts/*.shscripts/*.dictscripts/*-fuzz.servicescripts/apt-packages.txt
Do not commit/push runtime artifacts from the fuzz host:
src/build-afl/,build-afl-asan/,build-afl-cmplog/- large generated seed corpora
findings/crashes-triaged/
Three workers per target. Standard AFL++ multi-core pattern: primary
accumulates coverage-enriched entries; asan runs the ASAN build so
crashes come with stack traces without taxing the master's execs/s;
explore uses the broader power schedule to prioritize novel paths.
CMPLOG on primary. CMPLOG logs comparison operands at runtime and feeds them back to the mutator — the difference between "random bytes" and "the exact magic sentinel the parser expects" for format-heavy targets. Often worth 10× in coverage growth.
-m 1024 on fast workers, -m none on ASAN. AFL++ applies -m
as RLIMIT_AS on the target child. ASAN pre-allocates tebibytes of
virtual address space for shadow memory, so any finite limit kills it
at startup. Fast workers get 1 GB — high enough for normal decodes,
low enough that six concurrent children can't OOM an 8 GB host.
Watchdog at 5 minutes. systemd's Restart=on-failure handles
service-level crashes. The watchdog covers the sub-service case where
one afl-fuzz worker dies and its siblings keep running — without it,
a dead worker silently drops a third of throughput.
- Seeds — the biggest lever. 20 curated seeds beat 2000 random ones.
Favor diverse samples. Re-run
min-corpus.shafter adding any. - Dict files — drop one at
scripts/<target>.dictin the target setup (preferred, self-contained) or at$HOME/fuzzing/tools/AFLplusplus/dictionaries/<target>.dict.start-fuzz.shnow prefers the target-local file and falls back to AFL++'s shared dictionaries. AFL++ ships dicts for pdf, json, xml, png, and more. - Persistent-mode harness — 10–100× execs/s on fork-heavy targets. Worth it if you're stuck below 1k execs/s on a fast machine.
- Timeout —
start-fuzz.shuses 3000 ms (fast) / 5000 ms (ASAN). Raise for legitimate slow inputs; lower if the queue fills with pathological long-runners.
Watchdog keeps respawning workers. OOM flap. Run rig-check and
read the dmesg section. ASAN children that balloon to 6+ GB are normal
(watchdog handles it). Fast-build children over 1 GB are not — inspect
with pgrep -af afl-fuzz.
dmesg shows OOM kills at total-vm ≈ 38 TB. That's ASAN shadow
memory. The RSS number tells you the real pressure. Sporadic: expected.
More than 10/hour: investigate.
check-in shows execs/s < 200 for a target. Either calibration
(wait a minute), a slow harness (you may have picked a binary that
renders when you wanted a parse), or pathological inputs in the queue
(check hangs/).
Memlimit-kill entries cluttering new state. triage-one.sh
auto-tags new ones as ignore. For old pre-fix entries, bulk-sweep:
cd ~/fuzzing/targets/<t>/crashes-triaged
for d in */; do
tf=$(python3 -c "import json;print(json.load(open('$d/meta.json')).get('top_frame',''))" 2>/dev/null)
[[ "$tf" != "no-frames"* ]] && continue
asan=$(python3 -c "import json;print(json.load(open('$d/meta.json')).get('fuzzers',{}).get('asan',0))" 2>/dev/null)
[[ "$asan" == "0" ]] && echo ignore > "$d/.status"
doneWithout orb? Use any Linux host. shared/*.sh auto-detects — if
$HOME/fuzzing/targets/ exists locally, it runs commands directly
instead of via orb.
Without Claude or Codex? Every script in shared/ and
target-template/ is pure bash. Agents just wrap them with summaries.
Run the rig entirely from the CLI if you prefer.
- No default CVE scrape. Seed sources are target-specific; wire
your own in
fetch-seeds.sh. - No libfuzzer / honggfuzz / libafl integration. AFL++ only by design.
- No coverage visualization.
afl-covexists; adding it would balloon the kit's scope. - No email or Slack alerts. Crashes land in
crashes-triaged/<hash>/and inINDEX.md— wire your own alerting on top. - No upstream bug-report templates. Write your own in each target's
SETUP.mdonce you land a real bug.
Personal fuzzing rig, shared as-is. AFL++ itself is Apache-2.0 (see upstream). This kit contains no copyleft code; do what you want with it.