25、Fuzzing基础:什么是Fuzzing、AFL基础使用、Crash分析
说到漏洞挖掘,很多人第一反应是盯着代码一行行看。说实话,我早年也这么干过,盯着反汇编看到眼睛发酸。后来接触了Fuzzing,才明白什么叫「让机器替你打工」。Fuzzing说白了就是——往程序里灌各种奇奇怪怪的输入,看它会不会炸。
你想想看,一个程序处理正常输入时当然没问题。但你要是给它一个超长的字符串、一个负数、或者一个精心构造的畸形文件呢?很多隐藏的bug,就是这么被炸出来的。
什么是Fuzzing
Fuzzing(模糊测试)是一种自动化测试技术。核心思路很简单:生成大量随机或半随机的数据,作为输入喂给目标程序,然后监控程序是否崩溃、挂起或出现异常行为。
我刚开始做逆向时,总觉得Fuzzing是「瞎猫碰死耗子」。直到有一次,我用AFL对一个图像解析库跑了三天,炸出了7个crash,其中两个还是高危漏洞。嗯,从那以后我再也不敢小看它了。
Fuzzing的分类大致如下:
| 类型 | 特点 | 适用场景 |
|---|---|---|
| 黑盒Fuzzing | 不知道内部结构,纯随机生成输入 | 协议、文件格式测试 |
| 白盒Fuzzing | 基于源码分析,生成覆盖率高 | 有源码的项目 |
| 灰盒Fuzzing | 结合插桩和覆盖率反馈 | 二进制程序、闭源软件 |
我个人最常用的是灰盒Fuzzing。为什么呢?因为它效率高。黑盒像无头苍蝇,白盒又太依赖源码。灰盒通过插桩获取覆盖率信息,能智能地引导测试数据往「没走过」的代码路径去。
核心要点:Fuzzing不是乱撞,而是有策略地探索程序的每一条代码路径。覆盖率越高,找到漏洞的概率越大。
AFL基础使用
AFL(American Fuzzy Lop)是目前最流行的灰盒Fuzzer之一。我当年第一次用AFL时,被它的简洁震惊了——一个命令行就能跑起来。
先说说AFL的工作原理。它通过编译时插桩,在目标程序的每个基本块插入探针。运行时,AFL会监控哪些代码路径被执行了。如果某个输入触发了新的路径,AFL就会保留这个输入,并基于它进行变异。
安装AFL很简单:
# 下载源码
wget http://lcamtuf.coredump.cx/afl/releases/afl-latest.tgz
tar -xzf afl-latest.tgz
cd afl-2.52b
make
sudo make install
使用AFL的基本流程分三步:
- 编译目标程序:用afl-gcc或afl-clang替换原来的编译器
- 准备初始种子:给AFL一些合法的输入样本
- 启动Fuzzing:运行afl-fuzz开始测试
举个例子,假设我们要测试一个图片解析器:
# 第一步:插桩编译
afl-gcc -o target_program target.c -lm
# 第二步:创建种子目录
mkdir -p input_dir output_dir
# 放一个合法的图片进去
cp test.png input_dir/
# 第三步:启动Fuzzing
afl-fuzz -i input_dir -o output_dir ./target_program @@
这里的@@是个占位符,AFL会用生成的测试文件路径替换它。
个人经验:种子文件的质量直接影响Fuzzing效率。我习惯准备3-5个不同大小的合法样本,而不是只放一个。这样AFL能更快地探索到不同的代码路径。
跑起来之后,你会看到一个实时更新的界面。里面有几个关键指标:
- cycles done:已经完成的测试轮数
- total paths:发现的代码路径数
- unique crashes:唯一的崩溃数
- unique hangs:唯一的挂起数
我曾经遇到过一个情况:跑了12小时,unique crashes一直是0。当时差点放弃了。后来检查发现,是种子文件选错了——我放了一个空文件进去,导致AFL一直在无效路径上打转。换了种子后,半小时就炸出了第一个crash。
避坑指南:我曾经因为忘记设置echo core > /proc/sys/kernel/core_pattern,导致AFL无法捕获crash信息。跑了两天,一个crash都没记录到。嗯,这种低级错误犯一次就够了。
Crash分析
炸出crash只是第一步。真正的重头戏是分析这些crash——判断它是不是真正的漏洞,能不能被利用。
AFL会把crash文件保存在output_dir/crashes/目录下。每个文件都有一个唯一的ID。你可以用目标程序重新运行这些文件,看看会不会复现崩溃。
# 复现crash
./target_program output_dir/crashes/id:000000,sig:11,src:000001,op:flip1,pos:4
如果程序崩溃了,下一步就是用调试器分析。我个人习惯用GDB:
gdb ./target_program
(gdb) run < crash_file
# 程序崩溃后
(gdb) bt # 查看调用栈
(gdb) info registers # 查看寄存器状态
(gdb) x/10i $eip # 查看崩溃位置的指令
分析crash时,我重点关注几个方面:
- 崩溃类型:是段错误(SIGSEGV)还是非法指令(SIGILL)?
- 崩溃位置:在哪个函数、哪条指令上崩的?
- 可控性:崩溃时,寄存器或内存中的数据是否被输入控制?
举个例子,有一次我分析一个crash:
Program received signal SIGSEGV, Segmentation fault.
0x0804a1b2 in parse_header (data=0xbffff2a0, size=0x41414141) at parser.c:42
42 memcpy(buf, data, size);
看到size=0x41414141了吗?0x41就是大写字母A的ASCII码。这说明输入数据直接控制了拷贝的大小,典型的缓冲区溢出漏洞。
判断标准:如果崩溃时EIP或数据寄存器能被输入控制,那这个漏洞大概率是可利用的。如果只是空指针解引用,通常只能造成拒绝服务。
分析完crash后,我会做三件事:
- 最小化输入:用AFL自带的
afl-tmin工具,把crash文件缩小到最小复现样本 - 确认漏洞类型:是溢出、越界、还是释放后使用?
- 编写POC:写一个简单的Python脚本,能稳定触发漏洞
说实话,crash分析是最考验功力的环节。Fuzzing本身是体力活,但分析crash需要你对底层机制有深入理解。我建议初学者先从简单的crash入手——比如那些明显是缓冲区溢出的,慢慢积累经验。
我的习惯:每次分析完crash,我都会在笔记里记录三样东西:崩溃的输入特征、触发的代码路径、以及修复建议。时间长了,这就是一份宝贵的漏洞模式库。
最后说一句:Fuzzing不是万能的。它找不到逻辑漏洞,也发现不了需要特定时序触发的bug。但如果你要挖内存破坏类的漏洞,Fuzzing绝对是最趁手的工具之一。