上个一个part主要关注的是插桩相关部分的源码,这一节主要来查看afl-fuzz.c这个核心模块的源码部分
由于该部分代码体量过大,我们只对核心部分进行源码解析。这部分总体可以分为三块:
首先从main()
函数着手进行分析。在完成初始配置后,main()
同样利用time生成随机种子
gettimeofday(&tv, &tz);
srandom(tv.tv_sec ^ tv.tv_usec ^ getpid());
紧接着便是第一个循环
while ((opt = getopt(argc, argv, "+i:o:f:m:b:t:T:dnCB:S:M:x:QV")) > 0)
switch(opt){
...
}
这个循环的功能还是比较明显的。从下图的部分源代码和注释中,我们可以看出,其主要是获取命令行传入的参数,以及一些对应flag的设置。
紧接着便是setup_signal_handlers()
函数,主要是调用sigaction()
,注册一些信号处理函数,具体内容如下:
信号 | 作用 |
---|---|
SIGHUP/SIGINT/SIGTERM | 处理各种“stop”情况 |
SIGALRM | 处理超时的情况 |
SIGWINCH | 处理窗口大小 |
SIGUSER1 | 用户自定义信号,这里定义为skip request |
SIGSTP/SIGPIPE | 其他一些不重要信号 |
然后接着仍是一些参数处理的函数check_asan_opts()
,用来读取环境变量ASAN_OPTIONS和MSAN_OPTIONS;fix_up_sync()
用来处理-M,-S相关参数;check_if_tty
用于检查程序是否使用tty终端运行;最后还有get_core_count
, check_crash_handing
, check_cpu_governor
这些函数用来做CPU检查相关。
紧接着便是初始化shm和virgin_bits相关内容的重要函数setup_shm()
,这些变量用于记录tuple相关状态,源码如下:
关于tuple的定义可以查看我在AFL白皮书中有关Detecting new behaviors的内容:【fuzz】AFL白皮书中英双语版 - 鷺雨のBlog (loora1n.github.io)
static 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);
/*
* 分配共享内存,关于shmget的内容可以看看这篇有关进程通信的博客
* https://www.cnblogs.com/52php/p/5861372.html
*/
shm_id = shmget(IPC_PRIVATE, MAP_SIZE, IPC_CREAT | IPC_EXCL | 0600);
if (shm_id < 0) PFATAL("shmget() failed");
atexit(remove_shm);//注册atexit handler 为 remove_shm
shm_str = alloc_printf("%d", shm_id);
if (!dumb_mode) setenv(SHM_ENV_VAR, shm_str, 1);
ck_free(shm_str);
/*
* 启动进程对共享内存访问权限,返回共享内存首地址指针
*/
trace_bits = shmat(shm_id, NULL, 0);
if (trace_bits == (void *)-1) PFATAL("shmat() failed");
}
这里主要有四个核心变量,定义如下:
EXP_ST u8* trace_bits; /* SHM with instrumentation bitmap */
EXP_ST u8 virgin_bits[MAP_SIZE], /* Regions yet untouched by fuzzing */
virgin_tmout[MAP_SIZE], /* Bits we haven't seen in tmouts */
virgin_crash[MAP_SIZE]; /* Bits we haven't seen in crashes */
从注释我们可以看到,trace_bits
指向存放插桩情况位图的共享内存,virgin_bits[MAP_SIZE]
主要是用于记录未到达区域,而virgin_tmout
和virgin_crash
则是记录timeout和crash的情况。
然后仍是一些处理函数,我用注释标识函数用途
...
setup_dirs_fds(); /* Prepare output directories and fds. */
read_testcases(); /* Read all testcases from the input directory, then queue them for testing.
Called at startup. */
load_auto(); /* Load automatically generated extras. */
pivot_inputs(); /* Create hard links for input test cases in the output directory, choosing
good names and pivoting accordingly. */
if (extras_dir) load_extras(extras_dir); /* Read extras from the extras directory and sort them by size. */
if (!timeout_given) find_timeout(); /* The same, but for timeouts. The idea is that when resuming sessions without
-t given, we don't want to keep auto-scaling the timeout over and over
again to prevent it from growing due to random flukes. */
detect_file_args(argv + optind + 1); /* Detect @@ in args. */
if (!out_file) setup_stdio_file(); /* Setup the output file for fuzzed data, if not using -f. */
check_binary(argv[optind]); /* Do a PATH search and find target binary to see that it exists and
isn't a shell script - a common and painful mistake. We also check for
a valid ELF header and for evidence of AFL instrumentation. */
start_time = get_cur_time();
/*
* 处理qemu模式下的参数
*/
if (qemu_mode)
use_argv = get_qemu_argv(argv[0], argv + optind, argc - optind);
else
use_argv = argv + optind;
...
接下来调用perform_dry_run(use_argv)
,这是一个十分核心的函数,函数注释: Perform dry run of all test cases to confirm that the app is working as expected. This is done only for the initial inputs, and only once. 大致含义为:试运行所有初始的测试用例以确保应用可以正确运行,且仅执行一次。
struct queue_entry* q = queue;
u32 cal_failures = 0;
u8* skip_crashes = getenv("AFL_SKIP_CRASHES");
while (q) {
...
}
函数开头首先对输入队列进行遍历,queue相关变量含义如下:
static struct queue_entry *queue, /* Fuzzing queue (linked list) */
*queue_cur, /* Current offset within the queue */
*queue_top, /* Top of the list */
*q_prev100; /* Previous 100 marker */
在while(q)
部分中,首先根据路径,打开输入文件并传入变量use_mem
,接着调用函数进行检测
while(q){
...
/*
* 读取样本数据,传入use_mem
*/
u8* fn = strrchr(q->fname, '/') + 1;
ACTF("Attempting dry run with '%s'...", fn);
fd = open(q->fname, O_RDONLY);
if (fd < 0) PFATAL("Unable to open '%s'", q->fname);
use_mem = ck_alloc_nozero(q->len);
if (read(fd, use_mem, q->len) != q->len)
FATAL("Short read from '%s'", q->fname);
close(fd);
/*
* 调用calibrate_case校准测试用例
*/
res = calibrate_case(argv, q, use_mem, 0, 1); /* Calibrate a new test case. This is done when processing the input directory
to warn about flaky or otherwise problematic test cases early on; and when
new paths are discovered to detect variable behavior and so on. */
ck_free(use_mem);
...
}
接下来我们关注一下calibrate_case
函数的流程:
init_forkserver()
初始化write_to_testcase()
,将输入写入测试文件runtarget()
,fuzz目标程序并监控update_bitmap_score()
,来确定”more favorable path”简化后的函数源码如下:
static u8 calibrate_case(char** argv, struct queue_entry* q, u8* use_mem,
u32 handicap, u8 from_queue) {
...
/*
* 初始化
*/
if (!from_queue || resuming_fuzz)
use_tmout = MAX(exec_tmout + CAL_TMOUT_ADD,
exec_tmout * CAL_TMOUT_PERC / 100);
...
stage_name = "calibration";
stage_max = fast_cal ? 3 : CAL_CYCLES;
/*
* forkserver
*/
if (dumb_mode != 1 && !no_forkserver && !forksrv_pid)
init_forkserver(argv);
...
/*
* loop
*/
for (stage_cur = 0; stage_cur < stage_max; stage_cur++) {
...
write_to_testcase(use_mem, q->len);
fault = run_target(argv, use_tmout);
/*
* 判断是否存在新路径
*/
if (stop_soon || fault != crash_mode) goto abort_calibration;
if (!dumb_mode && !stage_cur && !count_bytes(trace_bits)) {
fault = FAULT_NOINST;
goto abort_calibration;
}
cksum = hash32(trace_bits, MAP_SIZE, HASH_CONST);
/*
* 存在新路径
*/
if (q->exec_cksum != cksum) {
/*
* 更新位图
*/
hnb = has_new_bits(virgin_bits);
if (hnb > new_bits) new_bits = hnb;
...
}
...
/*
* 确定more favorable path
*/
update_bitmap_score(q);
...
/*
* 不存在新路径
*/
abort_calibration:
...
}
基本到这里该函数就结束了,剩下的部分都是一些错误信息处理相关内容
在这里我们停顿一下,梳理下fuzzer与forkserver通信的大致流程。首先我们要知道,实际用于fuzz的子进程并不有fuzzer fork得到,而是由fuzzer 启动的forkserver进行fork得到,如图所示
两者之间通信我们可以由下图观之
其中
__afl_maybe_log()
在源码分析part1中可以看到,其实就是插桩代码的核心函数
首先,afl-fuzz
会创建两个管道:状态管道和控制管道,然后执行目标程序。此时的目标程序的main()
函数已经被插桩,程序控制流进入__afl_maybe_log
中。如果fuzz是第一次执行,则此时的程序就成了fork server们之后的目标程序都由该fork server通过fork生成子进程来运行。fuzz进行过程中,fork server会一直执行fork操作,并将子进程的结束状态通过状态管道传递给afl-fuzz
。
回到主函数,接下来即将进入主循环。再次之前会调用cull_queue()
函数,对队列进行精简。主要是通过遍历top_rated[]
条目,然后标记favored条目
这里的处理流程可以举个例子理解,现假设有如下tuple和seed信息:
temp_v = [1,1,1,1,1]
将按照如下过程进行筛选和判断:
trace_mini = [1,1,0,0,1]
;temp_v=[0,0,1,1,0]
, 标记s2为 “favored”;trace_mini=[0,0,1,1,0]
;temp_v=[0,0,0,0,0]
,标记s1为 “favored”;接下来便要进入主循环,简化版代码如下
while (1) {
u8 skipped_fuzz;
cull_queue();//favored
if (!queue_cur) {
/*
* 队列为空,代表已经完成一轮,此处剩余部分为一些参数处理
*/
...
}
skipped_fuzz = fuzz_one(use_argv);//核心fuzz case代码
...
}
可以看到主要流程便是cull_queue()
–>fuzz_one()
,不断循环往复。由于fuzz_one
的代码量过长,这里只做功能和流程分析:
pending_favored
和queue_cur
的情况,按照概率进行跳过;有pending_favored
, 对于已经fuzz过的或者non-favored的有99%的概率跳过;无pending_favored,95%跳过fuzzed&non-favored,75%跳过not fuzzed&non-favored,不跳过favored;基本AFL流程由两篇文章叙述,由于时间问题,不能将每个文件与函数分析透彻。比如像样本变异策略这样的核心内容并无介绍,但也希望对大家的AFL学习理解有所帮助。
文章如有纰漏,请在评论区指出,我会及时修改。