AFL运行的整体流程:
- 加载用户提供的初始测试用例到队列中
- 从队列中取出1个文件
- 对文件进行修剪,在不改变其执行轨迹的前提下将其修剪为最小size
- 使用多种变异的方法对当前文件进行变异
- 若变异文件产生新的路径了,那么就把它也加入队列中
- 重复第2步骤
总结起来:变异生成新输入、运行监控覆盖率
一. 运行监控覆盖率
运行:主要涉及的函数是initforkserver()、run_target()
覆盖率监控:主要涉及trace_bits数组(用于记录一次执行时的运行轨迹)
init_forkserver()
当开启了插桩模式时,会先调用init_forkserver()
说明:当以-n选项启动afl-fuzz时,即不开启插桩模式,一般情况下dumb_mode会被设为1
故当dumbmode!=1(也就是开启了插桩模式的fuzz)时,会去调用init_forkserver()来fork并初始化一个子进程,之后每次运行一个新的输入时,都会去调用这个子进程来运行目标程序。
init_forkserver()函数中大体流程是:
1. 创建2个管道,分别用于 "父进程写 -> 子进程读 (ctl_pipe)" 和 "父进程读 <- 子进程写 (st_pipe)"
2. 调用fork()
3. 子进程:
将ctl_pipe[0]与某个固定的文件描述符(FORKSRV_FD)绑定(用于读),将st_pipe[1]与FORKSRV_FD+1绑定(用于写)(以后子进程即通过这两个描述符来与父进程读写通信了);
execv(target_path, argv) 通过execv来创建新进程,覆盖当前子进程,即此时“子进程”执行target_path这个程序:
在Qemu插桩模式下(即以-Q选项启动afl-fuzz), target_path 为 afl-qemu-trace(利用qemu对二进制程序进行运行时插桩)
在利用AFL对源码插桩的模式下, target_path 为 插桩后的目标程序
因此:子进程的主要功能即对单个输入运行目标程序,通过插桩获取执行信息,提供反馈
4. 父进程:主要做些错误处理相关的工作,通过读取子进程返回的状态信息,来确定server是否正常运行起来了
上面第4步说到了父进程通过读取子进程返回的状态信息,从而判断是否正常启动。那么子进程是在什么时候返回信息的呢? ps:这里只以qemu插桩的模式来分析,因为这一模式下是通过对qemu打补丁的形式来实现这些功能的(详见qemu_mode/patches/afl-qemu-cpu-inl.h),所以直接是C程序;而利用对于源码直接插桩的情况,是用汇编实现的(详见afl-as.h文件),不那么容易理解,但两者原理是相同的。
在afl-qemu-cpu-inl.h中:
当程序执行到start处会调用afl_setup()和afl_forkserver()做一些预处理操作,其中afl_forkserver()即上述分析相关:
(1) 先是往FORKSRV_FD+1中写入一个4字节的任意数,以告诉父进程自己正常启动了(上面第4步中父进程即是通过这一信息判断的);
(2) 然后当前server就处于循环等待状态,一旦读取到父进程运行目标程序的通知(在run_target()中发出通知),则会再fork出一个子进程去运行目标程序;
此时server会将子进程的pid发送会其父进程,并当子进程执行完后将子进程的执行状态也返回给父进程。
上面提到了,父进程每次需要运行目标程序时,会调用run_target()函数,在该函数中通知forkserver开始工作。
run_target()
(1) run_target()中执行的操作与上述分析是对应的,先给forkserver发送通知,然后forkserver开始工作(fork出子进程来运行目标程序),最后读取forkserver返回的子进程pid和运行状态status。
至此,运行过程大体讲完了,其中主要的就是上文介绍的父子进程的通信部分。
trace_bits
trace_bits保存一次运行时的执行轨迹,它直接影响了输出目录中的fuzz_bitmap文件