Fuzzing技术:让漏洞自己撞上门来

说实话,我入行那会儿,挖漏洞基本靠翻代码、看汇编、猜逻辑。那时候一天能找到一个UAF就算烧高香了。后来接触了Fuzzing,我才意识到——原来可以让程序自己把漏洞“吐”出来。

Fuzzing,中文叫模糊测试。说白了,就是往目标程序里塞各种畸形数据,看它会不会崩溃。你想想看,一个程序处理正常输入时当然不会出问题,但如果你给它喂一些“边界值”、“随机变异”的数据,很多隐藏的bug就藏不住了。

什么是Fuzzing?

Fuzzing的核心思想很简单:用大量异常输入去触发程序中的未定义行为。这些输入可能是随机生成的,也可能是基于合法输入变异而来的。

我个人习惯把Fuzzing分成三类:

  • 黑盒Fuzzing:不知道程序内部结构,纯靠随机数据去撞。效率低,但实现简单。
  • 白盒Fuzzing:基于源码分析,生成能覆盖更多路径的输入。效果好,但成本高。
  • 灰盒Fuzzing:介于两者之间,通过插桩获取覆盖率反馈,指导变异方向。这是目前最主流的方式。

核心要点:Fuzzing不是乱扔数据,而是有策略地探索程序的所有执行路径。覆盖率越高,找到漏洞的概率越大。

我在项目中遇到过很多次,明明Fuzzing跑了几天都没出问题,结果换了个种子文件,半小时就崩了。所以,种子文件的质量往往决定了Fuzzing的成败

AFL:覆盖率引导的Fuzzing王者

AFL(American Fuzzy Lop)是我用得最多的Fuzzer之一。它的设计思路非常巧妙——通过插桩记录每个输入覆盖了哪些基本块,然后优先变异那些能触发新路径的输入。

使用AFL的基本流程是这样的:

  1. 准备一个种子输入文件(比如一个合法的PNG图片)
  2. 用afl-gcc编译目标程序(插桩版本)
  3. 运行afl-fuzz开始Fuzzing
  4. 观察崩溃样本和覆盖率变化

来看一个实际例子。假设我们要Fuzz一个图片解析库:

# 1. 用AFL的编译器编译目标
afl-gcc -o image_parser image_parser.c -lm

# 2. 创建种子目录
mkdir seeds
cp test.png seeds/

# 3. 启动Fuzzing
afl-fuzz -i seeds -o findings ./image_parser @@

这里的@@表示AFL会把生成的测试文件路径替换进去。运行后你会看到一个实时更新的界面,显示执行速度、崩溃数、路径覆盖率等信息。

个人经验:我曾经Fuzz一个网络协议解析器,跑了三天一个崩溃都没有。后来发现是种子文件太“干净”了,全是标准格式。我加了一个字节翻转的种子进去,半小时就出了三个crash。所以,种子文件要包含一些“脏数据”,比如截断的、重复的、边界值的数据。

libFuzzer:与代码深度绑定的Fuzzing

libFuzzer是LLVM项目的一部分,它和AFL最大的区别是:libFuzzer是进程内的Fuzzer。什么意思呢?它不需要反复启动进程,而是在同一个进程里不断调用目标函数,效率极高。

使用libFuzzer需要你写一个Fuzz Target函数:

#include <stdint.h>
#include <stddef.h>

extern "C" int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size) {
    // 这里调用你要测试的函数
    ParseMyProtocol(Data, Size);
    return 0;
}

编译时加上-fsanitize=fuzzer即可:

clang++ -fsanitize=fuzzer -o my_fuzzer my_fuzzer.cpp

运行后,libFuzzer会自动生成输入、调用你的函数、收集覆盖率。我个人觉得libFuzzer在Fuzz库函数时特别好用,因为它不需要处理文件I/O,直接操作内存数据。

注意:libFuzzer是进程内Fuzzing,如果目标函数有全局状态或静态变量,可能会影响后续测试的准确性。我建议在Fuzz Target里尽量重置状态,或者使用线程局部存储。

崩溃分析:从crash到漏洞

Fuzzing跑出崩溃只是第一步,真正的重头戏是分析这些崩溃。我见过太多人看到crash就兴奋,结果分析半天发现是个double free,其实根本不可利用。

