Continuous fuzzing is not a self-maintaining process. Projects enrolled in OSS-Fuzz for years still hide critical bugs, and the reason is consistent: someone has to watch coverage, write harnesses for code nobody reaches, and triage the crashes that surface. Fuzzing keeps a human in the loop.

The Fuzzing Taskflow is an autonomous fuzzing pipeline for C/C++ projects that asks how much of that human work can be handed to an LLM agent. Point it at a GitHub repository and it identifies entrypoints, analyzes the build system, writes harnesses, runs AFL++, reads coverage reports, improves the harnesses, triages every crash, and writes a vulnerability report for each unique bug. It is built on the GitHub Security Lab Taskflow Agent, the framework for LLM-driven security automation, so the pipeline is expressed as a set of taskflows run end to end.

Running a campaign

The quickest path is to open https://github.com/GitHubSecurityLab/seclab-taskflows-fuzzing and start a codespace, then invoke the driver script:

./scripts/fuzzing/run_fuzzing.sh PROJECT

For example:

./scripts/fuzzing/run_fuzzing.sh tukaani-project/xz

The argument is a GitHub owner/repo slug. From there the agent handles the preliminary steps itself: installing software such as AFL, cloning the repository, identifying the most relevant functions, and creating fuzz targets for them. A small target works well as a smoke test before a long campaign:

./scripts/fuzzing/run_fuzzing.sh DaveGamble/cJSON

The taskflow runs afl-fuzz, clang, and arbitrary build commands chosen by the LLM directly on the host, with no container in between. A prompt-injected agent could in principle do anything the invoking user can, so run it only inside a disposable environment such as a Codespace or throwaway VM, without elevated privileges.

Screenshot of Seclab Taskflows Fuzzing.

Choosing a model

Some frontier models impose security guardrails on their outputs. The fuzzing taskflow uses Claude Sonnet 5 by default because it passed all internal tests without issues. A different model can be selected by editing src/seclab_taskflows_fuzzing/configs/model_config.yaml.

Architecture and division of labor

Three layers make up the pipeline: a shell driver (run_fuzzing.sh) that chains the stages together; a set of taskflow YAMLs, one per stage, which serve as the prompts telling the agent what to do at each step; and a set of MCP tools the agent calls to do the work — run AFL, compile a harness, store a crash, read a coverage report.

The governing design rule is a clean separation of responsibility: the LLM agent owns the decisions, the MCP tools own the execution. The agent decides what to fuzz, what harness to write, and which coverage gap to chase. The tools expose primitives such as run_afl_for or compile_harness; the agent never calls AFL or clang directly, composing the pipeline out of these building blocks instead. All state lives in a SQLite database (fuzz_context.db), so stages hand data to each other only through the database, never in memory.

One important detail: every harness is built twice. AFL's edge instrumentation guides the fuzzer well but is useless for human-readable coverage reports, so each harness becomes both a .afl binary (built with afl-clang-lto -fsanitize=address,undefined) and a .cov binary (built with clang -fprofile-instr-generate -fcoverage-mapping). The .afl binary does the fuzzing; the .cov binary replays AFL's queue to produce real source-line and branch coverage.

Closing the coverage loop

Improving fuzzing coverage by hand is an iterative process:

Three bubbles that say: Run the fuzzers, Check the coverage, and Improve the fuzzers. They are connected by three arrows in a circular flow.

Reading an LCOV report to find uncovered branches, then writing a new harness or crafting a new input, now falls to the agent. Each iteration, for each harness, the agent runs AFL for a time budget, replays the queue against the .cov binary for a real coverage report, and reads the list of uncovered branches. It then picks one of several actions:

  • Add a new seed crafted to reach an uncovered branch
  • Edit the harness source to call an additional API
  • Auto-enrich the AFL dictionary with the magic constants a guard compares against
  • Skip the gap if it is a cold error path or vendor code not worth chasing

Time budgets double every iteration: 30s → 60s → 120s → 240s → 480s → 960s (≈ 32 min/target). Cheap short rounds come early when low-hanging coverage is plentiful; longer rounds come later, when the fuzzer needs more time to break a hard guard.

Termination uses plateau detection: once two consecutive iterations each gain less than 1% absolute line coverage (a configurable threshold), the loop treats further effort as diminishing returns and moves on, rather than burning hours squeezing out the last fraction of a percent.

