AFL源码分析

AFL源码分析

Serendy Magician

写在前面的话:首先因为本人基础较差,所以本文会插入很多比较基础的知识,大家请选择性跳过。其次,本次阅读事实上主要分析的是 afl 中的 afl-as 实现的汇编级插桩,但是就性能上、实用性上 LLVM 才是需要分析的重点,各位可以以本文作为踏脚石,后续建议花更多的时间对 LLVM 模式的代码进行分析,了解如何通过编写 LLVM Pass 实现编译级别的插桩。这也将是我下一轮的学习方向,与诸位共勉。

分析工具

understand 源码阅读器

源码阅读思路:

  1. 阅读白皮书了解 afl 的整体设计思路
  2. 阅读博客了解 AFL 整体架构,AFL 到底是如何运行的,每个代码部分分别对应哪个功能?既然准备研究定向模糊测试,那就先要确定定向模糊测试一般需要在 afl 的哪个运行环节进行改进。然后重点分析这部分代码。
  3. 根据 https://www.ruanx.net/afl-source-1/ 这篇博客的思路进行源码阅读

 我们阅读源码的主要目标应该是:

  1. 理清静态插桩过程(gcc、clang、llvm mode)
  2. 理清 fuzz 过程:如何变异、如何将 input 传递给程序、如何收集覆盖度信息

  因此,我们决定阅读顺序:

  1. 阅读 afl-gcc.cafl-as.c,即静态插桩相关代码
  2. 阅读 afl-fuzz.c

源码阅读经验

在阅读 afl-as.c 与 afl-gcc.c 的过程中,发现在阅读之前应该对于程序整个的编译过程存在一个基础的认识。

具体可以参考《深入理解计算机系统》的 P3,其中大致描述了一个 c 语言代码是如何一步步被编译成可执行文件的。

如上图所示,一个 c 语言代码需要通过各个工具一步步被编译成可执行文件。其中 afl 利用的 gcc 工具负责处理预处理器与编译器的工作,生成汇编程序。而 as 则是汇编器,负责将汇编程序翻译为机械码,将程序转化为二进制目标文件。

gcc 和 as 都是 GNU 中的工具,是直接安装在系统中的。我们可以通过 execve()等函数对这些工具进行调用。

afl-gcc 是对 gcc 的封装,而 afl-as 是对 as 的封装。为了理解 afl 中相关的代码,我们需要对 gcc、as 本身接受什么参数进行了解。

还需要说明的是,因为调用的是 GNU 重点工具,所以 afl 插桩所用到的汇编代码是 AT&T 风格的,为了理解插桩汇编代码的作用,对该风格的汇编语言的语法进行了解是十分必要的。

GCC 是什么?

GCC(GNU Compiler Collection)是一个编译器集合,其中包含多个编译器,其中之一是 C 编译器(通常称为 gcc)。GCC 的主要组成部分包括前端和后端。在编译源代码时,编译器执行以下主要步骤:

  1. 预处理(Preprocessing): 预处理器 (cpp) 负责处理源代码中的预处理指令,例如 #include#define 等,并将宏展开、头文件包含等操作应用到源代码上。
  2. 编译(Compilation): C 编译器 (gcc) 接收预处理后的源代码,将其转换为汇编代码。这个阶段主要包括语法分析、语义分析和中间代码生成。
  3. 汇编(Assembly): 汇编器 (as) 接收编译器生成的汇编代码,将其转换为目标文件。目标文件包含了机器代码的二进制表示,以及有关程序的其他信息。
  4. 链接(Linking): 链接器 (ld) 接收一个或多个目标文件,以及可能的库文件,将它们合并成一个可执行文件。链接器解析符号引用,处理重定位信息,确保所有的代码和数据正确地连接在一起。

在这个流程中,编译器 (gcc) 负责前两个阶段(预处理和编译),而汇编器 (as) 负责将生成的汇编代码转换为目标文件。因此,它们是编译过程中两个关键的组件。这两个组件的工作协同,确保最终生成的可执行文件能够正确地执行程序的逻辑。

afl 的插桩过程是在编译过程中实现的,所以我们需要分析 afl 与编译相关的组件,其中就包括了 afl-gcc 与 afl-as。这两个文件分别是 gcc 与 as 的包装。在具体执行中会先调用 afl-gcc 然后再调用 afl-as

afl 白皮书(技术细节)

阅读 afl 技术细节文档有助于理解具体的代码实现。

覆盖率检测

afl 采用分支命中次数来统计覆盖率

1
2
3
cur_location = <COMPILE_TIME_RANDOM>;
shared_mem[cur_location ^ prev_location]++;
prev_location = cur_location >> 1;

实现覆盖率的计算。

cur_location 值是随机生成的,以简化链接复杂项目的过程,并且使用 XOR 来保持输出均匀分布。shared_mem[] 数组是一个 64 kB 的 SHM(内存共享) 区域,由调用者传递给被插桩的二进制文件。 输出映射(map)中的每个字节集合都可以是对于元组 (branch_src, branch_dst) 的一次命中 hit。

这个产生的数组足够的小,所以可以很方便的放入二级缓存。

至于采用机制的原理不多赘述,看原版白皮书就好。总的来说就是相比于基本块命中率,这种机制能记录更多关于执行路径的信息。

路经检测

在 afl 中,我们要如何判断是否执行了一个新的路径呢?

下面是两条执行路径

1
2
A -> B -> C -> D -> E (tuples: AB, BC, CD, DE)
A -> B -> D -> C -> E (tuples: AB, BD, DC, CE)

afl 会维护小括号里面的元组,记录执行的路径,当产生新的元组元素的时候就判断产生了新的路径。值得注意的是及时存在一个全新的控制流,如果没有产生新的元组,afl 仍然会丢弃相对应的种子。

当命中次数区间发生改变的时候也会认为该种子产生了有趣的变化。

输入队列变异

afl 选择将上一轮中选择的有趣的种子添加到输入队列中,而不是直接替换掉原有的种子。

语料库的精简

为了优化模糊测试的效果,AFL 周期性地使用一种快速算法重新评估队列,该算法选择一小部分仍然覆盖到目前为止看到的每个元组的测试用例,并且它们的特征使它们对工具特别有利。

该算法通过为每个队列条目分配一个与其执行延迟和文件大小成比例的分数,然后选择每个元组的最低分候选者来工作。

在这个过程中,AFL 会生成一个“喜爱”的输入文件集合,这些文件集合通常比起始数据集小 5-10 倍。非“喜爱”的输入文件不会被丢弃,但当它们在队列中遇到时,会以不同的概率被跳过。具体跳过算法看白皮书。

修剪输入文件

因为 fuzz 过程中会不断为种子增加信息,而文件大小太大的种子会影响 fuzz 的性能,所以 fuzz 设计了修剪输入文件机制来限制、减小种子文件的大小,保证 fuzz 效率。

该机制主要包括两个部分,一个是对种子中不重要的部分进行裁剪,另一个是限制种子文件的最大大小。

模糊测试的策略

1
http://lcamtuf.blogspot.com/2014/08/binary-fuzzing-strategies-what-works.html

这篇文章讨论了二进制__fuzzing__的策略,以及它们的有效性和限制。文章提到,成功的 fuzzer 取决于它们的 fuzzing 策略。如果对输入文件做出的更改过于保守,fuzzer 将实现非常有限的覆盖率。如果调整过于激进,则会导致大多数输入在非常早期的阶段失败解析,浪费__CPU__周期并产生难以调查和排除故障的混乱__测试用例__。
文章介绍了 AFL(American Fuzzy Lop__)的设计,该设计提供了一种罕见的反馈循环:您可以仔细测量哪些类型的更改实际上导致代码中新分支的发现。AFL 通过一系列逐渐复杂但详尽和确定性的__fuzzing__策略(例如顺序位翻转和简单算术)来接近每个新输入文件,然后再进入纯随机行为。这样做的原因是先生成最简单和最优雅的__测试用例__;但是,该设计还提供了一种非常好的方法来量化每种新策略带来的价值 - 以及我们是否需要它。_
_文章提到了几种具体的__fuzzing__策略,包括 Walking bit flips、Walking byte flips、Simple arithmetics、Known integers、Stacked tweaks 和__Test case
splicing。每种策略都有其独特的优点和缺点,并且效果因格式而异。例如,Walking bit flips 是最基本的策略之一,可以发现大约 70 个新路径,但成本相对较高,每个输入文件的每个字节需要八个 execve()调用。另一方面,Simple arithmetics 可以以相对较低的成本发现更复杂的条件,但成本仍然相对较高,每个输入文件的每个字节需要约 20 个 execve()调用。

总之,文章提供了有关二进制__fuzzing__策略的详细信息,以及这些策略的有效性和限制。这对于正在开发自己的 fuzzer 的人们来说可能会很有用。

以上部分看起来更像是变异策略的选择与分析。之后进行定向模糊测试的时候可以考虑看看。

特别是在早期阶段,afl-fuzz 大部分工作都是高度确定性的,只有在后期才会逐渐转向随机堆叠修改和__测试用例__拼接。这些确定性策略包括:

  • 顺序位翻转,步长与翻转长度可变
  • 对小整数进行顺序加减
  • 顺序插入特殊整数(0、1、__INT_MAX__等)

sequential bit flips 和 simple arithmetics 之所以被称为确定性变异,是因为它们在相同输入上产生相同的输出,这对于测试工具的一致性和可重复性非常重要。

这些策略往往会产生紧凑的__测试用例__,并且非崩溃和崩溃输入之间的差异较小。

在确定性__fuzzing__完成后,AFL 会使用非确定性策略,包括堆叠位翻转、插入、删除、算术运算和不同__测试用例__的拼接。这些策略的相对收益和 execve()成本在上述博客文章中进行了讨论。

AFL 通常不会去考虑特定变异和程序状态之间的关系,主要是出于性能、简单性和可靠性的考虑。__fuzzing__步骤通常是盲目(随机)的,只由输入队列的进化设计来指导。

即便如此,这套规则也有一个(不太重要的)例外:当一个新的队列条目经过初始的确定性__fuzzing__步骤时,如果观察到对文件中某些区域的调整对执行路径的__校验和__没有影响,那么它们可以被排除在确定性 fuzzing 的其余阶段之外,fuzzer 可以直接进行随机调整。特别是对于冗长的、可读性高的__数据格式__,这可以将执行次数降低 10-40%,而覆盖率几乎没有下降。在极端情况下,例如通常块对齐的__tar__档案,收益可能高达 90%。

AFL 中的一种优化技巧,即”修剪输入文件和控制文件大小”。
具体来说,AFL 使用一种称为”effector maps”的机制来记录测试过程中每个输入文件的状态和效果,并根据这些信息来决定下一步的操作。这些”effector maps”是本地的,即每个队列条目都有自己的”effector maps”,并且只在不改变底层文件大小或布局的确定性阶段保持有效。通过这种机制,AFL 能够高效、可靠地进行__fuzzing__测试,并且这种机制的实现也相对简单。

字典的构建

字典是什么?字典的作用是什么?字典是如何被应用到 afl 中的?

在 AFL(American Fuzzy Lop)中,字典(Dictionary)是指一个包含已知有效输入或有趣输入的文件。字典可以帮助 AFL 在模糊测试时更快地发现有趣的输入,因为这些输入可能已经被证明可以导致程序中的特定路径或行为。

崩溃去重

我们应该如何判断哪些崩溃属于同一类的崩溃。

仅查看故障地址可能会导致完全不相关的问题被聚集在一起,如果故障发生在常见的库函数(例如 strcmp、strcpy)中,则会出现这种情况;而对调用栈回溯进行校验和可能会导致崩溃计数极度膨胀,如果故障可以通过许多不同的、可能是递归的代码路径到达。

afl-fuzz 实现的解决方案认为,如果满足以下两个条件之一,崩溃就是唯一的:

这种方法在早期可能会存在一些路径数量膨胀的问题,但它展示了非常强的自我限制(self-limiting)效果,类似于 afl-这种去重方式与执行路径分析的逻辑一起构成了 afl-fuzz 的基石。

崩溃调查

afl 提供了一种崩溃探索模式用于验证崩溃的可用性。

fork 服务器

为了提高性能,afl-fuzz 使用了一个”fork server”,其中 fuzzing 过程中只需经过一次 execve()、链接和 libc 初始化,然后通过利用写时复制(copy-on-write)从停止的进程映像中复制、clone。

execve() 是一个系统调用(system call),用于在一个新的进程中执行一个程序。

并行处理

请参阅 parallel_fuzzing.txt

二进制插桩

没看懂

afl 分析工具

动机分析

很长 看白皮书 对于 fuzz 的论文编写有一定的启发性

