AFL fuzz 入门 & 源码分析

0x00 前言

​ 昨天又失眠了,一失眠就是到早上五六点,失眠的时候总想找点事情做。想到自己最近看了这么久的AFL都没有总结,这样不太好,不管看了什么,学了什么,都要养成经常总结的习惯,不然过段时间就什么都忘了,主要是经常失眠感觉记忆力明显下降。
这篇主要是记录自己这段阅读源码时坐的一些笔记的整理。

0x01 AFL 简介

​ AFL Fuzzing工具的原理是:对源码进行重新编译时进行插桩,在进行fuzzing时,会把执行了新的tuple或新的tuple命中组的测试样例重新放回语料库进行变异,不断优化种子,来提高代码覆盖率。而且AFL是基于边覆盖率的,之所以没有采用基本块覆盖率是因为大部分的漏洞产生都是由于一些不确定性的跳转或者状态导致,而不是只是单纯的是否执行到了这块代码,所以采用边覆盖率比基于代码覆盖率更有意义。AFL用了一块64KB的共享内存来存放tuple的信息,而且是采用byte来记录tuple的信息,之所以采用byte不是bit是因为还要记录命中数。使用这块有限的共享内存存在碰撞,会导致边覆盖率不准确,这是AFL的一个缺点。

0x02 下载、安装

1
2
3
4
$ wget http://lcamtuf.coredump.cx/afl/releases/afl-latest.tgz
$ tar xvf afl-latest.tgz
$ cd afl-2.52b
$ sudo make && sudo make install

2.1 报错

1
2
3
4
5
6
7
8
9
10
11
12
[-] Hmm, your system is configured to send core dump notifications to an
external utility. This will cause issues: there will be an extended delay
between stumbling upon a crash and having this information relayed to the
fuzzer via the standard waitpid() API.

To avoid having crashes misinterpreted as timeouts, please log in as root
and temporarily modify /proc/sys/kernel/core_pattern, like so:

echo core >/proc/sys/kernel/core_pattern

[-] PROGRAM ABORT : Pipe at the beginning of 'core_pattern'
Location : check_crash_handling(), afl-fuzz.c:7275

解决方法:

1
2
$ sudo su
$ echo core >/proc/sys/kernel/core_pattern

0x03源码分析

ASAN : address-Sanitizier,Linux下的内存检测工具
afl-as.c : 处理汇编代码,在分支处插入探针(插桩代码),并最终再调用as进行真正的汇编

3.1 AFL源码目录结构

以下是afl源码各文件目录的功能说明。


15710161322527

3.3 插桩模块分析

插桩模块的主要代码逻辑在afl-as.c和afl-as.h文件中。afl-as.c文件中主要实现的是对程序进行插桩的逻辑,比如在什么位置进行插桩。afl-as.h文件中实现了插桩代码,在插桩时进行的具体操作。

3.3.1 afl-as.c源码分析

afl-as.c文件中,关键逻辑在add_instrumentation()函数,下面主要分析该函数的逻辑。
首先打开该文件,循环的读取文件的一行,然后进行一系列的判断。并且会将插桩后的文件写入到modified_file中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (input_file) { 

inf = fopen(input_file, "r");
if (!inf) PFATAL("Unable to read '%s'", input_file);

} else inf = stdin;

outfd = open(modified_file, O_WRONLY | O_EXCL | O_CREAT, 0600);

if (outfd < 0) PFATAL("Unable to write to '%s'", modified_file);

