3. Fuzz基础理论:Fuzz测试的定义、变异策略与覆盖率引导

好,我们正式开始聊Fuzz。说实话,Fuzz这个词听起来挺酷的,但很多人一开始会觉得它很玄乎。其实没那么复杂。我当年刚接触安全时,觉得Fuzz就是“乱搞”——往程序里塞一堆乱七八糟的数据,看它崩不崩。嗯,这个理解虽然糙,但方向是对的。

Fuzz测试,全称Fuzzing,说白了就是自动化地生成大量畸形或半合法的输入,喂给目标程序,然后监控它会不会异常退出、崩溃或者触发某些我们关心的行为。你想想看,一个程序处理正常输入时当然没问题,但遇到边界情况、编码错误、内存管理漏洞时,往往就露馅了。Fuzz就是专门干这个的。

核心定义:Fuzz测试是一种通过向目标程序提供非预期的输入,并监视其异常结果来发现软件漏洞的自动化测试技术。它的核心三要素是:输入生成器、目标程序、异常监控器。

3.1 变异策略:从Bit Flip到Block

Fuzz怎么生成这些“坏数据”?主要有两种思路:基于变异的Fuzz和基于生成的Fuzz。我们做Pwn,尤其是异架构Pwn,绝大多数场景用的都是基于变异——拿一个合法的种子文件,然后“搞破坏”。

我个人习惯把变异策略分成三个层次:位级、字节级、块级。下面这张图能帮你快速建立整体认知。

Fuzz 变异策略三层结构 种子输入 (Seed) Bit Flip (位翻转) 翻转单个/多个比特位 Byte Flip (字节翻转) 翻转/替换/增减字节 Block (块操作) 大块插入/删除/拼接 常见操作 单比特翻转、多比特翻转 随机位翻转 常见操作 字节替换、字节删除 字节插入、字节拼接 常见操作 大块数据替换、字典插入 块拼接、块分裂 粒度从细到粗,破坏性从低到高

3.1.1 Bit Flip(位翻转)

这是最细粒度的变异。把种子文件里的某个比特位,从0变成1,或者从1变成0。为什么有效?因为很多协议、文件格式的标志位、校验位、长度字段往往只占几个比特。你翻转一下,可能就把一个“合法”的包变成了一个“畸形”的包。

举个例子,一个PNG图片的IHDR块里,颜色类型字段占1个字节。你把这个字节的某一位翻转一下,解码器可能就懵了——分配了错误的内存,或者进入了错误的处理分支。我在一次Fuzz图像库时,就靠一个单比特翻转,触发了一个堆溢出。当时找了半天才定位到,原来就是那个标志位的问题。

我的经验:Bit Flip对协议解析器、文件格式解析器特别有效。但要注意,如果目标程序有强校验(比如CRC32),单比特翻转大概率会被直接丢弃。这时候就需要结合其他策略了。

3.1.2 Byte Flip(字节翻转)

字节级操作比位级更“暴力”。常见的操作包括:

  • 字节替换:把某个字节替换成随机值、0xFF、0x00等特殊值
  • 字节删除:随机删除一个或多个字节
  • 字节插入:在随机位置插入特殊字节
  • 字节拼接:把两个不同种子文件的片段拼在一起

字节翻转的破坏力更强。你想想看,一个长度字段是4字节的,你把它从0x00001000改成0xFFFFFFFF,程序接下来分配内存时会发生什么?要么分配失败,要么分配一个巨大的缓冲区——然后后面写入数据时,就可能越界。

我记得有一次Fuzz一个网络协议栈,就是通过字节替换把IP头部的总长度字段改大,结果触发了内核态的内存越界写。嗯,那是个挺经典的漏洞。

3.1.3 Block(块操作)

块操作是最高层级的变异。它不关心具体的字节内容,而是把数据当成一个个“块”来处理。常见的块操作包括:

  • 块替换:用另一个种子的大块数据替换当前块
  • 块插入:在块之间插入新的数据块
  • 块删除:删除整个数据块
  • 字典插入:从预定义的字典(比如SQL注入关键词、shellcode片段)中选取数据插入

