AFL技术实现分析
设计说明
American Fuzzy Lop(AFL)设计原理是不把注意力集中在任意单一的操作,也不是对任何的理论进行概念性验证。这是一个在实践测试中进行验证的工具,这个工具非常有效而且运行起来也非常简单。AFL可以做到速度,可靠性和易用性的要求。
代码插装
使用AFL,首先需要通过afl-gcc/afl-clang等工具来编译目标,在这个过程中会对其进行插桩。
我们以afl-gcc为例。如果阅读文件afl-gcc.c便可以发现,其本质上只是一个gcc的wrapper。我们不妨添加一些输出,从而在调用execvp()之前打印全部命令行参数,看看afl-gcc所执行的究竟是什么:
gcc /tmp/hello.c -B /root/src/afl-2.52b -g -O3 -funroll-loops -D__AFL_COMPILER=1 -DFUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION=1
可以看到afl-gcc
最终调用gcc
,并定义了一些宏,设置了一些参数。其中最关键的就是-B /root/src/afl-2.52b
这条。根据gcc --help
可知,-B
选项用于设置编译器的搜索路径,这里便是设置成/root/src/afl-2.52b
(是我设置的环境变量AFL_PATH
的值,即AFL目录,因为我没有make install)。
如果了解编译过程,那么就知道把源代码编译成二进制,主要是经过”源代码”->”汇编代码”->”二进制”这样的过程。而将汇编代码编译成为二进制的工具,即为汇编器assembler。Linux系统下的常用汇编器是as
。不过,编译完成AFL后,在其目录下也会存在一个as
文件,并作为符号链接指向afl-as
。所以,如果通过-B
选项为gcc设置了搜索路径,那么afl-as
便会作为汇编器,执行实际的汇编操作。
所以,AFL的代码插桩,就是在将源文件编译为汇编代码后,通过afl-as
完成。
接下来,我们继续阅读文件afl-as.c。其大致逻辑是处理汇编代码,在分支处插入桩代码,并最终再调用as
进行真正的汇编。具体插入代码的部分如下:
fprintf(outf, use_64bit ? trampoline_fmt_64 : trampoline_fmt_32, R(MAP_SIZE));
这里通过fprintf()将格式化字符串添加到汇编文件的相应位置。篇幅所限,我们只分析32位的情况,trampoline_fmt_32的具体内容如下:
static const u8* trampoline_fmt_32 =
"\n"
"/* --- AFL TRAMPOLINE (32-BIT) --- */\n"
"\n"
".align 4\n"
"\n"
"leal -16(%%esp), %%esp\n"
"movl %%edi, 0(%%esp)\n"
"movl %%edx, 4(%%esp)\n"
"movl %%ecx, 8(%%esp)\n"
"movl %%eax, 12(%%esp)\n"
"movl $0x%08x, %%ecx\n"
"call __afl_maybe_log\n"
"movl 12(%%esp), %%eax\n"
"movl 8(%%esp), %%ecx\n"
"movl 4(%%esp), %%edx\n"
"movl 0(%%esp), %%edi\n"
"leal 16(%%esp), %%esp\n"
"\n"
"/* --- END --- */\n"
"\n";
这一段汇编代码,主要的操作是:
保存edi
等寄存器
将ecx
的值设置为fprintf()
所要打印的变量内容
调用方法__afl_maybe_log()
恢复寄存器
__afl_maybe_log
作为插桩代码所执行的实际内容,会在接下来详细展开,这里我们只分析"movl $0x%08x, %%ecx\n"
这条指令。
回到fprintf()
命令:
fprintf(outf, use_64bit ? trampoline_fmt_64 : trampoline_fmt_32, R(MAP_SIZE));
可知R(MAP_SIZE)
即为上述指令将ecx
设置的值,即为。根据定义,MAP_SIZE
为64K,我们在下文中还会看到他;R(x)
的定义是(random() % (x))
,所以R(MAP_SIZE)
即为0到MAP_SIZE之间的一个随机数。
因此,在处理到某个分支,需要插入桩代码时,afl-as
会生成一个随机数,作为运行时保存在ecx
中的值。而这个随机数,便是用于标识这个代码块的key。在后面我们会详细介绍这个key是如何被使用的。
分支信息记录
在编译程序中注入的插装捕获了分支(边缘)覆盖,以及粗糙的分支命中计数。在分支点注入的代码本质上相当于:
cur_location = ;
shared_mem[cur_location ^ prev_location]++;
prev_location = cur_location >> 1;
这里显示的执行的伪代码,我们回到_afl_maybe_log()
中,可以看到执行代码为:
"__afl_store:\n"
"\n"
" /* Calculate and store hit for the code location specified in ecx. There\n"
" is a double-XOR way of doing this without tainting another register,\n"
" and we use it on 64-bit systems; but it's slower for 32-bit ones. */\n"
"\n"
#ifndef COVERAGE_ONLY
" movl __afl_prev_loc, %edi\n"
" xorl %ecx, %edi\n"
" shrl $1, %ecx\n"
" movl %ecx, __afl_prev_loc\n"
#else
" movl %ecx, %edi\n"
#endif /* ^!COVERAGE_ONLY */
"\n"
#ifdef SKIP_COUNTS
" orb $1, (%edx, %edi, 1)\n"
#else
" incb (%edx, %edi, 1)\n"
这里对应的便正是文档中的伪代码。具体地,变量__afl_prev_loc
保存的是前一次跳转的”位置”,其值与ecx
做异或后,保存在edi
中,并以edx
(共享内存)为基址,对edi
下标处进行加一操作。而ecx的值右移1位后,保存在了变量__afl_prev_loc
中。
那么,这里的ecx
,保存的应该就是伪代码中的cur_location
了。回忆之前介绍代码插桩的部分:
static const u8* trampoline_fmt_32 =
...
"movl $0x%08x, %%ecx\n"
"call __afl_maybe_log\n"
在每个插桩处,afl-as会添加相应指令,将ecx
的值设为0到MAP_SIZE之间的某个随机数,从而实现了伪代码中的cur_location = <COMPILE_TIME_RANDOM>
;。
因此,AFL为每个代码块生成一个随机数,作为其“位置”的记录;随后,对分支处的”源位置“和”目标位置“进行异或,并将异或的结果作为该分支的key,保存每个分支的执行次数。这个 cur_location
的值随机产生来简化对于复杂程序的链接过程而且还要保持XOR输出的均匀分布。
AFL会用一片大小为64KB的共享内存来存储二进制程序的信息。在输出的map中的每一个Byte字节用来保存被插装程序中的 (branch_src, branch_dst)命中信息。
因为保存执行次数的实际是一张哈希表所以会存在碰撞问题。我们选择MAP_SIZE=64K
,这其中可以保存大约 2k 到 10k程序分支点。对于不是很复杂的程序程序的碰撞还是可以接受的:
Branch cnt | Colliding tuples | Example targets
------------+------------------+-----------------
1,000 | 0.75% | giflib, lzo
2,000 | 1.5% | zlib, tar, xz
5,000 | 3.5% | libpng, libwebp
10,000 | 7% | libxml
20,000 | 14% | sqlite
50,000 | 30% | -
如果一个目标过于复杂,那么AFL状态面板中的map_density信息就会有相应的提示:
┬─ map coverage ─┴───────────────────────┤
│ map density : 3.61% / 14.13% │
│ count coverage : 6.35 bits/tuple │
┼─ findings in depth ────────────────────┤
这里的map density,就是这张哈希表的密度。可以看到,上面示例中,该次执行的哈希表密度仅为3.61%,即整个哈希表差不多有95%的地方还是空的,所以碰撞的概率很小。不过,如果目标很复杂,map density很大,那么就需要考虑到碰撞的影响了.
与此同时它的大小足够小,可以