ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从崩溃到根因:Fuzzing漏洞分析的完整链路与实战技巧

从崩溃到根因:Fuzzing漏洞分析的完整链路与实战技巧

一说到 Fuzzing 漏洞分析,很多人第一反应是跑工具、看崩溃数量,可真正拉开水平差距的,其实是崩溃之后的根因分析技术。跑一个晚上,模糊器能给你上万条异常输入,真正有价值的是你能否从这一堆崩溃里快速分清:哪些是同一个 bug 的重复触发,哪些只是环境噪声,以及那个真实漏洞到底出在哪一行代码。这篇内容没有门槛,适合刚接触漏洞挖掘、或者已经会跑 fuzzer 但一分析就头大的开发者和安全研究者。我会把整套根因分析的思路、环境准备、工具使用和实战案例拆开讲,尽量少给空话,多给能直接抄作业的步骤。

1. 从崩溃现场到根因:先建立正确的分析链路

1.1 Fuzzing 产出的崩溃,为什么不能直接当漏洞看

我见过不少新人拿到一个 crash 文件就立刻开调试器,这是直觉,但效率极低。Fuzzing 的特点是制造海量异常输入,同一个漏洞被触发几十上百次太常见了。这些触发样本可能栈回溯几乎一样,只是输入字节不同;也可能同一个漏洞在不同路径里表现为完全不同的崩溃信息,比如一次是越界读,一次是越界写。如果只盯着单个样本看,很容易把同一个根因误判成多个漏洞,或者把几个不同漏洞当成一个。

这里建议先做“家族分类”。我的习惯是提取崩溃栈的前几帧,把函数名、指令偏移、访问类型拼成一个签名,再按签名聚合。有些项目会用专门的崩溃去重工具,甚至能按二进制行号归并,但没有工具的时候,一个简单的脚本也能顶大用。关键不是工具多花哨,而是你要先意识到:拿到一堆 crash 不等于拿到一堆漏洞。

另外一个常见误区是只看崩溃地址。崩溃地址往往受堆布局、ASLR、输入长度影响,同一个根因可能落在完全不同的地址上,所以拿地址当分类依据极其不可靠。真正稳定的特征是调用关系,也就是从崩溃点往上回溯的那几层函数。

1.2 根因分析不是单点操作,而是完整链路

根因分析在我这里永远是一套动作,而不是某一个命令。完整链路大概是:原始崩溃收集、去重分类、稳定复现、用例最小化、初步定位、根因确认、修复点验证。每一步都有独立目标,跳过去往往要付出重复劳动。

  • 收集阶段:从 fuzzer 输出目录里把全部崩溃样本和日志保留下来,别在分析过程中反复覆盖。
  • 去重阶段:按栈签名把同类样本合并,挑一个代表样本继续往下走。
  • 复现阶段:保证崩溃能在本机稳定复现,这是后续所有分析的地基。
  • 最小化阶段:把几百 KB 的输入裁剪到几十字节,只保留触发漏洞的最小结构。
  • 定位阶段:结合 sanitizer 报告、调试器、反汇编找出崩溃发生的直接位置。
  • 确认阶段:回答“为什么这里会越界”“数据是怎么变成这个值的”,这一步才是根因。
  • 验证阶段:代码改动后,用原样本和最小化样本确认崩溃消失。

很多分析卡壳,不是技术不够,而是前两步没做好。同一个漏洞换了个输入又崩一次,你以为是新问题,重新分析半天,最后发现根因一模一样。所以我会先花十分钟做聚合和复现,再动手查细节。

2. 复现难,先从环境与输入两个变量下手

2.1 构建可复现的最小测试环境

复现不稳定是根因分析的第一大坑。你在自己机器上跑不出来的崩溃,常常不是不存在,而是环境不一样。最简单的要求是:用和 fuzzing 时完全相同的源码版本、编译器、编译选项和 sanitizer 配置重新编译目标程序。

我常用的编译参数是-g -O1 -fsanitize=address,undefined -fno-omit-frame-pointer。这里不是越优化越好,-O2会引入大量内联,栈回溯会很难读;-O0又太慢,不利于批量验证。-O1在还原难度和运行效率之间比较平衡。如果是自己写的测试目标,可以做成类似下面的构建命令:

cmake -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DCMAKE_C_FLAGS="-fsanitize=address,undefined -g -O1 -fno-omit-frame-pointer" \ -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined"