块操作特别适合结构化数据。比如你Fuzz一个XML解析器,用Bit Flip去改标签名,效率很低。但如果你用块操作,把一个完整的合法标签替换成另一个合法标签,或者插入一个嵌套的标签块,就很容易触发解析器的逻辑漏洞。

避坑指南:我曾经在一个项目里,只用了Bit Flip去Fuzz一个ELF加载器,跑了24小时一个crash都没出。后来换成块操作,把ELF的section header整块替换成随机数据,半小时就出了3个crash。所以,变异策略的选择一定要结合目标程序的输入格式,不要死磕一种。

3.2 覆盖率引导:AFL与LibFuzzer原理

早期的Fuzz是盲目的——随机生成输入,随机喂给程序。效率很低,因为大部分变异后的输入可能根本没触发新的代码路径。后来有了覆盖率引导(Coverage-guided),Fuzz才真正变得“智能”起来。

核心思想很简单:如果这次变异后的输入,让程序执行了之前没执行过的代码,那这个输入就是“好”的,应该保留下来继续变异。反之,如果它没有触发新路径,那就丢弃。

下面这张图展示了覆盖率引导Fuzz的工作流程。

覆盖率引导 Fuzz 工作流程 种子队列 (Queue) 变异器 (Mutator) 目标程序 (Target) 崩溃 (Crash) 覆盖率检测 (Coverage Check) 核心逻辑:只有触发新代码路径的输入才会被加入种子队列,继续下一轮变异 崩溃输入直接保存,不参与后续变异

3.2.1 AFL(American Fuzzy Lop)

AFL是覆盖率引导Fuzz的里程碑。它的核心创新在于轻量级的插桩和边覆盖率(Edge Coverage)。

AFL在编译时,会在每个基本块(Basic Block)的入口插入一段桩代码。这段代码记录当前基本块和前一个基本块的组合——也就是“边”。为什么是边而不是块?因为边能反映控制流的走向。两个不同的路径可能经过相同的基本块,但经过的边不同。

AFL用一个64KB的位图来记录覆盖率。每次执行完一个输入,它就把这次执行覆盖到的边,在位图上标记出来。如果发现有新的边被覆盖了,就把这个输入加入队列。

AFL的关键参数:

参数 含义 我的建议
-i 输入种子目录 种子文件越小越好,最好能覆盖不同路径
-o 输出目录 包含crashes、queue、plot_data等子目录
-x 字典文件 对文本协议Fuzz时,字典能大幅提升效率
-t 超时时间(ms) 异架构下建议设大一点,比如5000
-m 内存限制(MB) 防止目标程序吃掉所有内存

AFL还有一个很聪明的设计——确定性变异阶段和随机性变异阶段。刚开始,AFL会先用确定性策略(比如依次翻转每个比特、每个字节)去探索种子文件。等确定性阶段跑完了,再进入随机性阶段,用更激进的策略去变异。我刚开始用AFL时,总觉得确定性阶段太慢,想直接跳过。后来发现,确定性阶段虽然慢,但能稳定地发现新路径,为后续的随机阶段打好基础。

3.2.2 LibFuzzer

LibFuzzer是LLVM项目的一部分,和AFL的思路类似,但有一个关键区别:LibFuzzer是进程内的(In-process)。它把Fuzz目标和Fuzz引擎链接到同一个可执行文件里,通过函数调用来执行测试,而不是像AFL那样通过fork+exec来启动子进程。

这样做的好处很明显:速度快。没有进程创建的开销,没有IPC的延迟。LibFuzzer每秒能执行的测试用例数量,通常比AFL高一个数量级。

但代价也很明显:稳定性要求高。因为是在同一个进程里,如果目标程序崩溃了,整个Fuzz进程就挂了。所以LibFuzzer需要目标程序能优雅地处理错误,或者使用Sanitizer(比如ASan)来捕获崩溃。