项目架构

  • 插桩模块

    1. afl-as.h, afl-as.c, afl-gcc.c:普通插桩模式,针对源码插桩,编译器可以使用 gcc, clang;
    2. llvm_mode:llvm 插桩模式,针对源码插桩,编译器使用 clang;
    3. qemu_mode:qemu 插桩模式,针对二进制文件插桩。
  • fuzzer 模块

  • afl-fuzz.c:fuzzer 实现的核心代码,AFL 的主体。

  • 其他辅助模块

    1. afl-analyze:对测试用例进行分析,通过分析给定的用例,确定是否可以发现用例中有意义的字段;
    2. afl-plot:生成测试任务的状态图;
    3. afl-tmin:对测试用例进行最小化;
    4. afl-cmin:对语料库进行精简操作;
    5. afl-showmap:对单个测试用例进行执行路径跟踪;
    6. afl-whatsup:各并行例程 fuzzing 结果统计;
    7. afl-gotcpu:查看当前 CPU 状态。
  • 部分头文件说明

    1. alloc-inl.h:定义带检测功能的内存分配和释放操作;
    2. config.h:定义配置信息;
    3. debug.h:与提示信息相关的宏定义;
    4. hash.h:哈希函数的实现定义;
    5. types.h:部分类型及宏的定义。

关键变量

为了方便我们理解 afl 的工作,必须首先对一些变量进行解释。源码中对这些变量的解释过于简单,导致如果不分析具体代码就很难理解这些变量究竟代表什么。

trace_bits

源码注释:EXP_ST u8* trace_bits; /* SHM with instrumentation bitmap */

个人解释:关键变量,这是一个字节指针,指向一片内存空间,也可以理解为一个字节数组。这片空间是为了进行父子进程通信而开辟的共享内存。这片内存储存了测试用例在输入被测程序后执行的路径。trace_bits 上每一个字节代表一条基本块到基本块的边,这个字节的值代表这条边被执行的次数。

trace_mini[]

源码注释:u8* trace_mini; /* Trace bytes, if kept */

个人解释:每个测试用例都可能维护的一个简化版的,压缩版的 trace_bits。记录了这个测试用例会执行的路径。

virgin_bits

源码注释:EXP_ST u8 virgin_bits[MAP_SIZE], /* Regions yet untouched by fuzzing */

个人解释:这玩意记录了所有还未被探索的边,其中每个个位置的边与 trace_bits 中的边一一对应。值得注意的是这玩意每个元素的初始值为 1,标志该位置未被探索。
正因如此,在函数 has_new_bits 中会使用 *virgin &= ~*current; 对已探索位置置零。

top_rated[]

源码注释:static struct queue_entry* top_rated[MAP_SIZE]; /* Top entries for bitmap bytes */

个人解释:afl 会为 trace_bits 中每一个字节,也即是每一条边,挑选出一个最佳的(文件大小最小、执行时间最短)的测试用例。

插桩模块

普通插桩模式

afl-gcc.c

文件概述:

是 GCC 或 clang 的一个 wrapper(封装),常规的使用方法是在调用 ./configure 时通过 CC(编译器的命令)将路径传递给 afl-gccafl-clang。(对于 C++ 代码,则使用 CXX 并将其指向 afl-g++/afl-clang++。)afl-clang,afl-clang++afl-g++ 均为指向 afl-gcc 的一个符号链接。
上面这段话的关键意义在于我们会在 afl-gcc 中决定使用哪个编译器。
afl-gcc 的主要作用是实现对于关键节点的代码插桩,属于汇编级,从而记录程序执行路径之类的关键信息,对程序的运行情况进行反馈。
afl-gcc.c 会被放在我们电脑上的 GCC 源代码的 gcc 文件夹中,以此构成我们的 afl-gcc

源码:

  1. 关键变量
1
2
3
4
5
6
7
8
9
10
static u8*  as_path;                /* Path to the AFL 'as' wrapper,AFL的as的路径"AS" 是 "Auto Dictionary Sync" 的缩写
指的是自动字典同步。这是AFL的一个特性,它用于自动创建和维护测试用例的字典,以改进模糊测试的效率。 */
static u8** cc_params; /* Parameters passed to the real CC,CC实际使用的编译器参数 */
static u32 cc_par_cnt = 1; /* Param count, including argv0 ,参数计数 */
static u8 be_quiet, /* Quiet mode,静默模式 */
clang_mode; /* Invoked as afl-clang*? ,是否使用afl-clang*模式 */
# 数据类型说明
# typedef uint8_t u8;
# typedef uint16_t u16;
# typedef uint32_t u32;
  1. main 函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
int main(int argc, char** argv) {

if (isatty(2) && !getenv("AFL_QUIET")) {

SAYF(cCYA "afl-cc " cBRI VERSION cRST " by <lcamtuf@google.com>\n");

} else be_quiet = 1;

if (argc < 2) {

SAYF("\n"
"This is a helper application for afl-fuzz. It serves as a drop-in replacement\n"
"for gcc or clang, letting you recompile third-party code with the required\n"
"runtime instrumentation. A common use pattern would be one of the following:\n\n"

" CC=%s/afl-gcc ./configure\n"
" CXX=%s/afl-g++ ./configure\n\n"

"You can specify custom next-stage toolchain via AFL_CC, AFL_CXX, and AFL_AS.\n"
"Setting AFL_HARDEN enables hardening optimizations in the compiled code.\n\n",
BIN_PATH, BIN_PATH);

exit(1);

}

find_as(argv[0]);

edit_params(argc, argv);

execvp(cc_params[0], (char**)cc_params);

FATAL("Oops, failed to execute '%s' - check your PATH", cc_params[0]);

return 0;

}

可以看到三个调用的函数

  • find_as(argv[0]):查找使用的汇编器,这里查找汇编器只是为了在调用编译器之后可以指定执行 afl 设定的 as 也即是汇编器进行插桩。
  • edit_params(argc, argv):处理传入的编译参数,将确定好的参数放入 cc_params[] 数组
  • 调用 execvp(cc_params[0], (cahr**)cc_params) 执行 afl-gcc

这个函数十分关键的一个步骤是为 gcc 设置了选项 -B 将之覆盖为了 afl 代码所在地址,这个设置让 gcc 会选择执行 afl 中的 as 而不是原本的 as。下面是对 -B 选项的解释:

-Bprefix
这个选项指出在何处寻找可执行文件,库文件,以及编译器自己的数据文件.
编译器驱动程序需要执行某些下面的子程序: **cpp**', cc1‘ (或 C++ 的 **cc1plus**'), as‘和 **ld**'.他把_prefix_当作欲执行的程序的 前缀,既可以包括也可以不包括 machine/version/‘.
对于要运行的子程序,编译器驱动程序首先试着加上 **-B**'前缀(如果存在).如果没有找到文件,或没有指定 -B‘选项,编译器接着会试验两个标准前缀 **/usr/lib/gcc/**'和 /usr/local/lib/gcc-lib/‘.如果仍然没能够找到所需文件,编译器就在 **PATH**'环境变量 指定的路径中寻找没加任何前缀的文件名. 如果有需要,运行时(run-time)支持文件 libgcc.a‘也在 **-B**'前缀的搜索范围之内. 如果这里没有找到,就在上面提到的两个标准前缀中寻找,仅此而已.如果上述方法没有找到这个文件,就不连接他了.多数 情况的多数机器上, libgcc.a‘并非必不可少.
你可以通过环境变量 GCC_EXEC_PREFIX 获得近似的效果;如果定义了这个变量,其值就和上面说的 一样用做前缀.如果同时指定了 **-B**'选项和**GCC_EXEC_PREFIX**变量,编译器首先使用 -B‘选项,然后才尝试环境变量值.

afl-as.c

文件概述:

afl-as.c 文件用于修改汇编器的行为,以在目标程序的汇编代码中插入额外的代码,这些需要插入的代码储存在 afl-as.h 文件中。这些插入的代码通常用于记录程序执行的路径信息,并帮助 AFL 生成更多的测试用例来覆盖不同的执行路径。通过这种方式,AFL 可以监视和控制输入数据的变化,以提高模糊测试的效率。

源码:

  1. 关键变量
  2. main 函数
  3. edit_param 函数:关键问题在于:先要理解 as 到底接受什么参数?然后才能理解 edit 函数为什么会进行这样的修改。as 到底在哪个位置?
    as 是 gnu 中的汇编器,它是安装在系统中的,而不是 afl 下某个具体的文件。afl 会生成 as 文件作为 as 的封装调用 as 进行插桩等操作。
  4. add_instrumentation 函数
    值得注意的是 afl-gcc 生成的是 AT&T 风格的汇编语言,与 intel 风格的汇编语言存在差异性。在 AT&T 风格中,汇编语言格式如下:
    可以看到代码被.section 划分为一个个段,我们在进行插桩的时候只会针对.text 部分也即是代码部分进行插桩,所以在该函数中主要工作识别当前是否进入.text 段,如果不在则跳过当前读入的代码。
1
2
3
4
5
6
7
8
9
10
11
if (!pass_thru && !skip_intel && !skip_app && !skip_csect && instr_ok &&
instrument_next && line[0] == '\t' && isalpha(line[1])) {
//这里执行的就是插桩,把一部分代码输出到了out中,因为在函数中先插入已经读入的语句
// 再判断该语句是否需要插桩,所以事实上是在检测到符合条件的语句后面进行插桩
fprintf(outf, use_64bit ? trampoline_fmt_64 : trampoline_fmt_32,
R(MAP_SIZE));

instrument_next = 0;
ins_lines++;

}

trampoline_fmt_64 桩代码

我们插入的代码按照白皮书所言,事实上插入的 trampoline_fmt_64 这段汇编代码等价于下面这三行代码

1
2
3
4
5
6
7
8
//生成随机值作为当前基本块的标号,作为当前位置
//这个随机值是在程序编译的时候就已经确定了的,所以后续每次运行时每个基本快的标识是固定的
cur_location = <COMPILE_TIME_RANDOM>;
//在共享内存空间中对当前执行的边的计数器加一
//注意这里操作的内存空间对应afl-fuzz中的trace_bits
shared_mem[cur_location ^ prev_location]++;
//将当前位置右移一位并赋值给前置位置
prev_location = cur_location >> 1;

为什么这里要右移呢?是为了帮助我们判断基本块的执行顺序。
读到这里可能你可能仍然对这个边的概念仍然比较模糊,下面我用一个例子来详细解释一下:

为了理解 main_playload 有必要详细的分析插入的 trampoline_fmt_64

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
_/* --- AFL TRAMPOLINE (64-BIT) --- */_
.align 4

leaq -(128+24)(%rsp), %rsp //栈顶下降(因为内存中栈往低地址也就是下方生长,所以此处意为栈增长了)
movq %rdx, 0(%rsp) //存入rdx
movq %rcx, 8(%rsp) //存入rcx
movq %rax, 16(%rsp) //存入rax
//将一个随机生成的值传入rcx,解释了 cur_location 的来历。它是随机生成的,现在存放在 rcx 寄存器中。
//接下来调用 __afl_maybe_log ,可以猜测,它要实现「mem 自增」和「保存 prev_location」两项任务。
movq $0x%08x, %%rcx
//调用__afl_maybe_log
call __afl_maybe_log
//恢复三个寄存器的值,栈顶上升
movq 16(%rsp), %rax
movq 8(%rsp), %rcx
movq 0(%rsp), %rdx
leaq (128+24)(%rsp), %rsp
_/* --- END --- */_

上面这段代码调用了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// __afl_maybe_log的定义
__afl_maybe_log:
lahf ; 将标志寄存器(flags register)的状态加载到 %ah 寄存器中
seto %al ; 将溢出标志位设置到 %al 寄存器(如果溢出则 %al 置为 1,否则置为 0)

/* 检查 SHM 区域是否已经映射。*/
movl __afl_area_ptr, %edx ; 将全局变量 __afl_area_ptr 的值加载到 %edx 寄存器中
testl %edx, %edx ; 测试 %edx 寄存器的值是否为零
je __afl_setup ; 如果 %edx 的值为零,跳转到 __afl_setup 标签
//跟上面代码是连续的,如果上面没有发生跳转就会顺序执行下面的代码
__afl_store:

_/* Calculate and store hit for the code location specified in rcx. */_

xorq __afl_prev_loc(%rip), %rcx //将pre和cur异或
xorq %rcx, __afl_prev_loc(%rip) //通过异或的自反性将cur值存入pre中
shrq $1, __afl_prev_loc(%rip) //pre右移一位

incb (%rdx, %rcx, 1) //命中计数器加一

__afl_return:

addb $127, %al
sahf
ret

__afl_store 执行过程为:

  1. 将目前存储着 cur_locrcx 寄存器异或上 prev_loc

  2. prev_loc 设为 cur_loc (这里利用了异或运算的自反性)

  3. prev_loc 右移一位

  4. 增加 hit count
    __afl_maybe_log 中存在两个指令 lahfseto %al ,这两行代码负责存储 eflags 寄存器的值——将低 8 位保存在 ah,将 OF 位保存在 al

    __afl_return 在代码中执行 addb $127, %alsahf 恢复现场,使得整个桩代码对原程序透明

main_playload 桩代码

在完成所有代码的写入后还会写入一段 main_playload

这段汇编代码与 forkserver 模块息息相关,为了理解后面介绍的汇编代码,我们首先应该了解什么是 fork 函数。

一个进程,包括代码、数据和分配给进程的资源。fork()函数通过系统调用创建一个与原来进程几乎完全相同的进程,也就是两个进程可以做完全相同的事,但如果初始参数或者传入的变量不同,两个进程也可以做不同的事。

