覆盖率引导模糊测试实战:AFL++ 与 libFuzzer 的变异调度、语料蒸馏与 CI 落地

单元测试能证明代码"按你设想的那样工作",但它几乎无法证明代码"在没想到的输入下不会崩"。过去十年,真正把 C/C++、Rust、Go 生态里的内存安全漏洞成批挖出来的,不是更多的测试用例,而是模糊测试(Fuzzing)。而其中效率最高的那一类,是覆盖率引导模糊测试(Coverage-Guided Fuzzing,CGF)。

本文不谈"怎么装 AFL++",而是讲清楚三件事:覆盖率反馈为什么有效、怎样把一个 fuzz target 从"能跑"打磨到"能出活"、以及如何让它常驻 CI 而不是跑一次就吃灰。

一、随机测试为什么不行:组合爆炸与覆盖率反馈

假设一个函数接受 16 字节输入。随机生成输入命中某个特定分支的概率,粗略地说是在 2^128 的空间里捞针。纯随机测试(dumb fuzzing)在真实程序上基本只能触发最浅的那层解析逻辑。

CGF 的关键洞察是:我们不需要枚举输入空间,只需要枚举程序的行为空间。程序的分支数是有限的(几万到几十万条边),只要输入能带来"新的边",就把它当作有价值的种子保留下来,继续变异。这样搜索就从"猜输入"变成了"沿着覆盖率梯度爬山"。

这就是为什么 CGF 能挖出深藏的漏洞——它不是靠运气撞,而是靠反馈一步步走进嵌套的 if、走进状态机的冷门状态。

二、覆盖率到底是怎么采的

理解插桩细节,才能理解后面所有的调参。以 AFL 系和 libFuzzer 共用的 SanitizerCoverage 为例:

clang -fsanitize=fuzzer,address \
      -fsanitize-coverage=trace-pc-guard,trace-cmp,trace-div \
      -O1 -g -fno-omit-frame-pointer target.c -o fuzzer

编译时,编译器在每个基本块入口插入一段桩代码。经典 AFL 的桩做的是:

cur_location = <编译期分配的随机 ID>;
shared_mem[cur_location ^ prev_location]++;
prev_location = cur_location >> 1;

注意三个设计细节,它们是被反复验证过的工程权衡:

  1. 记录的是边(edge)而非块。cur_location ^ prev_location 把"从 A 跳到 B"编码成一个 key,因此 if/else 两个方向会被区分为两条不同的边,而同一块无论被执行多少次都只占一个槽位。
  2. hit count 被分桶。桶边界是 1, 2, 3, 4-7, 8-15, 16-31, 32-127, 128+。这意味着循环跑了 1000 次和跑了 200 次在覆盖率眼里是一样的。好处是避免"计数器饱和"导致的覆盖率假膨胀——否则一个 for(i=0;i<N;i++) 就能把整张 bitmap 刷满,fuzzer 会误以为发现了新世界。
  3. 位图是共享内存。默认 64KB(AFL++ 可扩到 1MB+),父进程与子进程通过 shm 交换,几乎零拷贝。这是 fork server 能每秒跑几千次执行的前提。

trace-cmp 和 trace-div 则额外记录比较指令的操作数与除数。这是 AFL++ 的 cmplog(源自 RedQueen 的 input-to-state 对应)的基础:当 fuzzer 看到代码里有 if (memcmp(buf, "MAGIC", 5) == 0),它可以直接把 "MAGIC" 这几个字节搬运到输入的对应位置,而不必靠随机变异去碰运气。这是突破 magic number / 校验和这类"硬门"的关键。

三、引擎选型:AFL++ 还是 libFuzzer

两者都用同一套 SanitizerCoverage 插桩,但执行模型不同,决定了它们的适用场景。

维度AFL++libFuzzer
执行模型fork server 起子进程进程内循环(in-process)
单次执行开销较高(fork 成本)极低
进程崩溃后自动重启,继续必须重启整个 fuzzer
状态污染天然隔离需要 target 自己保证无全局状态残留
二进制插桩QEMU / FRIDA / Unicorn 模式不支持,必须有源码
最佳场景有状态、易崩、闭源纯函数式解析库、高频次执行