崩溃分析的流程我一般这样走:

  1. 重现崩溃:用AFL或libFuzzer生成的样本文件,在目标程序上复现
  2. 获取调用栈:用GDB或LLDB加载core dump,查看崩溃时的调用栈
  3. 分析根因:检查崩溃地址是否可控制、是否越界、是否释放后使用
  4. 判断可利用性:看能否控制EIP/RIP,能否写入可控数据

举个例子,假设我们拿到了一个crash样本:

$ gdb ./image_parser core
(gdb) bt
#0  0x00007ffff7a3b2c0 in memcpy () from /lib/libc.so.6
#1  0x0000555555554a1f in decode_row (data=0x7fffffffd000, row=0x555555758080) at decoder.c:120
#2  0x0000555555554b88 in decode_image (img=0x555555758000) at decoder.c:200
#3  0x0000555555554d00 in main (argc=2, argv=0x7fffffffe218) at main.c:30

看到memcpy在调用栈顶部,基本可以判断是缓冲区溢出。接下来检查decode_row函数的参数:

(gdb) info registers rsi rdi
rsi  0x555555758080  (目标缓冲区)
rdi  0x7fffffffd000  (源数据)

嗯,这里要注意:rdi指向栈上的数据,而rsi指向堆上的缓冲区。如果源数据长度超过了目标缓冲区的大小,就会发生栈溢出。我曾经遇到过类似的情况,最后发现是decode_row里没有做长度校验。

漏洞验证:确认不是误报

Fuzzing跑出来的崩溃,有相当一部分是误报。比如:

  • 内存不足导致的分配失败
  • 断言失败(assert)
  • 未初始化变量导致的随机崩溃
  • 多线程竞争条件

我验证漏洞时,通常会写一个PoC(Proof of Concept)脚本,确认崩溃是否稳定复现,并且能控制关键寄存器。比如:

# 验证PoC
$ for i in {1..100}; do ./image_parser crash_sample; done
# 如果每次都崩溃,说明是稳定漏洞

然后我会用GDB单步调试,看崩溃时能否控制EIP:

(gdb) x/10i $rip
=> 0x7ffff7a3b2c0 <memcpy+0>:  mov    (%rsi),%rax
   0x7ffff7a3b2c3 <memcpy+3>:  mov    %rax,(%rdi)
   ...
(gdb) info registers rip
rip  0x7ffff7a3b2c0

如果rip指向的是libc中的函数,说明是调用参数问题,不一定能直接利用。但如果rip指向了堆或栈上的数据,那就有戏了。

关键判断:能稳定复现 + 能控制执行流 = 高危漏洞。如果只是偶尔崩溃且无法控制,可能是堆损坏或竞争条件,这类漏洞利用难度较大。

Fuzzing知识体系总览

下面这张图是我自己整理的Fuzzing技术路线,涵盖了从输入生成到漏洞验证的完整流程:

Fuzzing技术知识体系 输入生成 种子变异 覆盖率反馈 崩溃检测 AFL libFuzzer Honggfuzz OSS-Fuzz 重现崩溃 调用栈分析 根因定位 可利用性 PoC编写 稳定复现 控制流验证 漏洞定级

这张图把Fuzzing的四个阶段串起来了:从输入生成开始,经过工具执行,再到崩溃分析,最后验证漏洞。每一步都有对应的技术和工具,缺一不可。

避坑指南

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

  • 不要盲目跑Fuzzing:我曾经让AFL跑了整整一周,结果发现目标程序根本没插桩成功。先确认覆盖率数据在增长,再放心跑。
  • 注意内存限制:Fuzzing时如果内存不足,程序会频繁OOM,导致大量误报。我习惯用ulimit -v 500000限制虚拟内存。
  • 多准备种子文件:一个高质量的种子文件,比一万个随机输入都管用。我通常会从官方测试用例、开源项目、甚至网上找的样本里收集种子。
  • 别忽略asan:AddressSanitizer能帮你捕获很多内存错误。编译时加上-fsanitize=address,崩溃信息会清晰很多。

我的习惯:每次Fuzzing前,我都会先跑一遍种子文件,确保程序能正常处理。然后逐步增加变异强度,观察覆盖率变化。如果半小时内覆盖率没有明显增长,我会换种子或调整参数。

Fuzzing这门技术,说白了就是“暴力美学”——用计算量换漏洞。但真正的高手,懂得如何用最少的计算量,找到最有价值的漏洞。希望今天的分享能帮你少走一些弯路。


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