AFL源码分析(1)-GCC插桩模块源码分析

这篇文章主要分析 AFL 中插桩模块的源代码(GCC模式),包含afl-gcc.cafl-as.c两个文件。在 GCC 模式下实现插桩的方式是直接对汇编代码进行修改,相较于 LLVM 模式比较简单粗暴。本文主要是对AFL的大体实现思路进行分析,不会详细解释每一段代码的作用。

0x00. Overview

首先简单来看一下AFL的工作流程,正如AFL的技术白皮书所说,AFL的设计简单,但有效:

AFL(American Fuzzy Lop)是由安全研究员Michał Zalewski(@lcamtuf)开发的一款基于覆盖引导(Coverage-guided)的模糊测试工具,它通过记录输入样本的代码覆盖率,从而调整输入样本以提高覆盖率,增加发现漏洞的概率。其工作流程大致如下:

  1. 从源码编译程序时进行插桩,以记录代码覆盖率(Code Coverage);
  2. 选择一些输入文件,作为初始测试集加入输入队列(queue);
  3. 将队列中的文件按一定的策略进行“突变”;
  4. 如果经过变异文件更新了覆盖范围,则将其保留添加到队列中;
  5. 上述过程会一直循环进行,期间触发了crash的文件会被记录下来。

在这里插入图片描述

再来看看AFL的项目结构图,可以看到AFL的模块不算很多,并且大多数的代码都集中在afl-fuzz.c这个文件里:

image-20220413182945432

0x01. afl-gcc.c

afl-gccgcc的一个 wrapper,其主要作用是对测试程序的关键节点进行汇编级的插桩,从而记录程序执行路径,代码覆盖率等关键信息。在编译测试程序的源代码时,我们要用afl-gcc替换到原先的gcc,以完成对测试程序插桩工作(g++, clang等也是同理)。

1. main 函数

afl-gcc.c的 main 函数比较简单,基本上就只有三个函数调用:

  • find_as(argv[0]) :查找使用的汇编器
  • edit_params(argc, argv):处理传入的编译参数,将确定好的参数放入 cc_params[] 数组
  • 调用 execvp(cc_params[0], (cahr**)cc_params) 执行将参数传递给真正的gcc(或g++等)

image-20220413183637650

2. find_as 函数

简单来说这个函数的功能就是找到汇编器as的 wrapper afl-as的路径,不是很重要,这里略过。

3. edit_params 函数

首先根据你使用的是不是afl-clangafl-clang++确定是否为 clang 模式。其实afl-clangafl-clang++都是afl-gcc的符号链接:

image-20220413184607674

否则就是gcc系列:

image-20220413184807845

然后将 find_as 函数找到的alf-as路径也添加到参数列表中:

image-20220413185856699

-B这个参数好像不太常见,我特地去查了一下。简单来说就是会优先在-B参数指定的目录里寻找cpp, cc1, asld,之后的汇编环节会使用AFL中的 wrapper afl-as而不是原生的as

-Bprefix

The compiler driver program runs one or more of the subprograms cpp, cc1, as and ld. It tries prefix as a prefix for each program it tries to run, both with and without ‘machine/version/’ for the corresponding target machine and compiler version.

For each subprogram to be run, the compiler driver first tries the -B prefix, if any. If that name is not found, or if -B is not specified…

剩下的一些参数就不太重要了,跳过。

0x02. afl-as.c

afl-as的主要工作是对汇编代码进行插桩,使用 afl-gccafl-clang 编译程序时,工具链会自动调用它。

首先来了解几个关键变量,这里的Instrumentation一开始我也很迷惑,后来发现在AFL里就是插桩的意思(广义Instrumentation的可以看看Wikipedia的解释):

1
2
3
4
5
6
7
8
9
10
11
12
13
static u8** as_params;          /* Parameters passed to the real 'as'   */

static u8* input_file; /* Originally specified input file */
static u8* modified_file; /* Instrumented file for the real 'as' */

static u8 be_quiet, /* Quiet mode (no stderr output) */
clang_mode, /* Running in clang mode? */
pass_thru, /* Just pass data through? */
just_version, /* Just show version? */
sanitizer; /* Using ASAN / MSAN */

