C++ 模糊测试与覆盖率:libFuzzer、AFL++ 与 Sanitizer

手写单元测试只能覆盖你想到的输入,而内存安全漏洞几乎总出现在你没想到的那一分支。模糊测试用覆盖率反馈自动生成输入,配合 ASan/UBSan/MSan 能在几小时内挖出人工测试数月都发现不了的崩溃。本文从 libFuzzer 的 fuzz target 编写讲起,对比 AFL++ 的插桩模式与语料管理,系统梳理 Sanitizer 的组合策略、gcov 与 llvm-cov 的覆盖率度量方法,并给出可落地的 CI 流水线设计。

一个解析器写了 2000 行单元测试,覆盖率报告显示 85%,看起来已经很扎实。但当把同一个解析器丢给 libFuzzer 跑一小时后,它可能在第三分钟就找到一个越界读取——输入是一段精心构造的、长度恰好卡在某个缓冲区边界上的畸形数据。模糊测试的价值就在于此:它不依赖人对「异常输入」的想象力,而是用覆盖率反馈驱动变异,自动探索程序状态空间。本文系统讲解 C++ 生态中模糊测试与覆盖率度量的完整工具链。

一、为什么需要模糊测试

1.1 传统测试的盲区

单元测试与模糊测试是互补而非替代关系:

维度单元测试模糊测试
输入来源人手构造自动变异
判定标准断言是否成立是否崩溃/超时/触发 sanitizer
擅长发现逻辑错误、边界条件内存越界、整数溢出、解析器崩溃
覆盖方式针对已知分支覆盖率反馈驱动探索
运行时长秒级分钟到小时级
维护成本随代码增长一次编写长期复用

模糊测试尤其适合处理不可信输入的代码:解析器(JSON、protobuf、图片、网络协议)、解压缩、字体渲染、加密库。历史上大量 CVE 都由模糊测试发现。

1.2 模糊测试的三种模式

  • 黑盒(Black-box):只喂随机数据,不看覆盖率,效率极低,基本不用
  • 灰盒(Grey-box):用插桩获取覆盖率反馈,指导变异方向,libFuzzer 与 AFL++ 都属于此类
  • 白盒(White-box):结合符号执行求解路径约束,代表是 KLEE,能生成精确的触发输入但扩展性差

实践中的主力是灰盒:用覆盖率作为奖励信号,让变异算法优先保留能探索新代码路径的输入。

二、libFuzzer 实战

2.1 编写 fuzz target

libFuzzer 是 LLVM 内置的进程内模糊测试引擎,用法极简:实现一个 LLVMFuzzerTestOneInput 函数,把字节数组喂给被测代码:

#include <cstdint>
#include <cstddef>
#include <string>
#include <vector>

// 被测函数:解析形如 "key=value;key=value" 的配置串
std::vector<std::pair<std::string, std::string>>
parse_config(const std::string& input);

extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
    if (size > 4096) return 0;              // 限制输入规模,加速探索
    std::string input(reinterpret_cast<const char*>(data), size);
    try {
        auto cfg = parse_config(input);      // 任何崩溃都会被 libFuzzer 捕获
        (void)cfg;
    } catch (const std::exception&) {
        // 预期的异常不算崩溃;但注意不要吞掉 std::bad_alloc 之外的严重错误
    }
    return 0;
}

编译时链接 -fsanitize=fuzzer 即可生成可执行文件:

clang++ -std=c++20 -O1 -g -fsanitize=fuzzer,address,undefined \
        -fno-omit-frame-pointer \
        fuzz_target.cpp parse_config.cpp -o fuzz_config

./fuzz_config corpus/            # 从语料目录启动
./fuzz_config -max_total_time=300 corpus/

注意 -O1 而非 -O3:-O3 的激进优化会掩盖部分未定义行为,且降低插桩精度。ASan 与 UBSan 一起开是标准配置。

2.2 语料与字典

语料(corpus)是模糊测试的「初始种子」,好的语料能大幅加速探索:

# 语料目录 corpus/ 中放置种子:合法最小配置、边界长度、特殊字符各一份
./fuzz_config corpus/                 # 从语料目录启动
./fuzz_config -merge=1 corpus_min corpus/   # 最小化:删掉无贡献的种子
./fuzz_config crash-abc123            # 复现某个崩溃

字典提供目标语言的「关键词」,帮助变异算法构造出结构合法的输入:

# config.dict
"key="
"value;"
"="
";"
"\x00"
"true"
"false"
./fuzz_config -dict=config.dict corpus/

2.3 运行与调优

