写在前面的话:首先因为本人基础较差,所以本文会插入很多比较基础的知识,大家请选择性跳过。其次,本次阅读事实上主要分析的是 afl 中的 afl-as 实现的汇编级插桩,但是就性能上、实用性上 LLVM 才是需要分析的重点,各位可以以本文作为踏脚石,后续建议花更多的时间对 LLVM 模式的代码进行分析,了解如何通过编写 LLVM Pass 实现编译级别的插桩。这也将是我下一轮的学习方向,与诸位共勉。
分析工具
understand 源码阅读器
源码阅读思路:
- 阅读白皮书了解 afl 的整体设计思路
- 阅读博客了解 AFL 整体架构,AFL 到底是如何运行的,每个代码部分分别对应哪个功能?既然准备研究定向模糊测试,那就先要确定定向模糊测试一般需要在 afl 的哪个运行环节进行改进。然后重点分析这部分代码。
- 根据 https://www.ruanx.net/afl-source-1/这篇博客的思路进行源码阅读
我们阅读源码的主要目标应该是:
- 理清静态插桩过程(gcc、clang、llvm mode)
- 理清 fuzz 过程:如何变异、如何将 input 传递给程序、如何收集覆盖度信息
因此,我们决定阅读顺序:
- 阅读
afl-gcc.c
和afl-as.c
,即静态插桩相关代码 - 阅读
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 的主要组成部分包括前端和后端。在编译源代码时,编译器执行以下主要步骤:
- 预处理(Preprocessing): 预处理器 (
cpp
) 负责处理源代码中的预处理指令,例如#include
、#define
等,并将宏展开、头文件包含等操作应用到源代码上。 - 编译(Compilation): C 编译器 (
gcc
) 接收预处理后的源代码,将其转换为汇编代码。这个阶段主要包括语法分析、语义分析和中间代码生成。 - 汇编(Assembly): 汇编器 (
as
) 接收编译器生成的汇编代码,将其转换为目标文件。目标文件包含了机器代码的二进制表示,以及有关程序的其他信息。 - 链接(Linking): 链接器 (
ld
) 接收一个或多个目标文件,以及可能的库文件,将它们合并成一个可执行文件。链接器解析符号引用,处理重定位信息,确保所有的代码和数据正确地连接在一起。
在这个流程中,编译器 (gcc
) 负责前两个阶段(预处理和编译),而汇编器 (as
) 负责将生成的汇编代码转换为目标文件。因此,它们是编译过程中两个关键的组件。这两个组件的工作协同,确保最终生成的可执行文件能够正确地执行程序的逻辑。
afl 的插桩过程是在编译过程中实现的,所以我们需要分析 afl 与编译相关的组件,其中就包括了 afl-gcc 与 afl-as。这两个文件分别是 gcc 与 as 的包装。在具体执行中会先调用 afl-gcc 然后再调用 afl-as
afl 白皮书(技术细节)
阅读 afl 技术细节文档有助于理解具体的代码实现。
覆盖率检测
afl 采用分支命中次数来统计覆盖率
1 | cur_location = <COMPILE_TIME_RANDOM>; |
实现覆盖率的计算。
cur_location
值是随机生成的,以简化链接复杂项目的过程,并且使用 XOR 来保持输出均匀分布。shared_mem[]
数组是一个 64 kB 的 SHM(内存共享) 区域,由调用者传递给被插桩的二进制文件。 输出映射(map)中的每个字节集合都可以是对于元组 (branch_src, branch_dst) 的一次命中 hit。
这个产生的数组足够的小,所以可以很方便的放入二级缓存。
至于采用机制的原理不多赘述,看原版白皮书就好。总的来说就是相比于基本块命中率,这种机制能记录更多关于执行路径的信息。
路经检测
在 afl 中,我们要如何判断是否执行了一个新的路径呢?
下面是两条执行路径
1 | A -> B -> C -> D -> E (tuples: AB, BC, CD, DE) |
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 的论文编写有一定的启发性
项目架构
插桩模块
afl-as.h, afl-as.c, afl-gcc.c
:普通插桩模式,针对源码插桩,编译器可以使用 gcc, clang;llvm_mode
:llvm 插桩模式,针对源码插桩,编译器使用 clang;qemu_mode
:qemu 插桩模式,针对二进制文件插桩。
fuzzer 模块
afl-fuzz.c
:fuzzer 实现的核心代码,AFL 的主体。其他辅助模块
afl-analyze
:对测试用例进行分析,通过分析给定的用例,确定是否可以发现用例中有意义的字段;afl-plot
:生成测试任务的状态图;afl-tmin
:对测试用例进行最小化;afl-cmin
:对语料库进行精简操作;afl-showmap
:对单个测试用例进行执行路径跟踪;afl-whatsup
:各并行例程 fuzzing 结果统计;afl-gotcpu
:查看当前 CPU 状态。
部分头文件说明
alloc-inl.h
:定义带检测功能的内存分配和释放操作;config.h
:定义配置信息;debug.h
:与提示信息相关的宏定义;hash.h
:哈希函数的实现定义;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-gcc
或 afl-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 | static u8* as_path; /* Path to the AFL 'as' wrapper,AFL的as的路径"AS" 是 "Auto Dictionary Sync" 的缩写 |
- main 函数
1 | int main(int argc, char** argv) { |
可以看到三个调用的函数
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 可以监视和控制输入数据的变化,以提高模糊测试的效率。
源码:
- 关键变量
- main 函数
- edit_param 函数:关键问题在于:先要理解 as 到底接受什么参数?然后才能理解 edit 函数为什么会进行这样的修改。as 到底在哪个位置?
as 是 gnu 中的汇编器,它是安装在系统中的,而不是 afl 下某个具体的文件。afl 会生成 as 文件作为 as 的封装调用 as 进行插桩等操作。 - add_instrumentation 函数
值得注意的是 afl-gcc 生成的是 AT&T 风格的汇编语言,与 intel 风格的汇编语言存在差异性。在 AT&T 风格中,汇编语言格式如下:
可以看到代码被.section 划分为一个个段,我们在进行插桩的时候只会针对.text 部分也即是代码部分进行插桩,所以在该函数中主要工作识别当前是否进入.text 段,如果不在则跳过当前读入的代码。
1 | if (!pass_thru && !skip_intel && !skip_app && !skip_csect && instr_ok && |
trampoline_fmt_64 桩代码
我们插入的代码按照白皮书所言,事实上插入的 trampoline_fmt_64 这段汇编代码等价于下面这三行代码
1 | //生成随机值作为当前基本块的标号,作为当前位置 |
为什么这里要右移呢?是为了帮助我们判断基本块的执行顺序。
读到这里可能你可能仍然对这个边的概念仍然比较模糊,下面我用一个例子来详细解释一下:
为了理解 main_playload 有必要详细的分析插入的 trampoline_fmt_64
1 | _/* --- AFL TRAMPOLINE (64-BIT) --- */_ |
上面这段代码调用了:
1 | // __afl_maybe_log的定义 |
__afl_store
执行过程为:
将目前存储着
cur_loc
的rcx
寄存器异或上prev_loc
将
prev_loc
设为cur_loc
(这里利用了异或运算的自反性)将
prev_loc
右移一位增加 hit count
__afl_maybe_log 中存在两个指令lahf
和seto %al
,这两行代码负责存储 eflags 寄存器的值——将低 8 位保存在ah
,将 OF 位保存在al
,__afl_return 在代码中执行
addb $127, %al
和sahf
恢复现场,使得整个桩代码对原程序透明。
main_playload 桩代码
在完成所有代码的写入后还会写入一段 main_playload
这段汇编代码与 forkserver 模块息息相关,为了理解后面介绍的汇编代码,我们首先应该了解什么是 fork 函数。
一个进程,包括代码、数据和分配给进程的资源。fork()函数通过系统调用创建一个与原来进程几乎完全相同的进程,也就是两个进程可以做完全相同的事,但如果初始参数或者传入的变量不同,两个进程也可以做不同的事。
一个进程调用 fork()函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值不同。相当于克隆了一个自己。
fork 调用的一个奇妙之处就是它仅仅被调用一次,却能够返回两次,它可能有三种不同的返回值:
1)在父进程中,fork 返回新创建子进程的进程 ID;
2)在子进程中,fork 返回 0;
3)如果出现错误,fork 返回一个负值;
在大致了解 fork 函数是个什么东西之后,我们就能开始看 main_payload 了。下面这段代码的主要作用是在被测程序的尾部插入 main_payload:
1 | if (ins_lines) |
为了理解什么是 fork_server,我们需要具体分析 main_payload,具体代码在 afl-as.h 里面
main_payload 代码如下,事实上我们稍加观察就能看到前面讨论的汇编代码其实存在于 main_payload 里面
1 | .text |
从汇编中,我们看到,如果 __afl_area_ptr
等于 0,则认为 shm 没有映射好,进入 __afl_setup
逻辑。这里提一句, afl_area_ptr
这个 8 字节的变量是定义在 main payload 的末尾,一个 .lcomm
伪指令。在汇编阶段完成后,它被放在 .bss
段。
1 | //只有当afl_maybe_log中je __afl_setup执行时才会跳转到这部分代码 |
首先,如果发现 __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 | __afl_setup_first: |
我们先来看看错误处理代码:
1 | __afl_setup_abort: |
上面这段代码就是把 __afl_setup_failure
变量自增,还原所有寄存器并返回。至此,我们发现,如果环境变量 __AFL_SHM_ID
不存在,则共享内存的初始化会失败,但整个桩代码对于目标程序是透明的——无非是保存了一些寄存器、执行了一些无副作用的代码、最后恢复寄存器——因此目标程序可以正常执行。这也解释了为何我们不使用 afl-fuzz
而直接运行插桩后的目标程序,也能执行如常。这个设计体现了 AFL 的用户友好性。
好了我们看回 setup_first 后续的代码:
1 | _/* 此时 %rax 是指向环境变量 __AFL_SHM_ID 的指针 */_ |
shmat
是 shared memory attach 的缩写,用于 attach 共享内存。//如果想了解这些函数需要阅读 linux 相关的文档或数据
这里简单的介绍 shmget 和 shmat 函数:
shmget
和 shmat
是用于在 Unix/Linux 系统上进行进程间共享内存的系统调用。它们通常用于在多个进程之间共享数据,而无需通过进程间通信的其他方式来传递信息。
shmget
函数:
shmget
用于创建共享内存段或获取已经存在的共享内存段的标识符。- 原型:
int shmget(key_t key, size_t size, int shmflg);
key
是用于标识共享内存段的键值。size
指定共享内存的大小。shmflg
是一组标志,用于指定共享内存的访问权限和行为。
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-fuzz
在setup_shm()
流程中调用shmget()
创建了虚拟内存,并将 shm id 写入__AFL_SHM_ID
环境变量。如果我们并非通过afl-fuzz
运行程序,自然这片虚拟内存不会被创建,也不会存在__AFL_SHM_ID
环境变量了。
至此,程序成功地将afl-fuzz
进程所创建的一片共享内存,映射到了自己的虚拟地址空间内。接着看汇编:
1 | _/* Store the address of the SHM region. */_ |
将共享内存地址读入__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 | __afl_forkserver: |
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 | __afl_fork_wait_loop: |
从 fd 198 中读取四个字节(注意 read()
函数是阻塞的)。如果读取失败,则跳转到 __afl_die
。如果读取成功,则执行下面的代码:
1 | _/* Once woken up, create a clone of our process. This is an excellent use |
在这里进行 fork,如果失败了则跳转到 __afl_die
。对于子进程,跳转到 __afl_fork_resume
;对于父进程,则继续执行以下逻辑:
1 | _/* In parent process: write PID to pipe, then wait for child. */_ |
这段代码逻辑是:将子进程的 pid 保存到 __afl_fork_pid
变量;向 fd 199 写入子进程的 pid;调用 waitpid()
等待子进程执行完毕(如果等待失败,则进入 __afl_die
);将子进程的退出原因写进 fd 199,并回到 __afl_fork_wait_loop
。
之前提到,fork 之后,子进程会跳转到 __afl_fork_resume
逻辑。跟进:
1 | __afl_fork_resume: |
很明显这里主要做的是恢复现场的工作,首先关闭父进程中开启的两个用于传递信息的文件,然后恢复各个寄存器的值,最后跳转到 __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.c
和 afl-as.c
这两份代码,对插桩过程有了详细的了解。
afl 中存在另外两种插桩模式,但是因为我主要关注 fuzz 流程的优化,所以这里暂时先看看 afl_fuzz.c 文件吧。
AFL 的 Fuzzer: afl-fuzz.c
文件概述:
afl_fuzz.c 是 afl 的核心文件,也是代码量最大的程序。在该文件中负责控制整个 fuzz 的流程。
main 函数中的内容大概可以划分为四个部分:
- 模糊测试开始前的准备阶段
- 模糊测试开始前的第一次 dry_run
- 死循环前的准备
- 进行模糊测试的死循环
- 收尾工作
源码:
这里开始会分段的说明 main 函数中的代码
模糊测试开始前的准备阶段
用户输入参数处理
main 函数首先出现的是处理用户输入参数的代码
1 | while ((opt = getopt(argc, argv, "+i:o:f:m:b:t:T:dnCB:S:M:x:QV")) > 0) |
可以看到函数对各个可选参数进行操作,具体的参数说明如下:
{暂时空缺}
这里面有两个个人认为比较重要的函数:setup_shm()和 read_testcase()
这个函数负责创建共享内存,这里创建的共享内存就是之前介绍过的 afl-gcc 中的共享内存。所以为了理解 afl-gcc 与 afl-fuzz 间如何建立共享内存,有必要再看看这个函数
1 | /* Append new test case to the queue. */ |
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
函数中均有调用。步骤:
- 进行一系列参数设置,包括当前阶段 stage_cur,阶段名称 stage_name,新比特 new_bit 等初始化设置。
- 最后一个参数_from_queue_,判断是否是为队列中的 || 刚恢复 fuzz 以此设置较长的时间延迟。testcase 参数_q->cal_failed++_ 是否校准失败参数 ++
- 判断是否已经启动 forkserver ,调用函数
init_forkserver()
启动 fork 服务。如果是第一次接触 linux
中 fork()函数,不妨看一下 https://www.cnblogs.com/dongguolei/p/8086346.html 该函数的理解,init_forkserver()
的详细内容见 2.4- 拷贝 trace_bits 到 first_trace,并获取开始时间 start_us;
- -loop- 该 loop 循环多次执行这个 testcase,循环的次数 8 次或者 3 次,取决于是否快速校准。_对同一个初始 testcase 多次运行的意义可能是,觉得有些 targetApp 执行同一个 testcase 可能也会出现不同的路径(这是我的猜测_)
static void write_to_testcase(void* mem, u32 len)
将修改后的数据写入文件进行测试。如果 use_stdin 被清除了,那么取消旧文件链接并创建一个新文件。否则,prog_in_fd 将被缩短。将 testcase 写入到文件中去。该函数较简单,不做单独解释。run_target
详细内容见 2.5 主要作用是通知 forkserver 可以开始 fork 并且 fuzz 了。cksum = hash32(trace_bits, MAP_SIZE, HASH_CONST)
校验此次运行的 trace_bits,检查是否出现新的情况。hash32 函数较为简单,不多做分析。- 这段代码的主要意思是先用 cksum 也就是本次运行的出现 trace_bits 哈希和本次 testcase q->exec_cksum 对比。如果发现不同,则调用
has_new_bits
函数(见 2.6)和我们的总表_virgin_bits_ 对比。- 判断_q->exec_cksum_ 是否为 0,不为 0 那说明不是第一次执行。后面运行的时候如果,和前面第一次 trace_bits 结果不同,则需要多运行几次。这里把校准次数设为 40…
- -loop-end-
- 接着收集一些关于这个测试用例性能的统计数据。比如执行时间延迟,校准错误?,bitmap 大小等等。
update_bitmap_score(q)
对这个测试用例的每一个 byte 进行排序,用一个 top_rate[]来维护它的最佳入口。维护完成之后,我们这个函数在 我并不认可博客中的这个解释,我的理解在下面具体介绍函数时说明。- 如果这种情况没有从检测中得到 new_bit,则告诉父程序。这是一个无关紧要的问题,但是需要提醒用户注意。
总结:calibratecase
函数到此为止,该函数主要用途是 init_forkserver;将 testcase 运行多次;用 update_bitmap_score 进行初始的 byte 排序。
函数 calibrate_case()函数之所以重要,是因为里面又涉及四个十分关键的函数:
- init_forkserver() 负责创建 forkserver
- run_target() 负责把测试用例输入并执行被测程序
- has_new_bits()判断有没有检测到新的基本块
- update_bitmap_score()根据运行结果对各个元组选择一个最佳的测试用例
init_forkserver()
这个函数很长,主要分为创建子进程、子进程执行的代码、父进程与子进程进行通信并根据子进程返回结果向用户报告 forkserver 创建结果
首先函数通过 fork 函数创建子进程
1 | /* Spin up fork server (instrumented mode only). The idea is explained here: |
然后就会通过一个条件判断语句让子进程执行自己的代码
1 | if (!forksrv_pid) {//当为子进程时,因为子进程运行fork()返回值为0 |
然后就是父进程需要干的活了,首先父进程会设置一下管道,并且等待 forkserver 的创建结果
1 | /* Close the unneeded endpoints. */ |
之后父进程会根据 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 | /* Execute target application, monitoring for timeouts. Return status |
首先函数创建了一些变量,最为关键的,这里对 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 | /* When we bump into a new path, we call this to see if the path appears |
可以看到这个函数主要工作就是为了更新 top_rated 数组,为接下来的 cull_queue 做准备。在这一步我们筛选出了对于每个边最佳的测试用例。在下一个函数中就会在这些最佳用例中挑选对应着新的路径的测试用例,并将之标记为感兴趣的。这个函数是种子选择中的关键步骤,每一轮的模糊测试中都会调用这个函数。这个函数是唯一更新 top_rated 数组的函数,而 cull_queue 函数对种子的选择依赖于 top_rated,所以理解该函数以及该函数被调用的方式十分重要。让我们看看它的调用情况:
死循环前的准备工作
cull_queue
完成干运行后,main 函数会进行一次队列筛选操作。主要目的是从现有的测试用例集合中,挑选出一个能够覆盖已探索路径的最小测试用例集合。
让我们看看代码
1 | /* The second part of the mechanism discussed above is a routine that |
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 | show_init_stats();//进入主循环前的准备工作使用的函数之一,主要作用为在处理输入目录的末尾显示统计信息,警告信息以及硬编码的常量 |
进行模糊测试的死循环
ohhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
我们终于正式进入了激动人心的模糊测试的主要流程,执行模糊测试的死循环。让我们看看代码吧:
1 | while (1) {//主循环 变异的相关代码在这里面 |
这玩意也不是很长,其主要流程为:
- 判断 queue_cur 是否为空,如果是,则表示已经完成对队列的遍历,初始化相关参数,重新开始遍历队列
- 找到 queue 入口的 testcase,seek_to = find_start_position();直接跳到该 testcase
- 如果一整个队列循环都没新发现,尝试重组策略。
- 调用关键函数 fuzz_one()对该 testcase 进行 fuzz。fuzz_one()函数参见 3.4。
- 上面的变异完成后,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。
该函数大致流程如下:
- 根据是否有
pending_favored
和queue_cur
的情况,按照概率进行跳过;有pending_favored
, 对于已经 fuzz 过的或者 non-favored 的有 99% 的概率跳过;无 pending_favored,95% 跳过 fuzzed&non-favored,75% 跳过 not fuzzed&non-favored,不跳过 favored;- 假如当前项有校准错误,并且校准错误次数小于 3 次,那么就用 calibrate_case 进行测试;
- 如果测试用例没有修剪过,那么调用函数 trim_case 对测试用例进行修剪;
- 修剪完毕之后,使用 calculate_score 对每个测试用例进行打分;
- 如果该 queue 已经完成 deterministic 阶段,则直接跳到 havoc 阶段;
- deterministic 阶段变异 4 个 stage,变异过程中会多次调用函数 common_fuzz_stuff 函数,保存 interesting 的种子:
- bitflip,按位翻转,1 变为 0,0 变为 1
- arithmetic,整数加/减算术运算
- interest,把一些特殊内容替换到原文件中
- dictionary,把自动生成或用户提供的 token 替换/插入到原文件中
- havoc,中文意思是“大破坏”,此阶段会对原文件进行大量变异。
- splice,中文意思是“绞接”,此阶段会将两个文件拼接起来得到一个新的文件。
- 该轮完成。
- 完成了 fuzz 中计算资源的分配。
- 处理了校验失败的测试用例,每个测试用例有三次机会
- 调用了 trim_case,这个是需要详细看看的
- 调用了 calculate_score,需要简单了解
- 可以根据这个信息了解到 havoc 前的变异操作都是确定性变异
- 事实上 afl 会顺序执行各个变异策略
下面我们来看看一些比较重要的函数或变异代码
trim_case()
文件大小对模糊性能有很大影响,这是因为大文件使目标二进制文件变得更慢,并且因为它们减少了突变将触及重要的格式控制结构而不是冗余数据块的可能性。这在 perf_tips.txt 中有更详细的讨论。
用户可能会提供低质量的起始语料库,某些类型的突变可能会产生迭代地增加生成文件的大小的效果,因此应对这一趋势是很重要的。
幸运的是,插装反馈提供了一种简单的方法来自动删除输入文件,同时确保对文件的更改不会对执行路径产生影响。
在 afl-fuzz 中内置的修边器试图按可变长度和 stepover 顺序删除数据块;任何不影响跟踪映射校验和的删除都被提交到磁盘。修剪器的设计并不是特别彻底;相反,它试图在精度和在进程上花费的 execve()调用的数量之间取得平衡,选择块大小和 stepover 来匹配。每个文件的平均增益大约在 5% 到 20% 之间。
独立的 afl-tmin 工具使用了更详尽的迭代算法,并尝试在修剪过的文件上执行字母标准化。afl-tmin 的操作如下。
首先,工具自动选择操作模式。如果初始输入崩溃了目标二进制文件,afl-tmin 将以非插装模式运行,只需保留任何能产生更简单文件但仍然会使目标崩溃的调整。如果目标是非崩溃的,那么这个工具使用一个插装的模式,并且只保留那些产生完全相同的执行路径的微调。
所以我们需要对测试用例进行修剪,该函数的主要流程如下:
- 首先取 testcase 长度 2 的指数倍
- 第一个 while 循环,从文件大小 1/16 的步长开始,慢慢到文件大小的 1/1024 倍步长。
- 第二个 while 循环,嵌套在第一个内,从文件头开始按步长 cut testcase,然后 target_run();如果删除之后对文件执行路径没有影响那么就将这个删除保存至实际文件中。在删除之前会将 trace_bits 保存到起来。删除完成之后重新拷贝。
关键代码如下
1 | static u8 trim_case(char****** argv, struct queue_entry***** q, u8***** in_buf) { |
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()的一个辅助函数。该函数贯穿了整个变异过程的始终。
步骤:
- write_to_testcase() 将变异写到文件中
- run_target() 前面解释过;
- save_if_interesting() 判断一个文件是否为 interesting 种子,如果是那么就保存输入文件(queue)
其中 save_if_interesting()定义如下:
save_if_interestingstatic u8 save_if_interesting(char** argv, void* mem, u32 len, u8 fault)
判断是否为感兴趣的输入,判断一个文件是否是感兴趣的输入(has_new_bits),即是否访问了新的 tuple 或者 tuple 访问次数发生变化,如果是则保存输入文件(放到队列 queue 中)。
步骤:
Bitflip 阶段
- 基本原理: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。
- 自动检测 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
- 生成 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 选项指定的,如果没有则跳过相应的子阶段
- 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 | > 这里的UR(extras_cnt)是运行时生成的一个0到extras_cnt之间的随机数。所以,如果用户词典中一共有400个tokens,那么每个token就有200/400=50%的概率执行替换变异。我们可以修改MAX_DET_EXTRAS的大小来调整这一概率。 |
参考文章
afl_fuzz.c:
AFL afl_fuzz.c 详细分析 里面有很多相关文章
AFL 二三事 非常详细函数解析
afl_as afl_gcc:
AFL 源码阅读 大佬写的 afl 源码阅读文章,有对插桩汇编代码的详细解析,文风十分平易近人
afl 原理:
AFL 白皮书翻译与读书笔记 afl 的技术细节文档的翻译