经验法则:解析类库(JSON、protobuf、图片解码、协议解析)优先 libFuzzer,因为它的 exec/s 能到 AFL++ 的数倍到十倍;有全局状态、涉及文件/网络、或者只有二进制的场景用 AFL++。

libFuzzer 的 persistent mode 是免费的(它本来就是进程内),而 AFL++ 需要显式写:

int main(void) {
    while (__AFL_LOOP(10000)) {   // 复用同一进程跑 10000 次
        uint8_t *buf = malloc(MAX);
        ssize_t len = read(0, buf, MAX);
        if (len < 0) break;
        parse(buf, len);
        free(buf);
    }
    return 0;
}

注意 parse() 内部如果有 static 缓存或全局缓冲区,必须在这里清理,否则覆盖率会被上一次执行的残留状态污染,产生"不稳定的边"——这是 AFL++ 里 stability 指标掉到 90% 以下的最常见原因。

四、写好 fuzz target:FuzzedDataProvider 与不变量断言

一个糟糕的 target 会让 fuzzer 80% 的时间在测你的入口检查。对比两种写法:

// 糟糕:前 4 字节被当长度,大部分输入在长度校验处就被拒
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    if (size < 4) return 0;
    uint32_t len = data[0] | data[1]<<8 | data[2]<<16 | data[3]<<24;
    if (len != size - 4) return 0;      // 硬门,fuzzer 很难撞过
    handle(data + 4, len);
    return 0;
}
// 正确:用 FuzzedDataProvider 消费输入,任何长度都不会被过早拒绝
#include "fuzzer/FuzzedDataProvider.h"

extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    FuzzedDataProvider fdp(data, size);
    std::string s1 = fdp.ConsumeRandomLengthString(64);
    std::string s2 = fdp.ConsumeRandomLengthString(64);
    auto n = fdp.ConsumeIntegralInRange<int32_t>(0, 1000);

    // 不变量断言:同一个不变量用两种方式表达,结果必须一致
    auto a = my_strcmp(s1, s2);
    auto b = (s1 == s2) ? 0 : (s1 < s2 ? -1 : 1);
    assert(a == b);                       // 差分思想:抓逻辑 bug 而非崩溃

    handle(s1, s2, n);
    return 0;
}

FuzzedDataProvider 是 libFuzzer 提供的输入切分器,它把一团随机字节"结构化"成字符串、整数、字节序列,并且消费是确定性的——同样的输入永远得到同样的切分,崩溃可以稳定复现。

上面那段 assert(a == b) 值得单独说。崩溃只是 bug 的一种表现形式,更隐蔽的是"不崩但结果错"。把同一语义用两条独立路径算出来再对比,这类不变量断言能抓到纯 sanitizer 抓不到的逻辑错误。

五、变异策略与能量调度

AFL++ 的变异分两类:确定性阶段(bitflip 1/2/4、字节加减、已知整数替换)和破坏阶段(havoc:随机堆叠几十种操作;splice:把两个种子拼接)。

现代 AFL++ 默认会跳过大部分确定性变异,因为经验证明 havoc 的性价比更高。真正值得调的是能量调度(power schedule)——决定"给这个种子分配多少次变异机会":

# 按边被访问的稀有度加权,把算力倾斜给冷门路径
afl-fuzz -p rare -i corpus/ -o out/ -- ./target @@
# AFLFast 的 explore 变体,偏向低频路径
afl-fuzz -p fast -i corpus/ -o out/ -- ./target @@

fast(AFLFast)和 rare 的核心思想是:被很多种子走过的边,其价值边际递减;应该把能量给那些"只有极少数种子能到达"的边。在深状态机上,把 -p 从默认切到 fast,几小时内多挖一两个 bug 是很常见的。

libFuzzer 这边对应的常用参数:

./fuzzer corpus/ \
  -max_len=4096        # 输入上限。太大会让大部分变异浪费在尾部
  -len_control=0       # 关掉长度自适应,配合人工设定的 max_len
  -timeout=10          # 单次超时,超时即视为 hang
  -rss_limit_mb=2048   # 内存上限,防止 OOM 被误判成崩溃
  -use_value_profile=1 # 启用 value profile,近似 cmplog 的效果