outf = fdopen(outfd, "w");
//循环读取每一行,然后进行判断
while (fgets(line, MAX_LINE, inf)) {

在下面会进行一些判断,然后在合适的位置进行插桩,然后再把该行也写入到输出文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
if (!pass_thru && !skip_intel && !skip_app && !skip_csect && instr_ok &&
instrument_next && line[0] == '\t' && isalpha(line[1])) {

fprintf(outf, use_64bit ? trampoline_fmt_64 : trampoline_fmt_32,
R(MAP_SIZE));

instrument_next = 0;
ins_lines++;

}
/* Output the actual line, call it a day in pass-thru mode. */

fputs(line, outf);

插桩的位置主要是在.text段,所以每输入一行,会去判断是否是.text段也就是代码段。

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
if (line[0] == '\t' && line[1] == '.') {

/* OpenBSD puts jump tables directly inline with the code, which is
a bit annoying. They use a specific format of p2align directives
around them, so we use that as a signal. */

if (!clang_mode && instr_ok && !strncmp(line + 2, "p2align ", 8) &&
isdigit(line[10]) && line[11] == '\n') skip_next_label = 1;
//找代码段
if (!strncmp(line + 2, "text\n", 5) ||
!strncmp(line + 2, "section\t.text", 13) ||
!strncmp(line + 2, "section\t__TEXT,__text", 21) ||
!strncmp(line + 2, "section __TEXT,__text", 21)) {
instr_ok = 1;
continue;
}

if (!strncmp(line + 2, "section\t", 8) ||
!strncmp(line + 2, "section ", 8) ||
!strncmp(line + 2, "bss\n", 4) ||
!strncmp(line + 2, "data\n", 5)) {
instr_ok = 0;
continue;
}

}

判断是32位还是64位,并设置相应的标识位。

1
2
3
4
5
6
if (strstr(line, ".code")) {

if (strstr(line, ".code32")) skip_csect = use_64bit;
if (strstr(line, ".code64")) skip_csect = !use_64bit;

}

AFL会在main函数,条件分支语句后面插入探针。下面这段代码是找j开头,第二个字母不是m的跳转指令。但是会以一定的概率来决定插入还是不插入探针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (line[0] == '\t') {

if (line[1] == 'j' && line[2] != 'm' && R(100) < inst_ratio) {

fprintf(outf, use_64bit ? trampoline_fmt_64 : trampoline_fmt_32,
R(MAP_SIZE));

ins_lines++;

}

continue;

}

3.3.2 afl-as.h源码分析

前面分析fork server部分已经分析了一部分逻辑。这里再详细分析一下。这里只分析32位的逻辑,64位大同小异。

3.3.2.1 trampoline_fmt_32

这部分代码就是适当位置插入的探针,主要的作用就是调用afl_maybe_log函数,但是在每处插入的函数的参数都不一样,每处插入的代码是afl_maybe_log(random()%MAP_SIZE),即在不考虑碰撞的情况下执行被插入的代码时可以唯一的确定程序的当前运行位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static const u8* trampoline_fmt_32 =

"\n"
"/* --- AFL TRAMPOLINE (32-BIT) --- */\n"
"\n"
".align 4\n"//4 字节对齐
"\n"
"leal -16(%%esp), %%esp\n"//抬高栈顶
"movl %%edi, 0(%%esp)\n"//将edi保存到esp处
"movl %%edx, 4(%%esp)\n"//保存edx到esp+4
"movl %%ecx, 8(%%esp)\n"//保存ecx到esp+8
"movl %%eax, 12(%%esp)\n"//保存eax到esp+12
"movl $0x%08x, %%ecx\n"//把参数给ecx
"call __afl_maybe_log\n"//调用__afl_maybe_log
"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";
3.3.2.2 main_playload_32

这部分代码有afl_maybe_log函数的实现逻辑。
首先将共享内存的地址映射到当前进程。

1
2
3
4
5
"  /* Check if SHM(share memory) region is already mapped. */\n"
"\n"
" movl __afl_area_ptr, %edx\n"//__afl_area_ptr保存的是共享内存映射到target进程空间的地址
" testl %edx, %edx\n"
" je __afl_setup\n"

如果共享内存还没有加载,则调用afl_setup加载共享内存,afl_setup函数的具体逻辑如下:

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
"__afl_setup:\n"
"\n"
" /* Do not retry setup if we had previous failures. */\n"
"\n"
" cmpb $0, __afl_setup_failure\n"//if we had previous failous ,just ret
" jne __afl_return\n"
"\n"
" /* Map SHM, jumping to __afl_setup_abort if something goes wrong.\n"
" We do not save FPU/MMX/SSE registers here, but hopefully, nobody\n"
" will notice this early in the game. */\n"
"\n"
" pushl %eax\n"
" pushl %ecx\n"
"\n"
" pushl $.AFL_SHM_ENV\n"//获取共享内存的环境变量,得到共享内存的标识
" call getenv\n"
" addl $4, %esp\n"
"\n"
" testl %eax, %eax\n"
" je __afl_setup_abort\n"
"\n"
" pushl %eax\n"
" call atoi\n"//将得到的共享内存标识转换成整型
" addl $4, %esp\n"
"\n"
" pushl $0 /* shmat flags */\n"
" pushl $0 /* requested addr */\n"
" pushl %eax /* SHM ID */\n"
" call shmat\n" //将该共享内存区映射到当前进程空间
" addl $12, %esp\n"
"\n"
" cmpl $-1, %eax\n"
" je __afl_setup_abort\n"
"\n"
" /* Store the address of the SHM region. */\n"
"\n"
" movl %eax, __afl_area_ptr\n"//将内存空间地址保存在__afl_area_ptr指针和edx中
" movl %eax, %edx\n"
"\n"
" popl %ecx\n"
" popl %eax\n"
"\n"

afl_setup函数从环境变量中得到共享内存标识,最后将共享内存的地址保存在afl_area_ptr指针和edx中。
回到刚刚上面的逻辑,如果共享内存已经加载,即afl_area_ptr指针已经保存了共享内存的地址。
则继续往下走,会调用
afl_store函数,该函数的作用是记录tuple信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
 "__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" //edi = pre_location
" xorl %ecx, %edi\n" // cur_location ^ pre_location
" shrl $1, %ecx\n" //cur_location>>1
" movl %ecx, __afl_prev_loc\n" //pre_location = cur_location
#else
" movl %ecx, %edi\n"
#endif /* ^!COVERAGE_ONLY */
"\n"
#ifdef SKIP_COUNTS
" orb $1, (%edx, %edi, 1)\n"//如果不计数,则用0,1来标识,shm[cur_location^pre_location]=1
#else
" incb (%edx, %edi, 1)\n"//shm[cur_location^pre_location]++,这里会对tuple的命中次数计数。
#endif /* ^SKIP_COUNTS */
"\n"

后面的部分逻辑单独在下面分析。

3.3.2.3 fork server

在目标程序target编译之后,就会调用afl-fuzz进行fuzzing。afl会将输入的send进行变异,然后将这些变异的文件喂给target执行,并监测target的执行状态。其中涉及到大量的fork和执行target的过程。这个由fork server来实现。
fuzzer通过与fork server通信来进行fuzzing。在target进程启动之后,会启动一个fork server进程,该进程用来fork子进程和执行target。fuzzer通过两个管道与fork server 通信,一个状态管道,一个控制命令管道。fuzzer往控制命令管道写命令,fork server从命令管道读命令;fork server往状态管道写状态,fuzzer从状态管道读状态来控制接下来的fuzz流程。
下面是具体的fork server 的逻辑:
在进行fuzz前,fuzzer会fork子进程,也就是作为后面的fork server。之后的fork都是由fork server来执行。

1
1987   forksrv_pid = fork();

其中用于fuzzer 和 fork server通信的管道定义如下:

1
2
//分别是状态管道和命令管道。其中st_pipe[0]为读状态的端口,st_pipe[1]为写状态的端口
1980 int st_pipe[2], ctl_pipe[2];

fork server通过读命令管道来获取来自fuzzer的命令;通过写状态管道来向fuzzer报告target的执行状态。这里将两个管道分配到预先指定的FORKSRV_FD和FORKSRV_FD+1中。

1
2
3
4
5
2053     /* Set up control and status pipes, close the unneeded original fds. */
2054
2055 if (dup2(ctl_pipe[0], FORKSRV_FD) < 0) PFATAL("dup2() failed");
2056 if (dup2(st_pipe[1], FORKSRV_FD + 1) < 0) PFATAL("dup2() failed");
2057

fork server从状态管道中读取状态,如果得到四字节的“hello”消息,说明fork server已经准备就绪。

1
2
3
4
5
6
7
2113 //从管道读取状态
2114 rlen = read(fsrv_st_fd, &status, 4);
......
2124 if (rlen == 4) {
2125 OKF("All right - fork server is up.");
2126 return;
2127 }
3.3.2.4 fork server 与 fuzzer 通信

首先fork server 通过写状态管道来通知fuzzer,已经准备完毕可以开始fuzz了。

1
2
3
4
5
6
7
245   "__afl_forkserver:\n"
......
259 " pushl $4 /* length */\n"
260 " pushl $__afl_temp /* data */\n"
261 " pushl $" STRINGIFY((FORKSRV_FD + 1)) " /* file desc */\n"
262 " call write\n"
263 " addl $12, %esp\n"

然后进行循环等待状态,通过读命令管道,等待来自fuzzer的命令。

1
2
3
4
5
6
7
8
9
10
268   "__afl_fork_wait_loop:\n"
269 "\n"
270 " /* Wait for parent by reading from the pipe. Abort if read fails."
271 " 在这里处于等待状态,通过读命令管道,来接受来自fuzzer的命令,是否开始fuzz */\n"
272 "\n"
273 " pushl $4 /* length */\n"
274 " pushl $__afl_temp /* data */\n"
275 " pushl $" STRINGIFY(FORKSRV_FD) " /* file desc */\n"
276 " call read\n"
277 " addl $12, %esp\n"

一旦接收到来自fuzzer的命令,就调用fork得到子进程,如进程创建成功,则跳转到__afl_fork_resume函数,执行target进程。父进程继续作为fork server运行,并通过写状态管道将子进程的pid传给fuzzer。然后等待子进程执行完毕,再通过写状态管道将子进程结束状态传递给fuzzer。然后再次进入等待状态。这些具体的代码就不贴了。

3.4 FUZZ 模块分析

这个部分是AFL的核心模块,但我只讲其中几个比较核心的部分进行分析。

3.4。1 共享内存

AFL的共享内存用来保存tuple信息,其中包括tuple命中与否和命中次数,所以AFL中每个tuple都是占用一个字节来记录信息而不是一个比特。AFL的共享内存大小是固定的,设定为64KB。通过hash来进行映射,也就存在碰撞的问题,导致记录的tuple信息并不一定准确。
afl-fuzz.c源码中,共享内存初始化操作在setup_shm()函数中,具体逻辑如下:

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
EXP_ST void setup_shm(void) {

u8* shm_str;

if (!in_bitmap) memset(virgin_bits, 255, MAP_SIZE);

memset(virgin_tmout, 255, MAP_SIZE);
memset(virgin_crash, 255, MAP_SIZE);
//分配一块大小为64k的共享内存
shm_id = shmget(IPC_PRIVATE, MAP_SIZE, IPC_CREAT | IPC_EXCL | 0600);

if (shm_id < 0) PFATAL("shmget() failed");

atexit(remove_shm);//注册终止函数,即main函数执行结束后调用的函数,在函数结束之后,回收这块共享内存

shm_str = alloc_printf("%d", shm_id);

/* If somebody is asking us to fuzz instrumented binaries in dumb mode,
we don't want them to detect instrumentation, since we won't be sending
fork server commands. This should be replaced with better auto-detection
later on, perhaps? */
//将该共享变量的标识赋值给环境变量,从而之后的fork的子进程可以访问环境管理,来访问这块共享内存
if (!dumb_mode) setenv(SHM_ENV_VAR, shm_str, 1);

ck_free(shm_str);
//把共享内存区对象映射到调用进程的地址空间,内存标识存放在trace_bits变量
trace_bits = shmat(shm_id, NULL, 0);

if (!trace_bits) PFATAL("shmat() failed");

}

AFL中通过名为virgin_bits和trace_bits的bitmap来分别记录总的tuple和当前的tuple信息。

3.4.2 运行测试样例

在fork server准备就绪的前提下,当fuzzer要执行某个测试样例时,会调用run_target函数进程处理。run_target函数中,如果fuzz选择的是dumb模式,则不会依赖fork server来执行target,会直接调用execve()函数来执行。
若是non-dumb模式。fuzzer通过写命令管道通知fork server执行target,并通过读状态管道来获取target结束状态。

1
2
3
4
5
6
7
8
9
10
11
2365     /* In non-dumb mode, we have the fork server up and running, so simply
2366 tell it to have at it, and then read back PID. */
2367
2368 if ((res = write(fsrv_ctl_fd, &prev_timed_out, 4)) != 4) {
......
2375 if ((res = read(fsrv_st_fd, &child_pid, 4)) != 4) {
2376
......
2382 if (child_pid <= 0) FATAL("Fork server is misbehaving (OOM?)");
2383
2384 }

下面分析几个比较重要的函数源码。

3.4.2.1 calibrate_case()源码分析

在第一遍fuzz–dry run,会调用calibrate_case函数对测试样例进行校准。这个过程在处理输入目录时完成,以便于早期发现就警告有问题的测试样例,或者在发现新的路径来检测遍历行为等。
首先要检查fork_server是否已经运行起来了,若没有,首先运行fork_server。

1
2
if (dumb_mode != 1 && !no_forkserver && !forksrv_pid)
init_forkserver(argv);

下面有个循环,也就是每个测试样例,AFL会运行多次,而不是简单的运行一次,每次都会记录tuple的信息

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
//每个测试样例都会执行3次或8次,而不是简单的执行一次,执行多次并记录tuple的信息
//具体取决于是否快速校准
for (stage_cur = 0; stage_cur < stage_max; stage_cur++) {

u32 cksum;

if (!first_run && !(stage_cur % stats_update_freq)) show_stats();
//将测试样例写进文件
write_to_testcase(use_mem, q->len);
//真正运行程序的地方
fault = run_target(argv, use_tmout);

/* stop_soon is set by the handler for Ctrl+C. When it's pressed,
we want to bail out quickly. */

if (stop_soon || fault != crash_mode) goto abort_calibration;

if (!dumb_mode && !stage_cur && !count_bytes(trace_bits)) {
fault = FAULT_NOINST;
goto abort_calibration;
}
//计算trace_bits的checksums,即当前tuple的hash值
cksum = hash32(trace_bits, MAP_SIZE, HASH_CONST);
//如果此次hash与上次执行的hash不一致
if (q->exec_cksum != cksum) {

u8 hnb = has_new_bits(virgin_bits);//return value : 0,1,2
//new_bits default value is 0
if (hnb > new_bits) new_bits = hnb;
//如果不是第一次运行测试样例
if (q->exec_cksum) {

u32 i;
//这里是以MAP_SIZE来遍历的,是以字节为单位,需要改成以bit为单位
for (i = 0; i < MAP_SIZE; i++) {

if (!var_bytes[i] && first_trace[i] != trace_bits[i]) {

var_bytes[i] = 1;
stage_max = CAL_CYCLES_LONG;

}

}

var_detected = 1;

} else {//如果是第一次运行测试样例

q->exec_cksum = cksum;
memcpy(first_trace, trace_bits, MAP_SIZE);

}

}

}
3.4.2.2 run_target()源码分析

不想写了,下次再继续写吧。TODO

reference

https://blog.csdn.net/qq_32464719/article/details/80592902
afl实现细节 : http://rk700.github.io/2017/12/28/afl-internals/
https://bbs.pediy.com/thread-254705.htm