如果你是模糊测试的新手,想先学习基础知识,请查看我们的 Fuzzing 101 课程:gh.io/fuzzing101 。
持续模糊测试并不是能解决你所有问题的魔法方案。即便是已经在 OSS-Fuzz 中登记多年的项目,仍然可能隐藏着严重缺陷,而原因几乎总是相同的:需要有人持续关注覆盖率,为那些无人触及的代码编写新的测试桩,并对另一端产生的崩溃进行分诊。换句话说,模糊测试仍然需要人在环路中。
所以我不断问自己的一个自然问题是:这些人工工作中,究竟有多少可以真正交给 LLM 智能体来完成?
正是这个问题促使我构建了 Fuzzing Taskflow,一个面向 C/C++ 项目的自主模糊测试流水线。你只需要把它指向一个 GitHub 仓库,剩下的它都会完成:识别合适的入口点、分析构建系统、编写测试桩、运行 AFL++、读取覆盖率报告、改进测试桩、对每个崩溃进行分诊,并为每个唯一缺陷撰写漏洞报告,全程无需人工照看。
Fuzzing Taskflow 构建在 GitHub Security Lab Taskflow Agent 之上,这是我们用于编写 LLM 驱动的安全自动化的框架,因此该流水线被表达为一组由智能体端到端运行的任务流。
在这篇文章中,我将带你了解它的工作原理以及背后的设计决策。让我们开始吧!
如何运行它
最简单的运行方式就是前往 https://github.com/GitHubSecurityLab/seclab-taskflows-fuzzing 并启动一个 codespace。
然后,像这样运行脚本:
./scripts/fuzzing/run_fuzzing.sh PROJECT 例如:
./scripts/fuzzing/run_fuzzing.sh tukaani-project/xz 就是这样。参数只是一个 GitHub owner/repo slug。接下来,智能体会自行处理所有前期步骤:
- 安装 AFL 等软件
- 克隆代码仓库
- 识别代码中最相关的函数
- 为这些函数创建模糊测试目标
如果你只想在投入长时间测试活动之前做个快速冒烟测试,就把它指向一个小项目:
./scripts/fuzzing/run_fuzzing.sh DaveGamble/cJSON 运行之前有一点提醒:这个 taskflow 会直接在宿主机上运行 afl-fuzz、clang 以及 由 LLM 选定的任意构建命令,中间没有容器隔离。一个被提示词注入的智能体原则上可以做你的用户能做的任何事情。所以请只在一次性环境(例如 Codespace 或一次性虚拟机)中运行它,且不要使用提权权限。

模型选择
一些前沿模型会对其输出施加安全护栏。对于模糊测试任务流程,我们默认使用 Claude Sonnet 5,因为它在我们的所有内部测试中都顺利通过。你可以通过修改以下文件来选择其他模型:src/seclab_taskflows_fuzzing/configs/model_config.yaml。
一分钟了解架构
在进入有趣的部分之前,先了解各部分如何组合在一起会有所帮助。共有三层:
- 一个 shell 驱动脚本(run_fuzzing.sh),将各个流水线阶段串联在一起。
- 一组 taskflow YAML,每个阶段一个,它们本质上就是告诉 LLM 智能体在每个步骤该做什么的提示词。
- 一组 MCP 工具,智能体调用它们来实际完成工作:运行 AFL、编译 harness、存储崩溃、读取覆盖率报告等等。
我最看重的设计原则是职责的清晰分离:LLM 智能体负责决策,MCP 工具负责执行。智能体决定要 fuzz 什么、要编写什么 harness、接下来要追查哪个覆盖率缺口。工具只暴露诸如 run_afl_for 或 compile_harness 这样的原语。智能体从不直接调用 AFL 或 clang;它用这些构建块组合出流水线。所有状态都存放在一个 SQLite 数据库(fuzz_context.db)中,因此各个阶段之间从不通过内存传递数据,只通过数据库传递。
一个微小但重要的细节:每个 harness 都会被构建两次。AFL 的边缘插桩非常适合引导 fuzzer,但对于人类可读的覆盖率报告毫无用处。因此每个 harness 都会变成两个二进制文件:一个 .afl 二进制(用 afl-clang-lto -fsanitize=address,undefined 构建)和一个 .cov 二进制(用 clang -fprofile-instr-generate -fcoverage-mapping 构建)。.afl 二进制负责执行 fuzzing;.cov 二进制随后回放 AFL 的队列,以生成真实的源码行覆盖率和分支覆盖率。
覆盖率反馈循环
这是整条流水线的核心,也是最直接地自动化了我开头所描述的那套手动工作流程的部分。
如果你曾经尝试过手动提升 fuzzing 覆盖率,你就会知道这是一个迭代过程,大致如下:

“检查覆盖率”这一步过去由我手动完成,人工阅读 LCOV 报告,寻找未覆盖的分支。“提升覆盖率”这一步同样由我完成,这次是编写新的测试框架或构造新的输入。而 Fuzzing Taskflow 把这两步都交给了智能体。
每一轮迭代中,对于每个测试框架,智能体会在给定的时间预算内运行 AFL,将队列针对 .cov 二进制文件重放以获得真实的覆盖率报告,然后读取未覆盖分支的列表。根据所发现的情况,它会从若干操作中选择一项:
- 添加一个为触达未覆盖分支而专门构造的新种子
- 编辑测试框架源码以调用额外的 API
- 用某个守卫正在比较的魔术常量自动丰富 AFL 字典
- 如果只是冷门的错误路径或不值得追查的厂商代码,就直接跳过这个缺口
时间预算每一轮迭代翻倍:
30s → 60s → 120s → 240s → 480s → 960s(约 32 分钟/目标)
其思路是早期进行廉价、短时间的轮次(此时有大量唾手可得的覆盖率可以获取),后期则进行更长的轮次(此时模糊测试器需要更多时间才能突破某个难以攻克的守卫)。
而正如我手动工作流中的做法,我需要回答这个问题:我们什么时候停下来?在这里,循环使用了平台期检测:一旦连续两次迭代各自的增益都低于一个可配置的阈值(默认是 1% 的绝对行覆盖率),循环就判定已经进入收益递减,随即继续推进。这能避免智能体耗费数小时算力去榨取最后那百分之零点几的覆盖率。
结构感知模糊测试
AFL 默认的字节级变异器(位翻转、算术运算、块拼接)在二进制格式上表现出色,但面对结构化的、基于文本的输入时就力不从心了。经典的解决方案是为每种格式手写自定义变异器,但这是一项繁琐的工作。这一次,我希望流水线替我完成这项工作,因此它内置了四种互补机制来生成结构感知的输入。
1. 按格式定制的字典与自定义变异器。对于输入格式可被识别的目标(JSON、XML、regex、PNG、带长度前缀的二进制 TLV),该 taskflow 内置了预构建的 AFL 字典和 LLVMFuzzerCustomMutator C 文件。JSON 变异器执行 token 拼接和括号配对复制;XML 变异器了解标签、实体以及 billion-laughs token;regex 变异器则携带真实的 ReDoS 模式。每个变异器都会把一半的变异操作交还给 AFL 的默认字节变异器,这样我们就保留了引擎的随机化,而不是与之对抗。
2. 源级字典。对于流水线无法识别的格式,它会通过扫描目标自身的 .c/.h 文件即时生成一个自定义变异器。它提取字符串字面量和 32 位数值常量(来自 #define、case 和 enum),过滤掉噪声,并将它们用作拼接 token。其直觉很简单:解析器所检查的最有意思的魔法值,通常就写在它自己的源代码中的某处。
3. 一个动态生成的、由覆盖率驱动增强的 AFL 字典。同一组源 token 集合也会在第 1 次迭代之前作为 AFL 经典字典输出(数值常量同时以两种字节序给出,这样无论主机字节序如何,模糊测试器都能满足针对 4 字节魔法值的 memcmp)。随后,在每一个覆盖率步骤之后,流水线会查看未覆盖行附近的守卫(strncmp、memcmp、case 0xN、== ‘X’),并追加它发现的任何新 token。这个字典确实会朝着模糊测试器尚无法触及的代码不断增长。
4. 一个语料拼接算子。这个智能变异器还可以从语料目录加载文件,并将其中随机的子区域拼接到输入中,这是一种重组风格的算子,而 AFL 自带的 havoc 在这方面做得并不好。
不断演进的语料库
悄悄扼杀模糊测试效率的一个因素,就是丢弃已有的进展。如果每次运行都从原始种子开始,你就要一遍又一遍地重新付出发现相同路径的代价。
为了避免这种情况,每个测试框架都会获得一个稳定的语料库目录,它能在多次迭代乃至整个测试活动期间持续存在:
<workspace>/corpus/harness_<id>/ 在每次迭代结束时,AFL 的队列会被合并到这个目录中,并通过afl-cmin运行,以使其规模保持有界。其效果是,昨天发现的有趣输入会延续到今天的运行中,而上周测试活动中发现的输入会延续到这一次。如果你停止并重新启动一次测试活动,你不会丢失任何东西。
分类与漏洞报告
发现崩溃只是工作的一半。任何做过根因分析的人都知道,分类往往是整个过程中最乏味的部分。而这正是智能体大放异彩的另一个地方。
模糊测试循环结束后,会自动运行三个阶段。首先,每个崩溃都会用 afl-tmin 进行最小化,在 ASan 下重放以捕获堆栈跟踪,并通过 栈顶哈希 进行去重(对顶部规范化帧做哈希,剥离模板、内联命名空间和 LTO 后缀,使语义相同的崩溃归并到一起)。其次,将此前已知的崩溃针对当前二进制重放,以确认上游修复是否已解决它们。第三,智能体读取测试框架源码和崩溃函数,从公共 API 回溯调用链,并撰写每个崩溃的 markdown 报告。
每份报告会给出以下判定之一:
- 漏洞
- 库加固
- 测试框架缺陷
- OOM
- 超时
- 断言失败
- 重复
真正的漏洞(可通过公开 API 触达并利用)与单纯的harness_bug(问题出在我们自己的 harness 中,而非库本身)之间的区分,正是那种过去需要我坐下来手工追踪代码才能做出的判断。每份报告都包含根因分析,附有文件:行号引用、可达性论证、可利用性评估、以统一 diff 形式给出的建议修复,以及回归测试草案。
在此澄清一下:建议的补丁被标记为“需要审查”是有原因的。智能体的分析受限于模型对目标代码的理解,它确实会出错。请把这些判定视为一份为人类精心准备的起点,而非最终结果。
实时仪表盘
运行一场自主行动却看不到它在做什么,这让人很不踏实,因此该流水线会把所有内容发布到一个实时 HTML 仪表盘上。一旦你启动一场行动,它就会在后台自动启动,监听端口8765。在 Codespace 中该端口会自动转发,因此你可以在任意浏览器中打开它,在仪表盘上实时观看行动进展。

该页面展示的内容包括:
- 每个 harness 的“运行中”脉冲指示
- 一张带内联迷你走势图的覆盖率趋势表
- 崩溃热力图
- 迭代时间线
结论
我启动这个项目,是出于任何安全研究员都熟知的那些局限:模糊测试确实有效,但若没有人工投入就无法规模化,而人工投入正是瓶颈所在。Fuzzing Taskflow 是我尝试将这一瓶颈向后推移的成果——把重复性的部分(编写测试框架、阅读覆盖率、追踪覆盖缺口、分类崩溃)交给 LLM 智能体,同时在智能体的判断与执行实际工作的工具之间保持清晰的分离。
如果你是 C/C++ 项目的维护者,那么请试一试。如果你的项目此前从未进行过模糊测试,那么这个工具将帮助你快速上手。或者如果你的项目曾经进行过模糊测试,那么这个工具或许能通过提升你的模糊测试覆盖率来帮助发现新的 bug。
源代码是开源的,所以如果你遇到任何 bug,请创建 issue。也欢迎贡献代码!
If you’re new to fuzzing and want to learn the fundamentals first, check out our Fuzzing 101 course at gh.io/fuzzing101.
Continuous fuzzing is not a magic solution that solves all your problems . Even projects that have been enrolled in OSS-Fuzz for years can still hide critical bugs, and the reason is almost always the same: someone needs to keep an eye on coverage, write new harnesses for the code that nobody is reaching, and triage the crashes that come out the other end. In other words, fuzzing still needs a human in the loop.
So the natural question I kept asking myself was: how much of that human work can we actually hand over to an LLM agent?
That is what led me to build the Fuzzing Taskflow, an autonomous fuzzing pipeline for C/C++ projects. You only need to point it at a GitHub repository, and it does the rest: it identifies the suitable entrypoints, analyzes the build system, writes the harnesses, runs AFL++, reads the coverage reports, improves the harnesses, triages every crash, and writes a vulnerability report for each unique bug, all without a human babysitting it.
The Fuzzing Taskflow is built on top of the GitHub Security Lab Taskflow Agent, our framework for writing LLM-driven security automation, so the pipeline is expressed as a set of taskflows that an agent runs end to end.
In this post, I’ll walk you through how it works and the design decisions behind it. Let’s get going!
How to run it
The simplest way to run it’s just to go to https://github.com/GitHubSecurityLab/seclab-taskflows-fuzzing and start a codespace.
Then, run the script like this:
./scripts/fuzzing/run_fuzzing.sh PROJECT So, for example:
./scripts/fuzzing/run_fuzzing.sh tukaani-project/xz That’s it. The argument is just a GitHub owner/repo slug. Then, the agent, takes care of all the preliminary steps on its own:
- Installing software such as AFL
- Cloning the repository
- Identifying the most relevant functions in the code
- Creating fuzz targets for those functions
If you just want a quick smoke test before committing to a long campaign, point it at something small:
./scripts/fuzzing/run_fuzzing.sh DaveGamble/cJSON A word of warning before you run it: this 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 your user can. So please run it only inside a disposable environment (e.g., a Codespace or a throwaway VM), without elevated privileges.

Model selection
Some frontier models impose security guardrails on their outputs. For the fuzzing task flow, we use Claude Sonnet 5 by default because it passed all of our internal tests without issues. You can choose a different model by modifying the following file: src/seclab_taskflows_fuzzing/configs/model_config.yaml.
The architecture in one minute
Before getting into the interesting parts, it helps to know how the pieces fit together. There are three layers:
- A shell driver (run_fuzzing.sh) that chains the pipeline stages together.
- A set of taskflow YAMLs, one per stage, which are essentially the prompts that tell the LLM agent what to do at each step.
- A set of MCP tools that the agent calls to actually do the work: run AFL, compile a harness, store a crash, read a coverage report, and so on.
The design rule I cared about most is a clean separation of responsibility: the LLM agent owns the decisions, and the MCP tools own the execution. The agent decides what to fuzz, what harness to write, and what coverage gap to chase next. The tools just expose primitives like run_afl_for or compile_harness. The agent never calls AFL or clang directly; it composes the pipeline out of these building blocks. All the state lives in a SQLite database (fuzz_context.db), so the stages never hand data to each other in memory, only through the database.
One small but important detail: each harness is built twice. AFL’s edge instrumentation is great for guiding the fuzzer but useless for human-readable coverage reports. So every 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 afterwards to produce real source-line and branch coverage.
The coverage-feedback loop
This is the heart of the whole pipeline, and it’s the part that most directly automates the manual workflow I described at the start.
If you have ever tried to improve fuzzing coverage by hand, you’ll know that it’s an iterative process that looks like this:

The “check the coverage” step used to be completed by me, manually reading an LCOV report looking for uncovered branches. The “improve the coverage” step was also completed by me, this time, writing a new harness or crafting a new input. The Fuzzing Taskflow hands both of those steps to the agent.
Each iteration, for each harness, the agent runs AFL for a time budget, replays the queue against the .cov binary to get a real coverage report, and then reads the list of uncovered branches. Based on what it finds, it picks one of a handful of 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 is comparing against
- Simply skip the gap if it’s a cold error path or vendor code that isn’t worth chasing
The time budgets double every iteration:
30s → 60s → 120s → 240s → 480s → 960s (≈ 32 min/target)
The idea is to spend cheap, short rounds early (when there’s lots of low-hanging coverage to grab) and longer rounds later (when the fuzzer needs more time to break through a hard guard).
And just like in my manual workflow, I need an answer to the question: when do we stop? Here, the loop uses plateau detection: once two consecutive iterations each gain less than a configurable threshold (1% absolute line coverage by default), the loop decides it has hit diminishing returns and moves on. This keeps the agent from burning hours of compute squeezing out the last fraction of a percent.
Structure-aware fuzzing
AFL’s default byte-level mutators (bit flips, arithmetic, block splicing) do a great job on binary formats but struggle with structured, text-based inputs. The classic solution is to hand-write custom mutators for each format, which is tedious work. This time I want the pipeline to do that work for me, so it ships four complementary mechanisms for producing structure-aware inputs.
1. Per-format dictionaries and custom mutators. For targets whose input format is recognized (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 one knows about 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, so we keep the engine’s randomization instead of fighting it.
2. A source level dictionary. For formats the pipeline doesn’t recognize, it generates a custom mutator on the fly by scanning the target’s own .c/.h files. It extracts string literals and 32-bit numeric constants (from #define, case, and enum), filters out the noise, and uses them as splice tokens. The intuition is simple: the most interesting magic values that a parser checks for are usually written down somewhere in its own source.
3. A dynamically generated AFL dictionary with coverage-driven enrichment. The same source-token set is also emitted as an AFL classic dictionary before iteration 1 (numeric constants in both endiannesses, so the fuzzer can satisfy a memcmp against a 4-byte magic regardless of host byte order). Then, after every coverage step, the pipeline looks at the guards near the uncovered lines (strncmp, memcmp, case 0xN, == ‘X’) and appends any new tokens it finds. The dictionary literally grows toward the code the fuzzer can’t yet reach.
4. A corpus-splice operator. The smart mutator can also load files from a corpus directory and splice random sub-regions of them into the input, a recombination-style operator that AFL’s stock havoc doesn’t do well.
Evolving corpus
One of the things that quietly kills fuzzing efficiency is throwing away progress. If every run starts from the original seeds, you re-pay the cost of rediscovering the same paths over and over.
To avoid that, every harness 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 this directory and run through afl-cmin to keep its size bounded. The effect is that yesterday’s interesting inputs carry into today’s run, and the inputs you found in last week’s campaign carry into this one. If you stop and restart a campaign, you lose nothing.
Triage and vulnerability reports
Finding a crash is only half the job. As anyone who has done root-cause analysis knows, triaging is often the most tedious part of the whole process. This is the other place where the agent shines.
After the fuzzing loop finishes, three stages run automatically. First, 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). Second, previously known crashes are replayed against the current binary to see whether an upstream fix has resolved them. Third, the agent 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 of the following verdicts:
- vulnerability
- library_hardening
- harness_bug
- OOM
- timeout
- assertion_failure
- duplicate
The distinction between a real vulnerability (reachable and exploitable through a public API) and a mere harness_bug (the bug is in our own harness, not the library) is exactly the kind of judgment call that used to require me to sit down and trace the code by hand. Every report includes 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.
Just as clarification: the suggested patches are marked “review required” for a reason. The agent’s analysis is limited by the model’s understanding of the target code, and it does get things wrong. Treat the verdicts as a very well-prepared starting point for a human, not as final result.
Live dashboard
Running an autonomous campaign and not being able to see what it’s doing is uncomfortable, so the pipeline publishes everything to a live HTML dashboard. It auto-starts in the background as soon as you launch a campaign, on port 8765. In a Codespace that port is auto-forwarded, so you can open it in any browser and watch the campaign progress in real time on the dashboard.

The page shows amongst others:
- a per-harness “running” pulse
- a coverage-trend table with inline sparklines
- a crash heatmap
- an iteration timeline
Conclusion
I started this project motivated by the limitations that any security researcher knows well: fuzzing works, but it doesn’t scale without human attention, and that human attention is the bottleneck. The Fuzzing Taskflow is my attempt to push that bottleneck back by handing the repetitive parts (writing harnesses, reading coverage, chasing gaps, triaging crashes) to an LLM agent, while keeping a clean separation between the agent’s judgment and the tools that do the real work.
If you’re the maintainer of C/C++ project, then please give it a try. If your project has never been fuzzed before, then this tool will help you to get started quickly. Or if your project has been fuzzed before, then this tool might help to find new bugs by increasing your fuzzing coverage.
The source code is open source, so please create an issue if you encounter any bugs. Contributions are also welcome!