覆盖率
代码覆盖率,是一种通过计算测试过程中被执行的源代码占全部源代码的比例,进而间接度量软件质量的方法。其计量方式很多,但无论是 GCC 的 GCOV 还是 LLVM 的 SanitizerCoverage,都提供函数(function)、基本块(basic-block)、边界(edge)三种级别的覆盖率检测。
函数(Fuction-Level)
函数就是代码执行时调用到哪些函数,但是函数里面的具体代码行却不作统计,相对比较粗糙但高效的统计方式。所以,通常的统计方式是用基本块,简称BB。
基本块(BasicBlock-Level)
IDA中每一块代码就代表着一个基本块,就是以指令跳转为作划分界限的。
边界(Edge-Level)
edge本身就涵盖了基本块部分,唯一的差别是edge多记录了一些执行边界的信息。
我们可以将程序看成一个控制流图(CFG),图的每个节点表示一个基本块,而edge就被用来表示在基本块之间的转跳。知道了每个基本块和跳转的执行次数,就可以知道程序中的每个语句和分支的执行次数,从而获得比记录BB更细粒度的覆盖率信息。
具体到AFL的实现中,使用二元组(branch_src, branch_dst)来记录当前基本块 + 前一基本块 的信息,从而获取目标的执行流程和代码覆盖情况,下文会详细介绍。
代码插桩
afl
插桩的代码写在afl-gcc.c
里面,afl-gcc 是 gcc 的一个封装(wrapper)。主要实现的下述的三个功能。
1 | find_as(argv[0]); //找到gcc/clang/llvm编译器 |
我们在 execvp
之前,加入一段代码打印出cc_params
的参数如下:
1 | $: ./afl-gcc demo.c -o test |
我们知道一个二进制文件完整的流程是:预处理 -> 编译 -> 汇编 -> 链接。而将汇编代码编译成为二进制的工具,即为汇编器assembler。Linux系统下的常用汇编器是as
。编译完成AFL
后,在其目录下也会存在一个as
文件,并作为符号链接指向afl-as
。所以,如果通过-B
选项为gcc
设置了搜索路径(根据gcc –help可知),那么afl-as
便会作为汇编器,执行实际的汇编操作。
1 | -funroll-loops :执行循环强度消除并消除在循环内部使用的变量。这是用简单而快速的操作(如加法和减法)替代耗时操作(如乘法和除法)的过程 |
反汇编我们刚刚所编译出来的 test 文件可以发现其中多了一些汇编代码:
1 | .text:0000000000400860 |
阅读afl-as.c
发现插桩完成在 add_instrumentation
函数内部
fprintf(outf, use_64bit ? trampoline_fmt_64 : trampoline_fmt_32, R(MAP_SIZE));
这里 afl 通过调用 fprintf 将 trampoline_fmt_64 或者 trampoline_fmt_32 插入目标的代码段,以完成插桩来计算代码覆盖率。
1 | //afl-as.h |
可以看到插桩主要完成了(x64):
- 保存
rax
rcx
rdx
等寄存器的值 - 将
ecx
的值设置为随机数 - 调用
__afl_maybe_log
- 恢复原寄存器的数据
关于"movq $0x%08x, %%rcx\n"
这条汇编代码其对应fprintf
中的参数为R(MAP_SIZE)
,根据定义,宏MAP_SIZE
为64K;R(x)
的定义是(random() % (x))
,所以R(MAP_SIZE)
即为0到MAP_SIZE之间的一个随机数。这里的R(x)实际上是用来区分每个代码块的,也就是是一个标识。
关于__afl_maybe_log()
的详细实现会在下文提及。
fork server
afl 的流程大致是:对输入的样本文件不断地变异,并将这些 mutated input 喂给 loader 执行,检查是否会造成崩溃。因此,fuzzing 涉及到大量的 fork 和执行 loader 的过程。但是对于简单的库,我们会花费大量时间去等待execve()
,载入目标文件和库、解析符号地址等,为了避免这种情况,AFL实现了一套 fork server 机制。其基本思路是:启动target进程后,target会运行一个fork server;fuzzer并不负责fork子进程,而是与这个fork server通信,并由fork server来完成fork及继续执行目标的操作。
init fork server
1 | //afl-fuzz.c |
execv(target_path, argv)
带参数执行target,这个函数除非出错不然不会返回。
- execv会替换掉原有的进程空间为target_path代表的程序,所以相当于后续就是去执行target_path,这个程序结束的话,子进程就结束。
- 此时由于我们的目标程序的 main 函数已经被插桩,程序的控制流会交到
_afl_maybe_log
手中。所以关于 fork server 的其余工作都在_afl_maybe_log
中完成。而在这里非常特殊,第一个target会进入__afl_maybe_log
里的__afl_fork_wait_loop
,并充当fork server,在整个Fuzz的过程中,它都不会结束,每次要Fuzz一次target,都会从这个fork server fork出来一个子进程去fuzz。
__afl_maybe_log()
1 | char __usercall _afl_maybe_log@<al>(char a1@<of>, __int64 a2@<rcx>, __int64 a3@<xmm0>, __int64 a4@<xmm1>, __int64 a5@<xmm2>, __int64 a6@<xmm3>, __int64 a7@<xmm4>, __int64 a8@<xmm5>, __int64 a9@<xmm6>, __int64 a10@<xmm7>, __int64 a11@<xmm8>, __int64 a12@<xmm9>, __int64 a13@<xmm10>, __int64 a14@<xmm11>, __int64 a15@<xmm12>, __int64 a16@<xmm13>, __int64 a17@<xmm14>, __int64 a18@<xmm15>) |
通过阅读伪代码或者汇编,可以总结其工作流程如下:
先判断是否设置了共享内存,如果没设置则判断
_afl_setup_failure
是否为真,如果为真,则代表setup失败,直接返回。也就是说只有第一次执行__afl_maybe_log()
的时候,才会进入该 if 语句。- 如果初始化失败则直接返回
- 初始化成功后,读取
_afl_global_area_ptr
的值,不为 0 ,则赋值给_afl_area_ptr
_afl_global_area_ptr
为 0, 则把共享内存连接到当前进程的地址空间,将得到的地址,保存到_afl_area_ptr
和_afl_global_area_ptr
中。write(199, &_afl_temp, 4uLL) == 4
写4个字节到状态管道st_pipe[0]
,forkserver 告诉 fuzzer 自己准备好了,而这正好是rlen = read(fsrv_st_fd, &status, 4);
中等待的信息。read(198, &_afl_temp, 4uLL) != 4
forkserver 再从管道中读取 4 个字节,这时候表示 fuzzer 也准备好了。这时候 fork 出一个新的子进程,用来跑 target,而原本的父进程则用来通信。write(199, &_afl_fork_pid, 4uLL);
将子进程的 pid 写进管道,以为fuzzer
的监控。- 然后父进程即fork server等待子进程结束,并保存其执行结果到
_afl_temp
中,然后将子进程的执行结果,从_afl_temp
写入到状态管道,告知fuzz。 - 父进程不断执行
__afl_fork_wait_loop
循环,不断从控制管道读取,直到fuzz端命令fork server进行新一轮测试。
如果共享内存已经被设置,则直接进入
__afl_store
逻辑,看伪代码可以知道:就是将上一个桩点的值(prev_location)和当前桩点的值(R(MAP_SIZE)
)异或,取值后,使得共享内存里对应的槽的值加一,然后将prev_location设置为cur_location >> 1;
。其余内容我们在下文分析。
在fork server执行完毕后,当我们运行target
的时候,fuzzer会调用run_target()
,在此方法中,便是通过命令管道,通知fork server准备fork;并通过状态管道,获取子进程pid:
1 | //afl-fuzz.c |
简介来说整个 server 流程如下图:
afl 在初始化 forkserver 的时候会创建两个管道,fork 后通过 execve 去执行 target,因为目标程序的 main 函数已经被插桩,程序的控制流会交到_afl_maybe_log手中。如果 fuzz 实例是第一次运行,则此子进程则会充当 fuzz server,之后的程序都是由该 server fork出来的子进程。fuzz进行的时候,fuzz server会一直fork子进程,并且将子进程的结束状态通过pipe传递给afl-fuzz。
共享内存
我们知道AFL 是以无限 fork 的形式进行 fuzzing 的,那么可以了解到 fuzzer 和 target 直接信息是要共享的,比如:执行过程中的分支信息;随后,fuzzer便可以根据这些信息,判断这次执行的整体流程和代码覆盖情况。
AFL使用共享内存,来完成以上信息在fuzzer和target之间的传递。具体地,fuzzer在启动时,会执行setup_shm()
方法进行配置。
1 | //afl-fuzz.c |
shmget():用来创建共享内存
shmat() :第一次创建完共享内存时,它还不能被任何进程访问,shmat()函数的作用就是用来启动对该共享内存的访问,并把共享内存连接到当前进程的地址空间
首先调用
shemget()
分配一块共享内存,大小MAP_SIZE
为64K分配成功后,该共享内存的标志符会通过
setenv
设置到环境变量中,从而之后fork()
得到的子进程可以通过该环境变量,得到这块共享内存的标志符fuzzer 则会通过
trace_bits
来保存共享内存的地址每次 fuzzer 去运行 target 的时候都会初始化共享内存
1
2
3
4
5
6
7//afl-fuzz.c
static u8 run_target(char** argv, u32 timeout) {
...
memset(trace_bits, 0, MAP_SIZE);
MEM_BARRIER();
...
}而在 forkserver 内部
1
2
3
4
5
6
7
8
9
10
11
12char __usercall _afl_maybe_log@<al>(char a1@<of>, __int64 a2@<rcx>, __int64 a3@<xmm0>, __int64 a4@<xmm1>, __int64 a5@<xmm2>, __int64 a6@<xmm3>, __int64 a7@<xmm4>, __int64 a8@<xmm5>, __int64 a9@<xmm6>, __int64 a10@<xmm7>, __int64 a11@<xmm8>, __int64 a12@<xmm9>, __int64 a13@<xmm10>, __int64 a14@<xmm11>, __int64 a15@<xmm12>, __int64 a16@<xmm13>, __int64 a17@<xmm14>, __int64 a18@<xmm15>)
{
...
v22 = getenv("__AFL_SHM_ID");
if ( !v22 || (v23 = atoi(v22), v24 = shmat(v23, 0LL, 0), v24 == (void *)-1LL) )
{
++_afl_setup_failure;
v18 = v29;
return v18 + 127;
}
...
}则会先判断共享内存是否被设置,然后通过调用
shmat()
,target将这块共享内存也映射到了自己的内存空间中,并将其地址保存在__afl_area_ptr
及edx
中。由此,便完成了fuzzer与target之间共享内存的设置。
分支信息的记录
由官网文档可知,AFL 是根据二元 tuple (跳转的源地址和目标地址)来记录分支信息,从而获取 target 的执行流程和代码覆盖情况,其伪代码如下:
1 | .text:0000000000400CB0 __afl_store: ; CODE XREF: __afl_maybe_log+4F↓j |
其中 a2 保存的寄存器 rcx 的值,跟踪可以发现,rcx 存贮的是随机数,那么简单来说上述流程就是:就是将上一个桩点的值(prev_location)和当前桩点的值(R(MAP_SIZE)
)异或,取值后,使得共享内存里对应的槽的值加一,然后将cur_location
的值右移一位然后得到新的prev_location
1 | cur_location = <COMPILE_TIME_RANDOM>; |
因为,AFL在为每个代码块插桩的时候都会生成一个随机数,作为其”位置”的记录,然后对分支处的”源位置”和”目标位置”进行异或,讲其结果作为该分支的 key,并保存每个分支的执行次数。
用于保存执行次数的实际上是一个哈希表,大小为MAP_SIZE=64K
,当然会存在碰撞的问题;但根据AFL文档中的介绍,对于不是很复杂的目标,碰撞概率还是可以接受的:
1 | Branch cnt | Colliding tuples | Example targets |
如果一个目标过于复杂,那么AFL状态面板中的map_density信息就会有相应的提示:
1 | ┬─ map coverage ─┴───────────────────────┤ |
这里的map density,就是这张哈希表的密度。可以看到,上面示例中,该次执行的哈希表密度仅为3.61%,即整个哈希表差不多有95%的地方还是空的,所以碰撞的概率很小。不过,如果目标很复杂,map density很大,那么就需要考虑到碰撞的影响了。
另外,AFL需要将cur_location
右移1位后,再保存到prev_location
中。官方文档中解释了这样做的原因。假设target中存在A->A
和B->B
这样两个跳转,如果不右移,那么这两个分支对应的异或后的key都是0,从而无法区分;另一个例子是A->B
和B->A
,如果不右移,这两个分支对应的异或后的key也是相同的。
由上述分析可知,之前提到的共享内存,被用于保存一张哈希表,target在这张表中记录每个分支的执行数量。随后,当target执行结束后,fuzzer便开始对这张表进行分析,从而判断代码的执行情况。
分支信息的分析
首先,fuzzer通过调用 classify_counts
对trace_bits
(共享内存)进行预处理
具体地,target 是将每个分支的执行次数用 1 个 byte 来储存,而 fuzzer 则进一步把这个执行次数归入以下的 buckets 中:
1 | static const u8 count_class_lookup8[256] = { |
举个例子,如果某分支执行了 1 次,那么落入第 2 个 bucket,其计数 byte 仍为 1;如果某分支执行了 4 次,那么落入第 5 个 bucket,其计数 byte 将变为 8,等等。(执行了 4-7 次的其计数为 8)
这样处理之后,对分支执行次数就会有一个简单的归类。例如,如果对某个测试用例处理时,分支A执行了32次;对另外一个测试用例,分支A执行了33次,那么AFL就会认为这两次的代码覆盖是相同的。当然,这样的简单分类肯定不能区分所有的情况,不过在某种程度上,处理了一些因为循环次数的微小区别,而误判为不同执行结果的情况。
随后,对于某些mutated input来说,如果这次执行没有出现崩溃等异常输出,fuzzer还会检查其是否新增了执行路径。具体来说,是对trace_bits
计算hash并来实现:
1 | u32 cksum = hash32(trace_bits, MAP_SIZE, HASH_CONST); |
通过比较hash值,就可以判断trace_bits
是否发生了变化,从而判断此次mutated input是否带来了新路径,为之后的fuzzing提供参考信息。
参考
https://lcamtuf.blogspot.com/2014/10/fuzzing-binaries-without-execve.html
https://www.cnblogs.com/52php/p/5861372.html
http://rk700.github.io/2017/12/28/afl-internals/
https://xz.aliyun.com/t/4628#toc-10