我的选择:在异架构Pwn中,我通常这样选:

  • 如果目标程序是用户态、单次执行、无状态的(比如一个图片解码器),优先用LibFuzzer,速度快。
  • 如果目标程序是有状态、需要持久化连接、或者跑在模拟器里的,用AFL,因为它通过fork来重置状态,更稳定。

LibFuzzer的覆盖率检测和AFL类似,也是基于边覆盖率。但它有一个独特的优势——可以自定义Mutator。你可以写自己的变异函数,实现领域特定的变异策略。比如,你Fuzz一个网络协议,可以写一个Mutator,专门修改协议头部的特定字段,而不是随机乱改。

下面是一个简单的LibFuzzer示例,展示如何自定义Mutator:

// 自定义变异函数示例
extern "C" size_t LLVMFuzzerCustomMutator(
    uint8_t *Data, size_t Size, size_t MaxSize, unsigned int Seed) {
  
  // 1. 随机选择一个变异操作
  switch (rand() % 3) {
    case 0:  // 翻转第一个字节
      if (Size > 0) Data[0] ^= 0xFF;
      break;
    case 1:  // 在末尾追加一个魔数
      if (Size + 4 <= MaxSize) {
        Data[Size] = 0xDE;
        Data[Size+1] = 0xAD;
        Data[Size+2] = 0xBE;
        Data[Size+3] = 0xEF;
        Size += 4;
      }
      break;
    case 2:  // 删除中间一段
      if (Size > 10) {
        size_t pos = rand() % (Size - 5);
        memmove(Data + pos, Data + pos + 5, Size - pos - 5);
        Size -= 5;
      }
      break;
  }
  return Size;
}

避坑指南:我曾经在Fuzz一个嵌入式设备的网络协议栈时,直接用LibFuzzer默认的Mutator,跑了很久都没效果。后来写了一个自定义Mutator,专门针对协议头部的长度字段和校验和字段做变异——把长度改大、校验和改错。结果不到一小时就找到了一个远程拒绝服务漏洞。所以,不要偷懒,针对目标协议写自定义Mutator,回报率很高。

3.3 覆盖率指标:不只是“覆盖了多少代码”

很多人以为覆盖率就是“代码行覆盖了多少”。其实在Fuzz里,我们更关心的是边覆盖率(Edge Coverage)和路径覆盖率(Path Coverage)。

指标 含义 Fuzz中的意义
行覆盖率 执行了多少行代码 基础指标,但粒度太粗
函数覆盖率 执行了多少个函数 用于评估模块的测试深度
边覆盖率 执行了多少条控制流边 AFL/LibFuzzer的核心指标
路径覆盖率 执行了多少条完整路径 理论上最精确,但实际中路径爆炸

为什么边覆盖率比行覆盖率好?举个例子:

if (x > 0) {
    // A
} else {
    // B
}
if (y > 0) {
    // C
} else {
    // D
}

行覆盖率只能告诉你“A、B、C、D都执行过了”。但边覆盖率能告诉你,从A到C、A到D、B到C、B到D这四条边,哪些被执行过。如果只执行了A→C和B→D,那说明还有两条边没覆盖到——可能隐藏着bug。

我个人在Fuzz时,会同时关注两个数字:总边数和唯一崩溃数。总边数增长停滞了,说明Fuzz遇到了瓶颈,需要换种子或者调参数。唯一崩溃数突然增加,说明可能找到了一个“富矿”漏洞点。

总结一下本章的核心要点:

  • Fuzz测试 = 自动化生成畸形输入 + 监控异常行为
  • 变异策略从细到粗:Bit Flip → Byte Flip → Block,破坏性递增
  • 覆盖率引导让Fuzz从“盲目”变成“智能”,只保留触发新路径的输入
  • AFL用fork+exec,适合有状态、需要重置的场景
  • LibFuzzer是进程内的,速度快,适合无状态的单次执行场景
  • 边覆盖率是Fuzz的核心指标,比行覆盖率更能反映控制流覆盖情况