Structure-aware input generation

AFL's default byte-level mutators — bit flips, arithmetic, block splicing — handle binary formats well but struggle with structured text inputs. Rather than hand-writing custom mutators per format, the pipeline ships four complementary mechanisms.

Per-format dictionaries and custom mutators. For recognized input formats (JSON, XML, regex, PNG, length-prefixed binary TLV), the taskflow ships pre-built AFL dictionaries and LLVMFuzzerCustomMutator C files. The JSON mutator does token splicing and balanced-bracket duplication; the XML mutator knows tags, entities, and billion-laughs tokens; the regex one carries real ReDoS patterns. Each mutator delegates half of its mutations back to AFL's default byte mutator, preserving the engine's randomization.

Source-level dictionary. For unrecognized formats, the pipeline generates a custom mutator by scanning the target's own .c/.h files for string literals and 32-bit numeric constants (from #define, case, and enum), filtering noise and using the survivors as splice tokens. Parsers tend to check magic values written down somewhere in their own source.

Coverage-driven AFL dictionary enrichment. The same token set is emitted as a classic AFL dictionary before iteration 1, with numeric constants in both endiannesses so a 4-byte magic can be satisfied by a memcmp regardless of host byte order. After every coverage step, the pipeline inspects guards near uncovered lines (strncmp, memcmp, case 0xN, == 'X') and appends new tokens. The dictionary grows toward the code the fuzzer cannot yet reach.

Corpus-splice operator. The smart mutator can load files from a corpus directory and splice random sub-regions into the input — a recombination-style operator that AFL's stock havoc does not handle well.

Persistent corpora

Discarding progress quietly degrades efficiency: starting from the original seeds each run means re-paying the cost of rediscovering the same paths. Every harness therefore gets a stable corpus directory that survives across iterations and across entire campaigns:

<workspace>/corpus/harness_<id>/

At the end of each iteration, AFL's queue is merged into that directory and run through afl-cmin to bound its size. Inputs from earlier runs carry into later ones and across campaigns; stopping and restarting a campaign loses nothing.

Crash triage and reporting

Finding a crash is half the job; triage is often the most tedious part, and it is the second place the agent does heavy lifting. After the fuzzing loop finishes, three stages run automatically. Every crash is minimized with afl-tmin, replayed under ASan to capture a stack trace, and deduplicated by a stack-top hash — the top normalized frames with templates, inline namespaces, and LTO suffixes stripped, so semantically identical crashes collapse together. Previously known crashes are replayed against the current binary to see whether an upstream fix resolved them. The agent then reads the harness source and the crashing function, walks the call chain back from the public API, and writes a per-crash markdown report.

Each report assigns one verdict:

  • vulnerability
  • library_hardening
  • harness_bug
  • OOM
  • timeout
  • assertion_failure
  • duplicate

Separating a real vulnerability (reachable and exploitable through a public API) from a mere harness_bug is exactly the judgment call that otherwise requires tracing the code by hand. Reports include a root-cause analysis with file:line references, a reachability argument, an exploitability assessment, a suggested fix as a unified diff, and a regression-test sketch.

Suggested patches are marked "review required" for a reason. The agent's analysis is bounded by the model's understanding of the target code, and it does get things wrong. Treat the verdicts as a well-prepared starting point for a human, not a final result.

Live dashboard

Because watching an autonomous campaign blindly is uncomfortable, the pipeline publishes everything to a live HTML dashboard. It auto-starts in the background when a campaign launches, on port 8765. In a Codespace that port is auto-forwarded, so it can be opened in any browser.

Screenshot of the fuzzing dashboard.

The page shows a per-harness "running" pulse, a coverage-trend table with inline sparklines, a crash heatmap, and an iteration timeline.

Wrapping up

Fuzzing works but does not scale without human attention, and that attention is the bottleneck. The Fuzzing Taskflow hands the repetitive parts — writing harnesses, reading coverage, chasing gaps, triaging crashes — to an LLM agent while keeping the agent's judgment separate from the tools that do the real work. Maintainers of C/C++ projects can try it: projects never fuzzed before get a quick start, and projects already fuzzed may surface new bugs through increased coverage. The source is open source, so bugs can be filed as issues and contributions are welcome.