除了编译参数,系统级随机性也会影响复现。Linux 上建议把 ASLR 关掉:setarch -R ./target crash.bin,或者临时调整kernel.randomize_va_space。多线程类漏洞还要固定 CPU 核心,比如用taskset -c 0 ./target crash.bin,减少调度随机性。最后别忘了ulimit -c unlimited,万一 sanitizer 没有当场打印完整信息,还能靠 core 文件救命。

2.2 测试用例最小化:让根因从噪声里浮出来

很多崩溃样本被 fuzzer 拼得非常臃肿,里面大量字节和触发漏洞无关。带着这些无关字节去分析,你很容易被误导。比如一个图片解析器崩溃,可能关键字段只有文件头里的宽度和高度,其他全是填充数据。

最小化的原理说白了就是“不停地尝试删字节,删完还崩就保留”。常见工具是afl-tmin,用法很直接:

afl-tmin -i crash.bin -o crash.min -- ./target @@

它会在保留崩溃行为的前提下尽量缩短输入。我还会配合十六进制编辑器手工微调:把某个字段改成 0、改成最大值、改成边界值,观察崩溃行为是否变化。这一步做得越细,后面定位越轻松。最小化之后,你往往能一眼看到触发条件,比如“文件头第 8 字节控制分配大小”,这比在几 MB 的样本里大海捞针高效得多。

这里有一个很值得养成的习惯:把最小化后的输入保存成独立文件,并记录它对应的目标和构建版本。等代码改完需要回归验证时,这套组合就是最可靠的回归测试。

3. 读懂 Sanitizer 报告:第一线索往往就在输出里

3.1 ASan 报告里每一行在说什么

很多新手把 sanitizer 输出当成“报错信息”扫一眼就完事,其实每一行都有分析价值。以常见的堆缓冲区溢出为例,报告大概是这样的:

==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200000eff4 READ of size 4 at 0x60200000eff4 thread T0 #0 0x4a2b44 in parse_entry src/demo.c:121:9 #1 0x4a1c90 in parse_header src/demo.c:88:3 #2 0x4a1000 in main src/demo.c:40:3 0x60200000eff4 is located 8 bytes to the left of 16-byte region allocated by thread T0 here: #0 ... in malloc #1 0x4a1c30 in parse_header src/demo.c:80:15

第一行告诉我们这不是空指针解引用,而是访问了一块已分配堆内存附近的非法区域。第二行的READ of size 4提示是读 4 字节越界,位置在parse_entry第 121 行。allocated by部分更关键:它告诉你这块内存是谁分配的、分配时多大、在哪个调用路径上分配的。

我拿到报告后会同时看两块:崩溃栈和分配栈。如果崩溃点离分配点很远,说明这个对象可能是传了几层才被错误使用;如果两者就在同一个函数里,那问题大概率是长度计算有误。你把这层关系理清楚,就已经完成了 50% 的根因判断。

3.2 栈回溯不一定可信:注意优化、内联和栈破坏

不要太依赖栈回溯的完整性。编译优化、内联函数、尾调用优化都可能让回溯丢失中间层。更麻烦的是,某些漏洞会把栈本身写坏,导致调试器打印出来的调用栈完全是乱的。

如果地址没有自动翻译成函数名,我会先做一次手工翻译。常见命令是:

addr2line -e target -f -C 0x4a2b44

或者用llvm-symbolizer。对于被 strip 过的二进制,objdump -d配合地址计算也能定位到附近指令。如果栈回溯乱到没法看,我会回到反汇编层面,从当前崩溃指令往前推,看哪些值是从栈上哪个位置加载的,逐步重构出真实的调用关系。

还有一点容易忽略:-fno-omit-frame-pointer能显著提高 x86_64 上栈回溯的可靠性。加上这个选项后帧指针不会被优化掉,调试器更容易还原调用链,付出的代价只是少量性能损耗,在分析阶段完全值得。

4. 从崩溃现场逆推根因的核心手段

4.1 沿着调用链和数据流逆向判断

定位根因不是回答“崩在哪个函数”,而是回答“为什么这个函数会崩”。我通常先把崩溃点的参数、局部变量、对象大小全部打印出来,然后沿着数据流往前找:这个越界索引是从哪里读进来的?中间有没有做长度校验?分配大小是用哪个表达式算出来的?