libFuzzer 的关键运行参数:

参数作用
-max_total_time=N总运行秒数,CI 常用
-max_len=N输入长度上限,避免生成超大输入拖慢速度
-rss_limit_mb=N内存上限,超限视为 OOM 崩溃
-timeout=N单次执行超时秒数,捕捉死循环
-jobs=N -workers=N多进程并行模糊
-print_final_stats=1结束时打印统计

一个典型的 CI 配置:

./fuzz_config -max_total_time=600 -max_len=8192 -rss_limit_mb=2048 \
              -timeout=10 -dict=config.dict -print_final_stats=1 corpus/

三、AFL++ 与对比

3.1 AFL++ 用法

AFL++ 是 AFL 的社区增强版,采用插桩 + 共享内存的 fork 模型,适合测试独立的命令行程序或需要跨进程的场景:

# 1. 用 afl-clang-fast 编译目标(含插桩)
afl-clang-fast++ -std=c++20 -O1 -g -o parser parser.cpp
# 2. 准备输入种子
mkdir -p in out && echo 'a=1;b=2' > in/seed1
# 3. 启动模糊测试;多核并行用主从模式
afl-fuzz -i in -o out -- ./parser @@
afl-fuzz -i in -o out -M main   -- ./parser @@
afl-fuzz -i in -o out -S slave1 -- ./parser @@

AFL++ 的两种主要模式:

  • 持久模式(persistent mode):在单进程内循环调用目标函数,避免 fork 开销,速度快 10 倍以上
  • 延迟插桩(laf-intel):把复杂的比较拆解为逐字节比较,帮助突破 if (magic == 0xDEADBEEF) 这类检查
// AFL++ 持久模式:通过宏在单进程内反复执行
#include <unistd.h>
__AFL_FUZZ_INIT();

int main() {
    __AFL_INIT();
    unsigned char* buf = __AFL_FUZZ_TESTCASE_BUF;
    while (__AFL_LOOP(10000)) {
        int len = __AFL_FUZZ_TESTCASE_LEN;
        parse_config(std::string(reinterpret_cast<char*>(buf), len));
    }
    return 0;
}

3.2 两种工具对比

维度libFuzzerAFL++
执行模型进程内,函数调用fork / 持久模式
适用目标库函数、解析器命令行程序、跨进程
编译要求Clang + -fsanitize=fuzzerafl-clang-fast 插桩
并行方式-jobs/-workers多实例主从
语料最小化-merge=1afl-cmin / afl-tmin
结构感知需手写 FuzzedDataProvider需自定义 mutator
上手难度低中

选择建议:库级测试优先 libFuzzer(零样板代码);命令行工具、需要模拟真实进程边界或已有 AFL 体系时用 AFL++。两者可以共用同一份语料。

四、Sanitizer 联动

4.1 三类 Sanitizer

Sanitizer 是模糊测试的「放大器」——没有它,很多内存错误只会表现为静默的数据损坏而非崩溃:

Sanitizer标志检测内容性能开销
AddressSanitizer-fsanitize=address越界读写、use-after-free、double-free、泄漏约 2x
UndefinedBehaviorSanitizer-fsanitize=undefined整数溢出、空指针解引用、对齐错误、有符号溢出约 1.2x
MemorySanitizer-fsanitize=memory未初始化内存读取约 3x
ThreadSanitizer-fsanitize=thread数据竞争约 5-15x
# 标准组合:ASan + UBSan + libFuzzer
clang++ -O1 -g -fsanitize=fuzzer,address,undefined \
        -fno-omit-frame-pointer fuzz_target.cpp -o fuzz_a
# MSan 需要整条依赖链(含 libc++)重新编译,成本高但能抓未初始化读取
clang++ -O1 -g -fsanitize=fuzzer,memory fuzz_target.cpp -o fuzz_m

MSan 的严苛要求值得特别说明:它要求整条依赖链都用 MSan 重新编译,否则会对来自未插桩库的内存报出大量误报。实践中通常为 MSan 单独构建一套依赖,或只在关键模块上启用。

4.2 组合策略

不同 sanitizer 之间大多互斥(ASan 与 MSan 不能同时开),因此需要按阶段分别运行:

./fuzz_a -max_total_time=1800 corpus/   # 阶段 1:ASan+UBSan 快速探索
./fuzz_m -max_total_time=1800 corpus/   # 阶段 2:MSan 抓未初始化读取
./fuzz_t -max_total_time=600  corpus/   # 阶段 3:TSan 验证并发路径

