AFL的编译插桩是在afl-as部分完成的。本部分主要介绍afl-as以及相关编译插桩的内容。
开始之前
本篇是afl源码阅读的第二篇,在上一篇我没有主要介绍插桩相关的内容,放在这一章来简单讲一下。
在本篇之后还会有最后一篇第三篇来介绍AFL的 LLVM 优化的相关内容。
一个afl-gcc编译出来的程序是什么样的
首先我们不去看源码,直接先看一下插桩后的样子。
我们使用一个很简单的程序
可以看到这里已经显示了 Instrumented 10 locations
我们将其拉入IDA看一下。
可以看到afl在代码段进行了插桩,主要是 __afl_maybe_log 函数,用来探测、反馈程序此时的状态。
afl-as.c源码分析
main函数
main函数主要做了一下几步
通过调用edit_params(argc, argv)编辑了参数。
调用add_instrumentation()进行插桩。
fork出一个子进程,在子进程中执行我们编辑好的参数。
等待子进程执行完退出的信号。
退出,exit参数为WEXITSTATUS(status)
edit_params(int argc, char **argv)
首先获取环境变量TMPDIR、AFL_AS
如果设置了clang_mode,且由环境变量获取的afl_as为空设置use_clang_as = 1
afl_as为环境变量"AFL_CC"的值
如果还是没有获取到 afl_as,令afl_as为环境变量"AFL_CXX"的值
如果还是没有获取到 afl_as,令afl_as为"clang"
获取tmp目录的位置,跟上一步类似getenv("TEMP")
getenv("TMP")
若前两个环境变量都没有获取到,直接令其为"/tmp"
给as_params分配空间。
接下来处理as_params[0]如果afl_as不空的话,as_params[0]=afl_as
否则指定为"as"
令as_params[argc]为0.
接下来扫描argv中的参数。如果设置了"--64",令use_64bit = 1
如果是32为,那么use_64bit = 0
如果是macos。若"-arch"指定了"i386",那么abort。
Sorry, 32-bit Apple platforms are not supported.
若在clang mode下,并且当前的argv[i]不是q或Q,那么跳过这个参数,直接continue掉。
否则直接将当前的argv[i]放入as_params[]参数数组中
如果是macos且使用的use_clang_as向参数数组as_params[]中依次添加:-c -x assembler
argv[argc - 1]为input_file
如果input_file以'-'开头如果是"-version"设置just_version = 1
modified_file = input_file
直接跳转到wrap_things_up
如果input_file[1]还有其他值,告知用户使用错误,abort
否则input_file = NULL
否则比较当前input_file是否以tmp_dir或"/var/tmp/"或"/tmp/"开头。若均不是,则令pass_thru = 1
设置modified_file
modified_file = alloc_printf("%s/afl-%u-%u.s", tmp_dir, getpid(),(u32) time(NULL))
最后到达wrap_things_up:令as_params[]最后一个有效参数为modified_file
向as_params[]最后一个位置补NULL,标志结束。
插桩函数 add_instrumentation(void)在编辑完as_params[]参数数组后进入了此插桩函数。
Process input file, generate modified_file. Insert instrumentation in all the appropriate places.
如果设置了input_file只读打开input_file,fd为inf
input_file:/Users/apple/Desktop/AFL/AFL/cmake-build-debug/tmp/test-instr.s
否则inf为stdin
打开modified_file,返回out_fd
接下来通过while循环每次从input_file(test-instr.s)中读取一行到line中(大小为8192)static u8 line[MAX_LINE];
到了真正插桩的部分了,首先明确,afl只在.text段插桩。所以先要找到.text的位置,并在对应的位置设置instr_ok = 1代表找到了一个位置。
首先我们跳过所有的标签、宏、注释。
在这里我们判断读入的这一行line是否以"\t."开头。(即尝试匹配.s中声明的段)
如果是的话进入更深的判断。首先检查是否是".p2align "指令,如果是的话设置skip_next_label = 1
接下来尝试匹配:text\n "section\t.text" "section\t__TEXT,__text" "section __TEXT,__text"如果匹配到了设置instr_ok = 1,代表我们此时正在.text段。
然后直接continue跳本次循环
尝试匹配:"section\t" "section " "bss\n" "data\n"如果匹配到了说明我们在其他段中。设置instr_ok = 0然后continue
接下来判断一些其他信息,比如att汇编还是intel汇编,设置对应标志位。
AFL尝试抓住一些能标志程序变化的重要的部分:
稍微总结一下就是,AFL试图抓住:_main:(这是必然会插桩的位置)、以及gcc和clang下的分支标记,并且还有条件跳转分支。这几个关键的位置是其着重关注的。
如果是形如:\tj[^m].的指令,即条件跳转指令,并且R(100)产生的随机数小于插桩密度inst_ratio,那么直接使用fprintf将trampoline_fmt_64(插桩部分的指令)写入文件。写入大小为小于MAP_SIZE的随机数。R(MAP_SIZE)
然后插桩计数ins_lines加一。continue
接下来也是对于label的相关评估,有一些label可能是一些分支的目的地,需要自己的评判。
首先判断line中是否有形如类似:^L.*\d(:$)的字符串(比如"Ltext0:")
接下来更进一步的判断L之后是否为为数字 或者 是否满足在clang mode下,line为"LBB"。(L\ / LBB\)如果匹配到了,那么在满足插桩密度以及未设置skip_next_label的情况下。令instrument_next = 1(defer mode)
否则令skip_next_label = 0
而如果只匹配到了line中存在":"但line并非以L开头。那么说明是Function label。
此时设置instrument_next = 1进行插桩。
这一切进行完之后,回到while函数的下一个循环中。而在下一个循环的开头,对于以deferred mode进行插桩的位置进行了真正的插桩处理。
这里关键的两个判断:instr_ok && instrument_next,如果在代码段中,且设置了以deferred mode进行插桩,那么就在这个地方进行插桩,写入trampoline_fmt_64。
插桩完毕后生成的.s文件如图:
可以看到已经被插桩了。这里也就是我们一开始看到的:__afl_maybe_log
收尾工作
在插桩结束后,我们把参数打印一下:
可以看到这里在用汇编器as来将我们插桩好的.s文件生成可执行文件。
而真正的汇编过程是fork出一个子进程来执行的。
main函数中等待子进程执行完毕后退出。
至此整个插桩过程就结束了。
instrumentation trampoline究竟是什么?
在上一部分我们已经知道了,64位下AFL将trampoline_fmt_64写入.s文件的指定位置作为插桩。
本部分主要来讨论AFL究竟插进去了什么东西。
trampoline_fmt_64
我们直接看ida中的内容,非常直观,trampoline_fmt_64就是如下汇编:
__afl_maybe_log
大体流程如下:
在这之前我们首先要关注几个位于bss段的变量:
__afl_area_ptr:共享内存的地址。
__afl_prev_loc:上一个插桩位置(R(100)随机数的值)
__afl_fork_pid:由fork产生的子进程的pid
__afl_temp:缓冲区
__afl_setup_failure:标志位,如果置位则直接退出。
__afl_global_area_ptr:一个全局指针。
首先lahf用于将标志寄存器的低八位送入AH,即将标志寄存器FLAGS中的SF、ZF、AF、PF、CF五个标志位分别传送到累加器AH的对应位(八位中有三位是无效的)。
接下来seto溢出置位。
然后检查共享内存是否已经被设置了。即__afl_area_ptr是否为空?
如果为NULL则说明还没有被设置,跳转到__afl_setup进行设置。
否则继续运行。
__afl_setup
在__afl_setup:中用于初始化__afl_area_ptr,只有在运行到第一个桩时会进行本次初始化。
如果afl_setup_failure不为0的话,直接跳转到afl_return返回。
接下来检查afl_global_area_ptr文件指针是否为NULL,如果为空则跳转到```afl_setup_first```。
否则将afl_global_area_ptr的值赋给afl_area_ptr后跳转到__afl_store
__afl_setup_first
1.在__afl_setup_first中,首先保存寄存器的值(包括xmm寄存器组)
2.接下来进行rsp对齐操作。
3.获取环境变量"__AFL_SHM_ID"的值(共享内存的id)。如果获取失败,那么跳转到__afl_setup_abort,说明获取失败。
4.获取成功后调用shmat启用对共享内存的访问。如果启用失败,跳转到__afl_setup_abort。
5.将shmat返回的共享内存的地址存储在 __afl_area_ptr 与 __afl_global_area_ptr全局变量中。
6.一切顺利的话,接下来运行 __afl_forkserver
__afl_forkserver
首先向FORKSRV_FD+1即199号描述符(即状态管道)中写出__afl_temp中的四个字节,来通知afl我们的fork server已经启动成功。
顺带一提,这里的向状态管道中写的值,在afl-fuzz.c中的这个位置被读出来:
这样我们整个过程就串连起来了。
接下来进入:__afl_fork_wait_loop:
__afl_fork_wait_loop:
1.首先我们等待parent(fuzz)通过控制管道发来的命令,读入__afl_temp中。
2.如果读取失败,那么跳到 __afl_die,break出循环。
3._fork出一个子进程,子进程跳入执行:__afl_fork_resume
4.将fork出来的子进程pid赋值给__afl_fork_pid
5.向状态管道中写出子进程pid,告知parent。此时__afl_maybe_log中的父进程作为forksrever与我们的fuzz进行通信。
6.等待我们fork出的子进程执行完毕。然后写入状态管道告知fuzz。
7.重新执行下一轮 __afl_fork_wait_loop进行测试。
__afl_fork_resume 与
1.首先关闭子进程中的文件描述符。
2.恢复子进程的寄存器状态。
3.跳转执行__afl_store
__afl_store:
这一部分反编译出来如下:
而这个a2就是我们在调用_afl_maybe_log时传入的参数rcx
char __usercall _afl_maybe_log@(char a1@, __int64 a2@。
可以看到这个rcx实际就是我们此时用于标记当前这个桩的随机数,而_afl_prev_loc就是上一个桩的随机数
两次异或之后_afl_prev_loc=a2,然后将_afl_prev_loc右移1位为新的_afl_prev_loc。
最后在共享内存中存储当前插桩位置的地方计数加一,相当于:share_mem[_afl_prev_loc ^ a2]++,实际上是存入一个64k大小的哈希表,存在碰撞几率,但是问题不大。而这个索引是通过异或得到的。
更进一步的,关于为什么要对_afl_prev_loc = _afl_prev_loc >> 1;进行右移1位。
AFL主要考虑如下情况:如果此分支是A->A和B->B这样的情况那么异或之后就会都变成0,进而使得无法区分。亦或者考虑:A->B与B->A的情况,异或后的key也是一样的,难以区分。
至此,AFL的插桩就基本分析的差不多了。下一篇会着重讲llvm mode