举个典型场景:代码里memcpy(dst, src, len)崩溃在memcpy内部,第一反应不是看memcpy,而是看dst当初分配了多少字节。如果dst是 16 字节,复制长度是 64,那问题可能出在长度计算,而不是复制本身。接下来就找len的来源,以及它应该在哪个环节被限制。

对于手工分析,GDB 的watch命令偶尔能帮大忙。如果崩溃发生在某个全局缓冲区的写入,你可以在写入前对关键内存地址设置观察点,每次被修改都会停下来。不过 Fuzzing 场景里对象地址往往是堆地址,观察点对堆内存也有效,只是运行速度会慢不少,建议只在最后收网阶段使用。

4.2 用二分法和提交历史快速缩小范围

如果目标代码有版本管理记录,git bisect是定位“功能回归型漏洞”的利器。你不需要一开始就搞懂整个算法,只需要一个能稳定判断“这次构建是否包含缺陷”的脚本。比如判断崩溃输出里是否出现指定 sanitizer 关键字:

#!/bin/sh ./target /tmp/crash.min 2>&1 | grep -q 'AddressSanitizer: heap-buffer-overflow'

然后执行:

git bisect start git bisect bad HEAD git bisect good 上一个已知正常的提交

接下来每次让 bisect 自动切换到中间提交,重新编译并运行上面的脚本。几十个提交就能在十分钟内把问题定位到具体一次改动。这个方法的副作用是编译次数多,所以我会提前准备写好的自动化脚本,不让 bisect 停在半路要我手工输入。

4.3 没有源码、无法重编时怎么办

有些分析目标不是自己维护的项目,拿不到源码,也没法轻易切换到 sanitizer 构建。这时我会退回到二进制层面。首先把一个正常版本和一个崩溃版本都拿来做二进制 diff,观察关键函数的变化;其次关掉 ASLR 后用调试器反复运行,记录崩溃地址、寄存器值、堆分配调用序列,对比差异;还可以给分配器挂一层 hook,记录每次malloc的大小和调用栈,再和崩溃点的写入偏移对比。

动态污点追踪工具也能用,它对输入字节的传播路径做标记,能告诉你是输入文件里的哪一段字节最终影响到了崩溃地址。这类工具配置成本偏高,建议先人工分析,实在绕不开再上。

5. 实战案例:一个理论上的整数溢出越界是怎么定位的

5.1 现象与现场还原

为了把上面的方法串起来,我虚构一个典型的图像解析场景。某目标程序读取一个自定义格式的图片,文件头包含width和height,都是 32 位无符号整数。Fuzzer 短时间内抛出了几十个崩溃,去重后剩下一类栈回溯相同的heap-buffer-overflow。

最小化后的输入只有二十几个字节。ASan 报告显示,崩溃发生在decode_pixel对像素缓冲区的写入,分配的缓冲区大小来自一个几十行的函数parse_image。这时候我不会立刻改代码,先在关键行打断点看数值。

代码逻辑大致是这样的:

uint32_t channels = 4; uint32_t len = width * height * channels; // 32 位乘法 uint8_t *buf = malloc(len); for (uint32_t y = 0; y < height; y++) { for (uint32_t x = 0; x < width; x++) { uint32_t off = (y * width + x) * channels; memcpy(buf + off, pixel_data, channels); } }

输入里width很大、height也很大。width * height * channels在 32 位整数里直接溢出,len变成 0,malloc(0)分配了一块近乎空的内存。可循环里的off照样按原始宽高计算,于是一边写一边越界,ASan 终于在一次写入时拦了下来。

5.2 定位过程与修复方案

在 GDB 里,我把断点设在parse_image的malloc那一行,然后运行最小化样本:

(gdb) break parse_image:70 (gdb) run crash.min.bmp (gdb) p width $1 = 268435456 (gdb) p height $2 = 16 (gdb) p width * height * channels $3 = 0

看到第三个输出是 0 的时候,根因基本就清楚了。这不是简单的越界写,而是整数溢出导致分配长度错误。接下来我验证正确计算应该需要多少空间:(size_t)width * height * channels,结果远超 32 位范围,于是确认是乘法溢出。

修复要从两层入手。第一层是计算本身,改用足够宽的类型:

size_t len = (size_t)width * height * channels; if (width == 0 || height == 0 || width > SIZE_MAX / height / channels) { return -1; }