一个实用技巧是用 UBSan 的 -fno-sanitize-recover=all 让未定义行为直接终止进程,这样 libFuzzer 能自动保存触发输入;默认情况下 UBSan 只打印警告并继续执行。

clang++ -O1 -g -fsanitize=fuzzer,address,undefined \
        -fno-sanitize-recover=all fuzz_target.cpp -o fuzz_a

关于 sanitizer 的更多细节(含 ASan 的内存布局与报告解读),参见 https://plumephp.com/cpp-debug-sanitizers/。

五、覆盖率度量

5.1 gcov 与 llvm-cov

覆盖率回答的是「测试到底跑了多少代码」。GCC 用 gcov,Clang 用 llvm-cov(源码级覆盖率更精确):

# ---- GCC + gcov ----
g++ -O0 -g --coverage -o test_runner test_runner.cpp && ./test_runner
gcov -b -c test_runner.cpp                    # 生成 .gcov 报告
lcov --capture --directory . --output-file cov.info
genhtml cov.info --output-directory cov_html
# ---- Clang + llvm-cov(推荐) ----
clang++ -O0 -g -fprofile-instr-generate -fcoverage-mapping \
        -o test_runner test_runner.cpp
LLVM_PROFILE_FILE="cov.profraw" ./test_runner
llvm-profdata merge -sparse cov.profraw -o cov.profdata
llvm-cov report ./test_runner -instr-profile=cov.profdata
llvm-cov show ./test_runner -instr-profile=cov.profdata \
             --format=html --output-dir=cov_html

三种覆盖率指标的区别很重要:

指标含义说明
行覆盖率多少行被执行最直观但最弱
函数覆盖率多少函数被调用发现死代码
分支覆盖率多少分支方向被走到最能反映测试充分性
MC/DC每个条件独立影响结果安全关键领域(DO-178C)要求

行覆盖率高但分支覆盖率低,通常意味着「if 的真分支走了无数次,假分支一次没走」——这正是 bug 的藏身之处。

5.2 覆盖率驱动的改进

覆盖率数据应当驱动测试改进,形成闭环:

# 找出完全未被覆盖的函数
llvm-cov report ./test_runner -instr-profile=cov.profdata \
    | awk '$4 == 0 { print }'

# 用模糊测试的覆盖率与单元测试的覆盖率合并分析
# libFuzzer 也支持 -print_coverage 输出
./fuzz_config -runs=0 -print_coverage=1 corpus/

一个常见误区是追求 100% 覆盖率。覆盖率是「必要条件而非充分条件」:100% 分支覆盖不代表没有 bug,但覆盖率低一定意味着测试不足。实践中应关注「新增代码的覆盖率」(diff coverage),要求每个 PR 的新代码覆盖率达到阈值(如 80%),而非纠缠于历史存量。

六、CI 落地

6.1 流水线设计

把模糊测试接入 CI 的关键是区分「短时冒烟」与「长时深挖」:

# .github/workflows/fuzz.yml
name: fuzz
on:
  pull_request:
  schedule:
    - cron: '0 2 * * *'          # 每晚长跑

jobs:
  fuzz-smoke:                     # PR 冒烟:2 分钟快速拦截
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: 构建 fuzz target
        run: |
          clang++ -std=c++20 -O1 -g -fsanitize=fuzzer,address,undefined \
            -fno-sanitize-recover=all \
            fuzz/fuzz_target.cpp src/parse_config.cpp -o fuzz_config
      - name: 恢复语料缓存
        uses: actions/cache@v4
        with:
          path: corpus
          key: fuzz-corpus-${{ github.sha }}
          restore-keys: fuzz-corpus-
      - run: ./fuzz_config -max_total_time=120 -max_len=8192 corpus/  # 冒烟 2 分钟
      - name: 上传崩溃样本
        if: failure()
        uses: actions/upload-artifact@v4
        with: { name: crash-artifacts, path: 'crash-*' }

  fuzz-deep:                      # 夜间深挖:1 小时 8 进程并行
    if: github.event_name == 'schedule'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: |
          clang++ -std=c++20 -O1 -g -fsanitize=fuzzer,address,undefined \
            fuzz/fuzz_target.cpp src/parse_config.cpp -o fuzz_config
          ./fuzz_config -max_total_time=3600 -jobs=8 -workers=8 corpus/

关键设计点:

  • 语料要持久化:用 CI 缓存保存语料,让每次运行都从上一次的成果继续,而非从零开始
  • 崩溃样本必须归档:失败的构建要上传 crash-* 与 timeout-* 文件,便于本地复现
  • 冒烟与深挖分离:PR 上跑 2 分钟快速拦截,夜间跑 1 小时深度挖掘
  • 覆盖率门禁:把 diff coverage 作为 PR 的合并条件

