afl-gcc.c
afl-gcc.c文件在gcc的基础上套了层壳,主要作用为进行一些检查,然后运行afl-as。其中会设置一些参数,默认为
-g -O3 -funroll-loops -D__AFL_COMPILER=1 -DFUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION=1
最后调用execvp(cc_params[0], (char**)cc_params);
来启动afl-as。
afl-as.c
afl-as为执行gcc-as之前套的一层壳,主要作用为把afl-as里存放的汇编指令插入到编译过程生成的汇编指令文件中。
如图所示afl-as劫持了编译到汇编的过程。
- 使用pid和时间作为随机数种子生成在插桩时插入的随机数。
- edit_params函数中调用
modified_file = alloc_printf("%s/.afl-%u-%u.s", tmp_dir, getpid(), (u32)time(NULL));
设置中间文件的生成路径(调试时可以把前面的%s替换为./tmp方便查看插桩之后生成的指令)。 - 在add_instrumentation函数中取出经过汇编之后的汇编指令,进行插桩。在add_instrumentation函数调用前后加上sleep和scanf阻塞可以用gdb attach调试。
- 用execvp执行后续操作(对插桩之后的汇编指令进行编译和链接)生成可以执行的二进制文件。
c
rand_seed = tv.tv_sec ^ tv.tv_usec ^ getpid();
srandom(rand_seed); //这里是根据时间和pid来随机化seed
edit_params(argc, argv);
if (!just_version) add_instrumentation(); //关键的add_instrumentation函数
if (!(pid = fork())) { //插入完指令后,会fork子进程,用来执行as,将汇编变成二进制
execvp(as_params[0], (char**)as_params);
}
在add_instrumentation函数中instr_ok判断函数的开始和结束,instrument_next判断下一条指令是否插桩。过程中检测跳转和函数开始进行插桩。
插入的指令有两种类型,一种插入在跳转和函数开始trampoline_fmt_32,主要作用为保存和恢复寄存器;另一种插入在文件的末尾main_payload_32,为核心指令。R(MAP_SIZE)为标记每个基本块的随机数。
c
fprintf(outf, use_64bit ? trampoline_fmt_64 : trampoline_fmt_32,
R(MAP_SIZE));
afl-as.h
插入的汇编指令存放在afl-as.h中。保存和恢复寄存器的内容如下,$0x%08x
会被替换为8字节R(MAP_SIZE)生成的随机数。MAP_SIZE为随机数的范围,可以再config.h中设置,默认为2字节大小(0到0xffff)
c
trampoline_fmt_32=".align 4\n"
"\n"
"leal -16(%%esp), %%esp\n"
"movl %%edi, 0(%%esp)\n" // 保存edi等寄存器
"movl %%edx, 4(%%esp)\n"
"movl %%ecx, 8(%%esp)\n"
"movl %%eax, 12(%%esp)\n"
"movl $0x%08x, %%ecx\n" // 将ecx的值设置为fprintf()所要打印的变量内容
"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"
main_payload_32在汇编指令文件末尾插入。具体逻辑如下图中target_binary和fork_server所示。
在大多数情况下会执行target_binary部分的代码核心内容如下:其中edx为__afl_area_ptr
(共享内存地址)ecx为命中基本块的key。
__afl_prev_loc为前一次命中基本块的key。 让前一个命中基本块右移一位可以区分一次跳转的来源和目的(基本块a跳转到基本块b和b跳转到a的结果不同)。
c
"__afl_store:\n"
" movl __afl_prev_loc, %edi\n"
" xorl %ecx, %edi\n"
" shrl $1, %ecx\n"
" movl %ecx, __afl_prev_loc\n"
" incb (%edx, %edi, 1)\n"//Byte[edx+edi*1]+1 之后放回原处
在第一次执行会进入fork_server部分的逻辑:从环境变量获取共享内存id,通过共享内存id获取共享内存。此时初始化完成再进行fork,复制的子进程会和此时的内存一样。子进程执行命中逻辑,父进程告诉afl-fuzz子进程的pid和子进程的状态。
- 可以在里面加入调用sleep函数的汇编指令方便调试,但是添加的时候需要注意保存和恢复寄存器。
afl-fuzz.c
主要关注fuzz执行被测试二进制文件的过程。
测试的第一步为init_forkserver启动forkserver服务。让子进程进入上图中forkserver的循环。具体过程如下:
- fork函数会复制和父进程完全一样的内存。
- 设置读写的超时时间,为了方便调试可以把这个值设置的非常大,执行命令加上-t参数可以指定超时时间(如afl-fuzz -t 1000000 xxxxxx)
- 设置环境变量,把变量传递给被测试程序(如共享内存id)。 管道id为config.h里面指定的常量,不是靠环境变量传递的。
- execve执行会覆盖掉原本子进程的内存,切换为目标程序运行所需的内存。执行过程如上图fork_server部分。
- 父进程读取管道等待子进程发来的准备完毕的通知。
```c
EXP_ST void init_forkserver(char** argv) {
static struct itimerval it;
int st_pipe[2], ctl_pipe[2];
int status;
s32 rlen;
ACTF("Spinning up the fork server...");
if (pipe(st_pipe) || pipe(ctl_pipe)) PFATAL("pipe() failed");
forksrv_pid = fork();
if (!forksrv_pid) {
struct rlimit r;
if (!getrlimit(RLIMIT_NOFILE, &r) && r.rlim_cur < FORKSRV_FD + 2) {//设置read的等待时间
r.rlim_cur = FORKSRV_FD + 2;
setrlimit(RLIMIT_NOFILE, &r); / Ignore errors /
}
if (mem_limit) {
r.rlim_max = r.rlim_cur = ((rlim_t)mem_limit) << 20;
setrlimit(RLIMIT_AS, &r); /* Ignore errors */
}
r.rlim_max = r.rlim_cur = 0;
setrlimit(RLIMIT_CORE, &r); /* Ignore errors */
setsid();
dup2(dev_null_fd, 1); //重定位输入输出错误流
dup2(dev_null_fd, 2);
dup2(out_fd, 0);
close(out_fd);
......
setenv("MSAN_OPTIONS", "exit_code=" STRINGIFY(MSAN_ERROR) ":" //设置环境变量
"symbolize=0:"
"abort_on_error=1:"
"allocator_may_return_null=1:"
"msan_track_origins=0", 0);
execv(target_path, argv); //执行目标程序
} //子进程结束
......
//父进程
setitimer(ITIMER_REAL, &it, NULL);
rlen = read(fsrv_st_fd, &status, 4); //从fork_server读取四字节确定其初始化完毕
if (rlen == 4) {
OKF("All right - fork server is up.");
return;
} //收到回应fork_server初始化完成
......
if (waitpid(forksrv_pid, &status, 0) <= 0) //等待
```
run_target函数为执行一个测试用例的过程。如上图所示,当准备完毕测试用例之后,run_target函数执行下面流程获取到一个测试用例下被测试程序的pid和结束状态。
```c
static u8 run_target(char** argv, u32 timeout) {
memset(trace_bits, 0, MAP_SIZE);//初始化共享内存
if ((res = write(fsrv_ctl_fd, &prev_timed_out, 4)) != 4) { //通知fork_server可以开始工作了
if ((res = read(fsrv_st_fd, &child_pid, 4)) != 4) { //获取子进程pid
if (stop_soon) return 0;
RPFATAL(res, "Unable to request new process from fork server (OOM?)");
}
}
if ((res = read(fsrv_st_fd, &status, 4)) != 4) {//获取forkserver创建的子进程的pid
return 0;
}
}
classify_counts((u32*)trace_bits);//执行一轮结束,统计代码覆盖率
```
- 每个测试用例会初始化和统计一次命中的基本块,统计结果放入hash表。
总结
记录觉得可以借鉴的地方
- 使用fork_server的原因:实际上不用forkserver,每次使用execve也能完成对每个测试用例的运行。但是execve需要内存页的切换,切换过程非常消耗资源,而fork函数在写时复制技术的加持下消耗的资源非常小。
- 被测试程序如何获取输入:通常的思路为输入通过环境变量赋值给被测试程序。作者采用很精明的方法,即劫持输入流,把从命令行的输入劫持到从文件输入,这样只需要更改文件内容就可以修改测试用例。
- 从基本块a跳转到基本块b,先把a的key左移一位和b的key异或就可以区分跳转的方向了。
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论