AFL/QEMU 模式详解:当 Fuzz 遇上黑盒二进制

说实话,我第一次接触 AFL 的 QEMU 模式时,心里是有点抵触的。你想啊,AFL 本身是个白盒工具,靠编译时插桩拿覆盖率。突然来个黑盒模式,总觉得不够「正宗」。但后来在项目里遇到一个闭源的嵌入式固件,没有源码,只有 ARM 的二进制文件。嗯,那时候我才意识到,QEMU 模式简直就是救星。

这一章,我们就来拆解 AFL 的 QEMU 模式。它到底怎么工作的?为什么能插桩?性能到底有多差?我会结合我踩过的坑,给你讲清楚。

QEMU 模式的核心原理:用户态模拟 + 动态插桩

AFL 的 QEMU 模式,说白了就是跑在 用户态 QEMU 上的。它不模拟整个系统,只模拟一个 Linux 进程。你给它一个二进制文件,它用 QEMU 把这个进程翻译成 x86 指令来执行。

但关键问题来了:AFL 怎么知道代码覆盖情况?

答案就是 动态二进制插桩。AFL 在 QEMU 的翻译块(Translation Block, TB)生成阶段,偷偷插入了自己的覆盖率收集代码。每次 QEMU 翻译一个新的基本块,AFL 就记录下这个块的地址,然后更新边覆盖位图。

核心流程:

  1. AFL 启动 QEMU 用户态模式,加载目标二进制。
  2. QEMU 将目标代码(如 ARM、MIPS)翻译成 x86 微操作。
  3. 在翻译每个基本块时,AFL 的补丁代码注入边覆盖记录逻辑。
  4. 执行翻译后的代码,同时更新共享内存中的位图。
  5. AFL 主进程通过共享内存读取位图,判断是否发现新路径。

我曾经在调试一个 MIPS 的 IoT 设备固件时,发现 AFL 总是报「no instrumentation detected」。折腾了半天,才发现是 QEMU 版本不匹配。AFL 的 QEMU 模式对版本很敏感,我建议你直接用 AFL 源码里自带的 qemu-2.10.0 版本,别自己从系统装。

二进制插桩:AFL 是如何「偷看」代码执行的?

插桩的细节,其实挺巧妙的。AFL 修改了 QEMU 的 translate.c 文件,在 gen_intermediate_code 函数里加了个钩子。每次 QEMU 准备翻译一个新基本块时,这个钩子就会被调用。

钩子做的事情很简单:

  • 记录当前基本块的地址(PC)。
  • 记录上一个基本块的地址(Prev PC)。
  • 计算边覆盖的哈希值:hash = (prev_pc >> 4) ^ (pc << 4) & MAP_SIZE。
  • 在位图中对应位置加 1。

你看,这就是边覆盖(Edge Coverage)的收集方式。不是简单的「这个块被执行了」,而是「从哪个块跳到了哪个块」。这种粒度能更准确地反映程序的控制流。

// AFL 在 QEMU 中插入的伪代码逻辑
void afl_maybe_log(TranslationBlock *tb) {
    if (afl_area_ptr == NULL) return; // 未初始化
    
    uintptr_t cur_loc = tb->pc;
    uintptr_t prev_loc = afl_prev_loc;
    
    // 计算边覆盖哈希
    uint32_t hash = (prev_loc >> 4) ^ (cur_loc << 4);
    hash &= MAP_SIZE - 1;
    
    // 更新位图
    afl_area_ptr[hash]++;
    
    // 更新上一个位置
    afl_prev_loc = cur_loc >> 1;
}

避坑指南: 我曾经在 Fuzz 一个 RISC-V 的二进制时,发现覆盖率一直上不去。后来打印了插桩日志,发现 QEMU 把某些跳转指令优化成了直接跳转,导致边覆盖丢失。解决办法是关闭 QEMU 的 TB 链化优化(-d nochain),虽然会降低性能,但覆盖率更准确。

边覆盖(Edge Coverage)收集:位图与哈希碰撞

AFL 使用一个 64KB 的共享内存作为位图(MAP_SIZE = 2^16)。每次执行测试用例,QEMU 模式都会更新这个位图。AFL 主进程通过比较位图来判断是否发现了新路径。