-rss_limit_mb 必须显式设。否则一个无限增长的缓冲区会被 OOM killer 干掉,fuzzer 报告一个"崩溃",你兴冲冲去复现,发现只是内存不够——这类误报在长期运行里占比不低。

六、语料库是长期资产

种子语料库不是一个"随便塞几个文件进去"的目录,它是你整个 fuzzing 投资的沉淀。三件事必须做:

1. 种子要小而多样。 每个种子应该触发不同的代码路径,而不是 100 个都走同一条路。理想规模是几十到几百个,每个几 KB 以内。

2. 蒸馏(minimization)。 去掉对覆盖率无贡献的种子:

# AFL++:剔除冗余种子
afl-cmin -i raw_corpus/ -o corpus/ -- ./target @@
# 单个文件最小化(保留崩溃能力或覆盖率)
afl-tmin -i crash.bin -o crash_min.bin -- ./target @@

# libFuzzer:合并语料
./fuzzer -merge=1 new_corpus/ old_corpus/

afl-cmin 按"覆盖到的边集合"做贪心集合覆盖,通常能把几百个种子压到几十个而不损失任何覆盖率。跑之前先 cmin,能显著提升 exec/s。

3. 崩溃样本必须最小化后再归档。 afl-tmin 能把一个 10KB 的崩溃输入压到 20 字节,这一步直接决定了后续根因分析的效率。

七、Sanitizer 的选择与组合

Sanitizer抓什么代价注意
ASan越界读写、UAF、double-free约 2x 减速,内存 3x检测堆/栈/全局,最常用
UBSan整数溢出、未定义行为、对齐很轻与 ASan 可叠加;加 -fno-sanitize-recover 让首错即崩
MSan未初始化内存读取约 3x 减速必须用插桩版 libc++,否则全是误报
TSan数据竞争5-15x 减速不适合高吞吐 fuzzing,更适合定向并发测试

生产实践里最常见的是 ASan + UBSan 组合:

clang -fsanitize=address,undefined -fno-sanitize-recover=undefined \
      -fsanitize-coverage=trace-pc-guard -O1 -g ...

-fno-sanitize-recover=undefined 很重要:默认 UBSan 只是打印警告然后继续,fuzzer 感知不到,等于白跑。加上这个标志,遇到未定义行为立刻 abort,fuzzer 才能把它当崩溃捕获。

MSan 的坑必须强调:如果只给自己代码插桩而链接了系统未插桩的 libstdc++,MSan 会把库内部的正常内存读写报成未初始化读。要么全量插桩依赖,要么别用 MSan。

八、结构化输入:字典、自定义 mutator、Protobuf

当输入是高度结构化的(JSON、protobuf、SQL、二进制协议),纯字节变异的效率会急剧下降——99.9% 的变异在第一层解析就被拒。三个递进的武器:

字典(最简单,收益很高)。 把协议里的 token 写成 AFL 字典:

// json.dict
"false"       // 值
"true"
"null"
"\\u0000"     // 转义边界
"1e999"       // 浮点溢出
"["
"]"
afl-fuzz -i corpus/ -o out/ -x json.dict -- ./target @@

自定义 mutator(中等成本)。 AFL++ 支持用 C 或 Python 写 mutator,直接在语法树层面操作:

def afl_custom_fuzz(self, buf, add_buf, max_size):
    tree = parse_json(buf)          # 解析成 AST
    tree = mutate_ast(tree)         # 在 AST 上做变异:换类型、改深度
    return serialize(tree)[:max_size]

libFuzzer 侧对应的是 LLVMFuzzerCustomMutator,语义等价。

Protobuf mutator(成本最高,效果最好)。 用 .proto 描述输入结构,让 mutator 在结构化数据上做类型感知的变异,最后序列化成字节喂给 target。这是 Chromium、Envoy 等项目的标准做法,代价是要额外维护一份 schema。

实战建议:先上字典。字典是零代码成本的,在多数协议类 target 上能带来肉眼可见的覆盖率跃升。只有当字典收益见顶后,才值得投入自定义 mutator 或 protobuf schema。

九、差分测试:抓"不崩但错"的杀手锏

很多严重 bug 不崩溃——比如两个 JSON 解析器对同一个输入给出不同结果、一个压缩库解压后与原文不一致、一个加密库的实现与参考实现输出不同。这类问题 sanitizer 一个都抓不到。

