afl启动流程分析

admin 2022年4月16日16:20:50afl启动流程分析已关闭评论255 views字数 4381阅读14分36秒阅读模式

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劫持了编译到汇编的过程。

afl启动流程分析

  • 使用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所示。

afl启动流程分析

在大多数情况下会执行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异或就可以区分跳转的方向了。

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2022年4月16日16:20:50
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   afl启动流程分析https://cn-sec.com/archives/917346.html