1. 为什么 IoT 固件模糊测试不能照搬桌面程序那一套
AFL++、IoT、二进制文件、模糊测试、qemu——这五个词凑在一起,表面看是标准工具链组合,但实际动手时你会发现:在 IoT 设备固件上跑 AFL++,不是“把 AFL++ 装上就能 fuzz”,而是要亲手重建一套运行环境、重写输入通道、重定义崩溃判定逻辑的系统工程。我第一次在某款国产 Wi-Fi 模组固件上尝试 AFL++ 时,花了整整三天才让第一个 test case 进入目标函数——不是 AFL++ 没跑起来,而是它根本没机会执行到我们想测的代码段。原因很简单:桌面 Linux 下的fork()、execve()、stdin输入流,在嵌入式固件里全不存在。你面对的不是可执行 ELF 文件,而是一块烧录进 Flash 的裸机二进制镜像,里面没有 libc、没有动态链接器、没有进程调度器,甚至没有main()函数。
这个认知偏差,是绝大多数初学者踩坑的起点。很多人看到“AFL++ 支持 QEMU mode”,就默认“只要用afl-qemu-trace启动就行”,结果一跑就是No instrumentation detected或者Program terminated unexpectedly。这不是 AFL++ 的 bug,而是对 IoT 二进制本质的误判。IoT 固件通常由三部分构成:Bootloader(如 U-Boot)、Linux kernel(常带定制 patch)、Rootfs(squashfs/cramfs 镜像)。我们真正要 fuzz 的,往往不是/bin/busybox这类用户态程序,而是内核模块中的网络协议解析器、驱动里的 USB 控制器状态机、甚至 Bootloader 中的 TFTP 加载逻辑——这些代码从不通过argv或stdin接收数据,而是直接从寄存器、DMA 缓冲区或硬件 FIFO 中读取原始字节流。
所以,“使用 AFL++ 对 IoT 二进制文件进行模糊测试”这件事,核心矛盾从来不是 AFL++ 本身够不够强,而是如何把 AFL++ 的变异引擎与 IoT 二进制的真实数据入口精准耦合。QEMU 在这里不是“模拟器”,而是“桥梁”——它必须同时完成三件事:第一,模拟出目标芯片的指令集行为(ARMv7/ARM64/MIPS32);第二,在用户态构建一个可控的、可插桩的数据注入点;第三,把 AFL++ 的 forkserver 机制嫁接到无操作系统的裸机上下文中。这解释了为什么qemu-system-arm和afl-qemu-trace是两套完全不同的东西:前者是完整系统模拟器,后者是为 AFL++ 定制的轻量级用户态翻译器,它不启动内核,只翻译目标二进制的指令流,并在关键位置插入桩点回调 AFL++ 的 forkserver。
提示:不要试图用
qemu-system-arm -kernel vmlinux -initrd rootfs.cgz启动整个系统再跑 AFL++。那会引入不可控的内核调度、中断响应、内存管理开销,导致 AFL++ 的覆盖率反馈严重失真。真正的 IoT 模糊测试,必须降维到用户态二进制层面,哪怕这个二进制原本是为裸机设计的。
我后来在分析某款摄像头固件时发现,其图像解码库libjpeg_arm.so实际上是被 Bootloader 直接加载到物理地址0x80000000后跳转执行的。它没有.dynamic段,不依赖ld-linux.so,所有符号都是绝对地址。这时候,强行用afl-gcc编译重编译?不可能——源码早就不在了。唯一可行路径,就是用 QEMU 用户态模式(qemu-arm)加载这个 SO 文件,通过LD_PRELOAD注入桩代码,劫持memcpy、read等关键函数调用,把 AFL++ 的输入缓冲区映射成该库认为的“DMA 输入缓冲区”。这个过程,本质上是在虚拟机里给一个没有操作系统的二进制“人造操作系统”。
这也引出了本系列第一部分最核心的实操前提:我们必须放弃“fuzz 整个固件”的幻想,聚焦于可隔离、可控制、有明确输入边界的单个二进制组件。它可以是:
- 一个独立的网络服务程序(如
lighttpd、dnsmasq) - 一个内核模块的用户态测试桩(如
usbtest.ko的配套usbtest_app) - 一个 Bootloader 的命令解析函数(通过 patching 构建的最小化入口)
- 一个固件升级包解析器(如
uboot-env工具的静态链接版)
选哪个?取决于你的攻击面优先级。如果你关心远程代码执行,就选监听 TCP/UDP 端口的服务;如果关注物理接口漏洞,就选 USB/UART 协议解析器;如果目标是供应链投毒,就从固件签名验证逻辑入手。记住:IoT 模糊测试的第一步,永远是逆向定位输入边界,而不是急着敲afl-fuzz命令。
2. QEMU 用户态模式:不是模拟器,而是二进制翻译桩
当人们说“AFL++ + QEMU”,90% 的场景其实指的不是qemu-system-*,而是qemu-user-*系列工具,尤其是qemu-arm、qemu-aarch64、qemu-mips。它们和系统模拟器的根本区别在于:不模拟整个计算机硬件栈,只做指令集翻译 + 系统调用转换 + 用户态内存管理。这正是 IoT 二进制模糊测试需要的轻量级桥梁——它足够快(接近原生速度),足够可控(无内核干扰),且能精确拦截每一个read()、write()、memcpy()调用。
但问题来了:qemu-arm本身并不知道 AFL++ 的存在。它只是把 ARM 指令翻译成 x86_64 指令执行,然后把read(fd, buf, len)这样的系统调用,转换成宿主机 Linux 上的等效调用。AFL++ 要介入,必须在qemu-arm的翻译过程中“打补丁”。这就是afl-qemu-trace的由来:它不是 AFL++ 的一个插件,而是基于 QEMU 源码深度修改的专用分支,在 QEMU 的 TB(Translation Block)生成阶段,插入了 AFL++ 的 forkserver 初始化代码和覆盖率收集桩。
我们来看一个具体例子。假设你要 fuzz 的是一个 ARM32 架构的固件升级解析器fw_parser,它从/dev/ttyS0读取二进制升级包,解析头部字段后校验 CRC。正常运行流程是:
# 在 ARM 设备上 ./fw_parser /dev/ttyS0而在宿主机上,你不能直接qemu-arm ./fw_parser,因为/dev/ttyS0在宿主机上并不存在,且fw_parser可能硬编码了串口寄存器地址。正确做法是:
- 重写输入源:用
cat upgrade.bin | qemu-arm ./fw_parser,让fw_parser从 stdin 读取; - 劫持系统调用:用
afl-qemu-trace替代qemu-arm,使其在每次read()返回前,将读取的字节复制到 AFL++ 的共享内存中; - 注入桩点:在
fw_parser的parse_header()函数入口处,通过qemu的tcg_gen_callN插入回调,通知 AFL++ “此处已覆盖”。
这个过程的关键技术点,在于理解afl-qemu-trace如何利用 QEMU 的 TCG(Tiny Code Generator)机制。TCG 不是 JIT 编译器,而是一个中间表示层:QEMU 先把目标指令解码成 TCG ops(类似 LLVM IR),再把这些 ops 编译成宿主机指令。afl-qemu-trace就是在 TCG ops 生成阶段,检测到特定指令模式(如bl parse_header)时,动态插入一段tcg_gen_callN调用 AFL++ 的__afl_maybe_log函数。这个函数会:
- 读取 AFL++ 设置的共享内存地址(通过环境变量
AFL_SHM_ID) - 将当前基本块 ID(通过
__afl_area_ptr[bb_id]++)写入该内存 - 触发 forkserver 的子进程创建
所以,afl-qemu-trace的性能损耗,主要来自 TCG ops 的额外生成和共享内存的原子写入,而非指令翻译本身。实测下来,在 Intel i7-10875H 上,afl-qemu-trace运行 ARM32 二进制的速度,约为原生 ARM 设备的 1/3~1/2,远高于qemu-system-arm的 1/20。
但这里有个致命陷阱:QEMU 用户态模式默认启用--enable-linux-user,但它对某些系统调用的支持是“半吊子”的。比如mmap()的MAP_SHARED | MAP_ANONYMOUS组合,在旧版 QEMU 中会返回-ENOSYS;又比如clock_gettime(CLOCK_MONOTONIC_RAW)可能被错误映射为CLOCK_MONOTONIC。这些看似无关的调用失败,会导致fw_parser在初始化阶段就exit(1),AFL++ 根本等不到第一个 test case。
我的解决方案是:在afl-qemu-trace编译前,手动 patch QEMU 源码中的linux-user/syscall.c。例如,针对mmap问题,找到target_mmap()函数,在case TARGET_MAP_ANONYMOUS:分支下添加:
// 修复 MAP_ANONYMOUS + MAP_SHARED 组合 if (flags & TARGET_MAP_SHARED && flags & TARGET_MAP_ANONYMOUS) { flags &= ~TARGET_MAP_SHARED; flags |= TARGET_MAP_PRIVATE; }这个 patch 看似妥协,实则是务实之举——IoT 二进制极少真的需要跨进程共享匿名内存,把它降级为MAP_PRIVATE不影响功能,却能绕过 QEMU 的实现缺陷。
另一个常见问题是浮点异常。很多 ARM 固件在初始化 FPU 时会执行vmov.f32 s0, #0.0,而 QEMU 用户态模式默认不启用 VFP 协处理器模拟,导致SIGILL。解决方法是在afl-qemu-trace启动时加参数:
AFL_QEMU_CUSTOM_BIN="qemu-arm -cpu cortex-a9,vfp=on,neon=on" afl-fuzz -Q -i in -o out -- ./fw_parser注意,-cpu参数必须写在AFL_QEMU_CUSTOM_BIN环境变量里,而不是直接传给afl-fuzz,否则 AFL++ 无法正确传递给子进程。
注意:
afl-qemu-trace必须与目标二进制的架构严格匹配。ARMv7 和 ARM64 的qemu-arm与qemu-aarch64完全不兼容。曾有人用qemu-aarch64去跑 ARMv7 的busybox,结果 AFL++ 报Invalid instruction at 0xXXXX—— 因为qemu-aarch64把 ARMv7 的movw指令当成了非法指令。架构识别不能靠猜,要用file和readelf -A确认:file fw_parser readelf -A fw_parser | grep Tag_ABI_VFP_args
3. 从固件镜像中精准提取目标二进制:逆向即 fuzz 的第一步
拿到一个.bin固件文件,很多人第一反应是binwalk -e firmware.bin,然后在_firmware.bin.extracted/目录里翻找bin/、sbin/、usr/bin/。这没错,但远远不够。IoT 固件的结构比桌面 Linux 复杂得多:它可能是多段拼接(Bootloader + Kernel + Rootfs),可能是 LZMA/BROTLI 压缩的 SquashFS,甚至可能是自定义加密的分区(如某品牌路由器用 AES-ECB 加密 Rootfs)。更麻烦的是,你真正想 fuzz 的代码,可能根本不在文件系统里,而在内核镜像的.rodata段中,或者被编译进了 Bootloader 的.text段。
所以,“提取目标二进制”不是一个自动化步骤,而是一次完整的逆向分析流程。我把它拆解为四个递进阶段:
3.1 静态结构识别:用 binwalk + firmware-mod-kit 定位有效载荷
binwalk是起点,但不是终点。它的默认扫描规则(-B)对新型固件支持有限。必须配合-A(分析 CPU 架构)、-M(递归提取)、-d(深度扫描):
binwalk -AMd firmware.bin重点观察输出中的Squashfs filesystem、Cramfs filesystem、U-Boot legacy uImage、FIT Image等关键词。如果看到Zlib compressed data但没识别出文件系统,说明压缩算法被魔改,此时要用dd手动切片:
# 查找 zlib 头 0x78 0x9C xxd firmware.bin | grep "789c" # 假设在 offset 0x123400,长度未知,则尝试解压 dd if=firmware.bin of=zlib_part.bin bs=1 skip=1193472 python3 -c "import zlib; print(zlib.decompress(open('zlib_part.bin','rb').read()))" 2>/dev/null || echo "wrong decompress"一旦定位到 SquashFS,别急着unsquashfs。先用sasquatch(binwalk的增强版)处理:
sasquashfs -f -p 4 _firmware.bin.extracted/squashfs-root/-f强制解压,-p 4多线程,-p参数对大固件提速明显。解压后,进入squashfs-root/,用find . -type f -executable -size +10k | head -20列出可疑二进制。
3.2 动态行为捕获:用 strace + gdbserver 定位真实入口
静态提取的二进制,可能依赖特定环境才能运行。比如lighttpd需要/etc/lighttpd/lighttpd.conf,dnsmasq需要/etc/dnsmasq.conf。直接qemu-arm ./lighttpd会因配置缺失而退出。这时要用strace捕获真实设备上的系统调用:
# 在真实设备上(需 root) strace -f -e trace=open,openat,read,write,connect,bind -o strace.log ./lighttpd -D -f /etc/lighttpd/lighttpd.conf分析strace.log,重点关注:
open("/etc/lighttpd/lighttpd.conf", O_RDONLY)→ 配置文件路径open("/var/run/lighttpd.pid", O_WRONLY|O_CREAT|O_TRUNC)→ PID 文件路径socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)→ 监听端口
有了这些路径,就可以在宿主机上用mkdir -p构建最小化环境:
mkdir -p chroot/etc/lighttpd chroot/var/run cp lighttpd.conf chroot/etc/lighttpd/ # 创建空 PID 文件避免报错 touch chroot/var/run/lighttpd.pid3.3 函数级入口定位:用 Ghidra + Cross-Reference 锁定 fuzz 目标
即使拿到了lighttpd,也不能直接 fuzz 整个进程。它的主循环太复杂,覆盖率反馈噪声太大。我们要找的是协议解析的核心函数,比如 HTTP 请求头解析、CGI 参数处理、MIME 类型校验。在 Ghidra 中,打开lighttpd,用Search > For Strings查找"GET "、"POST "、"Content-Length:",然后右键Find References,追踪到http_request_parse()或http_parse_header()函数。
关键技巧:不要只看函数名,要看交叉引用的调用链深度。http_request_parse()可能被connection_state_machine()调用,而后者又被server_main_loop()调用——这个链越长,越难注入可控输入。理想目标是那些被read()直接喂数据的函数,比如buffer_append_string_len()的第二个参数src,它往往直接来自 socket recv 缓冲区。
我 fuzz 某款 NAS 设备的smbd时,发现其 SMB 协议解析器smb_read_andx_request()函数,在反汇编中有一段关键逻辑:
ldr r0, [r4, #0x10] ; load buffer address from struct mov r1, #0x1000 ; length = 4096 bl memcpy ; copy from network buffer这里r0就是输入缓冲区指针。只要我能控制r0指向的内存内容,就等于控制了 SMB 请求体。于是,我用 Ghidra 的Script Manager运行 Python 脚本,自动标记所有memcpy调用点,并按r0寄存器来源排序,最终锁定smb_read_andx_request为最佳 fuzz 入口。
3.4 构建最小化测试桩:剥离依赖,暴露输入接口
定位到smb_read_andx_request(buffer, len)后,不能直接 fuzz 它,因为它是静态链接在smbd里的。必须构建一个独立的测试桩(stub)。步骤如下:
- 用
objdump -t smbd | grep smb_read_andx_request获取函数地址(如0x00012340) - 用
dd从smbd中提取该函数的机器码(需计算长度,通常 200~500 字节) - 写一个 ARM 汇编桩,跳转到该地址,并设置好寄存器:
@ stub.s .section .text .global _start _start: @ set r0 = input buffer address (from AFL++) ldr r0, =input_buffer @ set r1 = input length (from AFL++) ldr r1, =input_len @ call target function ldr r2, =0x00012340 blx r2 @ exit cleanly mov r0, #0 mov r7, #1 swi #0 .data input_buffer: .word 0x100000 input_len: .word 0x100- 用
arm-linux-gnueabihf-gcc -nostdlib -o stub stub.s编译 - 用
afl-qemu-trace运行stub,并通过LD_PRELOAD注入桩库,把input_buffer映射为 AFL++ 的__afl_fuzz_ptr
这个过程听起来繁琐,但它是 IoT 模糊测试的基石。没有精准的入口定位,AFL++ 的变异就是大海捞针;没有最小化桩,覆盖率反馈就会被无关代码淹没。我统计过,对同一款摄像头固件,直接 fuzzlighttpd进程,10 小时内仅发现 2 个 crash;而 fuzz 其http_parse_header桩,1 小时内就触发了 7 个 unique crash,其中 3 个是堆溢出。
4. AFL++ QEMU 模式实战配置:从零构建可复现的 fuzz 环境
现在,我们手上有:一个 ARM32 架构的fw_parser二进制、一个in/目录下的初始 test case(upgrade.bin)、一台 x86_64 宿主机。接下来,是把 AFL++、QEMU、目标二进制真正串联起来的实操环节。这不是简单的afl-fuzz -Q -i in -o out -- ./fw_parser就能搞定的,它涉及环境变量、信号处理、内存限制、超时策略等十多个关键参数的协同。
4.1 编译 AFL++ 与 afl-qemu-trace:版本匹配是生死线
AFL++ 的qemu_mode插件必须与 QEMU 源码版本严格对应。官方文档建议用qemu-5.2.0,但实测qemu-6.2.0更稳定(尤其对 ARM64 支持更好)。编译步骤如下:
# 1. 克隆 AFL++(必须用 dev 分支,stable 版本对 QEMU 支持滞后) git clone https://github.com/AFLplusplus/AFLplusplus.git cd AFLplusplus git checkout dev # 2. 编译 AFL++ 主体(无需特殊选项) make clean all # 3. 进入 qemu_mode,指定 QEMU 源码路径 cd qemu_mode # 下载并解压 qemu-6.2.0 源码到 ../qemu-6.2.0/ wget https://download.qemu.org/qemu-6.2.0.tar.xz tar -xf qemu-6.2.0.tar.xz mv qemu-6.2.0 ../ # 4. 执行 build_qemu_support.sh,关键参数: CPU_TARGET=arm ./build_qemu_support.sh # 注意:CPU_TARGET 必须与目标二进制一致,arm=ARM32, aarch64=ARM64如果build_qemu_support.sh报错qemu not found,检查../qemu-6.2.0/目录是否存在,以及CPU_TARGET是否拼写错误(arm不是arm32)。编译成功后,会在AFLplusplus/qemu_mode/下生成afl-qemu-trace可执行文件。
提示:不要用
apt install qemu-user-static安装的qemu-arm,它没有 AFL++ 的桩代码。afl-qemu-trace必须自己编译,且CPU_TARGET必须匹配。
4.2 构建最小化输入语料库:质量比数量重要十倍
in/目录不是随便丢几个文件就行。AFL++ 的初始语料(seed corpus)决定了 fuzz 的起点高度。对fw_parser,我推荐三种种子:
- 合法升级包:从设备上抓取的真实
upgrade.bin,确保格式正确; - 边界值测试用例:用 Python 生成
header_size=0、crc=0xFFFFFFFF、payload_len=0x10000的畸形包; - 结构化变异模板:用
afl-showmap提取fw_parser的语法结构,生成.dict文件。
生成 dict 的方法:
# 先用合法 upgrade.bin 测试,获取覆盖率 echo "A" | AFL_DEBUG=1 afl-showmap -q -o .test_out -- ./fw_parser 2>&1 | grep "hitcount" # 找到关键字符串,如 "FWHDR"、"CRC32"、"VER1.0" echo '"FWHDR"' > fw.dict echo '"CRC32"' >> fw.dict echo '"VER1.0"' >> fw.dict然后在afl-fuzz中启用:
afl-fuzz -Q -i in -o out -x fw.dict -- ./fw_parser-x参数让 AFL++ 在变异时优先替换字典中的 token,极大提升对协议字段的探索效率。
4.3 关键环境变量与参数详解:每个都影响 fuzz 效果
AFL++ QEMU 模式下,以下环境变量和参数不是可选项,而是必调项:
| 环境变量/参数 | 推荐值 | 作用原理 | 不设置的后果 |
|---|---|---|---|
AFL_QEMU_PERSISTENT_ADDR=0x12340 | 函数入口地址 | 启用持久模式,复用进程避免重复初始化 | 每次变异都重启进程,速度下降 5x |
AFL_QEMU_PERSISTENT_RET=0x12345 | 函数返回地址 | 指定函数返回后跳回 forkserver | 进程卡死或崩溃 |
AFL_SKIP_CRASHES=1 | 1 | 跳过非目标 crash(如 SIGSEGV 在 libc) | 日志刷屏,漏掉真实漏洞 |
AFL_FAST_CAL=1 | 1 | 跳过校准阶段,直接 fuzz | 初始速度慢,但对已知二进制更稳 |
-m 1024 | 1024 MB | 内存上限,防止 OOM kill | 进程被系统杀死,fuzz 中断 |
其中,AFL_QEMU_PERSISTENT_*是 QEMU 模式提速的核心。它要求你手动在 Ghidra 中找到目标函数的起始地址(0x12340)和返回后跳转地址(0x12345)。这个地址是fw_parser在内存中的加载基址 + 偏移,可用readelf -S fw_parser查看.text段地址,再用objdump -d fw_parser | grep "<parse_header>:"定位。
实测对比:不启用 persistent mode,fw_parser的执行速度为 120 execs/sec;启用后,飙升至 680 execs/sec。这是因为避免了每次变异都重新加载.so、解析libc符号、初始化全局变量的开销。
4.4 监控与调试:读懂 AFL++ 的输出,比跑起来更重要
afl-fuzz启动后,终端会显示实时状态栏。新手常忽略的关键指标:
- cycles done:已完成的 fuzz 循环数。1 个 cycle = 对所有存活的 queue entries 各变异一次。低于 0.1 表示覆盖率增长停滞;
- pending favs:待变异的高价值路径数。持续为 0 说明初始语料太弱;
- corpus count:当前语料库大小。健康增长应为每小时 +5~20;
- last new path:最后发现新路径的时间。超过 2 小时无更新,需检查输入边界是否正确;
- saved crashes:崩溃数。注意区分
crashes/和hangs/目录——前者是 segfault,后者是超时 hang,后者更可能是 DoS 漏洞。
当发现saved crashes为 0,但pending favs持续下降,大概率是fw_parser在解析失败时调用了exit(0)而非abort(),导致 AFL++ 无法捕获 crash。此时要用gdb附加:
gdb --args qemu-arm ./fw_parser crash-000000 (gdb) catch syscall exit_group (gdb) run如果exit_group被捕获,说明程序“优雅退出”了,需 patch 二进制,在exit(0)前插入kill(getpid(), SIGABRT)。
另一个高频问题:afl-fuzz报Fork server handshake failed。这几乎 100% 是afl-qemu-trace与目标二进制的架构不匹配,或LD_LIBRARY_PATH污染了 QEMU 的运行环境。解决方案是清空环境:
env -i PATH=/usr/bin:/bin AFL_QEMU_CUSTOM_BIN="./afl-qemu-trace" afl-fuzz -Q -i in -o out -- ./fw_parser最后,分享一个血泪经验:永远在out/目录启用AFL_DEBUG=1运行一次,检查fuzzer_stats文件中的execs_per_sec和paths_total是否合理。我曾因PATH中混入了qemu-arm的旧版本,导致execs_per_sec显示 0.3,查了两天才发现是环境变量污染。IoT 模糊测试的成败,往往藏在这些不起眼的细节里。