一个进程调用 fork()函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值不同。相当于克隆了一个自己。

fork 调用的一个奇妙之处就是它仅仅被调用一次,却能够返回两次,它可能有三种不同的返回值:
1)在父进程中,fork 返回新创建子进程的进程 ID;
2)在子进程中,fork 返回 0;
3)如果出现错误,fork 返回一个负值;

在大致了解 fork 函数是个什么东西之后,我们就能开始看 main_payload 了。下面这段代码的主要作用是在被测程序的尾部插入 main_payload:

1
2
if (ins_lines)
fputs(use_64bit ? main_payload_64 : main_payload_32, outf);//插入main_playload与fork_server有关

为了理解什么是 fork_server,我们需要具体分析 main_payload,具体代码在 afl-as.h 里面
main_payload 代码如下,事实上我们稍加观察就能看到前面讨论的汇编代码其实存在于 main_payload 里面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
.text
.att_syntax
.code64
.align 8

__afl_maybe_log:

#if defined(__OpenBSD__) || (defined(__FreeBSD__) && (__FreeBSD__ < 9))
.byte 0x9f /* lahf */
#else
lahf
#endif /* ^__OpenBSD__, etc */
seto %al

/* Check if SHM region is already mapped. */

movq __afl_area_ptr(%rip), %rdx
testq %rdx, %rdx
je __afl_setup

__afl_store:

/* Calculate and store hit for the code location specified in rcx. */

#ifndef COVERAGE_ONLY
xorq __afl_prev_loc(%rip), %rcx
xorq %rcx, __afl_prev_loc(%rip)
shrq $1, __afl_prev_loc(%rip)
#endif /* ^!COVERAGE_ONLY */

#ifdef SKIP_COUNTS
orb $1, (%rdx, %rcx, 1)
#else
incb (%rdx, %rcx, 1)
#endif /* ^SKIP_COUNTS */

__afl_return:

addb $127, %al
#if defined(__OpenBSD__) || (defined(__FreeBSD__) && (__FreeBSD__ < 9))
.byte 0x9e /* sahf */
#else
sahf
#endif /* ^__OpenBSD__, etc */
ret

.align 8

__afl_setup:

/* Do not retry setup if we had previous failures. */

cmpb $0, __afl_setup_failure(%rip)
jne __afl_return

/* Check out if we have a global pointer on file. */

#ifndef __APPLE__
movq __afl_global_area_ptr@GOTPCREL(%rip), %rdx
movq (%rdx), %rdx
#else
movq __afl_global_area_ptr(%rip), %rdx
#endif /* !^__APPLE__ */
testq %rdx, %rdx
je __afl_setup_first

movq %rdx, __afl_area_ptr(%rip)
jmp __afl_store

__afl_setup_first:

/* Save everything that is not yet saved and that may be touched by
getenv() and several other libcalls we'll be relying on. */

leaq -352(%rsp), %rsp

movq %rax, 0(%rsp)
movq %rcx, 8(%rsp)
movq %rdi, 16(%rsp)
movq %rsi, 32(%rsp)
movq %r8, 40(%rsp)
movq %r9, 48(%rsp)
movq %r10, 56(%rsp)
movq %r11, 64(%rsp)

movq %xmm0, 96(%rsp)
movq %xmm1, 112(%rsp)
movq %xmm2, 128(%rsp)
movq %xmm3, 144(%rsp)
movq %xmm4, 160(%rsp)
movq %xmm5, 176(%rsp)
movq %xmm6, 192(%rsp)
movq %xmm7, 208(%rsp)
movq %xmm8, 224(%rsp)
movq %xmm9, 240(%rsp)
movq %xmm10, 256(%rsp)
movq %xmm11, 272(%rsp)
movq %xmm12, 288(%rsp)
movq %xmm13, 304(%rsp)
movq %xmm14, 320(%rsp)
movq %xmm15, 336(%rsp)

/* Map SHM, jumping to __afl_setup_abort if something goes wrong. */

/* The 64-bit ABI requires 16-byte stack alignment. We'll keep the
original stack ptr in the callee-saved r12. */

pushq %r12
movq %rsp, %r12
subq $16, %rsp
andq $0xfffffffffffffff0, %rsp

leaq .AFL_SHM_ENV(%rip), %rdi
CALL_L64("getenv")

testq %rax, %rax
je __afl_setup_abort

movq %rax, %rdi
CALL_L64("atoi")

xorq %rdx, %rdx /* shmat flags */
xorq %rsi, %rsi /* requested addr */
movq %rax, %rdi /* SHM ID */
CALL_L64("shmat")

cmpq $-1, %rax
je __afl_setup_abort

/* Store the address of the SHM region. */

movq %rax, %rdx
movq %rax, __afl_area_ptr(%rip)

#ifdef __APPLE__
movq %rax, __afl_global_area_ptr(%rip)
#else
movq __afl_global_area_ptr@GOTPCREL(%rip), %rdx
movq %rax, (%rdx)
#endif /* ^__APPLE__ */
movq %rax, %rdx

__afl_forkserver:

/* Enter the fork server mode to avoid the overhead of execve() calls. We
push rdx (area ptr) twice to keep stack alignment neat. */

pushq %rdx
pushq %rdx

/* Phone home and tell the parent that we're OK. (Note that signals with
no SA_RESTART will mess it up). If this fails, assume that the fd is
closed because we were execve()d from an instrumented binary, or because
the parent doesn't want to use the fork server. */

movq $4, %rdx /* length */
leaq __afl_temp(%rip), %rsi /* data */
movq $6, %rdi /* file desc */
CALL_L64("write")

cmpq $4, %rax
jne __afl_fork_resume

__afl_fork_wait_loop:

/* Wait for parent by reading from the pipe. Abort if read fails. */

movq $4, %rdx /* length */
leaq __afl_temp(%rip), %rsi /* data */
movq $5, %rdi /* file desc */
CALL_L64("read")
cmpq $4, %rax
jne __afl_die

/* Once woken up, create a clone of our process. This is an excellent use
case for syscall(__NR_clone, 0, CLONE_PARENT), but glibc boneheadedly
caches getpid() results and offers no way to update the value, breaking
abort(), raise(), and a bunch of other things :-( */

CALL_L64("fork")
cmpq $0, %rax
jl __afl_die
je __afl_fork_resume

/* In parent process: write PID to pipe, then wait for child. */

movl %eax, __afl_fork_pid(%rip)

movq $4, %rdx /* length */
leaq __afl_fork_pid(%rip), %rsi /* data */
movq $6, %rdi /* file desc */
CALL_L64("write")

movq $0, %rdx /* no flags */
leaq __afl_temp(%rip), %rsi /* status */
movq __afl_fork_pid(%rip), %rdi /* PID */
CALL_L64("waitpid")
cmpq $0, %rax
jle __afl_die

/* Relay wait status to pipe, then loop back. */

movq $4, %rdx /* length */
leaq __afl_temp(%rip), %rsi /* data */
movq $5, %rdi /* file desc */
CALL_L64("write")

jmp __afl_fork_wait_loop

__afl_fork_resume:

/* In child process: close fds, resume execution. */

movq $5, %rdi
CALL_L64("close")

movq $6, %rdi
CALL_L64("close")

popq %rdx
popq %rdx

movq %r12, %rsp
popq %r12

movq 0(%rsp), %rax
movq 8(%rsp), %rcx
movq 16(%rsp), %rdi
movq 32(%rsp), %rsi
movq 40(%rsp), %r8
movq 48(%rsp), %r9
movq 56(%rsp), %r10
movq 64(%rsp), %r11

movq 96(%rsp), %xmm0
movq 112(%rsp), %xmm1
movq 128(%rsp), %xmm2
movq 144(%rsp), %xmm3
movq 160(%rsp), %xmm4
movq 176(%rsp), %xmm5
movq 192(%rsp), %xmm6
movq 208(%rsp), %xmm7
movq 224(%rsp), %xmm8
movq 240(%rsp), %xmm9
movq 256(%rsp), %xmm10
movq 272(%rsp), %xmm11
movq 288(%rsp), %xmm12
movq 304(%rsp), %xmm13
movq 320(%rsp), %xmm14
movq 336(%rsp), %xmm15

leaq 352(%rsp), %rsp

jmp __afl_store

__afl_die:

xorq %rax, %rax
CALL_L64("_exit")

__afl_setup_abort:

/* Record setup failure so that we do not keep calling
shmget() / shmat() over and over again. */

incb __afl_setup_failure(%rip)

movq %r12, %rsp
popq %r12

movq 0(%rsp), %rax
movq 8(%rsp), %rcx
movq 16(%rsp), %rdi
movq 32(%rsp), %rsi
movq 40(%rsp), %r8
movq 48(%rsp), %r9
movq 56(%rsp), %r10
movq 64(%rsp), %r11

movq 96(%rsp), %xmm0
movq 112(%rsp), %xmm1
movq 128(%rsp), %xmm2
movq 144(%rsp), %xmm3
movq 160(%rsp), %xmm4
movq 176(%rsp), %xmm5
movq 192(%rsp), %xmm6
movq 208(%rsp), %xmm7
movq 224(%rsp), %xmm8
movq 240(%rsp), %xmm9
movq 256(%rsp), %xmm10
movq 272(%rsp), %xmm11
movq 288(%rsp), %xmm12
movq 304(%rsp), %xmm13
movq 320(%rsp), %xmm14
movq 336(%rsp), %xmm15

leaq 352(%rsp), %rsp

jmp __afl_return

.AFL_VARS:

#ifdef __APPLE__

.comm __afl_area_ptr, 8
#ifndef COVERAGE_ONLY
.comm __afl_prev_loc, 8
#endif /* !COVERAGE_ONLY */
.comm __afl_fork_pid, 4
.comm __afl_temp, 4
.comm __afl_setup_failure, 1

#else

.lcomm __afl_area_ptr, 8
#ifndef COVERAGE_ONLY
.lcomm __afl_prev_loc, 8
#endif /* !COVERAGE_ONLY */
.lcomm __afl_fork_pid, 4
.lcomm __afl_temp, 4
.lcomm __afl_setup_failure, 1

#endif /* ^__APPLE__ */

.comm __afl_global_area_ptr, 8, 8

.AFL_SHM_ENV:
.asciz "AFL_SHM_ENV"

/* --- END --- */

从汇编中,我们看到,如果 __afl_area_ptr 等于 0,则认为 shm 没有映射好,进入 __afl_setup 逻辑。这里提一句, afl_area_ptr 这个 8 字节的变量是定义在 main payload 的末尾,一个 .lcomm 伪指令。在汇编阶段完成后,它被放在 .bss 段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//只有当afl_maybe_log中je  __afl_setup执行时才会跳转到这部分代码
//__afl_setup的定义,应注意64为汇编代码与32位存在差异,这里分析的是64位版本的
__afl_setup:

/* Do not retry setup if we had previous failures. */

cmpb $0, __afl_setup_failure(%rip)
jne __afl_return

/* Check out if we have a global pointer on file. */
movq __afl_global_area_ptr(%rip), %rdx

testq %rdx, %rdx
je __afl_setup_first

movq %rdx, __afl_area_ptr(%rip)
jmp __afl_store

首先,如果发现 __afl_setup_failure 变量不等于 0,则直接返回;接着,尝试从 GOT 表中取 __afl_global_area_ptr,如果发现有值,则用其覆盖 __afl_area_ptr ,跳转到 __afl_store ,执行常规的 hit count++;如果发现 GOT 表中的这个 __afl_global_area_ptr 指针为 0,则跳转到 __afl_setup_first ,开始初始化共享内存区域。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
__afl_setup_first:

_/* Save everything that is not yet saved and that may be touched by
__ getenv() and several other libcalls we'll be relying on. */_
//这里基本就是保存寄存器的值方便之后恢复现场
leaq -352(%rsp), %rsp

movq %rax, 0(%rsp)
movq %rcx, 8(%rsp)
movq %rdi, 16(%rsp)
movq %rsi, 32(%rsp)
movq %r8, 40(%rsp)
movq %r9, 48(%rsp)
movq %r10, 56(%rsp)
movq %r11, 64(%rsp)

movq %xmm0, 96(%rsp)
movq %xmm1, 112(%rsp)
movq %xmm2, 128(%rsp)
movq %xmm3, 144(%rsp)
movq %xmm4, 160(%rsp)
movq %xmm5, 176(%rsp)
movq %xmm6, 192(%rsp)
movq %xmm7, 208(%rsp)
movq %xmm8, 224(%rsp)
movq %xmm9, 240(%rsp)
movq %xmm10, 256(%rsp)
movq %xmm11, 272(%rsp)
movq %xmm12, 288(%rsp)
movq %xmm13, 304(%rsp)
movq %xmm14, 320(%rsp)
movq %xmm15, 336(%rsp)

_/* Map SHM, jumping to __afl_setup_abort if something goes wrong. */_

