最近在写跟AFL有关的fuzz,因此就大概过了一下AFL的源码。这里大概记录一下自己看的过程。
0x01 AFL基本使用
1.1 使用AFL程序插桩
目标程序
1 |
|
使用afl-gcc进行插桩编译
afl-gcc -g -o ./zerotest/vuln ./zerotest/vuln.c
1.2 开始fuzz
afl-fuzz -m 300 -i ./zerotest/fuzz_in -o ./zerotest/fuzz_out ./zerotest/vuln -f
PS: 常见参数的含义如下
- f参数表示:testcase的内容会作为afl_test的stdin
- m参数表示分配的内存空间
- i 指定测试样本的路径
- o 指定输出结果的路径
- /dev/null 使错误信息不输出到屏幕
- t:设置程序运行超时值,单位为 ms
- M:运行主(Master) Fuzzer
- S:运行从属(Slave) Fuzzer
1.3 fuzz的结果

从界面上主要注意以下几点:
- last new path 如果报错那么要及时修正命令行参数,不然继续fuzz也是徒劳(因为路径是不会改变的);
- cycles done 如果变绿就说明后面及时继续fuzz,出现crash的几率也很低了,可以选择在这个时候停止
- uniq crashes 代表的是crash的数量
1.4 crash分析
![Image [2].png](https://pic.imgdb.cn/item/6197beb62ab3f51d910de2e1.png)
PS: xxd命令的作用就是将一个文件以十六进制的形式显示出来
看起来上面两个crash,第一个大概是栈溢出,第二个看起来是F开头,字符长度为6触发的
0x02 源码阅读
2.1 合法代码插桩——插入调用__afl_maybe_log的汇编码
若pass_thru、skip_intel、skip_app、skip_csect四个标志位均被清除,且instr_ok(这个标志位表征当前读入的行处于.text部分,将在后续设置,初始为清除状态)、instrument_next两个标志位均被设置,且当前行的第一个字符是\\t且第二个字符是字母,则向已插桩的文件写入trampoline_fmt_64/trampoline_fmt_32(取决于use_64bit标志位状态)
经整理最后插入的汇编代码为:
1 | /* --- AFL TRAMPOLINE (32-BIT) --- */ |
⚠️:此处的%08x由(random() % ((1 << 16)))生成,在编译期确定。插入结束后,将instrument_next标志位清除,桩代码计数器ins_lines加一。最后将原始的汇编码(即line变量的内容),追加到插桩后文件中。此时检查pass_thru标志位是否被置位,若已置位,则忽略以下流程,继续循环,读取下一行待插桩文件。
2.2 寻找合法有效的待插桩段
这里是真正的插桩函数的核心了,但是在这里我们真正感兴趣的事实上只有.text段
简单概括就是,先找到.text段,不在该段的情况下不会进行打桩操作
然后会在分支跳转(条件分支跳转)的部分插入trampoline_fmt_64/trampoline_fmt_32
注意,JMP表示无条件跳转,因此其另一条分支将永远不会被运行到,那么将不会影响代码覆盖率,因此不在JMP指令后插桩。
识别label以后,会在label的下一行插入trampoline_fmt_64/trampoline_fmt_32
2.3 末尾插桩代码
最后,若桩代码计数器ins_lines不为0,那么将main_payload_64/main_payload_32(取决于use_64bit标志位状态)插入整个汇编文件末尾。
main_payload_64整体逻辑如下
简要概括就是,将随机生成的rcx与一个值做一个抑或,再把以此作为索引的共享区加一。
相当于一个散列表,这样可以知道fuzz是不是经过了一个新的基本块。
0x03 AFL 变异部分源码分析
3.1 fuzz_one
Take the current entry from the queue, fuzz it for a while. This function is a tad too long…
首先把testcase映射在内存中,主要是把testcase里的内容读进in_buf里
1 | /* Map the test case into memory. */ |
接着对testcase进行一个裁剪的过程,然后把in_buf里的内容拷贝到out_buf里
1 | /************ |
计算performance score ,这个值的计算方法有点复杂,先留个坑,暂且不深究
1 | /********************* |
第一阶段的Bitflip 也就是 bitflip 1/1 ,可以看到 stage_max = len << 3; 然后进行了stage_max次数的循环,也就是每字节会做8次bitflip,逐个字节逐个比特进行反转。
反转后调用common_fuzz_stuff 函数,进行fuzz,fuzz过后再次把比特反转回复过来。
在这个阶段还实现了采集token的功能。即如果连续几个字节,反转其最后一个比特后其执行路径相同,且与初始路径不同。那么这连续的几个字节很大概率是有特殊语义的token ,属于关键字性质的。
例如,PNG文件中用IHDR作为起始块的标识,那么就会存在类似于以下的内容
1 | ........IHDR........ |
当翻转到字符I的最高位时,因为IHDR被破坏,此时程序的执行路径肯定与处理正常文件的路径是不同的;随后,在翻转接下来3个字符的最高位时,IHDR标识同样被破坏,程序应该会采取同样的执行路径。由此,AFL就判断得到一个可能的token:IHDR,并将其记录下来为后面的变异提供备选。
AFL采取的这种方式是非常巧妙的:就本质而言,这实际上是对每个byte进行修改并检查执行路径;但集成到bitflip后,就不需要再浪费额外的执行资源了。此外,为了控制这样自动生成的token的大小和数量,AFL还在config.h中通过宏定义了限制:
1 | /* Length limits for auto-detected dictionary tokens: */ |
对于一些文件来说,我们已知其格式中出现的token长度不会超过4,那么我们就可以修改MAX_AUTO_EXTRA为4并重新编译AFL,以排除一些明显不会是token的情况。遗憾的是,这些设置是通过宏定义来实现,所以不能做到运行时指定,每次修改后必须重新编译AFL。
1 | /********************************************* |
后面跟着的就是2比特2比特的逐个翻转,4比特4比特位移是1位的逐个翻转,然后是1字节1字节的翻转等等等。。。。总之是长度不一,步长不同的比特反转。
在进行bitflip 8/8变异时,AFL还生成了一个非常重要的信息:effector map。这个effector map几乎贯穿了整个deterministic fuzzing的始终。
具体地,在对每个byte进行翻转时,如果其造成执行路径与原始路径不一致,就将该byte在effector map中标记为1,即“有效”的,否则标记为0,即“无效”的。
这样做的逻辑是:如果一个byte完全翻转,都无法带来执行路径的变化,那么这个byte很有可能是属于”data”,而非”metadata”(例如size, flag等),对整个fuzzing的意义不大。所以,在随后的一些变异中,会参考effector map,跳过那些“无效”的byte,从而节省了执行资源。
由此,通过极小的开销(没有增加额外的执行次数),AFL又一次对文件格式进行了启发式的判断。看到这里,不得不叹服于AFL实现上的精妙。
不过,在某些情况下并不会检测有效字符。第一种情况就是dumb mode或者从fuzzer,此时文件所有的字符都有可能被变异。第二、第三种情况与文件本身有关:
1 | /* Minimum input file length at which the effector logic kicks in: */ |
即默认情况下,如果文件小于128 bytes,那么所有字符都是“有效”的;同样地,如果AFL发现一个文件有超过90%的bytes都是“有效”的,那么也不差那10%了,大笔一挥,干脆把所有字符都划归为“有效”。
后面仍有arithmetic,interest,dictionary,havoc,splice,cycle等变异手段。具体可以参考https://rk700.github.io/2018/01/04/afl-mutations/ 此处不再赘述。
总而言之,AFL的变异策略既有逐位变异的运气成分,同时也合理运用了覆盖率反馈的信息来启发性的创造token 和 effort map 等概念,帮助算法更好的进行变异。
3.2 save_if_interesting
覆盖率反馈信息除了一定程度上可以启发性的指导变异以外,最大作用就是在这个函数内实现的选种功能。
如果这个has_new_bits返回为0的话,那么该函数直接返回0,程序继续进行下一次的fuzz。但是如果发现了新的cov信息或者bb信息,那么就会将产生新路径的testcase保存为新的种子。
1 | if (!(hnb = has_new_bits(virgin_bits))) { |
代表current 是刚刚返回的覆盖率信息,virgin_map是当前我们的覆盖率状态。可以注意到virgin_map在setup_shm过程中被初始化成了全 1 , 代表current的trace_bits 初始化状态是全0的状态。根据插桩部分的逻辑,每有新的覆盖率信息,会在相应的trace_bits索引位置加一。
1 | memset(virgin_bits, 255, MAP_SIZE); |
每个trace_bits对应索引里的值其实就代表着走到这条路径的次数,当第1,4,8,128次走到这条路径时,current里的值就是0,而virgin_map里的值则是ff,此时函数将返回2。其余情况都会返回1。最后将current里的值取反,然后与vrigin相与,更新virgin的状态。
1 | /* Check if the current execution path brings anything new to the table. |
0x04 参考
AFL源码分析(I)——白盒模式下的afl-gcc分析 - 安全客,安全资讯平台 (anquanke.com)
[AFL内部实现细节小记](