6.2 常见问题

  • fuzz target 里不能有副作用:不能写文件、不能改全局状态,否则第二次执行结果不一致
  • 不要吞掉异常:catch (...) 会把真实崩溃变成「正常返回」,应只捕获明确预期的异常类型
  • 注意内存泄漏检测:libFuzzer 的 -detect_leaks=1 默认开启,泄漏会被当作崩溃;若目标有故意的全局缓存,需用 __lsan_ignore_object 排除
  • 限制输入规模:不设 -max_len 时,变异可能生成几百 MB 的输入,把时间浪费在慢路径上
  • 复现要固定随机种子:用 -seed=N 与保存的语料可精确复现;崩溃文件本身就是最小复现用例
  • 定期最小化语料:语料会无限增长,定期跑 -merge=1 可压缩到几十个文件

单元测试与模糊测试的分工,在 https://plumephp.com/cpp-testing-gtest-catch2/ 中已有讨论:GoogleTest 负责验证「已知的正确行为」,libFuzzer 负责探索「未知的输入空间」,两者结合才能覆盖质量的全貌。

相关阅读

  • https://plumephp.com/cpp-debug-sanitizers/ — ASan/UBSan/TSan 的原理与报告解读
  • https://plumephp.com/cpp-testing-gtest-catch2/ — GoogleTest 与 Catch2 单元测试框架实践
  • https://plumephp.com/cpp-engineering-practices/ — 静态分析、CI/CD 与整体质量工程

延伸阅读

  • https://plumephp.com/posts/security/ — 内存安全漏洞的成因、利用与防御体系
  • https://plumephp.com/posts/devops/ — CI/CD 流水线设计、缓存策略与质量门禁

文末完整示例

// 完整可运行示例:可被 libFuzzer 与单元测试共用的解析器
// 编译(模糊测试):
//   clang++ -std=c++20 -O1 -g -fsanitize=fuzzer,address,undefined \
//           -fno-sanitize-recover=all fuzz_demo.cpp -o fuzz_demo
// 编译(单元测试):g++ -std=c++20 -O2 -DUNIT_TEST fuzz_demo.cpp -o unit_demo

#include <cstdint>
#include <cstddef>
#include <string>
#include <vector>
#include <utility>
#include <cassert>

// ====== 被测代码:解析 "key=value;key=value" ======
std::vector<std::pair<std::string, std::string>>
parse_config(const std::string& input) {
    std::vector<std::pair<std::string, std::string>> out;
    std::size_t pos = 0;
    while (pos < input.size()) {
        std::size_t semi = input.find(';', pos);
        std::string item = input.substr(pos, semi == std::string::npos
                                              ? std::string::npos
                                              : semi - pos);
        if (!item.empty()) {
            std::size_t eq = item.find('=');
            if (eq == std::string::npos) out.emplace_back(item, std::string{});
            else out.emplace_back(item.substr(0, eq), item.substr(eq + 1));
        }
        if (semi == std::string::npos) break;
        pos = semi + 1;
    }
    return out;
}

#ifdef UNIT_TEST
// ====== 单元测试入口 ======
int main() {
    auto r1 = parse_config("a=1;b=2");
    assert(r1.size() == 2);
    assert(r1[0].first == "a" && r1[0].second == "1");

    auto r2 = parse_config("flag;x=");
    assert(r2.size() == 2 && r2[0].second.empty());

    assert(parse_config("").empty());
    auto r3 = parse_config(";;a=b;;");
    assert(r3.size() == 1 && r3[0].first == "a");
    std::string big(10000, 'x'); big += "=1";
    (void)parse_config(big);          // 长输入不崩溃
    return 0;
}
#else
// ====== libFuzzer 入口 ======
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
    if (size > 4096) return 0;        // 限制输入规模
    std::string input(reinterpret_cast<const char*>(data), size);
    auto cfg = parse_config(input);
    // 不变量检查:键中不含 '=',且结果数不超过分号数加一
    for (const auto& kv : cfg) assert(kv.first.find('=') == std::string::npos);
    assert(cfg.size() <= input.size() + 1);
    return 0;
}
#endif

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cpp」更多文章

  1. C++ 移动语义与完美转发:从右值引用到引用折叠
  2. C++ 无锁数据结构:栈、队列与安全内存回收
  3. C++ 序列化库选型实战:从 JSON 到 FlatBuffers