但这里有个问题:哈希碰撞。因为位图只有 65536 个槽位,而程序可能的边数量远大于这个数。不同的边可能会映射到同一个槽位。

特性 说明
位图大小 64KB (MAP_SIZE = 65536)
哈希算法 (prev_loc >> 4) ^ (cur_loc << 4)
碰撞处理 不处理,直接覆盖计数
路径判断 比较位图是否出现新的非零槽位

你可能会问:碰撞了怎么办?AFL 的设计哲学是「够用就好」。少量的碰撞不会影响 Fuzz 效果,因为只要有一个新边被映射到空槽位,AFL 就会认为发现了新路径。而且,随着 Fuzz 的进行,位图会越来越满,碰撞概率增加,但 AFL 会通过 top_rated 队列和 trim 机制来缓解。

我个人习惯是在 Fuzz 开始前,先用 afl-showmap 跑一遍种子,看看位图的填充率。如果填充率超过 70%,我会考虑增大 MAP_SIZE(需要重新编译 AFL)。

性能开销分析:为什么 QEMU 模式这么慢?

这是大家最关心的问题。QEMU 模式的性能开销,主要来自三个方面:

  1. 指令翻译开销:QEMU 要把目标架构指令翻译成 x86 指令,这个过程本身就很慢。相比原生执行,QEMU 用户态模式大约有 2-5 倍的性能损失。
  2. 插桩开销:每个基本块都要执行 AFL 的插桩代码,包括哈希计算和位图更新。这部分大约增加 20-30% 的开销。
  3. TB 链化失效:为了插桩,AFL 禁用了 QEMU 的部分 TB 链化优化,导致翻译块之间的跳转变慢。

性能对比数据(基于我自己的测试):

  • 原生编译模式(AFL-Clang):约 1000 execs/sec
  • QEMU 模式(x86 二进制):约 200-300 execs/sec
  • QEMU 模式(ARM 二进制):约 80-150 execs/sec
  • QEMU 模式(MIPS 二进制):约 50-100 execs/sec

注意:这些数据受 CPU、二进制复杂度、输入大小影响很大。我在 Fuzz 一个 500KB 的 ARM 二进制时,execs/sec 只有 30 左右,差点崩溃。

为什么会这么慢?你想想看,QEMU 模式每执行一条目标指令,背后可能是几十条 x86 指令在干活。再加上 AFL 的插桩,相当于在每条路径上都设了个检查站。

我建议的优化策略:

  • 使用 -Q 参数启用 QEMU 模式的多线程支持(AFL 的并行化)。
  • 减少输入文件大小,用 afl-tmin 最小化种子。
  • 如果目标架构是 x86,考虑使用 afl-dyninst 或 retrowrite 做静态重写,性能比 QEMU 模式好很多。
  • 关闭不必要的 QEMU 日志和调试输出。

SVG:AFL/QEMU 模式工作流程图

下面这张图展示了 AFL 在 QEMU 模式下的完整工作流,从输入测试用例到输出覆盖率反馈。

AFL/QEMU 模式工作流程图 AFL/QEMU 模式工作流程图 AFL 主进程 测试用例队列 QEMU 用户态模式 二进制翻译 (TB) AFL 插桩代码注入 边覆盖位图更新 路径反馈 关键点 • 无源码依赖 • 跨架构支持 • 性能开销大

实战建议:什么时候用 QEMU 模式?

QEMU 模式不是万能的,但它有自己独特的应用场景。我总结了一下:

  • 没有源码:闭源商业软件、遗留系统、第三方库。这是 QEMU 模式最大的价值。
  • 跨架构 Fuzz:你只有 x86 机器,但目标是 ARM/MIPS/RISC-V 的固件。QEMU 模式让你能在本地跑。
  • 快速验证:在投入时间做源码插桩之前,先用 QEMU 模式跑一轮,看看目标程序是否容易崩溃。
  • 对比测试:用 QEMU 模式跑一遍,再用源码模式跑一遍,对比覆盖率差异,找出编译优化导致的问题。

我的经验: 如果你要 Fuzz 一个 IoT 固件,我建议先用 QEMU 模式跑 24 小时,收集 crash 样本。然后根据 crash 地址,反汇编分析漏洞点。最后针对性地写 PoC。这样比直接逆向整个固件效率高得多。