-
-
[原创]WinAFL 实战:从 0 到 crash
-
发表于: 2026-10-12 22:56 796
-
本文记录一次完整的 WinAFL 模糊测试流程:从源码编译 WinAFL,到用 IDA 选取目标函数、计算偏移,再到正式 fuzz,最后用 WinDbg 分析两个不同类型的 crash。整个过程在 Win10 虚拟机中执行,宿主机的 VS2026 负责编译。
环境准备:VMware + Win10 虚拟机(1909)、VS2026(宿主机,用于编译)、DynamoRIO、IDA Pro、WinDbg。
GitHub 仓库:e01K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6Y4L8$3!0Y4L8r3g2H3M7X3!0B7k6h3y4@1P5X3g2J5L8#2)9J5c8Y4N6A6L8X3q4X3L8l9`.`. ,Code → Download ZIP 下载源码。

WinAFL 默认使用 DynamoRIO 做动态二进制插桩,需要单独下载。

解压到工作目录,与 winafl-master 并列。
在「x64 Native Tools Command Prompt for VS 2026」中逐行执行(普通 cmd 没有编译器环境,必须用 VS 自带的命令行):
说明:

把整个 winafl-master 目录复制到 Win10 虚拟机中(包含编译产物、DynamoRIO、官方测试用例)。
本次第一个目标是 WinAFL 官方提供的 test_gdiplus.exe(位于 build64\bin\Release\),它调用系统 GDI+ 组件解析图片文件。先验证 DynamoRIO 在虚拟机里工作正常:
程序正常结束、无 DynamoRIO 报错输出,即说明插桩环境可用。
fuzz 闭源程序前,必须先选定一个"目标函数":WinAFL 会拦截在这个函数上,每次迭代重写输入文件后把执行流跳回函数开头,在一个进程内循环执行,避免反复重启程序。目标函数需要满足四个条件:
定位方法:无参数运行 test_gdiplus.exe 会打印 Usage: test_gdiplus.exe <image file>,在 IDA 的字符串窗口(Shift+F12)中搜到该字符串,顺交叉引用找到所在函数 sub_140001070(即 main)。


反编译后分析 main 的流程:



main 完成了「打开文件 → 解析 → 关闭 → 正常返回」的完整过程,四个条件全部满足,选定为目标函数。
注意两个容易选错的层:一是 sub_140001240(Image 构造函数)——它的第一个参数是 this 指针,依赖 main 先分配对象,直接 fuzz 它会把垃圾值当 this 用,崩溃毫无意义;二是 CRT 库函数(如 fread、printf 内部函数)——它们只操作控制台/文件句柄等基础设施,与输入文件的解析逻辑无关,目标函数必须是处理输入文件的业务代码。
计算偏移:main 的虚拟地址 VA = 0x140001070,64 位 exe 默认 ImageBase = 0x140000000,所以:
注意:WinAFL README 示例中的偏移(0x16e0)是官方旧版本编译产物,自己编译的版本布局不同,偏移必须自己算,不能照抄。
正式 fuzz 前,先用 drrun 的 debug 模式验证 offset 和参数是否正确。模板:
各段含义:
drrun.exe -c winafl.dll:drrun 是 DynamoRIO 的启动器,负责启动目标程序并在运行时进行动态二进制插桩;-c winafl.dll 加载 WinAFL 的客户端模块,由它记录覆盖率、控制持久化循环、与 afl-fuzz 通信。之后的参数全部传给 winafl.dll。
winafl.dll 的参数:
-- 之后:目标程序的启动命令。-- 是分隔符,之后的内容原样传给目标程序。
实际执行:

运行后在当前目录生成 afl.test_gdiplus.*.log 日志文件:

检查三项:
三项全部满足,说明 offset、模块、参数全部正确,可以正式 fuzz。
在虚拟机中建立种子目录并启动 afl-fuzz:
也可以手动添加几张不同的 .bmp/.jpg/.png 作为种子。

