26、模糊测试(Fuzzing)入门:结合libFuzzer与单元测试,发现隐藏的边界bug。
说实话,我见过太多项目在边界条件上翻车了。
你写了一个解析函数,单元测试覆盖了正常输入、空输入、最大长度输入……你觉得稳了。结果上线第一天,用户传了个带特殊字符的字符串,程序直接崩了。这种场景,我经历过不下十次。
为什么会这样?因为手写的单元测试,永远只能覆盖你「想得到」的边界。而那些你没想到的、奇奇怪怪的输入组合,才是真正的隐患。这时候,就该模糊测试登场了。
什么是模糊测试?
模糊测试,说白了就是让程序「吃」一堆随机数据,看看它会不会炸。你不需要手动构造每个测试用例,工具会自动生成大量变异数据,喂给你的代码。
我个人习惯把模糊测试看作「暴力穷举的智能版」。它不聪明,但它够快、够狠。尤其是结合 libFuzzer 这种工具,几秒钟就能跑出几百万个测试用例。
核心思想: 用随机生成的输入,触发程序未定义行为、内存错误、断言失败等隐藏问题。
libFuzzer 是什么?
libFuzzer 是 LLVM 项目中的一个库,专门做进程内、覆盖引导的模糊测试。它和你的被测代码链接在一起,然后不断生成输入、调用你的测试入口、监控覆盖率。
嗯,这里要注意:libFuzzer 不是独立工具,它需要你写一个「模糊测试入口函数」,然后编译链接。
环境准备
你需要 Clang 编译器(版本 6.0 以上),并且开启 -fsanitize=fuzzer 标志。我个人推荐用 Clang 12 或更高版本,稳定性好很多。
# 检查你的 clang 版本
clang++ --version
# 编译一个简单的模糊测试目标
clang++ -fsanitize=fuzzer,address -g -O1 -o fuzz_test fuzz_target.cpp
小技巧: 加上 -fsanitize=address 可以同时检测内存错误,一举两得。
第一个模糊测试目标
假设我们有一个解析函数,它从字符串中提取数字。看起来很简单对吧?
// parser.cpp
#include <cstdint>
#include <cstddef>
#include <string>
#include <vector>
bool ParseNumbers(const std::string& input, std::vector<int>& out) {
out.clear();
if (input.empty()) return true;
size_t pos = 0;
while (pos < input.size()) {
// 跳过非数字字符
while (pos < input.size() && !isdigit(input[pos])) {
pos++;
}
if (pos >= input.size()) break;
// 提取数字
int num = 0;
while (pos < input.size() && isdigit(input[pos])) {
num = num * 10 + (input[pos] - '0');
pos++;
}
out.push_back(num);
}
return true;
}
现在,我们来写它的模糊测试入口:
// fuzz_target.cpp
#include <cstdint>
#include <cstddef>
#include <vector>
#include <string>
extern bool ParseNumbers(const std::string& input, std::vector<int>& out);
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* Data, size_t Size) {
// 将原始字节转为字符串
std::string input(reinterpret_cast<const char*>(Data), Size);
std::vector<int> result;
ParseNumbers(input, result);
return 0; // 0 表示正常处理
}
编译并运行:
clang++ -fsanitize=fuzzer,address -g -O1 -o fuzz_parser fuzz_target.cpp parser.cpp
./fuzz_parser
你会看到 libFuzzer 开始疯狂生成输入,并打印覆盖率信息。如果发现崩溃,它会保存导致崩溃的输入文件。
注意: 模糊测试可能会跑很久。我建议设置一个超时时间,比如 -max_total_time=300(5分钟)。
发现隐藏的边界bug
跑了几秒钟后,libFuzzer 可能就找到了问题。比如输入字符串特别长,或者包含大量连续数字,导致 num 整数溢出。
我曾经在一个 JSON 解析器上跑模糊测试,结果发现了一个只有在特定 Unicode 字符组合下才会触发的内存越界。那个 bug 在代码里躺了两年,手写测试根本覆盖不到。
常见的边界bug类型:
- 整数溢出: 比如上面的
num = num * 10 + digit,当数字很长时,int 会溢出。 - 缓冲区溢出: 固定大小的数组,但输入超过了预期长度。
- 除零错误: 某些解析逻辑中,除以用户可控的值。
- 无限循环: 输入导致循环条件永远不满足。
- 断言失败: 内部假设被打破。
结合单元测试
模糊测试不能替代单元测试,它们是互补的。我个人习惯这样搭配:
- 单元测试 覆盖正常逻辑、已知边界、回归用例。
- 模糊测试 探索未知边界、随机组合、压力测试。
你可以在 CI 中同时跑两者。单元测试跑得快,每次提交都跑。模糊测试可以跑得久一些,比如每晚跑一次。
我的做法: 把模糊测试发现的崩溃用例,直接添加到单元测试的回归用例中。这样以后就不会再犯同样的错误了。
知识体系与核心逻辑
下面这张图展示了模糊测试在整个测试体系中的位置,以及它和单元测试的关系:
实战:修复整数溢出
假设模糊测试发现了上面的整数溢出问题。我们来修复它:
// 修复后的 ParseNumbers
bool ParseNumbers(const std::string& input, std::vector<int>& out) {
out.clear();
if (input.empty()) return true;
size_t pos = 0;
while (pos < input.size()) {
while (pos < input.size() && !isdigit(input[pos])) {
pos++;
}
if (pos >= input.size()) break;
long long num = 0; // 改用 long long 防止溢出
while (pos < input.size() && isdigit(input[pos])) {
num = num * 10 + (input[pos] - '0');
// 检查溢出
if (num > INT_MAX) {
num = INT_MAX; // 或者返回错误
break;
}
pos++;
}
out.push_back(static_cast<int>(num));
}
return true;
}
重新编译并跑模糊测试,确认不再崩溃。然后把那个导致崩溃的输入文件保存下来,加到单元测试里。
避坑指南: 我曾经在修复一个模糊测试发现的 bug 时,只改了表面问题,没改根本原因。结果第二天又触发了另一个类似的崩溃。所以修复时一定要想清楚:这个 bug 的根因是什么?有没有同类问题需要一起修?
进阶技巧
- 字典文件: 如果你的程序解析特定格式(如 JSON、XML),可以提供一个字典文件,告诉 libFuzzer 哪些关键词更容易触发新路径。
- 种子语料库: 提供一些有效的输入样本,libFuzzer 会基于它们进行变异,加速覆盖。
- 多核并行: 用
-jobs=4 -workers=4开启多核并行,效率翻倍。
# 使用字典和种子语料库
./fuzz_parser -dict=json.dict -max_len=1024 corpus/
总结
模糊测试不是银弹,但它绝对是发现隐藏边界 bug 的利器。你不需要成为安全专家,只要会写一个简单的入口函数,就能让 libFuzzer 帮你找到那些手写测试永远覆盖不到的角落。
我个人建议:每个新项目都至少跑一次模糊测试。哪怕只跑 10 分钟,也经常能发现一两个意想不到的问题。这 10 分钟的投资,回报率极高。
记住: 单元测试告诉你「代码按预期工作」,模糊测试告诉你「代码在意外情况下不会崩溃」。两者缺一不可。
公众号:蓝海资料掘金营,微信deep3321