简单阅读一下 fuzz_one 函数。
fuzz_one 从 queue 中取出 entry 进行 fuzz 并执行,成功返回 0,跳过或退出的话返回 1。
fuzz 阶段:
- deterministic 阶段:
- non-deterministic 阶段:
种子选择
首先是对 IGNORE_FINDS
模式的处理。IGNORE_FINDS
定义在 config.h 中,对它的介绍是:
IGNORE_FINDS
如果开启了 IGNORE_FINDS
,则对于能够发现新路径的输入,AFL 不会将它们作为 fuzzing 的种子。这是为了测试 dump fuzzing 算法可以获取的覆盖率。
指向原始笔记的链接
在 IGNORE_FINDS
中,如果当前队列的深度大于 1 就直接返回 1。
如果当前队列的深度大于 1,那么说明当前队列包含可以发现新路径的输入,根据 IGNORE_FINDS
的想法,AFL 不会对这样的输入进行变异,因此会直接返回。
而如果我们没有开启 IGNORE_FINDS
选项,则会先判断队列中有没有收到青睐的,没有被 fuzz 过的新来者(种子)。
具体到代码上,如果当前队列的种子已经被 fuzz 过或不是感兴趣的种子,则有 99% 的概率跳过,不会继续 fuzz。
如果有感兴趣的输入则进入下一个判断,进入这个逻辑的要求为:
- 当前队列没有感兴趣的种子;
- 不处于
dump_mode
(也就是无插桩模式);
- 当前队列的种子不感兴趣;
- 队列中测试用例总数大于 10。
在这种情况下,如果:
- 已经进行了超过一轮测试;
- 队列中当前的种子没有被 fuzz 过。
则有 75% 的概率跳过,否则有 95% 的概率跳过。
除了上述的跳过情况外,我们可以总结一定会进入接下来 fuzz 的情况为:
- 如果开启了
IGNORE_FINDS
选项,只要当前种子的路径深度小于 1,就一定会进入下面的处理逻辑;
- 如果没有开启
IGNORE_FINDS
选项:
- 如果开启了
dump_mode
、当前的种子是感兴趣的、队列中测试用例小于等于 10,则一定会进入下面的处理逻辑。
总的来说,这一步进行了种子选择。有趣的是,在 fuzzing 后期,按照 AFL 的规则,不感兴趣的种子只有 5% 的概率被选择。在如何选择种子上,学术界也进行了广泛的讨论。尽管到现在也没有一个通用的方法,但围绕种子选择确实也产出了很多论文,可喜可贺。
一些可以参考的关于种子选择的文章
- Coverage-based Greybox Fuzzing as Markov Chain(AFLFast)
获取输入
校准
进入了队列的种子都需要校准,因此如果之前的校准失败了,这里还会再次校准。
根据代码来看,默认校准错误是 FAULT_TMOUT
也就是超时,接下来如果校准次数小于 3 次就重新校准,否则就按照超时对待这个种子。
在校准后,如果用户没要求停止(也就是 Ctrl+C)也没有开启 crash_mode
选项,就进入收尾工作。
修剪
种子修剪的要求是:
- 不处于无插桩模式;
- 种子从未修剪过。
具体的 trim 细节还得去看 trim_case 实现。
在 trim 之后会设置标记,避免重复修剪,并更新当前种子的长度,最后更新 out_buf。
打分
这里会调用 calculate_score 函数对每个种子进行打分。在打分后根据分数决定havoc阶段要投入的资源。
在打分后,如果
- 设置中启用了“跳过确定性变异”;
- 当前种子已经变异过了;
- 当前种子已经通过了确定性测试阶段;
则直接跳转到 havoc 阶段。
此外,如果同时运行了多个 AFL 实例,且当前种子的执行校验和超出主实例的范围,也将跳过确定性 fuzz 阶段。
最后设置 doing_det,这将在后面决定当前的执行阶段。
接下来进入变异过程。
bitflip
这一部分包含位翻转与字典构建。bitflip 包含如下子阶段:
FLIP_BIT
这里首先定义了 FLIP_BIT
宏。根据下文,每个位翻转的原子操作都是 FLIP_BIT(out_buf, stage_cur);
,其中 out_buf
代表输出,stage_cur
代表一个自增的循环变量。
假设当前进行的是 bitflip 1/1 阶段,那么 stage_cur
的最大值是 queue_cur->len << 3
,相当于 x8
了。
假设 out_buf
的长度为 1,我们模拟一下 bitflip 1/1 阶段:
FLIP_BIT(out_buf, 8) ->
out_buf[0] ^= (128 >> (0 & 7))
... ...
out_buf[0] ^= (128 >> (7 & 7))
这样,相当于 out_buf
中的每一位都做一次位翻转。总结一下就是“每次翻转 1 个 bit,按照每 1 个 bit 的步长从头开始”。
bitflip 1/1
步长为 1 bit,每次翻转 1 bit:
如果我们仔细看这一步,会发现它还做了这么一个操作(common_fuzz_stuff 函数执行输入):
简单来说就是,在变异阶段,如果翻转某个字节后程序的执行路径没有变化且与原执行路径不一致,则把这一段连续的 bytes 认为是 token。这一段在 AFL 作者的 Pulling JPEGs out of thin air 这篇文章中也有描述。
这一段的操作首先将某一位翻转,执行后再翻转回来。接下来的操作就是构建字典的操作,当 AFL 发现 token 的时候就会把它们收集到字典中。
AFL 会在翻转字节最低位 bit(LSB)的时候检查变异后的执行路径和初始种子的执行路径是否一致,如果执行路径和初始种子不一样,且连续翻转几次之后得到的路径相同,就把这一段当作 token 加入字典中:
具体来说:
- 如果不在
dumb_mode
且翻转到了一个字符的最低位:
- 计算得到本次运行的哈希值
cksum
;
- 如果当前阶段是最后一个字符(文件中的最后一个字符)且本次运行的路径和上次运行的路径相同:
- 如果收集到的字符串长度未到上限,收集当前字符;
- 如果收集到的字符串在收集范围内,则调用 maybe_add_auto 函数收集 token。
- 如果不是最后一个字符,且本次运行的路径和上次不一样:
- 尝试收集 token,并重置 token;
- 修改 prev_cksum 的值为本次的执行路径哈希值。
- 如果本次执行路径和被变异的种子的执行路径不同:
- 此时的种子可能是带 token 的,因此添加当前字符。
bitflip 2/1
步长为 1 bit,每次翻转相邻的 2 bits:
bitflip 4/1
步长为 1 bit,每次翻转相邻的 4 bits:
创建 effort map
这里 effort map 的长度和当前测试的种子长度一致,并将头尾字节设置为 1,其余字节设置为 0。
bitflip 8/8
步长为 1 byte,每次翻转 1 byte:
在进行第一次翻转之后并不会再次翻转回来,而是检查执行过程中完全翻转也对执行路径没有影响的情况,以便在确定性 fuzz 阶段跳过,这样的字符会加入 effort map 中,例如算数或已知的整数。
具体到代码上:
- 对于长度小于
EFF_MIN_LEN
也就是 128 字节的字符串,AFL 会认为它们都是“有效”的,不在这里浪费时间;
- 对于长度大于等于 128 字节的字符串,如果超过 90% 的字符串都是“有效”的,就把整个文件都视为“有效”的。
bitflip 16/8
步长为 1 byte,每次翻转相邻的 2 bytes:
如果两个字节都没意义就跳过当前字节,继续翻转后面的字节:
如果种子长度小于四,跳过后面的位翻转阶段:
bitflip 32/8
步长为 1 byte,每次翻转 4 bytes:
如果连续的四个字节都无效,那么跳过当前字节,继续翻转后面的字节
arithmetic
在位翻转后会进入整数加减运算,整数运算也分为多个子阶段:
- arith 8/8:每次对 8 个 bit 进行加减运算,按照每 8 个 bit 的步长从头开始,即对文件的每个 byte 进行整数加减变异;
- arith 16/8:每次对 16 个 bit 进行加减运算,按照每 8 个 bit 的步长从头开始,即对文件的每个 word 进行整数加减变异;
- arith 32/8:每次对 32 个 bit 进行加减运算,按照每 8 个 bit 的步长从头开始,即对文件的每个 dword 进行整数加减变异。
arith 8/8
每次对 8 个 bit 进行加减运算,按照每 8 个 bit 的步长从头开始,即对文件的每个 byte 进行整数加减变异:
ARITH_MAX
的值被设置为 35。
如果当前字节无效的话,跳过该字节
尝试对种子中的每个字节做整数运算,在这里,如果对某个字符做了加减操作之后的效果和 bitflip 相同则跳过。
arith 16/8
每次对 16 个 bit 进行加减运算,按照每 8 个 bit 的步长从头开始,即对文件的每个 word 进行整数加减变异:
在这里,AFL 还对大端和小端的情况进行了处理:
对于大小端,除了要满足和 bitflip 操作不一致外,还需要能够影响多个字节:
arith 32/8
每次对 32 个 bit 进行加减运算,按照每 8 个 bit 的步长从头开始,即对文件的每个 dword 进行整数加减变异:
这里需要被变异的种子能够影响两个及以上字节:
interest
阶段:
- interest 8/8:每次对8个 bit 进替换,按照每8个 bit 的步长从头开始,即对文件的每个 byte 进行替换;
- interest 16/8:每次对16个 bit 进替换,按照每8个 bit 的步长从头开始,即对文件的每个 word 进行替换;
- interest 32/8:每次对32个 bit 进替换,按照每8个 bit 的步长从头开始,即对文件的每个 dword 进行替换。
所谓的有趣值,定义在这些数组中:
具体的,数组中的有趣值 INTERESTING_8
等定义在 config.h
中:
这些数都是容易引起溢出的数字。
interest 8/8
每次对 8 个 bit 进替换,按照每 8 个 bit 的步长从头开始,即对文件的每个 byte 进行替换:
从头开始遍历种子:
处理种子有效情况:
遍历字符中的每一位,对应 stage_max = len * sizeof(interesting_8);
跳过与位翻转相同的情况:
替换为有趣值:
interest 16/8
每次对16个 bit 进替换,按照每8个 bit 的步长从头开始,即对文件的每个 word 进行替换;
这里处理和位翻转、整数运算、有趣值及大小端的情况:
interest 32/8
每次对32个 bit 进替换,按照每8个 bit 的步长从头开始,即对文件的每个 dword 进行替换,做法和 interest 16/8 差不多:
dictionary
这里代表着 deterministic 的最后一个阶段,包含以下几个阶段:
从头开始,将用户提供的 tokens 依次替换到原文件中,其中用户提供的 tokens 就是通过 load_extras 加载的:
对于用户提供的 tokens,AFL 先按照长度从小到大进行排序。这样做的好处是,只要按照顺序使用排序后的 tokens,那么后面的 token 不会比之前的短,从而每次覆盖替换后不需要再恢复到原状。
随后,AFL 会检查 tokens 的数量,如果数量大于预设的 MAX_DET_EXTRAS
(默认值为200),那么对每个 token 会根据概率来决定是否进行替换:
从头开始,将用户提供的 tokens 依次插入到原文件中:
从头开始,将自动检测的 tokens 依次替换到原文件中,其中的 tokens 就是在 bitflip 1/1 阶段检测的。
havoc
dumb mode 会直接从这一阶段开始,跳过上述确定性过程。后续所有的变异都是随机的。
在这一阶段,AFL 会计算得到一个操作轮数,每一轮再产生一个随机数作为每轮的操作次数,每次在以下操作中选择一个:
- 随机选取某个 bit 进行翻转
- 随机选取某个 byte,将其设置为随机的 interesting value
- 随机选取某个 word,并随机选取大、小端序,将其设置为随机的 interesting value
- 随机选取某个 dword,并随机选取大、小端序,将其设置为随机的 interesting value
- 随机选取某个 byte,对其减去一个随机数
- 随机选取某个 byte,对其加上一个随机数
- 随机选取某个 word,并随机选取大、小端序,对其减去一个随机数
- 随机选取某个 word,并随机选取大、小端序,对其加上一个随机数
- 随机选取某个 dword,并随机选取大、小端序,对其减去一个随机数
- 随机选取某个 dword,并随机选取大、小端序,对其加上一个随机数
- 随机选取某个 byte,将其设置为随机数
- 随机删除一段 bytes
- 随机删除一段 bytes
- 随机选取一个位置,插入一段随机长度的内容,其中 75% 的概率是插入原文中随机位置的内容,25% 的概率是插入一段随机选取的数
- 随机选取一个位置,替换为一段随机长度的内容,其中 75% 的概率是替换成原文中随机位置的内容,25% 的概率是替换成一段随机选取的数
- 随机选取一个位置,用随机选取的 token(用户提供的或自动生成的)替换
- 随机选取一个位置,用随机选取的 token(用户提供的或自动生成的)插入
(摘自 https://bbs.pediy.com/thread-254705.htm)
在充满随机的 havoc 大杂烩中,AFL 对测试用例做了一系列天马行空的变异尝试:
随机选取从 0 到 16 的数字,注意只有存在用户提供的字典或通过 flip 1/1 提取出字典后才会 case 15 或 16
splice
splice 会随机拼接两个种子:
具体地,AFL 在 seed 文件队列中随机选取一个,与当前的 seed 文件做对比。如果两者差别不大,就再重新随机选一个;如果两者相差比较明显,那么就随机选取一个位置,将两者都分割为头部和尾部。最后,将当前文件的头部与随机文件的尾部拼接起来,就得到了新的文件。在这里,AFL 还会过滤掉拼接文件未发生变化的情况。
收尾工作
参考资料