第二十八讲:模糊测试与漏洞发现——让程序自己“撞南墙”

各位好,今天我们来聊一个很有意思的话题——模糊测试。

说实话,我刚开始做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检测到的内存错误,它会立即停止并输出崩溃信息。

我的经验:libFuzzer跑起来之后,你会看到终端上不断刷新的“#”号。每个“#”代表一个测试用例。如果长时间没有新路径发现,说明覆盖率已经饱和了。这时候可以考虑换个种子,或者调整变异策略。

28.2.2 AFL 的使用

AFL的思路不太一样。它不要求你写专门的入口函数,而是直接对编译好的二进制程序进行插桩。

使用AFL的典型流程:

  1. 用afl-gcc或afl-clang编译目标程序
  2. 准备一个种子输入目录(比如叫input/
  3. 运行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)

看到这个信息,我一般会这样做:

  1. 找到崩溃输入文件(通常叫crash-xxx
  2. 用调试模式重新运行程序,加载这个输入
  3. 在GDB或LLDB中查看崩溃时的堆栈和变量值
gdb --args ./fuzz_target crash-xxx
(gdb) run
(gdb) bt  # 查看调用栈
(gdb) info locals  # 查看局部变量
(gdb) x/16xb 0x6020000000e0  # 查看内存内容
注意:有些崩溃是“间歇性”的,比如多线程竞争导致的。这时候你需要多跑几次,或者用ThreadSanitizer来检测。我曾经遇到过一个bug,在100次复现中只有3次会崩溃,排查起来特别痛苦。

28.5 知识体系总览

为了让你更直观地理解模糊测试的完整流程,我画了一张图:

模糊测试与漏洞发现流程 种子输入 有效样本 / 字典 变异引擎 位翻转 / 字节替换 / 拼接 目标程序 插桩 / 带Sanitizer 覆盖率反馈 新路径?加入种子队列 循环迭代 崩溃发现 保存crash输入文件 崩溃分析与复现 GDB调试 / 定位漏洞 正常路径 崩溃路径

从这张图你可以看到,模糊测试其实是一个闭环反馈系统。种子输入经过变异后喂给目标程序,如果程序正常执行,就根据覆盖率信息决定是否把这次输入加入种子队列;如果程序崩溃,就保存输入并进入分析阶段。

28.6 避坑指南

最后,分享几个我踩过的坑:

  • 不要在生产环境直接跑模糊测试。我曾经在一台线上服务器上跑AFL,结果它把磁盘写满了——因为生成的测试用例太多了。一定要在隔离环境里跑。
  • 注意测试的“噪音”。有些崩溃是环境问题导致的,比如内存不足、磁盘IO错误。要确认崩溃是否可复现。
  • 不要只盯着崩溃。有时候程序没有崩溃,但产生了错误的结果(比如计算错误)。这时候可以用断言来辅助检测。

好了,关于模糊测试的内容就讲到这里。工具只是手段,真正重要的是你对待代码安全的态度。记住:没有经过模糊测试的代码,就像没有经过风暴测试的船——你永远不知道它什么时候会翻。


公众号:蓝海资料掘金营,微信deep3321