差分 fuzzing 的写法是把两个实现塞进同一个 target:

extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    FuzzedDataProvider fdp(data, size);
    auto input = fdp.ConsumeRemainingBytesAsString();

    auto r1 = fast_path_parse(input);    // 优化过的快路径
    auto r2 = slow_path_parse(input);    // 语义等价的朴素参考实现

    if (r1.has_value() != r2.has_value()) {
        // 一边拒绝一边接受 —— 几乎一定是 bug
        std::abort();
    }
    if (r1.has_value() && r1.value() != r2.value()) {
        std::abort();                    // 结果不一致 —— 一定是 bug
    }
    return 0;
}

这个模式的威力在于:你不需要知道正确答案是什么,只需要知道两条路径必须一致。它能把"正确性"这个模糊的属性变成可自动判定的断言。

十、CI 落地:从冒烟到常驻

fuzzing 最大的失败模式不是找不到 bug,而是跑了一晚上就再也没人管。正确的定位是分三层:

第一层:PR 冒烟(每次提交)。 不追求发现 bug,只做回归——用归档的语料库跑固定时长,确保以前的崩溃不再复现:

# .github/workflows/fuzz-smoke.yml
- name: Regression smoke
  run: |
    clang -fsanitize=fuzzer,address -O1 -g -o fuzzer target.c
    ./fuzzer corpus/ -max_total_time=120 -runs=0 \
             -rss_limit_mb=2048 -timeout=10

第二层:夜间长跑(每天)。 跑 1-6 小时,语料库回灌到仓库或对象存储。这一层负责真正发现新问题。

第三层:OSS-Fuzz / ClusterFuzzLite(可选)。 如果你的项目是开源的基础设施库,直接上 OSS-Fuzz——它提供免费的 Google 算力、自动去重、自动最小化、以及 90 天后的漏洞披露流程。ClusterFuzzLite 是自托管的轻量版,能跑在任意 CI 上,适合不想把代码交给 Google 的场景。

关键的工程细节:语料库必须回灌。夜间跑出来的新种子如果不写回仓库,第二天 fuzzer 又从零开始,长跑等于白跑。用 -merge=1 合并后提交,或者在 CI 里同步到 S3/GCS。

十一、踩坑清单

  1. stability 低于 90%:同一输入两次执行跑出的覆盖率不一致。原因按概率排序:target 里残留的全局/静态状态、未初始化的内存被当作分支条件、依赖了地址无关但随机的量(ASLR 下的指针值、随机哈希序)。对策是 persistent mode 里显式重置状态、设置 ASAN_OPTIONS=abort_on_error=1:detect_leaks=0、以及多核跑时按需设 AFL_NO_AFFINITY=1。stability 不修好,后面所有调优都是白费——fuzzer 会不断追逐虚假的新边。
  2. exec/s 只有个位数:target 在每次执行里做了 I/O(读文件、建 socket)。必须改成内存输入,或用 persistent mode 复用进程。
  3. 崩溃无法复现:检查是否用了随机数、时间、哈希遍历顺序(Go map / Python dict)。fuzzing 要求确定性,所有随机源必须从输入派生。
  4. 全是 OOM 崩溃:设 -rss_limit_mb,并清理 target 里未释放的分配。
  5. 覆盖率长期不涨:大概率是卡在某个硬门(magic number、checksum)。启用 cmplog(AFL++ 的 -c)或 use_value_profile,或者干脆在 target 里为 fuzzing 单独绕过校验——用编译宏隔离,绝不能进生产构建。

结语

覆盖率引导模糊测试的价值,不在于它"又找到了一个 CVE",而在于它把"正确性验证"从人工编写用例变成了机器自动搜索。这需要前期投入:写 target、准备语料、配 sanitizer、接 CI。但这个投入是一次性的,之后每次提交都在自动为你工作。

如果你的项目里有任何一处解析不可信输入的代码——一个协议、一个文件格式、一段正则——那它就值得有一个 fuzz target。从今天开始,先花两小时写一个最小的 target,跑十分钟,看看覆盖率长什么样。绝大多数人第一次跑完都会发现:原来有这么多分支,从来没有任何测试走到过。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部