最近在写跟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分析
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内部实现细节小记](