第二十八课:模糊测试与漏洞挖掘——LibFuzzer使用、AFL集成、C语言测试用例
模糊测试,说白了就是“乱拳打死老师傅”。
你写了一个C函数,自认为逻辑滴水不漏。但把它丢给模糊测试工具,几秒钟就能给你炸出个段错误。我刚开始接触这玩意儿时,心里是不服的——觉得这不过是随机填数据,能有多大本事?直到有一次,我维护的一个网络协议解析库,被LibFuzzer在五分钟内找到了三个堆溢出。嗯,从那以后,我再也不敢小看“乱拳”了。
28.1 模糊测试的核心思想
模糊测试(Fuzzing)的原理很简单:构造大量畸形、随机或半随机的输入,喂给目标程序,观察它会不会崩溃、挂起或产生异常行为。
你想想看,人工测试能覆盖多少边界情况?一百个?一千个?而模糊测试一跑就是几百万、几千万个用例。那些你根本想不到的输入组合,恰恰是漏洞的温床。
模糊测试的三大优势:
- 自动化程度高:写好测试驱动(harness),剩下的交给工具
- 覆盖路径广:尤其适合处理二进制格式、网络协议、解析器这类代码
- 可复现:崩溃用例可以保存下来,用于调试和修复
我个人习惯把模糊测试分为两类:基于变异的模糊测试(比如AFL)和基于生成的模糊测试(比如LibFuzzer配合结构化数据)。前者从种子文件出发,不断变异;后者根据语法规则直接生成输入。实际项目中,我经常两者混用。
28.2 LibFuzzer:轻量级进程内模糊测试
LibFuzzer是LLVM项目的一部分。它跟你的被测代码链接在一起,变成一个可执行文件。每次测试时,LibFuzzer生成一个输入,调用你的测试入口函数,然后观察有没有崩溃。
它的好处是:快。因为不需要反复启动进程,所有测试都在同一个进程内完成。代价是:被测代码必须能容忍反复调用,不能有全局状态残留。
28.2.1 编写LibFuzzer测试驱动
假设我们有一个解析函数,专门处理某种自定义的二进制协议:
// 被测函数:解析二进制数据包
int parse_packet(const uint8_t *data, size_t size);
LibFuzzer的测试驱动长这样:
#include <stdint.h>
#include <stddef.h>
extern int parse_packet(const uint8_t *data, size_t size);
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
// 直接调用被测函数
parse_packet(data, size);
return 0; // 必须返回0
}
就这么简单。你只需要提供一个函数,LibFuzzer会不断调用它,每次传入不同的data和size。
我的经验:测试驱动里不要做太多额外工作。比如不要分配大内存、不要写日志文件。这些操作会拖慢模糊测试的速度。我曾经在一个测试驱动里加了详细的日志输出,结果每秒只能跑几百次。去掉日志后,直接飙到每秒几万次。
28.2.2 编译与运行
编译时需要开启SanitizerCoverage和ASan(AddressSanitizer):
clang++ -g -fsanitize=address,fuzzer \
-fsanitize-coverage=trace-pc-guard \
-o fuzz_parser fuzz_driver.cpp parser.cpp
运行:
./fuzz_parser -max_len=1024 -timeout=5 ./corpus
参数说明:
-max_len=1024:输入最大长度1024字节-timeout=5:单个用例超时5秒./corpus:种子文件夹,里面放一些合法的输入样本
LibFuzzer会从种子文件开始,不断变异,寻找新的代码路径。一旦发现崩溃,它会生成一个文件,比如crash-xxxxxxxx,你可以直接用这个文件复现问题。
注意:种子文件的质量直接影响模糊测试的效果。我建议至少准备5-10个有代表性的合法输入。如果种子文件太少或太单一,LibFuzzer可能很长时间都探索不到深层路径。
28.3 AFL:基于覆盖率引导的模糊测试
AFL(American Fuzzy Lop)是另一款经典工具。它跟LibFuzzer的思路不同:AFL通过插桩技术,在每次执行时记录哪些基本块被覆盖了。然后它优先变异那些能触发新路径的输入。
说白了,AFL是个“贪心算法”——它只关心那些能带来新覆盖率的输入。其他输入,哪怕能跑出花来,它也不感兴趣。
28.3.1 使用AFL编译被测程序
AFL提供了自己的编译器包装器:
afl-gcc -g -o parse_packet parse_packet.c
afl-fuzz -i ./input_dir -o ./output_dir ./parse_packet @@
这里的@@是占位符,AFL会把生成的输入文件名替换进去。
如果被测程序是从标准输入读取数据,可以省略@@:
afl-fuzz -i ./input_dir -o ./output_dir ./parse_packet
28.3.2 AFL与LibFuzzer的对比
| 特性 | LibFuzzer | AFL |
|---|---|---|
| 运行模式 | 进程内(in-process) | 进程外(out-of-process) |
| 速度 | 极快(每秒数万到数百万次) | 较慢(每秒数百到数千次) |
| 插桩方式 | 编译时插桩(Clang) | 编译时插桩(afl-gcc)或QEMU模式 |
| 适用场景 | 库函数、解析器、协议处理 | 完整程序、命令行工具、网络服务 |
| 种子管理 | 自动维护,支持最小化 | 自动维护,支持确定性变异 |
我个人习惯是:能上LibFuzzer就上LibFuzzer。速度快,调试方便。但如果被测程序是个完整的可执行文件,或者不方便改代码,那就用AFL。
28.4 编写高质量的C语言测试用例
模糊测试工具再强,也需要你提供好的测试驱动和种子文件。这里分享几个我踩过的坑。
28.4.1 测试驱动的设计原则
- 不要假设输入合法:很多人在写测试驱动时,潜意识里认为输入是“合理的”。比如先检查长度再memcpy。但模糊测试会给你各种长度——0、1、超大、负数(如果size_t被截断)。
- 避免过早返回:如果被测函数在入口处就检查了魔数(magic number),然后直接返回,那模糊测试永远探索不到后面的逻辑。我建议在测试驱动里跳过这类检查,或者提供合法的魔数。
- 处理全局状态:如果被测函数依赖全局变量,记得在每次调用前重置。否则第二次调用可能因为状态污染而崩溃,但这不是真正的漏洞。
一个典型的错误示例:
// 错误:没有重置全局状态
static int initialized = 0;
extern "C" int LLVMFuzzerTestOneInput(...) {
if (!initialized) {
init_global_state();
initialized = 1;
}
// 后续调用会复用第一次的状态
parse_packet(data, size);
return 0;
}
正确做法:每次调用前都重置,或者把状态封装到局部变量中。
28.4.2 种子文件的准备技巧
种子文件不是随便扔几个字节就行的。我总结了几条经验:
- 覆盖主要功能路径:比如你的解析器支持三种消息类型,那就每种类型准备一个合法样本。
- 包含边界值:比如空消息、最大长度消息、带特殊字符的消息。
- 不要太大:种子文件越小,变异效率越高。我一般控制在100字节以内。
- 使用最小化工具:AFL和LibFuzzer都提供了种子最小化功能。跑完一轮后,用
afl-cmin或-merge=1把冗余的种子去掉。
28.5 知识体系与核心逻辑
下面这张图展示了本章的核心脉络:
28.6 实战:用LibFuzzer挖一个真实漏洞
我们来看一个具体的例子。假设有这样一个函数:
// 一个故意留了漏洞的解析函数
int parse_message(const uint8_t *data, size_t size) {
if (size < 4) return -1;
uint32_t len = *(uint32_t*)data; // 从输入中读取长度
if (len > 1024) return -1; // 检查长度上限
// 漏洞:没有检查 len 是否小于剩余数据长度
uint8_t buffer[1024];
memcpy(buffer, data + 4, len); // 如果 len > size - 4,这里就溢出了
// 后续处理...
return 0;
}
这个漏洞很典型:长度检查只检查了上限,没检查下限与剩余空间的匹配。如果传入的len是1000,但size只有10,那memcpy就会从data+4开始读取1000字节,其中大部分是堆上的随机数据。
用LibFuzzer跑一下:
clang++ -g -fsanitize=address,fuzzer -o fuzz_msg fuzz_driver.cpp message.c
./fuzz_msg -max_len=100 ./corpus
几秒钟后,LibFuzzer就会报出堆缓冲区溢出。它会生成一个crash文件,你可以用xxd查看内容:
xxd crash-xxxxxxxx
00000000: e803 0000 ... // 0x03e8 = 1000,这就是那个超大的len
修复方法很简单:在memcpy前加上if (len > size - 4) return -1;。
避坑指南:我曾经在修复这类漏洞时,只加了上限检查,忘了考虑size - 4可能下溢(如果size小于4)。结果修复后的代码在size=2时,size - 4变成了一个巨大的无符号数,检查形同虚设。所以,无符号整数的减法一定要小心。
28.7 总结与建议
模糊测试不是银弹,但它绝对是C语言安全编程的必备技能。我建议你从LibFuzzer入手,因为它简单、快速、集成方便。等熟悉了流程,再尝试AFL处理更复杂的场景。
最后说一句:不要等到上线了才跑模糊测试。我见过太多项目,开发阶段跑得飞快,一上生产环境就被黑客用畸形数据打穿。模糊测试应该作为CI/CD的一部分,每次提交都自动跑一轮。哪怕只跑几分钟,也能拦住大部分低级错误。
嗯,今天就到这里。记住:你的代码远没有你想象的那么健壮。让模糊测试来证明这一点。
公众号:蓝海资料掘金营,微信deep3321