afl-fuzz.c细节分析之主循环分析
afl-fuzz.c细节分析之主循环分析
Ivoripuionafl-fuzz.c细节分析之主循环分析
cull_queue阶段
afl的dry run环节结束后就会进入cull_queue()
:
1 | cull_queue(); |
cull_queue函数分析
该函数用于精简队列。
若处于dumb模式或者没有发现更优的样例就返回:
1 | if (dumb_mode || !score_changed) return; |
将标记位score_changed
改为0
:
1 | score_changed = 0; |
设置temp_v
数组中的值为0xff:
1 | memset(temp_v, 255, MAP_SIZE >> 3); |
设置queued_favored
和pending_favored
为0:
1 | queued_favored = 0; |
遍历queue
,将所有样例的favored
设置为0:
1 | q = queue; |
若对某条分支信息,top_rated[i]
存在(存在favorable
的样例),且在temp_v
中对应的bit被置1:
1 | for (i = 0; i < MAP_SIZE; i++) |
trace_mini
记录了覆盖的分支信息,这里将temp_v
中的内容与其进行与操作,消除掉覆盖到的path,将对应的bit置为0:
1 | while (j--) |
有关这段难顶的字节运算与莫名其妙的标识符,这个文章解释的很好,实际上就是一个贪心算法,目的是为了择出队列中第一个最优的覆盖足够分支的样例,文章对这段代码解释摘要如下:
1 | tuple t0,t1,t2,t3,t4;seed s0,s1,s2 初始化temp_v=[1,1,1,1,1] |
设置top_rated[i]->favored
为1,queued_favored
加1:
1 | top_rated[i]->favored = 1; |
遍历队列,将不是favored的样例标记成redundant_edges,存放到/queue/.state/redundant_edges/
文件夹下。
一些准备工作
这部分在第一篇笔记中记录过了。
- 显示快速统计信息以及警告,处理一些硬编码相关的工作等。
1 | show_init_stats(); |
- 恢复时找到要开始的队列位置。
1 | seek_to = find_start_position(); |
- 更新一些状态。
1 | write_stats_file(0, 0, 0); |
- 自动更新token。
1 | save_auto(); |
- ctrl+C就退出。
1 | if (stop_soon) goto stop_fuzzing; |
- 不在TTy退出。
1 | if (!not_on_tty) { |
然后进入while(1)
的循环。
while (1)循环
首先精简队列:
1 | cull_queue(); |
若queue_cur
为0,则说明当前队列已经遍历完毕,进行参数的重新设置,重新开始遍历队列:
1 | if (!queue_cur) { |
还是上述if
分支。
刷新展示信息:
1 | show_stats(); |
不在tty就进行信息展示:
1 | if (not_on_tty) { |
若queue里的样例数与之前的一样,则说明没有发现新的样例(分支信息),则需要对样例进行重组:
1 | if (queued_paths == prev_queued) { |
将样例数量进行更新:
1 | prev_queued = queued_paths; |
设置了AFL_IMPORT_FIRST
且并行进行fuzzer,则会从其他fuzzer中择取样例:
1 | if (sync_id && queue_cycle == 1 && getenv("AFL_IMPORT_FIRST")) |
若queue_cur
不为空,使用fuzz_one
函数对queue_cur
进行fuzz:
1 | skipped_fuzz = fuzz_one(use_argv); |
fuzz_one函数
从队列中获取当前样例,对其进行fuzz。如果fuzz成功则返回0,如果跳过或退出则返回1。
若pending_favored
不为0,当前样例被fuzz过了或者不是favored
的,有99%概率直接返回1:
1 | if (pending_favored) { |
若pending_favored
为0,不是dumb mode,当前样例不是favored
,队列中样例总数大于10,则进一步判断循环次数是否大于1且当前样例未被fuzz过,若是,有99%概率直接返回1,不是有95%概率返回1:
1 | else if (!dumb_mode && !queue_cur->favored && queued_paths > 10) { |
tty相关处理:
1 | if (not_on_tty) { |
打开队列中正在处理的样例:
1 | fd = open(queue_cur->fname, O_RDONLY); |
获取样例长度:
1 | len = queue_cur->len; |
将样例映射到内存中,地址赋值给in_buf
和orig_in
:
1 | orig_in = in_buf = mmap(0, len, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0); |
失败显示信息:
1 | if (orig_in == MAP_FAILED) PFATAL("Unable to mmap '%s'", queue_cur->fname); |
分配大小为len
的内存,地址赋值给out_buf
:
1 | out_buf = ck_alloc_nozero(len); |
初始化运行超过时间的样例个数为0:
1 | subseq_tmouts = 0; |
初始化当前样例的cur_depth
:
1 | cur_depth = queue_cur->depth; |
CALIBRATION阶段
若样例标记为了校验失败了,且校验次数小于CAL_CHANCES
(3),则再次校验,否则就根据是否即将关闭fuzzer或者是否处于crash_mode
来判断是否抛弃样例:
1 | if (queue_cur->cal_failed) { |
TRIMMING阶段
若该样例没有被trim过且不处于dumb模式,则对其进行修剪,然后设置trim_done
为1,以防止二次修剪:
1 | if (!dumb_mode && !queue_cur->trim_done) { |
将in_buf
中len
个字节拷贝到out_buf
中:
1 | memcpy(out_buf, in_buf, len); |
trim_case函数分析
若样例长度小于5字节则不需要修剪:
1 | if (q->len < 5) return 0; |
设置stage_name
为tmp
,将修建的长度加上样例长度:
1 | stage_name = tmp; |
将长度向上取整2的次方,记录为len_p2
:
1 | len_p2 = next_p2(q->len); |
设置remove_len
为len_p2*1/16
与4
中较大的值,即起始的每次修剪的长度:
1 | remove_len = MAX(len_p2 / TRIM_START_STEPS, TRIM_MIN_BYTES); |
除非remove_len
小于len_p2*1/1024
或者4
,否则进行循环:
1 | while (remove_len >= MAX(len_p2 / TRIM_END_STEPS, TRIM_MIN_BYTES)) { |
循环内容如下。
记录阶段信息:
1 | sprintf(tmp, "trim %s/%s", DI(remove_len), DI(remove_len)); |
当remove_pos < q->len
不断裁剪样例,且测试样例:
1 | while (remove_pos < q->len) { |
从内存中in_buf
偏移为remove_pos
开始跳过remove_len
个字节,写q->len
个字节到input文件夹中:
1 | write_with_gap(in_buf, q->len, remove_pos, trim_avail); |
然后运行样例:
1 | fault = run_target(argv, exec_tmout); |
trim_exec
计数器加1:
1 | trim_execs++; |
计算校验和:
1 | cksum = hash32(trace_bits, MAP_SIZE, HASH_CONST); |
若修剪了以后对其覆盖信息没有影响则就永久修剪:
1 | if (cksum == q->exec_cksum) { |
若不相等则恢复修建部分:
1 | else remove_pos += remove_len; |
展示信息,计数器stage_cur
加1:
1 | if (!(trim_exec++ % stats_update_freq)) show_stats(); |
循环的最后增加步长:
1 | remove_len >>= 1; |
对in_buf
做的改变也需要体现在文件上,主要根据needs_write
标记改变:
1 | if (needs_write) { |
PERFORMANCE SCORE阶段
计算queue_cur
分数并保存:
1 | orig_perf = perf_score = calculate_score(queue_cur); |
若跳过确定性变异就直接开始havoc:
1 | if (skip_deterministic || queue_cur->was_fuzzed || queue_cur->passed_det) |
设置doing_det
为1:
1 | doing_det = 1; |
calculate_score函数
根据queue entry的执行速度、覆盖到的path数和路径深度来评估出一个得分,这个得分perf_score在后面havoc的时候使用。
前面是根据上述的一些定义算分:
1 | if (q->exec_us * 0.1 > avg_exec_us) perf_score = 10; |
根据depth
算分:
1 | switch (q->depth) { |
有关深度的一些说明:
每次add_to_queue
时将cur_depth
加1赋值给depth
:
1 | q->depth = cur_depth + 1; |
cur_depth
初始化时为1。
所以每次有新的样例加入queue就depth
加1。
SIMPLE BITFLIP (+dictionary construction) 阶段
比特翻转阶段。
这里就是不断使用下面的宏,对连续的1,2,4,8,16位:
1 |
这里((_bf) & 7)
可以得到{0,1,2,3,4,5,6,7}(_bf > 7
)的循环数组,128进行移位操作后得到了以下这些数:
1 | 1000 0000 |
arf[(_bf)>>3]
与上面这些数进行抑或操作相当于翻转了上面这些数中为1位置的比特。
bitflip 1/1
环节设定stage_max
为len << 3
:
1 | stage_short = "flip1"; |
_ar
赋值为out_buf
,_b
赋值为[0,len << 3)
:
1 | for (stage_cur = 0; stage_cur < stage_max; stage_cur++) { |
这里进行的是对out_buf
中每个比特的翻转,循环中的操作如下:
1 | 将第一个字节的第一个比特(从左至右数的第一个)翻转; |
翻转完成进行一轮common_fuzz_stuff
操作:
1 | if (common_fuzz_stuff(argv, out_buf, len)) goto abandon_entry; |
然后翻转回来:
1 | FLIP_BIT(out_buf, stage_cur); |
在进行单个比特翻转时,还进行token的处理,这里token的含义就是一段连续的字节,若改变了之后路径与原始路径相比发生了变化,而这些改变后产生的路径都一样,那就把这段字节成为token:
1 | if (!dumb_mode && (stage_cur & 7) == 7) { |
这段解释这篇文章讲的非常好:
比如对于SQL的
SELECT *
,如果SELECT
被破坏,则肯定和正确的路径不一致,而被破坏之后的路径却肯定是一样的,比如AELECT
和SBLECT
,显然都是无意义的,而只有不破坏token,才有可能出现和原始执行路径一样的结果,所以AFL在这里就是在猜解关键字token。token默认最小是3,最大是32,每次发现新token时,通过maybe_add_auto
添加到a_extras
数组里。
new_hit_cnt
赋值为queued_path + unique_crashes
:
1 | new_hit_cnt = queued_paths + unique_crashes; |
统计得到的有效样例以及循环次数:
1 | stage_finds[STAGE_FLIP1] += new_hit_cnt - orig_hit_cnt; |
bitflip 2/1
环节设定stage_max
为(len << 3) - 1
:
1 | stage_name = "bitflip 2/1"; |
连续翻转两个比特位:
1 | for (stage_cur = 0; stage_cur < stage_max; stage_cur++) { |
记录信息:
1 | new_hit_cnt = queued_paths + unique_crashes; |
bitflip 4/1
环节是连续翻转四个比特位,记录信息:
1 | for (stage_cur = 0; stage_cur < stage_max; stage_cur++) { |
设置effector map
的前置工作:
1 | /* Effector map setup. These macros calculate: |
初始化effector map
:
1 | eff_map = ck_alloc(EFF_ALEN(len)); |
由于8比特翻转实则就是单字节翻转,所以可以通过一个启发式判断节省执行资源:
这样做的逻辑是:如果一个byte完全翻转,都无法带来执行路径的变化,那么这个byte很有可能是属于”data”,而非”metadata”(例如size, flag等),对整个fuzzing的意义不大。所以,在随后的一些变异中,会参考effector map,跳过那些“无效”的byte,从而节省了执行资源。
这里判断是否执行路径变化的方式就是检查校验和:
1 | if (cksum != queue_cur->exec_cksum) { |
若afl发现文件小于128字节,那么就认为整个文件都是有效的;若afl发现文件中90%的字节都是有效的,那么也认为整个文件都是有效的:
1 | if (eff_cnt != EFF_ALEN(len) && |
这里以及之后的字节翻转就比较简单,直接和0xFF
抑或即可,然后使用common_fuzz_stuff
跑一遍,最后再翻转回来:
1 | for (stage_cur = 0; stage_cur < stage_max; stage_cur++) { |
双字节翻转是和0xFFFF
抑或:
1 | for (i = 0; i < len - 1; i++) { |
四字节翻转是和0xFFFFFFFF
抑或:
1 | for (i = 0; i < len - 3; i++) { |
比特翻转环节执行完毕后进入数学运算环节。
ARITHMETIC INC/DEC阶段
使用数学运算的方式分别对文件每1/2/4个字节做加或者减的运算。
加或减的上限由宏定义限制,然后在每个计算方式中得出:
1 |
|
单字节数学计算包括加和减:
1 | //加 |
2字节的数学计算包括加和减(这里是小端):
1 | stage_cur_val = j; |
还对大端的情况进行了设置:
1 | if ((orig >> 8) + j > 0xff && !could_be_bitflip(r3)) { |
四字节数学计算的与2字节相同,也设置了对大小端的情况区分:
1 | if ((orig & 0xffff) + j > 0xffff && !could_be_bitflip(r1)) { |
此外,AFL还会智能地跳过某些arithmetic变异。第一种情况就是前面提到的effector map:如果一个整数的所有bytes都被判断为”无效”,那么就跳过对整数的变异。第二种情况是之前bitflip已经生成过的变异:如果加/减某个数后,其效果与之前的某种bitflip相同,那么这次变异肯定在上一个阶段已经执行过了,此次便不会再执行。
INTERESTING VALUES阶段
特殊值替换阶段,对每1/2/4个字节替换为”interesting value”。
插入的内容定义在了config.c
中:
1 |
这里就分析下1字节的情况,即插入interesting_8
。
循环内首先还是检查effector map
中对应的字节为1/0
,跳过无效的byte:
1 | if (!eff_map[EFF_APOS(i)]) { |
跳过与前两个阶段变异后覆盖路径重合的字节:
1 | if (could_be_bitflip(orig ^ (u8)interesting_8[j]) |
替换字节后common fuzz:
1 | out_buf[i] = interesting_8[j]; |
替换回来,stage_cur
加1:
1 | out_buf[i] = orig; |
统计期间获得的crash:
1 | new_hit_cnt = queued_paths + unique_crashes; |
后面的2字节和4字节的特殊值替换的变异方式与上述的1字节替换类似。
DICTIONARY STUFF
确定性变异最后的阶段就是字典阶段,将特殊token插入样例。
该阶段分为以下几个小阶段:
1 | user extras (over),从头开始,将用户提供的tokens依次替换到原文件中 |
token存储在了extra
数组中,由load_extras_file
函数将用户自定义的token导入,用户token相关阶段开始会检查用户是否使用-x
进行字典的设置,没有就会跳过用户token阶段:
1 | if (!extras_cnt) goto skip_user_extras; |
user extras (over)
循环内部首先对token数量进行判断,若token的数量大于MAX_DET_EXTRAS
,则会根据概率插入一定数量的token,若样例没有空间插入这些token,就会跳过token:
1 | if ((extras_cnt > MAX_DET_EXTRAS && UR(extras_cnt) >= MAX_DET_EXTRAS) || |
向样例头部开始替换token,然后common fuzz:
1 | memcpy(out_buf + i, extras[j].data, last_len); |
user extras (insert)
这里只会检查添加token后样例长度大于预设的最大值:
1 | if (len + extras[j].len > MAX_FILE) { |
插入token然后common fuzz:
1 | memcpy(ex_tmp + i, extras[j].data, extras[j].len); |
auto extras (over)
该阶段也是跳过用户token进入的阶段,且若没有token也会跳过该阶段:
1 | skip_user_extras: |
该阶段与用户token覆盖阶段相似,不过插入的自动检测出的token,插入后还是会commom fuzz:
1 | memcpy(out_buf + i, a_extras[j].data, last_len); |
至此确定性变异阶段结束,进入havoc阶段。
RANDOM HAVOC
以下摘自:http://rk700.github.io/2018/01/04/afl-mutations/
对于非dumb mode的主fuzzer来说,完成了上述deterministic fuzzing后,便进入了充满随机性的这一阶段;对于dumb mode或者从fuzzer来说,则是直接从这一阶段开始。
havoc,顾名思义,是充满了各种随机生成的变异,是对原文件的”大破坏”。具体来说,havoc包含了对原文件的多轮变异,每一轮都是将多种方式组合(stacked)而成:
1 | 随机选取某个bit进行翻转 |
AFL会生成一个随机数,作为变异组合的数量,并根据这个数量,每次从上面那些方式中随机选取一个(可以参考高中数学的有放回摸球),依次作用到文件上。如此这般丧心病狂的变异,原文件就大概率面目全非了,而这么多的随机性,也就成了fuzzing过程中的不可控因素,即所谓的”看天吃饭”了。
SPLICING
该阶段对两个样例裁剪后拼接。
这里会找一个合理的拼接位置:
1 | locate_diffs(in_buf, new_buf, MIN(len, target->len), &f_diff, &l_diff); |
拼接:
1 | len = target->len; |
拼接完成后进行havoc:
1 | goto havoc_stage; |
至此AFL中的确定性以及随机性变异都已经结束,后面是扫尾阶段,并被设置为abandon_entry
:
1 | abandon_entry: |
common_fuzz_stuff函数
该函数广泛的用于变异阶段中对变异后样例的fuzz。
将out_buf
写入文件并执行,然后处理结果,如果出现错误,就返回1。
写入文件:
1 | write_to_testcase(out_buf, len); |
运行样例:
1 | fault = run_target(argv, exec_tmout); |
对运行时间的判断:
1 | if (subseq_tmouts++ > TMOUT_LIMIT) { |
若用户发送SIGUSR1
信号就跳过当前样例:
1 | if (skip_requested) { |
保存发现了新的分支路径的种子:
1 | queued_discovered += save_if_interesting(argv, out_buf, len, fault); |
展示信息并返回:
1 | if (!(stage_cur % stats_update_freq) || stage_cur + 1 == stage_max) |
while(1)尾部
fuzz_one
结束后会根据是否使用了并行的fuzzer来将其他fuzzer的queue保存到自己的queue中:
1 | if (!stop_soon && sync_id && !skipped_fuzz) { |
一些阶段性参数的设置:
1 | if (!stop_soon && exit_1) stop_soon = 2; |
main函数扫尾
这部分简单流程已说过,这里不多说了。
1 | fclose(plot_file); |
总结
最后总结下,while(1)
循环的主要内容就是fuzz_one
函数,对种子进行变异然后测试。
确定性变异阶段通过使用update_bitmap_score
设置的评分,effector map
子集对有效字节的判断,启发式的进行了整个变异的过程。
随机性变异阶段主要使用了havoc以及splice两种方式,对种子进行混乱的变异。
参考文章:
http://rk700.github.io/2017/12/28/afl-internals/
http://rk700.github.io/2018/01/04/afl-mutations/
https://www.anquanke.com/post/id/213431
https://bbs.pediy.com/thread-254705.htm
https://blog.csdn.net/weixin_43820701/article/details/110427004