_/* The 64-bit ABI requires 16-byte stack alignment. We'll keep the
__ original stack ptr in the callee-saved r12. */_
// 保存 r12 寄存器的值;然后用 r12 寄存器记录原始的 rsp;把 rsp 减去 16,并清空最低 4 bit。
// 注释中提到,根据 [ABI](https://docs.oracle.com/cd/E19253-01/816-5138/fcowb/index.html) 要求,栈地址要做 16 字节对齐。这样一顿操作之后, rsp 一定是 16 的倍数了。
pushq %r12
movq %rsp, %r12
subq $16, %rsp
andq $0xfffffffffffffff0, %rsp

leaq .AFL_SHM_ENV(%rip), %rdi
call getenv@PLT
//如果没有获取到环境变量就会转入abort进行错误处理
testq %rax, %rax
je __afl_setup_abort

。。。。。。后续还有但是先看看错误处理代码吧

我们先来看看错误处理代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
__afl_setup_abort:

/* Record setup failure so that we do not keep calling
shmget() / shmat() over and over again. */

incb __afl_setup_failure(%rip)

movq %r12, %rsp
popq %r12

movq 0(%rsp), %rax
movq 8(%rsp), %rcx
movq 16(%rsp), %rdi
movq 32(%rsp), %rsi
movq 40(%rsp), %r8
movq 48(%rsp), %r9
movq 56(%rsp), %r10
movq 64(%rsp), %r11

movq 96(%rsp), %xmm0
movq 112(%rsp), %xmm1
movq 128(%rsp), %xmm2
movq 144(%rsp), %xmm3
movq 160(%rsp), %xmm4
movq 176(%rsp), %xmm5
movq 192(%rsp), %xmm6
movq 208(%rsp), %xmm7
movq 224(%rsp), %xmm8
movq 240(%rsp), %xmm9
movq 256(%rsp), %xmm10
movq 272(%rsp), %xmm11
movq 288(%rsp), %xmm12
movq 304(%rsp), %xmm13
movq 320(%rsp), %xmm14
movq 336(%rsp), %xmm15

leaq 352(%rsp), %rsp

jmp __afl_return

上面这段代码就是把 __afl_setup_failure 变量自增,还原所有寄存器并返回。至此,我们发现,如果环境变量 __AFL_SHM_ID 不存在,则共享内存的初始化会失败,但整个桩代码对于目标程序是透明的——无非是保存了一些寄存器、执行了一些无副作用的代码、最后恢复寄存器——因此目标程序可以正常执行。这也解释了为何我们不使用 afl-fuzz 而直接运行插桩后的目标程序,也能执行如常。这个设计体现了 AFL 的用户友好性。

好了我们看回 setup_first 后续的代码:

1
2
3
4
5
6
7
8
9
10
11
_/* 此时 %rax 是指向环境变量 __AFL_SHM_ID 的指针  */_
movq %rax, %rdi
call atoi@PLT // 将id转化为整数

xorq %rdx, %rdx _/* shmat flags */_
xorq %rsi, %rsi _/* requested addr */_
movq %rax, %rdi _/* SHM ID */_
call shmat@PLT //调用库函数创建共享内存

cmpq $-1, %rax
je __afl_setup_abort

shmat 是 shared memory attach 的缩写,用于 attach 共享内存。//如果想了解这些函数需要阅读 linux 相关的文档或数据

这里简单的介绍 shmget 和 shmat 函数:

shmgetshmat 是用于在 Unix/Linux 系统上进行进程间共享内存的系统调用。它们通常用于在多个进程之间共享数据,而无需通过进程间通信的其他方式来传递信息。

  1. shmget 函数:
  • shmget 用于创建共享内存段或获取已经存在的共享内存段的标识符。
  • 原型:int shmget(key_t key, size_t size, int shmflg);
  • key 是用于标识共享内存段的键值。
  • size 指定共享内存的大小。
  • shmflg 是一组标志,用于指定共享内存的访问权限和行为。
  1. shmat 函数:
  • shmat 用于将共享内存段连接到调用进程的地址空间。
  • 原型:void *shmat(int shmid, const void *shmaddr, int shmflg);
  • shmid 是由 shmget 返回的共享内存标识符。
  • shmaddr 是指定共享内存连接到进程地址空间的位置,通常设为 NULL 以由系统选择适当的位置。
  • shmflg 包含连接共享内存的选项。

关于父子进程的问题,一般情况下,如果你希望共享内存在父子进程之间共享数据,那么在每个进程中都需要调用 shmat 进行连接。这是因为每个进程有自己独立的地址空间,共享内存需要被映射到每个进程的地址空间中。
请注意,当一个进程结束时,它使用的共享内存不会自动被释放。你需要在每个进程中调用 shmdt 来断开连接,并且在不再需要共享内存时调用 shmctl 来删除共享内存段。
总之,在父子进程中都需要调用 shmat 来连接共享内存,除非你有其他特定的设计需求。

shmat 函数的返回值是共享内存起点的位置。若 attach 失败,则返回 −1。
上面的汇编代码中,如果发现 attach 失败,则进入 AFL 的错误处理流程。不过读到这里,我们有一个小疑问:一片共享内存,首先应当由 shmget() 创建,再由 shmat() 映射到当前程序的虚拟内存空间中。那么,这片虚拟内存是什么时候创建的?答案是 afl-fuzzsetup_shm() 流程中调用 shmget() 创建了虚拟内存,并将 shm id 写入 __AFL_SHM_ID 环境变量。如果我们并非通过 afl-fuzz 运行程序,自然这片虚拟内存不会被创建,也不会存在 __AFL_SHM_ID 环境变量了。
至此,程序成功地将 afl-fuzz 进程所创建的一片共享内存,映射到了自己的虚拟地址空间内。接着看汇编:

1
2
3
4
5
6
7
8
9
10
11
_/* Store the address of the SHM region. */_

movq %rax, %rdx
movq %rax, __afl_area_ptr(%rip)

movq __afl_global_area_ptr@GOTPCREL(%rip), %rdx
movq %rax, (%rdx)
movq %rax, %rdx

__afl_forkserver:
_/* ...... */_

将共享内存地址读入__afl_area_ptr,以及全局变量__afl_global_area_ptr。这两个变量存在什么区别呢?
可以看到,__afl_area_ptr 是一个 lcomm,而 __afl_global_area_ptr 是一个 comm 。根据一篇 Stackoverflow 问答 lcomm 有点类似于全局 static 变量,不同文件中的重名 lcomm 是指向不同的地址;但 comm 有点类似于全局变量,不同文件中的重名 comm 指向同一个地址。

因此,这个问题的解答是:假如目标程序是多份代码链接形成的,那么,每份汇编文件都拥有 AFL main payload;对于编译出的每个代码片段,都会有自己对应的 __afl_area_ptr 。但 __afl_global_area_ptr 只有一份,只需要优先参考 __afl_global_area_ptr ,就能让所有桩代码访问的 shm 保持一致。

接下来我们就进入到 forkserver 相关代码了

forkserver 中为了提高执行效率,采用了 copy-on-write 技术

Copy-on-write (COW) 是一种内存管理技术,其核心思想是在需要修改数据时才会进行实际的复制操作,而在此之前,多个引用会共享相同的数据。这种方式可以节省内存,并提高性能,特别是在涉及大型数据结构时。

具体而言,当多个进程或线程共享相同的资源(如内存区域)时,初始阶段它们都指向同一个数据块。只有在其中一个尝试修改数据时,才会触发实际的数据复制,确保修改操作不会影响其他引用。这种延迟复制的方式有效减少了不必要的数据复制,提高了系统的效率。

在操作系统和编程语言中,COW 技术通常应用于以下场景:

总体而言,COW 技术是一种优化策略,通过延迟数据复制来降低内存开销,提高系统性能。

显而易见的是,这里我们主要是把这个技术应用于进程复制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
__afl_forkserver:

_/* Enter the fork server mode to avoid the overhead of execve() calls. We
__ push rdx (area ptr) twice to keep stack alignment neat. */_

_/* 此时 %rdx 内保存 shm 地址 */_
pushq %rdx
pushq %rdx

_/* Phone home and tell the parent that we're OK. (Note that signals with
no SA_RESTART will mess it up). If this fails, assume that the fd is
closed because we were execve()d from an instrumented binary, or because
__ the parent doesn't want to use the fork server. */_

movq $4, %rdx _/* length */_
leaq __afl_temp(%rip), %rsi _/* data */_
movq $(198 + 1), %rdi _/* file desc */_
call write@PLT

cmpq $4, %rax
jne __afl_fork_resume

__afl_fork_wait_loop:
_/* ...... */_
1
把 shm 地址连续压栈两次(以保证 esp 仍然是 16 的倍数),然后调用 `write(198+1, __afl_temp, 4)`。其中,198 这一 magic number,是 `config.h` 中的 `FORKSRV_FD` 常量。fork server 使用 198、199 这两个 fd 传递指令。而 `__afl_temp` 是 bss 段的长度 4 字节的变量。

这里需要说明的是,如果想要理解 198 和 199 这两个 fd(文件描述符),我们首先需要了解 linux 中的 pipe 函数,它是用于进程间通信的函数,存在两个文件描述符,分别对应写入端与读出端。管道是一个单向的队列。在 afl_fuzz.c 文件中大概率会存在该函数的调用,建议先了解完这个函数再往后看。

  如果这四个字节写入失败(write() 的返回不等于 4),则直接进入 __afl_fork_resume 逻辑;否则,进入 __afl_fork_wait_loop 逻辑。

  跟进 __afl_fork_wait_loop

1
2
3
4
5
6
7
8
9
10
__afl_fork_wait_loop:

_/* Wait for parent by reading from the pipe. Abort if read fails. */_

movq $4, %rdx _/* length */_
leaq __afl_temp(%rip), %rsi _/* data */_
movq $198, %rdi _/* file desc */_
call read@PLT
cmpq $4, %rax
jne __afl_die

从 fd 198 中读取四个字节(注意 read() 函数是阻塞的)。如果读取失败,则跳转到 __afl_die。如果读取成功,则执行下面的代码:

1
2
3
4
5
6
7
8
9
_/* Once woken up, create a clone of our process. This is an excellent use
case for syscall(__NR_clone, 0, CLONE_PARENT), but glibc boneheadedly
caches getpid() results and offers no way to update the value, breaking
__ abort(), raise(), and a bunch of other things :-( */_
//因为fork函数的特性,如果是子进程调用该函数,返回值为0,所以子进程会执行__afl_fork_resume
call fork@PLT
cmpq $0, %rax
jl __afl_die
je __afl_fork_resume

在这里进行 fork,如果失败了则跳转到 __afl_die。对于子进程,跳转到 __afl_fork_resume ;对于父进程,则继续执行以下逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
_/* In parent process: write PID to pipe, then wait for child. */_

movl %eax, __afl_fork_pid(%rip)

movq $4, %rdx _/* length */_
leaq __afl_fork_pid(%rip), %rsi _/* data */_
movq $(198 + 1), %rdi _/* file desc */_
call write@PLT

movq $0, %rdx _/* no flags */_
leaq __afl_temp(%rip), %rsi _/* status */_
movq __afl_fork_pid(%rip), %rdi _/* PID */_
call waitpid@PLT
cmpq $0, %rax
jle __afl_die

_/* Relay wait status to pipe, then loop back. */_

movq $4, %rdx _/* length */_
leaq __afl_temp(%rip), %rsi _/* data */_
movq $(198 + 1), %rdi _/* file desc */_
call write@PLT

jmp __afl_fork_wait_loop

这段代码逻辑是:将子进程的 pid 保存到 __afl_fork_pid 变量;向 fd 199 写入子进程的 pid;调用 waitpid() 等待子进程执行完毕(如果等待失败,则进入 __afl_die);将子进程的退出原因写进 fd 199,并回到 __afl_fork_wait_loop

之前提到,fork 之后,子进程会跳转到 __afl_fork_resume 逻辑。跟进:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
__afl_fork_resume:

_/* In child process: close fds, resume execution. */_

movq $198, %rdi
call close@PLT

movq $(198 + 1), %rdi
call close@PLT

popq %rdx
popq %rdx

movq %r12, %rsp
popq %r12

movq 0(%rsp), %rax
movq 8(%rsp), %rcx
movq 16(%rsp), %rdi
movq 32(%rsp), %rsi
movq 40(%rsp), %r8
movq 48(%rsp), %r9
movq 56(%rsp), %r10
movq 64(%rsp), %r11

movq 96(%rsp), %xmm0
movq 112(%rsp), %xmm1
movq 128(%rsp), %xmm2
movq 144(%rsp), %xmm3
movq 160(%rsp), %xmm4
movq 176(%rsp), %xmm5
movq 192(%rsp), %xmm6
movq 208(%rsp), %xmm7
movq 224(%rsp), %xmm8
movq 240(%rsp), %xmm9
movq 256(%rsp), %xmm10
movq 272(%rsp), %xmm11
movq 288(%rsp), %xmm12
movq 304(%rsp), %xmm13
movq 320(%rsp), %xmm14
movq 336(%rsp), %xmm15

leaq 352(%rsp), %rsp

jmp __afl_store