第二层是业务合法性校验。图片宽高本身应该有上限,不能只依赖除法检查,因为即使不溢出,一个“合理”的巨大宽高也会导致内存耗尽。我在实际修复里会增加一个和业务强相关的最大尺寸限制,比如单边不能超过 8192。修复之后再跑原样本、最小化样本以及完整 fuzz 一轮,看到崩溃消失才算闭环。

这个案例里,如果一开始就开了 UndefinedBehaviorSanitizer 的整数溢出检测,问题可能更早暴露。但很多逻辑漏洞并不会触发编译器约定的未定义行为,所以双手准备最稳妥:一方面关注 sanitizer 的越界信号,另一方面始终保留对“长度从哪来”的怀疑。

6. 常见问题与排查避坑清单

现象可能原因处理思路
崩溃只在 fuzzer 环境出现,本地复现不了编译参数不一致、ASLR、多线程调度差异用相同 build、关 ASLR、固定 CPU 核,必要时上容器
多个崩溃栈完全相同同一根因的多次触发按栈签名合并,选最小样本继续分析
报错发生在 free 时,而不是访问时堆元数据被更早的越界写破坏往前翻分配栈,查所有写入点,不要只盯 free
ASan 没崩,但 UBSan 报整数溢出计算逻辑有边界问题但还没转化为越界把溢出点当成根因,先修计算再回归测试
栈回溯没有函数名符号缺失、strip 或内联优化用 addr2line、llvm-symbolizer,必要时关内联重编
同样的输入,崩溃地址每次都不同堆布局或 ASLR 影响关闭 ASLR,重点比较调用栈而不是地址
多线程程序崩溃不稳定数据竞争、时序问题用线程检测器或锁分析,固定核数反复重跑

除了这些,我还有一个长期踩坑经验:每次分析前先确认目标版本。很多崩溃是旧代码已经修复过的问题,只是 fuzzer 跑的是残留的旧二进制。我会在开始前记录源码 commit、编译时间、sanitizer 类型,甚至直接把这三者写进输出文件名。否则分析到一半发现版本不对,前面所有时间都白费。

另一个容易忽略的点是 sanitizer 本身的配置。ASAN_OPTIONS=abort_on_error=1能保证崩溃时立即生成 core;detect_leaks=0可以关闭内存泄漏检查,避免把泄漏误当成漏洞干扰视线。分析时默认开启 symbolize,必要时关闭它再看原始地址,反而能排除符号化过程的误导。

7. 工具链清单与快速上手建议

7.1 一套够用的最小工具链

根因分析不需要太多重型平台,常备几个工具就能干活。我按用途整理过一套组合:

用途推荐选择替代方案
内存错误检测ASan、UBSanValgrind(慢,但兼容部分场景)
崩溃复现GDB、LLDB直接看 sanitizer 输出
符号解析addr2line、llvm-symbolizer反汇编手动计算
输入最小化afl-tmin、自写滴滴裁剪脚本手工十六进制编辑
崩溃去重自写栈签名脚本专用崩溃管理平台
版本二分git bisect手工回归旧版本
性能分析perf、callgrind不需要时可以略过

不要把工具数量当成目标。我见过有人同时装五六个分析平台,真正落到崩溃样本上的时间却很少。先用好 GDB 和一个裁剪工具,跑通一次完整分析,再按需扩充。

7.2 新手怎么在最短时间里上手

我的建议是找一个自己熟悉的小型解析项目,故意写一个越界读或整数溢出,然后用 fuzzer 去测,测出崩溃以后再按这套流程分析。整个过程不需要追求“挖到真洞”,目的是把“崩溃样本到根因”这条路走熟。

具体路径可以是:先用最简单的输入跑通复现,再练习手工改造输入字段,接着尝试gdb打印关键变量,最后写一个git bisect脚本验证某个历史提交。这个训练做三次,再回头看真实项目的崩溃,你会发现思路清晰得多。

还有一个提升效率的小技巧:把每次分析的结论写成简短记录,包括根因类型、触发条件、修复位置、分析耗时。积累几十条之后,你会慢慢发现某些代码模式高概率对应某类漏洞,下次看到类似报告,几分钟就能判断大致方向。

我自己带人时最常用的训练就是让他们连续分析十个崩溃样本,但要求前十次不许直接改代码,只许写分析报告。等他们把“崩在哪”和“为什么崩”分开想清楚,后面的修复验证往往快得惊人。根因分析这项技术,说到底就是一层层追问“为什么”,问得足够深,答案自然就浮出来了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进