尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

IoT固件模糊测试:AFL++与QEMU用户态模式实战指南

IoT固件模糊测试:AFL++与QEMU用户态模式实战指南 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、Rootfssquashfs/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 的 TBTranslation 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 的 TCGTiny 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/31/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_BINqemu-arm -cpu cortex-a9,vfpon,neonon 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_args3. 从固件镜像中精准提取目标二进制逆向即 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 iffirmware.bin ofzlib_part.bin bs1 skip1193472 python3 -c import zlib; print(zlib.decompress(open(zlib_part.bin,rb).read())) 2/dev/null || echo wrong decompress一旦定位到 SquashFS别急着unsquashfs。先用sasquatchbinwalk的增强版处理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.confdnsmasq需要/etc/dnsmasq.conf。直接qemu-arm ./lighttpd会因配置缺失而退出。这时要用strace捕获真实设备上的系统调用# 在真实设备上需 root strace -f -e traceopen,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 caseupgrade.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_TARGETarm ./build_qemu_support.sh # 注意CPU_TARGET 必须与目标二进制一致armARM32, aarch64ARM64如果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_size0、crc0xFFFFFFFF、payload_len0x10000的畸形包结构化变异模板用afl-showmap提取fw_parser的语法结构生成.dict文件。生成 dict 的方法# 先用合法 upgrade.bin 测试获取覆盖率 echo A | AFL_DEBUG1 afl-showmap -q -o .test_out -- ./fw_parser 21 | 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_ADDR0x12340函数入口地址启用持久模式复用进程避免重复初始化每次变异都重启进程速度下降 5xAFL_QEMU_PERSISTENT_RET0x12345函数返回地址指定函数返回后跳回 forkserver进程卡死或崩溃AFL_SKIP_CRASHES11跳过非目标 crash如 SIGSEGV 在 libc日志刷屏漏掉真实漏洞AFL_FAST_CAL11跳过校准阶段直接 fuzz初始速度慢但对已知二进制更稳-m 10241024 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 modefw_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~20last 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_DEBUG1运行一次检查fuzzer_stats文件中的execs_per_sec和paths_total是否合理。我曾因PATH中混入了qemu-arm的旧版本导致execs_per_sec显示 0.3查了两天才发现是环境变量污染。IoT 模糊测试的成败往往藏在这些不起眼的细节里。
返回列表