正式 fuzz 命令模板:
实际命令:
@@ 是占位符,每轮迭代会被替换成当前测试文件的路径。
运行结果:持续运行约 3 小时,执行约 91 万次,发现 220 条路径,未出现 crash。
这个结果在预期之内:GDI+ 是存在近二十年的系统组件,支持 BMP/JPEG/PNG/GIF/TIFF 等多种格式解析,历史上漏洞多发,也因此被微软和安全研究者长期 fuzz,BMP 这类成熟路径上容易触发的漏洞已基本修复。crash 不是预设结果,只有变异恰好命中程序缺陷才会产生。选目标时应评估其被 fuzz 的程度:成熟系统组件适合练流程,出洞更可能发生在冷门软件的解析代码上。
两个实用提醒:
输出目录结构:
为了演示 crash 分析,改用 WinAFL 自带的 test.exe(同样位于 Release 目录)。它内部检查输入文件的字符:前 4 字节为 "test" 时,第 5 字节为 '1' 触发空指针写,为 '2' 触发栈缓冲区溢出。
建立种子并启动 fuzz:
由于缺陷离种子仅一个字节,fuzzer 开始运行后很快产生 2 个 crash。

检查 out2\crashes\ 中的两个文件,内容均以 "test1" 开头——两个文件第 5 字节都是 '1',实际触发的是同一个空指针写缺陷。这里需要注意:unique crash 不等于 unique bug,fuzzer 按崩溃上下文去重,同一个缺陷经由不同输入触发可能被保存为多份,最终去重仍需人工核对。

将 crash 文件与 test.exe 复制到宿主机,用 WinDbg 打开(File → Open Executable,Arguments 填 crash 文件路径):


首次输出的报告中包含大量符号加载报错(WRONG_SYMBOLS、not a valid store 等),原因是 WinDbg 符号路径配置错误,无法从微软符号服务器下载符号。这些报错不影响崩溃本身的信息,也可以通过以下命令修复后重新分析:
报告中需要关注的内容只有两处。
崩溃指令(断下时显示的指令):
当前指令向 rcx+rax 指向的地址写入字节 1,而该地址计算结果为 0x0。用 r rcx、r rax 查看,两个寄存器均为 0:
EXCEPTION_RECORD 段:
结论:test+0x124a 位于目标函数 test_target(0x1000 起始)内部,对应源码 if (c == '1') { crash[0] = 1; }——crash 为 nullptr,输入第 5 字节为 '1' 即进入该分支向空指针写入。
crash 的四种常见类型(按可利用性从低到高):
可控性的判断方法是逐参数追问"这个值攻击者能不能决定":本例中写入地址固定为 0、写入值为指令中的立即数 1,输入只能决定是否进入该分支,不能决定写入的内容和位置。因此定级为第①类,空指针解引用,仅造成拒绝服务。
构造内容为 test2 的输入文件(第 5 字节为 '2' 即触发溢出分支),同样用 WinDbg 加载运行。
第一阶段(first chance):程序先断在访问违例:
!analyze -v 的 FAILURE_BUCKET_ID 为 INVALID_POINTER_READ_c0000005,即无效指针读。查看 STACK_TEXT(此时符号已修复,显示正常):
STACK_TEXT 是 WinDbg kb 输出,x64 下各列含义为:Child-SP(栈指针)、RetAddr(返回地址)、Args to Child(参数)、Call Site(当前帧所在函数)。其中 RetAddr 指向调用者,Call Site 是当前函数,因此从"Call Site = B、RetAddr = A+xxx"可知 A 调用了 B;WinDbg 默认从上到下为"最内层 → 最外层",从下往上读即时间顺序。
可以看到两层信息:
第二阶段:继续 g,程序断在另一个异常:
再次 !analyze -v,报告开头的 Key 值直接给出结论:
EXCEPTION_RECORD: