第二十八讲:模糊测试与漏洞发现——让程序自己“撞南墙”
各位好,今天我们来聊一个很有意思的话题——模糊测试。
说实话,我刚开始做C语言开发那会儿,对模糊测试是有点不屑的。总觉得“我写的代码,逻辑那么清晰,怎么可能有隐藏的bug?”结果呢?有一次我写了一个网络协议解析模块,自认为测试覆盖率已经很高了,结果一上模糊测试,不到三分钟就崩了。嗯,从那以后,我再也不敢小看这玩意儿了。
模糊测试,说白了就是“乱拳打死老师傅”。你给程序喂一堆乱七八糟的输入,看它会不会崩溃、挂死、或者出现内存错误。它不关心你的代码逻辑有多优雅,只关心一件事——你的程序够不够“抗造”。
28.1 模糊测试的核心思想
模糊测试的原理其实很简单:用大量变异或生成的输入数据,去冲击目标程序。如果程序崩溃了,说明那里可能有个漏洞。
你想想看,一个函数处理字符串时,如果传入一个超长的输入,会不会缓冲区溢出?如果传入一个负数作为长度,会不会整数溢出?如果传入一个空指针,会不会段错误?这些边界情况,靠人工测试很难覆盖全,但模糊测试可以。
我个人习惯把模糊测试分为两类:
- 基于变异的模糊测试:从一个有效的种子输入出发,随机修改其中的字节,生成大量变体。
- 基于生成的模糊测试:根据输入格式的规则(比如协议规范),从头构造合法的输入,再在其中插入异常数据。
实际项目中,我更喜欢把两者结合起来用。先用基于生成的方式构造一批“看起来合法”的输入,再用变异的方式去“折腾”它们。
28.2 主流模糊测试工具
目前C/C++领域最常用的模糊测试工具,我接触过的有这几个:
| 工具 | 特点 | 适用场景 |
|---|---|---|
| libFuzzer | LLVM内置,进程内测试,速度快 | 库函数、API级别的测试 |
| AFL (American Fuzzy Lop) | 基于覆盖率引导,支持插桩 | 二进制程序、文件解析类 |
| Honggfuzz | 支持硬件特性,性能优秀 | 需要高吞吐量的场景 |
我个人用得最多的是libFuzzer和AFL。为什么?因为libFuzzer集成在Clang里,用起来特别方便;而AFL的覆盖率反馈机制,能让你直观地看到测试进展到了哪一步。
28.2.1 libFuzzer 的使用
libFuzzer是LLVM项目的一部分。它要求你写一个模糊测试入口函数,然后编译器会自动帮你插桩。
来看一个最简单的例子。假设我们有一个解析函数:
// target.c
#include <stdint.h>
#include <stddef.h>
int parse_data(const uint8_t *data, size_t size) {
if (size < 4) return -1;
int len = (data[0] << 24) | (data[1] << 16) |
(data[2] << 8) | data[3];
// 这里有个潜在问题:len可能很大
if (len > size - 4) return -1;
// 处理数据...
return 0;
}
// Fuzz target 入口
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
parse_data(data, size);
return 0;
}
编译命令很简单:
clang -fsanitize=address,fuzzer target.c -o fuzz_target
然后直接运行:
./fuzz_target
libFuzzer会自动生成输入,不断调用你的函数。如果触发了AddressSanitizer检测到的内存错误,它会立即停止并输出崩溃信息。
28.2.2 AFL 的使用
AFL的思路不太一样。它不要求你写专门的入口函数,而是直接对编译好的二进制程序进行插桩。
使用AFL的典型流程:
- 用afl-gcc或afl-clang编译目标程序
- 准备一个种子输入目录(比如叫
input/) - 运行afl-fuzz
afl-gcc -o target_program target.c
mkdir input output
echo "hello" > input/seed.txt
afl-fuzz -i input -o output ./target_program @@
这里的@@是个占位符,AFL会把生成的测试用例文件名替换到这里。
我记得有一次,我用AFL测试一个图片解码库。种子文件就放了一张正常的PNG图片。跑了大概两个小时,AFL发现了一个崩溃——原来库在处理畸形IHDR块时,会读取一个未初始化的内存区域。这个bug如果靠人工审查,可能得翻半天代码才能找到。
28.3 测试用例生成策略
模糊测试的效果,很大程度上取决于测试用例的质量。我见过不少新手,随便扔几个文件就开始跑,结果跑了一天什么都没发现。
这里分享几个我常用的策略:
- 种子多样化:不要只放一个种子。放几个不同大小、不同结构的文件。比如测试XML解析器,放一个简单的、一个嵌套深的、一个带命名空间的。
- 字典辅助:AFL支持字典文件,里面可以放一些关键词。比如测试HTTP解析器,字典里放"GET"、"POST"、"HTTP/1.1"等。这样变异时更容易生成有意义的输入。
- 结构化变异:对于libFuzzer,你可以自定义变异器。比如你知道输入的前4个字节是长度字段,那你可以让变异器优先修改这个字段。
核心原则:好的测试用例,应该能覆盖到代码的“深水区”。那些只在特定条件下才会执行的代码路径,才是漏洞的高发地带。
28.4 崩溃分析与复现
找到崩溃只是第一步。真正的挑战在于——分析崩溃原因,并复现它。
libFuzzer和AFL都会把导致崩溃的输入保存下来。通常是一个二进制文件。你需要用这个文件去重现崩溃。
举个例子,libFuzzer崩溃后会输出类似这样的信息:
==12345== ERROR: AddressSanitizer: heap-buffer-overflow on address 0x6020000000f0
READ of size 4 at 0x6020000000f0 thread T0
#0 0x4a1b2c in parse_data /home/user/target.c:12:5
#1 0x4a1d0e in LLVMFuzzerTestOneInput /home/user/target.c:20:5
#2 0x4a2f00 in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long)
0x6020000000f0 is located 0 bytes to the right of 16-byte region [0x6020000000e0,0x6020000000f0)
看到这个信息,我一般会这样做:
- 找到崩溃输入文件(通常叫
crash-xxx) - 用调试模式重新运行程序,加载这个输入
- 在GDB或LLDB中查看崩溃时的堆栈和变量值
gdb --args ./fuzz_target crash-xxx
(gdb) run
(gdb) bt # 查看调用栈
(gdb) info locals # 查看局部变量
(gdb) x/16xb 0x6020000000e0 # 查看内存内容
28.5 知识体系总览
为了让你更直观地理解模糊测试的完整流程,我画了一张图:
从这张图你可以看到,模糊测试其实是一个闭环反馈系统。种子输入经过变异后喂给目标程序,如果程序正常执行,就根据覆盖率信息决定是否把这次输入加入种子队列;如果程序崩溃,就保存输入并进入分析阶段。
28.6 避坑指南
最后,分享几个我踩过的坑:
- 不要在生产环境直接跑模糊测试。我曾经在一台线上服务器上跑AFL,结果它把磁盘写满了——因为生成的测试用例太多了。一定要在隔离环境里跑。
- 注意测试的“噪音”。有些崩溃是环境问题导致的,比如内存不足、磁盘IO错误。要确认崩溃是否可复现。
- 不要只盯着崩溃。有时候程序没有崩溃,但产生了错误的结果(比如计算错误)。这时候可以用断言来辅助检测。
好了,关于模糊测试的内容就讲到这里。工具只是手段,真正重要的是你对待代码安全的态度。记住:没有经过模糊测试的代码,就像没有经过风暴测试的船——你永远不知道它什么时候会翻。
公众号:蓝海资料掘金营,微信deep3321