static u32 inst_ratio = 100, /* Instrumentation probability (%) */
as_par_cnt = 1; /* Number of params to 'as' */

1. main 函数

这里的 main 函数稍微复杂一点,首先是生成随机数种子,然后提取参数。这些都不太重要,跳过:

image-20220413200006553

接下来这个比较有意思,当使用ASAN或者MSAN时直接把inst_ratio/3,这是因为在进行ASAN的编译时,AFL无法识别出ASAN特定的分支,导致插入很多无意义的桩代码,所以直接暴力地将插桩概率/3:

image-20220413200250065

关于什么是ASANMSAN

ASan 是用来检测 释放后使用(use-after-free)多次释放(double-free)缓冲区溢出(buffer overflows)下溢(underflows) 的内存问题。

ASan 无法覆盖到未初始化的内存,对于未初始化的内存,进行读取的行为同样危险,这时候就需要 MSan 出马了。

接着由 add_instrumentation 函数完成实际的插桩工作,这个函数待会再细说:

image-20220413200603203

最后如果设置了AFL_KEEP_ASSEMBLY环境变量,则保留汇编文件,否则删除:

image-20220413200734694

2. add_instrumentation 函数

1
2
/* Process input file, generate modified_file. Insert instrumentation in all
the appropriate places. */

首先看到前面是指定了输入和输出的文件,插桩后的汇编代码会输出到outf中:

image-20220413201655564

进入一个大 while 循环,可以看出来是对每一行代码进行扫描:

image-20220413202012040

接下来有一个小 trick,可以直接搜索trampoline_fmt_64关键字(插入的汇编代码)直接定位再什么情况下会插桩,非常省事。通过这个方法我们可以找到第一个插桩点:

image-20220413202128027

这里涉及到后面的代码,在当前行包含:且第一个字符不是.时(即函数开头,如foo:),将instrument_next设为1:

image-20220413203256305

image-20220413203247831

第二个插桩点是在条件跳转的时候,意味着遇到了分支,很好理解:

image-20220413202258642

总的来说在一般情况下,AFL会在函数开头处和分支处插桩。全部插桩完后,只要进行了至少一次插桩,就会再插入一段叫做main_payload的汇编代码:

image-20220413203441160

接下来我们看看trampoline_fmtmain_payload的汇编代码具体完成了什么工作:

首先看到trampoline_fmt,以64位为例。这段代码很简单,完成了1) 保存栈帧和寄存器; 2) 调用__afl_maybe_log函数; 3) 恢复栈帧和寄存器; 这几个功能。其中rcx相当于__afl_maybe_log函数的参数,这个值是由编译器随机生成的,在这里是0x000040be。所以关键还是__afl_maybe_log函数的实现`:

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

.align 4

leaq -(128+24)(%rsp), %rsp
movq %rdx, 0(%rsp)
movq %rcx, 8(%rsp)
movq %rax, 16(%rsp)
movq $0x000040be, %rcx
call __afl_maybe_log
movq 16(%rsp), %rax
movq 8(%rsp), %rcx
movq 0(%rsp), %rdx
leaq (128+24)(%rsp), %rsp

/* --- END --- */

main_payload是最后插入的一段代码,这段代码实现了__afl_maybe_log函数,有点长,为了方便可以直接用 IDA 对编译后的文件进行分析。中间这一团代码以后再细看,总之是有fork和一些通信操作:

image-20220413210950955

最后一段代码根据AFL白皮书的介绍是用来记录Coverage的,即代码覆盖率:

image-20220413211652120

其实shared_mem为共享内存,大小为64KB,用来记录代码覆盖率。instrument_id是随机生成的,但不会超过MAP_SIZE

image-20220413212802427

这样的设计非常巧妙,因为这样可以记录由(branch_src, branch_dst)组成的边的覆盖率,而不单纯是基本块的覆盖率,因为:

This aids the discovery of subtle fault conditions in the underlying code, because security vulnerabilities are more often associated with unexpected or incorrect state transitions than with merely reaching a new basic block.

shared_mem的大小为64KB因为很少有什么软件的分支数超过64K,并且64K大小的数组也能在毫秒级别内遍历完成,计算覆盖率非常快。