一个解析器写了 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 两种工具对比
| 维度 | libFuzzer | AFL++ |
|---|---|---|
| 执行模型 | 进程内,函数调用 | fork / 持久模式 |
| 适用目标 | 库函数、解析器 | 命令行程序、跨进程 |
| 编译要求 | Clang + -fsanitize=fuzzer | afl-clang-fast 插桩 |
| 并行方式 | -jobs/-workers | 多实例主从 |
| 语料最小化 | -merge=1 | afl-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
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。