很明显这里主要做的是恢复现场的工作,首先关闭父进程中开启的两个用于传递信息的文件,然后恢复各个寄存器的值,最后跳转到 __afl_store。而 __afl_store 在增加 hit count 之后,跳转回 AFL 往基本块头部插入的桩代码——到此为止,子进程恢复所有状态,开始了程序中第一个基本块的执行。

总结

所以回顾一下之前的内容,forkserver 是在程序一开始就执行的么?这几段代码是如何实现让程序停在了第一个基本块的位置的?又是如何唤醒子进程让其开始执行的?copy_and_write 技术在这段汇编代码中体现在哪里呢?
AFL main payload 设计得相当精妙。它让程序在第一个基本块处停下,初始化共享内存,并每次从这个位置 fork 出子进程、恢复程序状态、继续执行业务逻辑;子进程执行完后,fork server 则向 fuzzer 报告执行情况,开始新一轮执行。通过 fork server,AFL 得以避免频繁的 execve 调用,从而提升了 fuzz 效率。
  共享内存由 afl-fuzz 程序建立,其 shm id 记录在环境变量 __AFL_SHM_ID 中。目标程序初始化时,会将这块共享内存 attach 到自己的虚拟地址空间,从而 afl-fuzz 进程可以统计 hit count。
  fork server 的动作由 afl-fuzz 进程控制。目标程序与 afl-fuzz 透过 fd 传递信息——fd 198 用于 fuzzer 向 fork server 发送启动指令,fd 199 用于 fork server 向 fuzzer 报告子进程的 pid 和执行结果。
  与直觉相反,一个运行超时的子进程,并不是由 fork server 结束的,而是由 afl-fuzz 结束的。这些内容要等到我们阅读 afl-fuzz.c 时才能细致掌握。

  总之,我们已经阅读完了 afl-gcc.cafl-as.c 这两份代码,对插桩过程有了详细的了解。

afl 中存在另外两种插桩模式,但是因为我主要关注 fuzz 流程的优化,所以这里暂时先看看 afl_fuzz.c 文件吧。

AFL 的 Fuzzer: afl-fuzz.c

文件概述:

afl_fuzz.c 是 afl 的核心文件,也是代码量最大的程序。在该文件中负责控制整个 fuzz 的流程。
main 函数中的内容大概可以划分为四个部分:

  1. 模糊测试开始前的准备阶段
  2. 模糊测试开始前的第一次 dry_run
  3. 死循环前的准备
  4. 进行模糊测试的死循环
  5. 收尾工作

源码:

这里开始会分段的说明 main 函数中的代码

模糊测试开始前的准备阶段

用户输入参数处理

main 函数首先出现的是处理用户输入参数的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
while ((opt = getopt(argc, argv, "+i:o:f:m:b:t:T:dnCB:S:M:x:QV")) > 0)

switch (opt) {

case 'i': /* input dir */

if (in_dir) FATAL("Multiple -i options not supported");
in_dir = optarg;

if (!strcmp(in_dir, "-")) in_place_resume = 1;

break;

case 'o': /* output dir */

if (out_dir) FATAL("Multiple -o options not supported");
out_dir = optarg;
break;

case 'M': { /* master sync ID */

u8* c;

if (sync_id) FATAL("Multiple -S or -M options not supported");
sync_id = ck_strdup(optarg);

if ((c = strchr(sync_id, ':'))) {

*c = 0;

if (sscanf(c + 1, "%u/%u", &master_id, &master_max) != 2 ||
!master_id || !master_max || master_id > master_max ||
master_max > 1000000) FATAL("Bogus master ID passed to -M");

}

force_deterministic = 1;

}

break;

case 'S':

if (sync_id) FATAL("Multiple -S or -M options not supported");
sync_id = ck_strdup(optarg);
break;

case 'f': /* target file */

if (out_file) FATAL("Multiple -f options not supported");
out_file = optarg;
break;

case 'x': /* dictionary */

if (extras_dir) FATAL("Multiple -x options not supported");
extras_dir = optarg;
break;

case 't': { /* timeout */

u8 suffix = 0;

if (timeout_given) FATAL("Multiple -t options not supported");

if (sscanf(optarg, "%u%c", &exec_tmout, &suffix) < 1 ||
optarg[0] == '-') FATAL("Bad syntax used for -t");

if (exec_tmout < 5) FATAL("Dangerously low value of -t");

if (suffix == '+') timeout_given = 2; else timeout_given = 1;

break;

}

case 'm': { /* mem limit */

u8 suffix = 'M';

if (mem_limit_given) FATAL("Multiple -m options not supported");
mem_limit_given = 1;

if (!strcmp(optarg, "none")) {

mem_limit = 0;
break;

}

if (sscanf(optarg, "%llu%c", &mem_limit, &suffix) < 1 ||
optarg[0] == '-') FATAL("Bad syntax used for -m");

switch (suffix) {

case 'T': mem_limit *= 1024 * 1024; break;
case 'G': mem_limit *= 1024; break;
case 'k': mem_limit /= 1024; break;
case 'M': break;

default: FATAL("Unsupported suffix or bad syntax for -m");

}

if (mem_limit < 5) FATAL("Dangerously low value of -m");

if (sizeof(rlim_t) == 4 && mem_limit > 2000)
FATAL("Value of -m out of range on 32-bit systems");

}

break;

case 'b': { /* bind CPU core */

if (cpu_to_bind_given) FATAL("Multiple -b options not supported");
cpu_to_bind_given = 1;

if (sscanf(optarg, "%u", &cpu_to_bind) < 1 ||
optarg[0] == '-') FATAL("Bad syntax used for -b");

break;

}

case 'd': /* skip deterministic */

if (skip_deterministic) FATAL("Multiple -d options not supported");
skip_deterministic = 1;
use_splicing = 1;
break;

case 'B': /* load bitmap */

/* This is a secret undocumented option! It is useful if you find
an interesting test case during a normal fuzzing process, and want
to mutate it without rediscovering any of the test cases already
found during an earlier run.

To use this mode, you need to point -B to the fuzz_bitmap produced
by an earlier run for the exact same binary... and that's it.

I only used this once or twice to get variants of a particular
file, so I'm not making this an official setting. */

if (in_bitmap) FATAL("Multiple -B options not supported");

in_bitmap = optarg;
read_bitmap(in_bitmap);
break;

case 'C': /* crash mode */

if (crash_mode) FATAL("Multiple -C options not supported");
crash_mode = FAULT_CRASH;
break;

case 'n': /* dumb mode */

if (dumb_mode) FATAL("Multiple -n options not supported");
if (getenv("AFL_DUMB_FORKSRV")) dumb_mode = 2; else dumb_mode = 1;

break;

case 'T': /* banner */

if (use_banner) FATAL("Multiple -T options not supported");
use_banner = optarg;
break;

case 'Q': /* QEMU mode */

if (qemu_mode) FATAL("Multiple -Q options not supported");
qemu_mode = 1;

if (!mem_limit_given) mem_limit = MEM_LIMIT_QEMU;

break;

case 'V': /* Show version number */

/* Version number has been printed already, just quit. */
exit(0);

default:

usage(argv[0]);

}

可以看到函数对各个可选参数进行操作,具体的参数说明如下:
{暂时空缺}
这里面有两个个人认为比较重要的函数:setup_shm()和 read_testcase()
这个函数负责创建共享内存,这里创建的共享内存就是之前介绍过的 afl-gcc 中的共享内存。所以为了理解 afl-gcc 与 afl-fuzz 间如何建立共享内存,有必要再看看这个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/* Append new test case to the queue. */

static void add_to_queue(u8* fname, u32 len, u8 passed_det) {

struct queue_entry* q = ck_alloc(sizeof(struct queue_entry));

q->fname = fname;
q->len = len;
q->depth = cur_depth + 1;
q->passed_det = passed_det;

if (q->depth > max_depth) max_depth = q->depth;

if (queue_top) {

queue_top->next = q;
queue_top = q;

} else q_prev100 = queue = queue_top = q;

queued_paths++;
pending_not_fuzzed++;

cycles_wo_finds = 0;

/* Set next_100 pointer for every 100th element (index 0, 100, etc) to allow faster iteration. */
if ((queued_paths - 1) % 100 == 0 && queued_paths > 1) {

q_prev100->next_100 = q;
q_prev100 = q;

}

last_path_time = get_cur_time();

}

perform_dry_run()

perform_dry_run 是 AFL 中的一种预处理步骤,它的目的是在进行正常的模糊测试之前执行一次“干跑”(dry run)。这个干跑的主要目的有以下几点:
确定执行路径: 干跑能够帮助 AFL 确定程序的一些基本执行路径。在正常的干跑中,AFL 不进行变异,只是简单地运行程序,监测其执行路径。这有助于建立一个基本的执行路径图,作为测试的起点。
初始化基本块计数: AFL 使用基本块计数(basic block counting)的方式来评估测试用例的质量。在干跑中,AFL 可以初始化基本块计数,为之后的正常模糊测试建立基准。
提高变异效率: 干跑可以帮助 AFL 识别程序中的一些基本块,从而有助于在变异阶段集中精力变异那些尚未被探索的执行路径。这有助于提高变异的效率,使测试更有可能发现新的执行路径。
筛选不良样本: 干跑还可以用于筛选掉一些“不良”样本,例如那些导致程序崩溃或无法正常执行的输入。这有助于确保在正常的模糊测试中使用的样本是有效的,从而提高测试效率。
总体而言,perform_dry_run 是一个预处理步骤,通过一次简单的运行来收集一些基本信息,以便更好地引导正常的模糊测试过程,提高测试效率,发现更多的执行路径和潜在的漏洞。

这个函数主要分为两步:

AFL 关键函数:calibrate_case(char** argv, struct queue_entry* q, u8* use_mem,u32 handicap, u8 from_queue) 校准一个新的测试用例。这是在处理输入目录时完成的,以便在早期就警告有问题的测试用例;当发现新的路径来检测变量行为等等。
这个函数是 AFL 的重点函数之一,在 perform_dry_run,save_if_interesting,fuzz_one,pilot_fuzzing,core_fuzzing 函数中均有调用。

步骤:

  1. 进行一系列参数设置,包括当前阶段 stage_cur,阶段名称 stage_name,新比特 new_bit 等初始化设置。
  2. 最后一个参数_from_queue_,判断是否是为队列中的 || 刚恢复 fuzz 以此设置较长的时间延迟。testcase 参数_q->cal_failed++_ 是否校准失败参数 ++
  3. 判断是否已经启动 forkserver ,调用函数 init_forkserver() 启动 fork 服务。如果是第一次接触 linux
    中 fork()函数,不妨看一下 https://www.cnblogs.com/dongguolei/p/8086346.html 该函数的理解,init_forkserver() 的详细内容见 2.4
  4. 拷贝 trace_bits 到 first_trace,并获取开始时间 start_us;
  5. -loop- 该 loop 循环多次执行这个 testcase,循环的次数 8 次或者 3 次,取决于是否快速校准。_对同一个初始 testcase 多次运行的意义可能是,觉得有些 targetApp 执行同一个 testcase 可能也会出现不同的路径(这是我的猜测_)
  6. static void write_to_testcase(void* mem, u32 len) 将修改后的数据写入文件进行测试。如果 use_stdin 被清除了,那么取消旧文件链接并创建一个新文件。否则,prog_in_fd 将被缩短。将 testcase 写入到文件中去。该函数较简单,不做单独解释。
  7. run_target 详细内容见 2.5 主要作用是通知 forkserver 可以开始 fork 并且 fuzz 了。
  8. cksum = hash32(trace_bits, MAP_SIZE, HASH_CONST) 校验此次运行的 trace_bits,检查是否出现新的情况。hash32 函数较为简单,不多做分析。
  9. 这段代码的主要意思是先用 cksum 也就是本次运行的出现 trace_bits 哈希和本次 testcase q->exec_cksum 对比。如果发现不同,则调用 has_new_bits 函数(见 2.6)和我们的总表_virgin_bits_ 对比。
  10. 判断_q->exec_cksum_ 是否为 0,不为 0 那说明不是第一次执行。后面运行的时候如果,和前面第一次 trace_bits 结果不同,则需要多运行几次。这里把校准次数设为 40…
  11. -loop-end-
  12. 接着收集一些关于这个测试用例性能的统计数据。比如执行时间延迟,校准错误?,bitmap 大小等等。
  13. update_bitmap_score(q) 对这个测试用例的每一个 byte 进行排序,用一个 top_rate[]来维护它的最佳入口。维护完成之后,我们这个函数在 我并不认可博客中的这个解释,我的理解在下面具体介绍函数时说明。
  14. 如果这种情况没有从检测中得到 new_bit,则告诉父程序。这是一个无关紧要的问题,但是需要提醒用户注意。
    总结:calibratecase 函数到此为止,该函数主要用途是 init_forkserver;将 testcase 运行多次;用 update_bitmap_score 进行初始的 byte 排序。
    函数 calibrate_case()函数之所以重要,是因为里面又涉及四个十分关键的函数:
  1. init_forkserver() 负责创建 forkserver
  2. run_target() 负责把测试用例输入并执行被测程序
  3. has_new_bits()判断有没有检测到新的基本块
  4. update_bitmap_score()根据运行结果对各个元组选择一个最佳的测试用例
init_forkserver()

这个函数很长,主要分为创建子进程、子进程执行的代码、父进程与子进程进行通信并根据子进程返回结果向用户报告 forkserver 创建结果
首先函数通过 fork 函数创建子进程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* Spin up fork server (instrumented mode only). The idea is explained here:

http://lcamtuf.blogspot.com/2014/10/fuzzing-binaries-without-execve.html

In essence, the instrumentation allows us to skip execve(), and just keep
cloning a stopped child. So, we just execute once, and then send commands
through a pipe. The other part of this logic is in afl-as.h. */

EXP_ST void init_forkserver(char** argv) {//理论上来讲这里应该执行execve()函数,然后开始fork,这里的代码是如何与afl_as中关联起来的呢?

static struct itimerval it;
int st_pipe[2], ctl_pipe[2];
int status;
s32 rlen;

ACTF("Spinning up the fork server...");

if (pipe(st_pipe) || pipe(ctl_pipe)) PFATAL("pipe() failed");

forksrv_pid = fork();//创建子进程

if (forksrv_pid < 0) PFATAL("fork() failed");

然后就会通过一个条件判断语句让子进程执行自己的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
if (!forksrv_pid) {//当为子进程时,因为子进程运行fork()返回值为0

struct rlimit r;

//针对OpenBSD的特殊处理,不用看
/*------------------------------------------------------------------------------------*/

/* Umpf. On OpenBSD, the default fd limit for root users is set to
soft 128. Let's try to fix that... */

if (!getrlimit(RLIMIT_NOFILE, &r) && r.rlim_cur < FORKSRV_FD + 2) {

r.rlim_cur = FORKSRV_FD + 2;
setrlimit(RLIMIT_NOFILE, &r); /* Ignore errors */

}

if (mem_limit) {

r.rlim_max = r.rlim_cur = ((rlim_t)mem_limit) << 20;

#ifdef RLIMIT_AS

setrlimit(RLIMIT_AS, &r); /* Ignore errors */

#else

/* This takes care of OpenBSD, which doesn't have RLIMIT_AS, but
according to reliable sources, RLIMIT_DATA covers anonymous
maps - so we should be getting good protection against OOM bugs. */

setrlimit(RLIMIT_DATA, &r); /* Ignore errors */

#endif /* ^RLIMIT_AS */


}
//针对OpenBSD的特殊处理结束
/*------------------------------------------------------------------------------------*/
/* Dumping cores is slow and can lead to anomalies if SIGKILL is delivered
before the dump is complete. */

r.rlim_max = r.rlim_cur = 0;

setrlimit(RLIMIT_CORE, &r); /* Ignore errors */

/* Isolate the process and configure standard descriptors. If out_file is
specified, stdin is /dev/null; otherwise, out_fd is cloned instead. */

setsid();
/*设置守护进程,执行这条语句后,子进程成为领头进程,不再受终端影响
其实这里我们创建的子进程就转生成为forkserver进程了,负责创建新的
子进程进行模糊测试。值得一提的是虽然我们让forkserver成为了领头进程,但之前创建的
父子进程之间的管道仍然可用,所以父进程还是可以和forkserver进程进行通信的*/

//创建设置管道
dup2(dev_null_fd, 1);
dup2(dev_null_fd, 2);

if (out_file) {

dup2(dev_null_fd, 0);

} else {

dup2(out_fd, 0);
close(out_fd);

}

/* Set up control and status pipes, close the unneeded original fds. */

if (dup2(ctl_pipe[0], FORKSRV_FD) < 0) PFATAL("dup2() failed");
if (dup2(st_pipe[1], FORKSRV_FD + 1) < 0) PFATAL("dup2() failed");

close(ctl_pipe[0]);
close(ctl_pipe[1]);
close(st_pipe[0]);
close(st_pipe[1]);

close(out_dir_fd);
close(dev_null_fd);
close(dev_urandom_fd);
close(fileno(plot_file));

//一堆获取环境变量的操作
/* This should improve performance a bit, since it stops the linker from
doing extra work post-fork(). */

if (!getenv("LD_BIND_LAZY")) setenv("LD_BIND_NOW", "1", 0);

/* Set sane defaults for ASAN if nothing else specified. */

setenv("ASAN_OPTIONS", "abort_on_error=1:"
"detect_leaks=0:"
"symbolize=0:"
"allocator_may_return_null=1", 0);

/* MSAN is tricky, because it doesn't support abort_on_error=1 at this
point. So, we do this in a very hacky way. */

setenv("MSAN_OPTIONS", "exit_code=" STRINGIFY(MSAN_ERROR) ":"
"symbolize=0:"
"abort_on_error=1:"
"allocator_may_return_null=1:"
"msan_track_origins=0", 0);

execv(target_path, argv);
//执行被测程序,这里的参数是作为本函数的参数传入的;
//这里就会开始执行已经插桩的被测程序,被测程序中的汇编代码将通过管道与forkserver进程通信
/*
execv()会替换原有进程空间为目标程序,所以后续执行的都是目标程序。
第一个目标程序会进入__afl_maybe_log里的__afl_fork_wait_loop,并充当fork server。
在整个过程中,每次要fuzz一次目标程序,都会从这个fork server再fork出来一个子进程去fuzz。
因此可以看作是三段式:fuzzer -> fork server -> target子进程
*/

/* Use a distinctive bitmap signature to tell the parent about execv()
falling through. */

*(u32*)trace_bits = EXEC_FAIL_SIG;//通过在共享内存中写入信号表示forkserver执行完毕
exit(0);//子进程退出

}
//子进程执行部分结束

然后就是父进程需要干的活了,首先父进程会设置一下管道,并且等待 forkserver 的创建结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* Close the unneeded endpoints. */

close(ctl_pipe[0]);
close(st_pipe[1]);

fsrv_ctl_fd = ctl_pipe[1];// 父进程只能发送命令
fsrv_st_fd = st_pipe[0]; // 父进程只能读取状态


/* Wait for the fork server to come up, but don't wait too long. */

it.it_value.tv_sec = ((exec_tmout * FORK_WAIT_MULT) / 1000);
it.it_value.tv_usec = ((exec_tmout * FORK_WAIT_MULT) % 1000) * 1000;

setitimer(ITIMER_REAL, &it, NULL);

rlen = read(fsrv_st_fd, &status, 4);

it.it_value.tv_sec = 0;
it.it_value.tv_usec = 0;

setitimer(ITIMER_REAL, &it, NULL);

之后父进程会根据 forkserver 的创建结果对用户进行反馈,代码很多这里就不贴出来了。其实就是把错误信息打到屏幕上。
forkserver 的相关机制可以参考下面这段

实际流程:fuzzer(调用 init_forkserver)->fork fueezer–targetApp(forkserver) call fork ->targetAppfork(实际 fuzz 的程序)。fuzzer 还是实际 fuzzer 程序的祖父进程。这样接下来的代码就很容易理解了

下图可以很好的总结 forkserver 和被 fuzzed 程序之间的状态。

首先 alf-fuzz 会创建两个管道(init_forkserver()),然后会去执行 afl-gcc 编译出来的目标程序。结合之前的分析,目标程序的 main 函数位置已经被插桩,程序的控制流会交到_afl_maybe_log 手中。如 fuzz 是第一次运行,则此时的程序便成为了 fuzz server,之后运行的目标程序都是由该 server fork 出来的子进程。fuzz 进行的时候,fuzz server 会一直 fork 子进程,并且将子进程的结束状态通过 pipe 传递给 afl-fuzz。
这里有几点需要注意:afl 在这里利用了 fork()的特性(creates a new process by duplicating the calling process)来实现目标程序反复执行。实际的 fuzz server(_afl_maybe_log)由 afl 事先插桩在目标程序中,在进入 main 函数之前,fuzz server 便会 fork()新的进程,进行 fuzz。

run_target()

我们再上一个函数中已经创建了一个 forkserver,这个函数主要就是通知 forkserver 运行目标程序。这个函数也是挺长的,并且需要处理 dumb 模式的情况。

在 AFL(American Fuzzy Lop)中,dumb_mode 是一种模式,用于指示 AFL 在模糊测试中是否启用 “dumb 模式”。Dumb 模式是一种简单的测试模式,它通常用于一些特殊的情况或调试目的。
在 Dumb 模式下,AFL 不会使用其复杂的启发式策略,而是采用一种简单的测试策略。具体来说,Dumb 模式中 AFL 通常会采用随机测试或其他基本的测试生成方法,而不使用更复杂的输入变异或路径导向的测试生成技术。
让我们看看这个函数的代码吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* Execute target application, monitoring for timeouts. Return status
information. The called program will update trace_bits[]. */

static u8 run_target(char** argv, u32 timeout) {

static struct itimerval it;
static u32 prev_timed_out = 0;
static u64 exec_ms = 0;

int status = 0;
u32 tb4;

child_timed_out = 0;

/* After this memset, trace_bits[] are effectively volatile, so we
must prevent any earlier operations from venturing into that
territory. */

memset(trace_bits, 0, MAP_SIZE); //将trace_bits中数据归零
MEM_BARRIER();//插入内存屏障,这个概念不是很懂,需要查一下

首先函数创建了一些变量,最为关键的,这里对 trace_bits 这个关键的共享变量进行了初始化以及并行保护。确保共享数据的一致性和可预测性是非常重要的,因此这些初始化和同步操作是为了提供正确的并发行为。
因为我不太在意 dumb 模式,所以这里就跳过 dumb 相关的代码了。程序之后会通过管道通知 forkserver 开始干活,并获取创建的子进程的 pid,并根据返回结果通知 calibrate_case()执行结果

has_new_bits()

检查当前执行路径是否为 virgin_bits 带来了新内容。更新 virgin_bits 以反映发现。如果唯一更改的是特定元组的命中计数,则返回 1;如果有新的元组出现,则返回 2。更新 virgin_bits,这样后续调用该函数将始终返回 0。
这个函数是在相当大的缓冲区上的每个 exec()之后调用的,因此它需要非常快。我们以 32 位和 64 位的方式做这件事。

update_bitmap_score()

这个函数不长,为了理解这个函数我们首先应该明确两个变量。
trace_bits 是字节数组,是共享内存,记录了一个测试用例执行过的边。trace_bits[i]就是一条边被命中的次数。
top_rated[]是一个测试用例数组,top_rated[i]对应着 trace_bits[i] 。top_rated[i]代表执行该边的最佳测试用例。
现在我们来看看代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/* When we bump into a new path, we call this to see if the path appears
more "favorable" than any of the existing ones. The purpose of the
"favorables" is to have a minimal set of paths that trigger all the bits
seen in the bitmap so far, and focus on fuzzing them at the expense of
the rest.

The first step of the process is to maintain a list of top_rated[] entries
for every byte in the bitmap. We win that slot if there is no previous
contender, or if the contender has a more favorable speed x size factor. */

static void update_bitmap_score(struct queue_entry* q) {

u32 i;
u64 fav_factor = q->exec_us * q->len;//利用执行轮次与测试用例长度的乘积作为排序的键值

/* For every byte set in trace_bits[], see if there is a previous winner,
and how it compares to us. */

for (i = 0; i < MAP_SIZE; i++)

if (trace_bits[i]) {

if (top_rated[i]) {//维护一个队列,每个数组元素即是一个最佳的测试用例

/* Faster-executing or smaller test cases are favored. */

if (fav_factor > top_rated[i]->exec_us * top_rated[i]->len) continue;

/* Looks like we're going to win. Decrease ref count for the
previous winner, discard its trace_bits[] if necessary. */
//trace_mini是额外的信息,只有当一个种子是最佳用例时才会维护这个变量,当它不再优秀时选择释放这个变量
if (!--top_rated[i]->tc_ref) {
ck_free(top_rated[i]->trace_mini);
top_rated[i]->trace_mini = 0;
}

}

/* Insert ourselves as the new winner. */

top_rated[i] = q;
q->tc_ref++;//代表一个测试用例被选为最佳种子的次数

if (!q->trace_mini) {
q->trace_mini = ck_alloc(MAP_SIZE >> 3);
minimize_bits(q->trace_mini, trace_bits);//trace_mini是trace_bits的简化表示,会被利用与队列筛选环节,帮助判断哪些路径还没有探索
}

score_changed = 1;

}

}

可以看到这个函数主要工作就是为了更新 top_rated 数组,为接下来的 cull_queue 做准备。在这一步我们筛选出了对于每个边最佳的测试用例。在下一个函数中就会在这些最佳用例中挑选对应着新的路径的测试用例,并将之标记为感兴趣的。这个函数是种子选择中的关键步骤,每一轮的模糊测试中都会调用这个函数。这个函数是唯一更新 top_rated 数组的函数,而 cull_queue 函数对种子的选择依赖于 top_rated,所以理解该函数以及该函数被调用的方式十分重要。让我们看看它的调用情况:

死循环前的准备工作

cull_queue

完成干运行后,main 函数会进行一次队列筛选操作。主要目的是从现有的测试用例集合中,挑选出一个能够覆盖已探索路径的最小测试用例集合。
让我们看看代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/* The second part of the mechanism discussed above is a routine that
goes over top_rated[] entries, and then sequentially grabs winners for
previously-unseen bytes (temp_v) and marks them as favored, at least
until the next run. The favored entries are given more air time during
all fuzzing steps. */
//主要是要被感兴趣种子的favored设为1
static void cull_queue(void) {

struct queue_entry* q;
static u8 temp_v[MAP_SIZE >> 3];//这里当比特数值为1则代表仍未探索
u32 i;

if (dumb_mode || !score_changed) return;

score_changed = 0;

memset(temp_v, 255, MAP_SIZE >> 3);

queued_favored = 0;
pending_favored = 0;

q = queue;
//初始化所有测试用例的favored属性
while (q) {
q->favored = 0;
q = q->next;
}

/* Let's see if anything in the bitmap isn't captured in temp_v.
If yes, and if it has a top_rated[] contender, let's use it. */

for (i = 0; i < MAP_SIZE; i++)
if (top_rated[i] && (temp_v[i >> 3] & (1 << (i & 7)))) {

u32 j = MAP_SIZE >> 3;

/* Remove all bits belonging to the current entry from temp_v. */

while (j--)
if (top_rated[i]->trace_mini[j])
temp_v[j] &= ~top_rated[i]->trace_mini[j];//将探索过的基本块置零

top_rated[i]->favored = 1;
queued_favored++;

if (!top_rated[i]->was_fuzzed) pending_favored++;

}

q = queue;

while (q) {
mark_as_redundant(q, !q->favored);
q = q->next;
}

}

if (top_rated[i] && (temp_v[i >> 3] & (1 << (i & 7)))) 这条条件判断语句到底是什么意思呢?
afl 在这里不知道出于什么需求,搞了个骚操作。首先把 temp_v 设置成了一个压缩后的数组,temp_v 中的一个元素代表 trace_bits 中的四个基本块元素。然后又利用 temp_v[i >> 3] & (1 << (i & 7)) 实现了 temp_v 元素中的后四个比特分别代表 trace_bits 中的四个基本块元素是否被探索过。只能说很抽象。可能是为了和 trace_mini 配套吧,方便节约内存空间。
让我们看看其他人是如何评价这段代码的:

为了优化模糊工作,AFL 使用快速算法定期重新评估队列,该算法选择一个较小的测试用例子集,该子集仍覆盖到目前为止所看到的每个元组,并且其特征使它们对 Fuzzing 特别有利。该算法通过为每个队列条目分配与其执行延迟和文件大小成正比的分数来工作;然后为每个 tuples 选择最低得分候选者。
cull_queue()遍历 top_rated[]中的 queue,然后提取出发现新的 edge 的 entry,并标记为 favored,使得在下次遍历 queue 时,这些 entry 能获得更多执行 fuzz 的机会。
这里本质上采用了贪婪算法,如果 top_rated[i]存在,且对应 temp_v[]中对应 bit 位还没抹去,即这一轮选出的 queue 还没覆盖 bit_map[i]对应的边,则取出这个 top_rated[i]。抹去 temp_v 中 top_rated[i]能访问到的位。最后将这个 top_rated[i]标记为 favored,如果这个 queue 还没 fuzzed,pending_favored++.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
show_init_stats();//进入主循环前的准备工作使用的函数之一,主要作用为在处理输入目录的末尾显示统计信息,警告信息以及硬编码的常量

seek_to = find_start_position();//用于寻找resume时开始fuzz的队列的位置

write_stats_file(0, 0, 0);
save_auto();

if (stop_soon) goto stop_fuzzing;

/* Woop woop woop */

if (!not_on_tty) {
sleep(4);
start_time += 4000;
if (stop_soon) goto stop_fuzzing;
}

进行模糊测试的死循环

ohhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
我们终于正式进入了激动人心的模糊测试的主要流程,执行模糊测试的死循环。让我们看看代码吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
while (1) {//主循环 变异的相关代码在这里面

u8 skipped_fuzz;

cull_queue();//运行前先根据之前的信息进行种子筛选

if (!queue_cur) {//queue_cur为空代表已经变异完了一轮,重新设置各参数进行新一轮的变异

queue_cycle++;
current_entry = 0;
cur_skipped_paths = 0;
queue_cur = queue;
//寻找作为入口的测试用例
while (seek_to) {
current_entry++;
seek_to--;
queue_cur = queue_cur->next;
}

show_stats();

if (not_on_tty) {
ACTF("Entering queue cycle %llu.", queue_cycle);
fflush(stdout);
}

/* If we had a full queue cycle with no new finds, try
recombination strategies next. */

if (queued_paths == prev_queued) {
//因为afl采用循环队列储存测试用例,所以采用了这种方式判断是否执行过全部测试用例

if (use_splicing) cycles_wo_finds++; else use_splicing = 1;

} else cycles_wo_finds = 0;

prev_queued = queued_paths;

if (sync_id && queue_cycle == 1 && getenv("AFL_IMPORT_FIRST"))
sync_fuzzers(use_argv);

}

skipped_fuzz = fuzz_one(use_argv);//对该测试用例进行一次模糊测试

if (!stop_soon && sync_id && !skipped_fuzz) {

if (!(sync_interval_cnt++ % SYNC_INTERVAL))
sync_fuzzers(use_argv);

}

if (!stop_soon && exit_1) stop_soon = 2;

if (stop_soon) break;

queue_cur = queue_cur->next;
current_entry++;

}

这玩意也不是很长,其主要流程为:

  1. 判断 queue_cur 是否为空,如果是,则表示已经完成对队列的遍历,初始化相关参数,重新开始遍历队列
  2. 找到 queue 入口的 testcase,seek_to = find_start_position();直接跳到该 testcase
  3. 如果一整个队列循环都没新发现,尝试重组策略。
  4. 调用关键函数 fuzz_one()对该 testcase 进行 fuzz。fuzz_one()函数参见 3.4。
  5. 上面的变异完成后,AFL 会对文件队列的下一个进行变异处理。当队列中的全部文件都变异测试后,就完成了一个”cycle”,这个就是 AFL 状态栏右上角的”cycles done”。而正如 cycle 的意思所说,整个队列又会从第一个文件开始,再次进行变异,不过与第一次变异不同的是,这一次就不需要再进行 deterministic fuzzing 了。如果用户不停止 AFL,那么 seed 文件将会一遍遍的变异下去。
    这个函数调用了一个需要解释的函数 fuzz_one(),它对一个测试用例进行了模糊测试。

fuzz_one()

static u8 fuzz_one_original(char** argv) 从队列中取出当前 testcase 并模糊。这个函数长的离谱,有 1600 行左右。如果 fuzzed 成功,返回 0;如果跳过或退出,返回 1。
该函数大致流程如下:

  1. 根据是否有 pending_favoredqueue_cur 的情况,按照概率进行跳过;有 pending_favored, 对于已经 fuzz 过的或者 non-favored 的有 99% 的概率跳过;无 pending_favored,95% 跳过 fuzzed&non-favored,75% 跳过 not fuzzed&non-favored,不跳过 favored;
  2. 假如当前项有校准错误,并且校准错误次数小于 3 次,那么就用 calibrate_case 进行测试;
  3. 如果测试用例没有修剪过,那么调用函数 trim_case 对测试用例进行修剪;
  4. 修剪完毕之后,使用 calculate_score 对每个测试用例进行打分;
  5. 如果该 queue 已经完成 deterministic 阶段,则直接跳到 havoc 阶段;
  6. deterministic 阶段变异 4 个 stage,变异过程中会多次调用函数 common_fuzz_stuff 函数,保存 interesting 的种子:
    • bitflip,按位翻转,1 变为 0,0 变为 1
    • arithmetic,整数加/减算术运算
    • interest,把一些特殊内容替换到原文件中
    • dictionary,把自动生成或用户提供的 token 替换/插入到原文件中
    • havoc,中文意思是“大破坏”,此阶段会对原文件进行大量变异。
    • splice,中文意思是“绞接”,此阶段会将两个文件拼接起来得到一个新的文件。
  7. 该轮完成。
  1. 完成了 fuzz 中计算资源的分配。
  2. 处理了校验失败的测试用例,每个测试用例有三次机会
  3. 调用了 trim_case,这个是需要详细看看的
  4. 调用了 calculate_score,需要简单了解
  5. 可以根据这个信息了解到 havoc 前的变异操作都是确定性变异
  6. 事实上 afl 会顺序执行各个变异策略
    下面我们来看看一些比较重要的函数或变异代码
trim_case()

文件大小对模糊性能有很大影响,这是因为大文件使目标二进制文件变得更慢,并且因为它们减少了突变将触及重要的格式控制结构而不是冗余数据块的可能性。这在 perf_tips.txt 中有更详细的讨论。
用户可能会提供低质量的起始语料库,某些类型的突变可能会产生迭代地增加生成文件的大小的效果,因此应对这一趋势是很重要的。
幸运的是,插装反馈提供了一种简单的方法来自动删除输入文件,同时确保对文件的更改不会对执行路径产生影响。
在 afl-fuzz 中内置的修边器试图按可变长度和 stepover 顺序删除数据块;任何不影响跟踪映射校验和的删除都被提交到磁盘。修剪器的设计并不是特别彻底;相反,它试图在精度和在进程上花费的 execve()调用的数量之间取得平衡,选择块大小和 stepover 来匹配。每个文件的平均增益大约在 5% 到 20% 之间。
独立的 afl-tmin 工具使用了更详尽的迭代算法,并尝试在修剪过的文件上执行字母标准化。afl-tmin 的操作如下。
首先,工具自动选择操作模式。如果初始输入崩溃了目标二进制文件,afl-tmin 将以非插装模式运行,只需保留任何能产生更简单文件但仍然会使目标崩溃的调整。如果目标是非崩溃的,那么这个工具使用一个插装的模式,并且只保留那些产生完全相同的执行路径的微调。
所以我们需要对测试用例进行修剪,该函数的主要流程如下:

  1. 首先取 testcase 长度 2 的指数倍
  2. 第一个 while 循环,从文件大小 1/16 的步长开始,慢慢到文件大小的 1/1024 倍步长。
  3. 第二个 while 循环,嵌套在第一个内,从文件头开始按步长 cut testcase,然后 target_run();如果删除之后对文件执行路径没有影响那么就将这个删除保存至实际文件中。在删除之前会将 trace_bits 保存到起来。删除完成之后重新拷贝。
    关键代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
static u8 trim_case(char****** argv, struct queue_entry***** q, u8***** in_buf) {
....
static u8 clean_trace[MAP_SIZE];
u8 needs_write **=** 0
**/*** 从文件长度1**/**16开始最到最小1**/**1024步长,设置移除文件的大小 ***/**
**while** (remove_len >**=** MAX(len_p2 **/** TRIM_END_STEPS, TRIM_MIN_BYTES)) {
**/***按选定的步长,移除,然后循环该文件***/**
**while** (remove_pos < q**-**>len) {
**//**删除
write_with_gap(in_buf, q**-**>len, remove_pos, trim_avail);
**//**执行
fault **=** run_target(argv, exec_tmout);
**/*** 检查trace_bit是否不一样 ***/**
cksum **=** hash32(trace_bits, MAP_SIZE, HASH_CONST);
**/*** 如果删除对跟踪没有影响,则使其永久。作者表明可能可变路径会对此产生一些影响,不过没有大碍***/**
**if** (cksum **==** q**-**>exec_cksum) {
memmove(in_buf **+** remove_pos, in_buf **+** remove_pos **+** trim_avail, move_tail);
**/*** 保存之前的trace_bits,因为执行如果改变了trace_bits***/**
**if** (!needs_write) {
memcpy(clean_trace, trace_bits, MAP_SIZE);}
} **else** remove_pos **+=** remove_len;
}
remove_len >>**=** 1; **//**增加步长
}
....
}
calculate_score()

static u32 calculate_score(struct queue_entry* q) 根据 case 的执行速度/bitmap 的大小/case 产生时间/路径深度等因素给 case 进行打分,返回值为一个分数,用来调整在 havoc 阶段的用时。使得执行时间短,代码覆盖高,新发现的,路径深度深的 case 拥有更多 havoc 变异的机会。此段代码没有较强逻辑,在这里就简单介绍。

变异策略

这里 afl 会顺序执行各个变异策略,其中有一个函数十分关键

common_fuzz_stuff

common_fuzz_stuff(char** argv, u8* out_buf, u32 len) 编写修改后的测试用例,运行程序,处理结果。处理错误条件,如果需要退出,返回 1。这是 fuzz_one()的一个辅助函数。该函数贯穿了整个变异过程的始终。
步骤:

  1. write_to_testcase() 将变异写到文件中
  2. run_target() 前面解释过;
  3. save_if_interesting() 判断一个文件是否为 interesting 种子,如果是那么就保存输入文件(queue)
    其中 save_if_interesting()定义如下:
    save_if_interesting
    static u8 save_if_interesting(char** argv, void* mem, u32 len, u8 fault) 判断是否为感兴趣的输入,判断一个文件是否是感兴趣的输入(has_new_bits),即是否访问了新的 tuple 或者 tuple 访问次数发生变化,如果是则保存输入文件(放到队列 queue 中)。
    步骤:
Bitflip 阶段
  1. 基本原理:bitflip,按位翻转,1 变为 0,0 变为 1。

拿到一个原始文件,打头阵的就是 bitflip,而且还会根据翻转量/步长进行多种不同的翻转,按照顺序依次为:
bitflip 1/1,每次翻转 1 个 bit,按照每 1 个 bit 的步长从头开始
bitflip 2/1,每次翻转相邻的 2 个 bit,按照每 1 个 bit 的步长从头开始
bitflip 4/1,每次翻转相邻的 4 个 bit,按照每 1 个 bit 的步长从头开始
bitflip 8/8,每次翻转相邻的 8 个 bit,按照每 8 个 bit 的步长从头开始,即依次对每个 byte 做翻转
bitflip 16/8,每次翻转相邻的 16 个 bit,按照每 8 个 bit 的步长从头开始,即依次对每个 word 做翻转
bitflip 32/8,每次翻转相邻的 32 个 bit,按照每 8 个 bit 的步长从头开始,即依次对每个 dword 做翻转
作为精妙构思的 fuzzer,AFL 不会放过每一个获取文件信息的机会。这一点在 bitflip 过程中就体现的淋漓尽致。具体地,在上述过程中,AFL 巧妙地嵌入了一些对文件格式的启发式判断。包括自动检测 token 和生成 effector map。

  1. 自动检测 token

在进行 bitflip 1/1 变异时,对于每个 byte 的最低位(least significant bit)翻转还进行了额外的处理:如果连续多个 bytes 的最低位被翻转后,程序的执行路径都未变化,而且与原始执行路径不一致(检测程序执行路径的方式可见上篇文章中“分支信息的分析”一节),那么就把这一段连续的 bytes 判断是一条 token。
例如,PNG 文件中用 IHDR 作为起始块的标识,那么就会存在类似于以下的内容
.......IHDR........
当翻转到字符 I 的最高位时,因为 IHDR 被破坏,此时程序的执行路径肯定与处理正常文件的路径是不同的;随后,在翻转接下来 3 个字符的最高位时,IHDR 标识同样被破坏,程序应该会采取同样的执行路径。由此,AFL 就判断得到一个可能的 token:IHDR,并将其记录下来为后面的变异提供备选。
AFL 采取的这种方式是非常巧妙的:就本质而言,这实际上是对每个 byte 进行修改并检查执行路径;但集成到 bitflip 后,就不需要再浪费额外的执行资源了。此外,为了控制这样自动生成的 token 的大小和数量,AFL 还在 config.h 中通过宏定义了限制.
对于一些文件来说,我们已知其格式中出现的 token 长度不会超过 4,那么我们就可以修改 MAX_AUTO_EXTRA 为 4 并重新编译 AFL,以排除一些明显不会是 token 的情况。遗憾的是,这些设置是通过宏定义来实现,所以不能做到运行时指定,每次修改后必须重新编译 AFL

  1. 生成 effector map

在进行 bitflip 8/8 变异时,AFL 还生成了一个非常重要的信息:effector map。这个 effector map 几乎贯穿了整个 deterministic fuzzing 的始终。

Effector map 定义如下:

arithmetic 阶段

在 bitflip 变异全部进行完成后,便进入下一个阶段:arithmetic。与 bitflip 类似的是,arithmetic 根据目标大小的不同,也分为了多个子阶段:
arith 8/8,每次对 8 个 bit 进行加减运算,按照每 8 个 bit 的步长从头开始,即对文件的每个 byte 进行整数加减变异
arith 16/8,每次对 16 个 bit 进行加减运算,按照每 8 个 bit 的步长从头开始,即对文件的每个 word 进行整数加减变异
arith 32/8,每次对 32 个 bit 进行加减运算,按照每 8 个 bit 的步长从头开始,即对文件的每个 dword 进行整数加减变异
加减变异的上限,在 config.h 中的宏 ARITH_MAX 定义,默认为 35。所以,对目标整数会进行 +1, +2, …, +35, -1, -2, …, -35 的变异。特别地,由于整数存在大端序和小端序两种表示方式,AFL 会贴心地对这两种整数表示方式都进行变异。
此外,AFL 还会智能地跳过某些 arithmetic 变异。第一种情况就是前面提到的 effector map:如果一个整数的所有 bytes 都被判断为“无效”,那么就跳过对整数的变异。第二种情况是之前 bitflip 已经生成过的变异:如果加/减某个数后,其效果与之前的某种 bitflip 相同,那么这次变异肯定在上一个阶段已经执行过了,此次便不会再执行。

interest 阶段

interest 8/8,每次对 8 个 bit 进替换,按照每 8 个 bit 的步长从头开始,即对文件的每个 byte 进行替换
interest 16/8,每次对 16 个 bit 进替换,按照每 8 个 bit 的步长从头开始,即对文件的每个 word 进行替换
interest 32/8,每次对 32 个 bit 进替换,按照每 8 个 bit 的步长从头开始,即对文件的每个 dword 进行替换
而用于替换的”interesting values”,是 AFL 预设的一些比较特殊的数。这些数的定义在 config.h 文件中,可以看到,用于替换的基本都是可能会造成溢出的数。
与之前类似,effector map 仍然会用于判断是否需要变异;此外,如果某个 interesting value,是可以通过 bitflip 或者 arithmetic 变异达到,那么这样的重复性变异也是会跳过的。

dictionary 阶段

进入到这个阶段,就接近 deterministic fuzzing 的尾声了。具体有以下子阶段:
user extras (over),从头开始,将用户提供的 tokens 依次替换到原文件中
user extras (insert),从头开始,将用户提供的 tokens 依次插入到原文件中
auto extras (over),从头开始,将自动检测的 tokens 依次替换到原文件中
其中,用户提供的 tokens,是在词典文件中设置并通过-x 选项指定的,如果没有则跳过相应的子阶段

  1. user extras (over)

对于用户提供的 tokens,AFL 先按照长度从小到大进行排序。这样做的好处是,只要按照顺序使用排序后的 tokens,那么后面的 token 不会比之前的短,从而每次覆盖替换后不需要再恢复到原状。
随后,AFL 会检查 tokens 的数量,如果数量大于预设的 MAX_DET_EXTRAS(默认值为 200),那么对每个 token 会根据概率来决定是否进行替换:

1

for (j = 0; j < extras_cnt; j**++) {
if ((extras_cnt > MAX_DET_EXTRAS && UR(extras_cnt) >
=** MAX_DET_EXTRAS) ||
extras[j].len > len - i ||
!memcmp(extras[j].data, out_buf + i, extras[j].len) ||
!memchr(eff_map + EFF_APOS(i), 1, EFF_SPAN_ALEN(i, extras[j].len))) {
stage_max**–**;
continue;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
> 这里的UR(extras_cnt)是运行时生成的一个0到extras_cnt之间的随机数。所以,如果用户词典中一共有400个tokens,那么每个token就有200/400=50%的概率执行替换变异。我们可以修改MAX_DET_EXTRAS的大小来调整这一概率。
由上述代码也可以看到,effector map在这里同样被使用了:如果要替换的目标bytes全部是“无效”的,那么就跳过这一段,对下一段目标执行替换。
1. user extras (insert)
> 这一子阶段是对用户提供的tokens执行插入变异。不过与上一个子阶段不同的是,此时并没有对tokens数量的限制,所以全部tokens都会从原文件的第1个byte开始,依次向后插入;此外,由于原文件并未发生替换,所以effector map不会被使用。
这一子阶段最特别的地方,就是变异不能简单地恢复。之前每次变异完,在变异位置处简单取逆即可,例如bitflip后,再进行一次同样的bitflip就恢复为原文件。正因为如此,之前的变异总体运算量并不大。
但是,对于插入这种变异方式,恢复起来则复杂的多,所以AFL采取的方式是:将原文件分割为插入前和插入后的部分,再加上插入的内容,将这3部分依次复制到目标缓冲区中(当然这里还有一些小的优化,具体可阅读代码)。而对每个token的每处插入,都需要进行上述过程。所以,如果用户提供了大量tokens,或者原文件很大,那么这一阶段的运算量就会非常的多。直观表现上,就是AFL的执行状态栏中,”user extras (insert)”的总执行量很大,执行时间很长。如果出现了这种情况,那么就可以考虑适当删减一些tokens
1. auto extras (over)
> 这一项与”user extras (over)”很类似,区别在于,这里的tokens是最开始bitflip阶段自动生成的。另外,自动生成的tokens总量会由USE_AUTO_EXTRAS限制(默认为10)。
###### **havoc 大破坏阶段**
> 对于非dumb mode的主fuzzer来说,完成了上述deterministic fuzzing后,便进入了充满随机性的这一阶段;对于dumb mode或者从fuzzer来说,则是直接从这一阶段开始。
havoc,顾名思义,是充满了各种随机生成的变异,是对原文件的“大破坏”。具体来说,havoc包含了对原文件的多轮变异,每一轮都是将多种方式组合(stacked)而成:
随机选取某个bit进行翻转
随机选取某个byte,将其设置为随机的interesting value
随机选取某个word,并随机选取大、小端序,将其设置为随机的interesting value
随机选取某个dword,并随机选取大、小端序,将其设置为随机的interesting value
随机选取某个byte,对其减去一个随机数
随机选取某个byte,对其加上一个随机数
随机选取某个word,并随机选取大、小端序,对其减去一个随机数
随机选取某个word,并随机选取大、小端序,对其加上一个随机数
随机选取某个dword,并随机选取大、小端序,对其减去一个随机数
随机选取某个dword,并随机选取大、小端序,对其加上一个随机数
随机选取某个byte,将其设置为随机数
随机删除一段bytes
随机选取一个位置,插入一段随机长度的内容,其中75%的概率是插入原文中随机位置的内容,25%的概率是插入一段随机选取的数
随机选取一个位置,替换为一段随机长度的内容,其中75%的概率是替换成原文中随机位置的内容,25%的概率是替换成一段随机选取的数
随机选取一个位置,用随机选取的token(用户提供的或自动生成的)替换
随机选取一个位置,用随机选取的token(用户提供的或自动生成的)插入
怎么样,看完上面这么多的“随机”,有没有觉得晕?还没完,AFL会生成一个随机数,作为变异组合的数量,并根据这个数量,每次从上面那些方式中随机选取一个(可以参考高中数学的有放回摸球),依次作用到文件上。如此这般丧心病狂的变异,原文件就大概率面目全非了,而这么多的随机性,也就成了fuzzing过程中的不可控因素,即所谓的“看天吃饭”了
###### **splice阶段**
> 历经了如此多的考验,文件的变异也进入到了最后的阶段:splice。如其意思所说,splice是将两个seed文件拼接得到新的文件,并对这个新文件继续执行havoc变异。
具体地,AFL在seed文件队列中随机选取一个,与当前的seed文件做对比。如果两者差别不大,就再重新随机选一个;如果两者相差比较明显,那么就随机选取一个位置,将两者都分割为头部和尾部。最后,将当前文件的头部与随机文件的尾部拼接起来,就得到了新的文件。在这里,AFL还会过滤掉拼接文件未发生变化的情况。
### 收尾工作

```sql
if (queue_cur) show_stats();

/* If we stopped programmatically, we kill the forkserver and the current runner.
If we stopped manually, this is done by the signal handler. */
if (stop_soon == 2) {
if (child_pid > 0) kill(child_pid, SIGKILL);
if (forksrv_pid > 0) kill(forksrv_pid, SIGKILL);
}
/* Now that we've killed the forkserver, we wait for it to be able to get rusage stats. */
if (waitpid(forksrv_pid, NULL, 0) <= 0) {
WARNF("error waitpid\n");
}

write_bitmap();
write_stats_file(0, 0, 0);
save_auto();

stop_fuzzing:

SAYF(CURSOR_SHOW cLRD "\n\n+++ Testing aborted %s +++\n" cRST,
stop_soon == 2 ? "programmatically" : "by user");

/* Running for more than 30 minutes but still doing first cycle? */

if (queue_cycle == 1 && get_cur_time() - start_time > 30 * 60 * 1000) {

SAYF("\n" cYEL "[!] " cRST
"Stopped during the first cycle, results may be incomplete.\n"
" (For info on resuming, see %s/README.)\n", doc_path);

}

fclose(plot_file);
destroy_queue();
destroy_extras();
ck_free(target_path);
ck_free(sync_id);

alloc_report();

OKF("We're done here. Have a nice day!\n");

exit(0);

}

参考文章

afl_fuzz.c:

AFL afl_fuzz.c 详细分析 里面有很多相关文章

AFL 二三事 非常详细函数解析

afl_as afl_gcc:

AFL 源码阅读 大佬写的 afl 源码阅读文章,有对插桩汇编代码的详细解析,文风十分平易近人

afl 原理:

AFL 白皮书翻译与读书笔记 afl 的技术细节文档的翻译

  • Title: AFL源码分析
  • Author: Serendy
  • Created at : 2024-09-11 18:18:07
  • Updated at : 2024-09-11 18:20:08
  • Link: https://mapleqian.github.io/2024/09/11/AFL源码分析/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments