AFL白皮书&实现细节
作者:互联网
源自AFL白皮书和个人理解
AFL白皮书地址
https://lcamtuf.coredump.cx/afl/technical_details.txt
0) 设计声明
AFL尽量不关注任何单一的操作原理,也不为任何特定的理论提供概念证明。该工具可以被认为是一组经过实践测试的黑客技术,实践结果表明它们非常有效,并且以作者当时能想到的最简单、最强大的方式实施。
AFL作为通用测试工具的基础轻量级应用程序具有高度可用性,许多研究者在AFL上改进产生许多新的功能,AFL这种机制可被视为达到测试目的的一种手段。 测试器的原则是测试速度、可靠性和易用性。
1) 覆盖率度量
注入到编译程序中的检测程序会捕获边缘覆盖率以及粗略的边缘命中计数。在分支点注入的伪代码:
cur_location = <COMPILE_TIME_RANDOM>;
shared_mem[cur_location ^ prev_location]++;
prev_location = cur_location >> 1;
cur_location 值是随机生成的,好处有二:
- 简化链接复杂项目过程的复杂度
- 保持 XOR(二进制异或) 输出的均匀分布。
shared_mem[] 数组(又称位图)是一个 64 kB 共享内存,由调用者传递给二进制程序。位图中每个字节都可以被认为是对被测程序中特定 (branch_src, branch_dst) 元组的命中。
关于位图碰撞率的论文可见 CollAFL
Gan S, Zhang C, Qin X, et al. Collafl: Path sensitive fuzzing[C]//2018 IEEE Symposium on Security and Privacy (SP). IEEE, 2018: 679-696.
https://ieeexplore.ieee.org/abstract/document/8418631
位图的大小设定为64KB是平衡测试速度和碰撞率最后做出的折中,很多程序的边缘数在 2k 到 10k 可之间,同时它的大小足够小,允许接收端在几微秒内分析位图,匹配 L2 缓存速度。
边数 | 元组碰撞率 | 代表应用 |
---|---|---|
1,000 | 0.75% | giflib, lzo |
2,000 | 1.5% | zlib, tar, xz |
5,000 | 3.5% | libpng, libwebp |
10,000 | 7% | libxml |
20,000 | 14% | sqlite |
50,000 | 30% | - |
这种形式的覆盖比简单的块覆盖提供了对程序执行路径的更多细节。比如,它可以轻松区分以下执行路径:
A -> B -> C -> D -> E(元组:AB、BC、CD、DE)
A -> B -> D -> C -> E(元组:AB、BD、DC、CE)
这有助于发现底层代码中的细微故障条件,因为安全漏洞通常与意外的或不正确的状态转换相关,而不仅仅是到达一个新的基本块。比边覆盖更细粒度的是路径覆盖,由于路径爆炸问题,大多模糊测试器没有采用路径覆盖。
路径覆盖参考论文可见PathAFL
Yan S, Wu C, Li H, et al. PathAFL: Path-Coverage Assisted Fuzzing[C]//Proceedings of the 15th ACM Asia Conference on Computer and Communications Security. 2020: 598-609.
https://dl.acm.org/doi/abs/10.1145/3320269.3384736
prev_location = cur_location >> 1;
伪代码的最后一行中进行移位操作的原因是为了保留元组的方向性(没有这个,A ^ B 将与 B ^ A 无法区分)并保留紧密循环的身份(否则, A ^ A 显然等于 B ^ B)。
Intel CPU 上没有简单的饱和算术操作码,这意味着命中计数器有时会回零。由于这是一个不太可能发生的小概率局部事件,因此它被视为一种可接受的性能权衡。
2)检测新行为
fuzzer 维护了之前执行中检测到的元组的全局位图;这些数据可以与单独的跟踪进行快速比较,并在几个 dword(两字节) 或 qword(四字节)范围的指令和一个简单的循环中进行更新。
fuzzer跟踪到变异输入产生包含新元组的边时,相应的输入文件将被保留并路由以供稍后进行额外处理(参见第 3 节)。不会在执行跟踪中触发新的局部尺度状态转换(即,不产生新元组)的输入将被丢弃,即使它们的整体控制流序列是唯一的。
这种方法允许对程序状态进行非常细粒度和长期的探索,同时不必对复杂的执行轨迹执行任何计算密集型和脆弱的全局比较,同时避免路径爆炸。
为了说明该算法的特性,请考虑下面显示的第二条轨迹将被认为是新的,因为存在新元组(CA,AE):
#1:A -> B -> C -> D -> E
#2:A -> B -> C -> A -> E
同时,处理 #2 后,尽管整体执行路径明显不同,但以下路径不会被视为新的行为:
#3: A -> B -> C -> A -> B -> C -> A -> B -> C -> D -> E
除了检测新元组外,模糊器还会考虑粗略的元组命中计数。命中计数分为几个桶来统一计算:
1、2、3、4-7、8-15、16-31、32-127、128+
在某种程度上,桶的数量是一个实现工具:它允许将插桩生成的 8 位计数器就地映射到模糊器可执行文件所依赖的 8 位位图,以跟踪已经看到的每个元组的执行计数。
忽略单个桶范围内的变化;从一个桶到另一个桶的过渡被标记为程序控制流程中的有趣变化,并被路由到下一节中概述的进化过程。
命中计数行为提供了一种方法来区分可能感兴趣的控制流更改,比如一个通常只命中一次的代码块被执行了两次就可以被认为是有趣值。同时对从经验上来讲不太显著的变化相当不敏感,例如一个循环从47个循环到48个循环,并没有太大的变化,在桶中就会忽略命中该次数变化。计数器还为密集跟踪映射中的元组碰撞提供了某种程度的“意外”免疫。
通过内存和执行时间限制对执行进行了相当严格的监管;默认情况下,超时设置为初始校准执行速度的 5 倍,四舍五入为 20 毫秒。积极的超时设置是为了防止模糊器的性能急剧下降,例如,在慢100倍的情况下提高1%的覆盖率;我们拒绝这种高耗时的种子,并希望 fuzzer 能找到一种更简单的种子来达到相同的代码。实证测试表明,设定更宽泛时限是低效的。
3)进化输入队列
在程序中产生新状态转换的变异测试用例被添加到输入队列中,并用作未来几轮模糊测试的起点。它们是对现有发现的补充,但不会自动取代现有发现。
与更贪婪的遗传算法相比,这种方法允许工具逐步探索底层数据格式的各种不相交和可能相互不兼容的特征,如下图所示:
这里讨论了使用AFL的几个实际示例:
有人使用一个只包含“hello”的文本文件,使用fuzzer将其模糊最终生成了JPEG图像,并在djpeg中进行测试
http://lcamtuf.blogspot.com/2014/11/pulling-jpegs-out-of-thin-air.html
模糊生成符合XML格式的种子
http://lcamtuf.blogspot.com/2014/11/afl-fuzz-nobody-expects-cdata-sections.html
这个过程产生的合成语料库本质上是一个紧凑的集合–“嗯,这有新的有趣的东西!就把它作为输入种子”的输入文件,种子还可用于为任何其他测试过程提供种子(例如,手动压力测试资源密集型桌面应用程序)。
使用这种方法,大多数目标的队列增长到 1k 到 10k 条目之间;其中大约 10-30% 可归因于新元组的发现,其余部分与命中计数的变化有关。
下表比较了使用几种不同的引导模糊测试方法时发现文件语法和探索程序状态的相对能力。检测的目标是 GNU 2.7.3,使用 -O3 编译并使用虚拟文本文件作为种子;该会话由一个使用afl- fuzzy的输入队列传递组成:
使用的模糊器引导策略 | 到达块数 | 到达边缘数 | 边缘命中率 | 生成的最高覆盖率测试用例 |
---|---|---|---|---|
(初始文件) | 156 | 163 | 1.00 | (none) |
S型盲测 | 182 | 205 | 2.23 | First 2 B of RCS diff |
L型盲测 | 228 | 265 | 2.23 | First 4 B of -c mode diff |
块覆盖 | 855 | 1,130 | 1.57 | Almost-valid RCS diff |
边覆盖 | 1,452 | 2,070 | 2.18 | One-chunk -c mode diff |
AFL模型 | 1,765 | 2,597 | 4.99 | Four-chunk -c mode diff |
blind fuzzing(盲测)生成测试数据的时候不考虑数据的质量,通过大量测试数据来概率性地触发漏洞。Guided fuzzing(有引导的测试)则关注测试数据的质量,期望生成更有效的测试数据来触发漏洞的概率。比如,通过测试覆盖率来衡量测试输入的质量,希望生成有更高测试覆盖率的数据,从而提升触发漏洞的概率。
S型盲测的一个种子对应于仅执行一轮测试;L型盲测则是fuzzer在一个循环中运行,其种子执行周期与模糊测试器运行的周期相当,这需要很多的时间来处理不断增长的队列。
在另一个独立的实验中也取得了大致相似的结果。在新实验中,限制fuzzer不使用随机改变种子长度的变异策略,只留下一系列基本、连续的操作,例如位反转(bit flips)。因为这种模式将不改变输入文件的的大小,所以种子会有统一的diff:
使用的队列扩展策略 | 到达块数 | 到达边缘数 | 边缘命中率 | 发现的唯一崩溃数 |
---|---|---|---|---|
(初始文件) | 624 | 717 | 1.00 | (none) |
盲测 | 1,101 | 1,409 | 1.60 | 0 |
块覆盖 | 1,255 | 1,649 | 1.48 | 0 |
边覆盖 | 1,259 | 1,734 | 1.72 | 0 |
AFL模型 | 1,452 | 2,040 | 3.16 | 1 |
如前所述,先前关于遗传模糊的一些工作依赖于维护单个测试用例并对其进行改进以最大限度地扩大覆盖范围。至少在上面描述的测试中,这种“贪婪”的方法似乎没有给盲模糊策略带来实质性的好处。
4) 种子库筛选
上面概述的渐进状态探索方法意味着在模糊测试中后期合成的一些测试用例可能具有先前种子的边缘覆盖,这是它们祖先提供的覆盖的严格超集。
为了优化模糊测试工作,AFL 使用快速算法定期重新评估队列,该算法选择一个较小的测试用例子集,这些测试用例仍然涵盖目前看到的每个元组,并且其特性使它们特别适合该工具。
该算法的工作原理是为每个队列条目分配一个与其执行延迟和文件大小成正比的分数;然后为每个元组选择得分最低的候选者。
AFL使用简单的工作流程顺序处理元组:
-
在临时工作集中查找下一个元组,
-
找到这个元组的获胜队列条目,
-
在工作集中注册该条目的跟踪中出现的所有元组,
-
如果在集合中有任何缺失的元组,则转到#1。
生成的“喜欢”条目的语料库通常比起始数据集小 5-10 倍。不受欢迎的条目不会被丢弃,但在种子队列中遇到时,它们会以不同的概率被跳过:
-
如果队列中有新的、尚未模糊的收藏种子,99% 的非收藏种子将被跳过以到达收藏种子。
-
如果没有新的收藏种子:
-
如果当前不受欢迎的种子之前被 fuzz,它将被跳过的概率为 95% 。
-
如果它还没有经过任何模糊测试,跳过的几率会下降到 75%。
-
基于经验,这些设置在队列循环速度和测试用例多样性之间提供了合理的平衡。
可以使用 afl-cmin 对输入或输出语料库执行稍微复杂但更慢的剔除。该工具永久丢弃冗余条目并生成适合与 afl-fuzz 或外部工具一起使用的较小语料库。
5)修剪输入文件
文件大小对模糊测试性能有显着影响,因为大文件会使目标二进制文件变慢,而且因为它们降低了突变重要控制结构而不是冗余数据块的可能性。这在afl perf_tips.txt 中有更详细的讨论。
撇开用户提供低质量起始语料库的可能性不谈,某些类型的变异会产生迭代增加生成文件大小的影响,因此应对这种种子数量扩张的趋势很重要。
幸运的是,检测反馈提供了一种简单的方法来自动修剪输入文件,同时确保对文件所做的更改不会影响执行路径。
afl-fuzz内置的修剪器使用变化的长度和步距来连续地删除数据块;任何不影响位图跟踪校验和的删除块将被保存下来。这个修剪器的设计并不算特别地周密,相反地,它试着在精确度和进程调用的次数之间选取一个平衡,找到一个合适的块大小和步长。平均每个文件将增大约5-20%。
独立的 afl-tmin 工具使用更详尽的迭代算法,并尝试对修剪后的文件执行字母规范化。 afl-tmin的操作如下。
首先,该工具会自动选择操作模式。如果初始输入使目标二进制文件崩溃,afl-tmin 将在非插桩模式下运行,只保留任何产生更简单文件但仍使目标崩溃的调整。如果目标未崩溃,则该工具使用插桩模式并仅保留产生完全相同执行路径的调整。
实际的最小化算法是:
-
尝试使用大步长的方式对大的块进行归零处理。经验表明,可通过稍后更细粒度的工作来减少执行次数。
-
以二进制搜索方式执行块删除过程,减少块大小和步长。
-
通过计算唯一性字符,以及尝试使用0值进行批量替换,达到进行字母标准化的目的。
-
最后,对非零字节执行逐字节规范化。
afl-tmin 不是使用 0x00 字节归零,而是使用 ASCII 数字“0”。这样做是因为这样的修改不太可能干扰文本解析,因此更有可能成功地最小化文本文件。
这里采用的算法比一些学术界采用的其他的测试用例最小化的方法要少一些,但应用到现实世界应用程序中是,我们只需要更少的执行开销,并具有相当好的效果。
补充 afl-cmin 和 afl-tmin:
网上找到的一些大型语料库中往往包含大量的文件,这时就需要对其精简,这个工作有个术语叫做——语料库蒸馏(Corpus Distillation)。AFL提供了两个工具来帮助我们完成这部工作——afl-cmin和afl-tmin。
本节参考自https://www.freebuf.com/articles/system/191543.html
(1) 移除执行相同代码的输入文件——afl-cmin
afl-cmin的核心思想是:尝试找到与语料库全集具有相同覆盖范围的最小子集。举个例子:假设有多个文件,都覆盖了相同的代码,那么就丢掉多余的文件。其使用方法如下:
$ afl-cmin -i input_dir -o output_dir -- /path/to/tested/program [params]
更多的时候,我们需要从文件中获取输入,这时可以使用“@@”代替被测试程序命令行中输入文件名的位置。Fuzzer会将其替换为实际执行的文件:
$ afl-cmin -i input_dir -o output_dir -- /path/to/tested/program [params] @@
下面的例子中,我们将一个有1253个png文件的语料库,精简到只包含60个文件。
(2) 减小单个输入文件的大小——afl-tmin
整体的大小得到了改善,接下来还要对每个文件进行更细化的处理。afl-tmin缩减文件体积的原理这里就不深究了,有机会会在后面文章中解释,这里只给出使用方法(其实也很简单,有兴趣的朋友可以自己搜一搜)。
afl-tmin有两种工作模式,instrumented mode和crash mode。默认的工作方式是instrumented mode,如下所示:
$ afl-tmin -i input_file -o output_file -- /path/to/tested/program [params] @@
如果指定了参数-x,即crash mode,会把导致程序非正常退出的文件直接剔除。
$ afl-tmin -x -i input_file -o output_file -- /path/to/tested/program [params] @@
afl-tmin接受单个文件输入,所以可以用一条简单的shell脚本批量处理。如果语料库中文件数量特别多,且体积特别大的情况下,这个过程可能花费几天甚至更长的时间!
for i in *; do afl-tmin -i $i -o tmin-$i -- ~/path/to/tested/program [params] @@; done;
下图是经过两种模式的修剪后,语料库大小的变化:
这时还可以再次使用afl-cmin,发现又可以过滤掉一些文件了。
6)变异策略
插桩提供的反馈使得我们更容易理解各种不同fuzzing策略的价值,从而优化(optimize)他们的参数。使得他们对不同的文件类型都能同等地进行工作。afl-fuzz用的策略通常是format-agnostic,详细说明在下边的连接中:
http://lcamtuf.blogspot.com/2014/08/binary-fuzzing-strategies-what-works.html
有点值得注意的是,早期模糊阶段,afl-fuzz 所做的大部分工作实际上是高度确定性的,并且只有在后期才发展到 havoc (随机大破坏)和 splice (随机拼接)。确定性策略包括:
-
具有不同长度和步距的顺序位翻转,
-
小整数的顺序加减法,
-
已知有趣整数(0、1、INT_MAX 等)的顺序插入,
使用这些确定步骤的目的在于,生成紧凑的测试用例,以及在产生non-crashing的输入和产生crashing的输入之间,有很小的差异。
非确定性策略的步骤包括:
- (havoc)多位翻转、插入、删除、算数加减等
- (splice)不同测试用例之间的随机拼接。
在这些所有的策略中,相关的收益和执行次数代价已经在之前提到的博客中相似说明了。
由于在historical_notes.txt 中提到的原因(性能、简易性、可靠性),AFL通常不试图去推断某个特定的变异和程序状态的关系。fuzzing的步骤名义上来说是盲目的,只被输入队列的进化方式的设计(见第三部分)所影响。
对上述的规则,有一些特例:当一个新的种子队列经过一组初始的确定性模糊步骤,并且观察到对文件中某些区域的调整对执行路径的校验和没有影响时,它们可能被排除在确定性模糊化的剩余阶段之外,模糊器可能直接进行随机阶段。特别的,人可读的数据格式,能在没有很大的降低覆盖率的前提下大约减小execs数量的10-40%。在极端情况下,通常块对齐的tar打包文件,能减小90%。
由于底层“变异策略有效性映射”在每个种子队列中都是因种而异,并且仅在不改变底层文件的大小或总体布局的确定性阶段保持有效,因此该机制看起来工作非常可靠,并且被证明易于实现。
相关论文可见MOPT
Lyu C, Ji S, Zhang C, et al. {MOPT}: Optimized mutation scheduling for fuzzers[C]//28th {USENIX} Security Symposium ({USENIX} Security 19). 2019: 1949-1966.
https://www.usenix.org/conference/usenixsecurity19/presentation/lyu
补充:
确定性的策略包括:
1.bitflip,位反转
即按位进行翻转,0变1,1变0。
根据翻转量/步长,按位翻转的策略有以下几种
bitflip 1/1,每次翻转1个bit,按照每1个bit的步长从头开始
bitflip 2/1,每次翻转相邻的2个bit,按照每1个bit的步长从头开始
bitflip 4/1,每次翻转相邻的4个bit,按照每1个bit的步长从头开始
bitflip 8/8,每次翻转相邻的8个bit,按照每8个bit的步长从头开始,即依次对每个byte做翻转
bitflip 16/8,每次翻转相邻的16个bit,按照每8个bit的步长从头开始,即依次对每个word做翻转
bitflip 32/8,每次翻转相邻的32个bit,按照每8个bit的步长从头开始,即依次对每个dword做翻转
这里有一个afl->stage_max的变量,应该是将要进行多少次变异
bitflip1/1
afl->stage_short = "flip1";
afl->stage_max = len << 3;
afl->stage_name = "bitflip 1/1";
afl->stage_val_type = STAGE_VAL_NONE;
orig_hit_cnt = afl->queued_paths + afl->unique_crashes;
prev_cksum = afl->queue_cur->exec_cksum;
for (afl->stage_cur = 0; afl->stage_cur < afl->stage_max; ++afl->stage_cur) {
afl->stage_cur_byte = afl->stage_cur >> 3;
FLIP_BIT(out_buf, afl->stage_cur);
if (common_fuzz_stuff(afl, out_buf, len)) { goto abandon_entry; }
FLIP_BIT(out_buf, afl->stage_cur);
这里的变量
afl->stage_max = len << 3
即文件长度*8,那么可以推断出这个stage_max就是可以用来变异的次数。
看到下面的for循环,从0遍历到stage_max,每个比特都调用FLIP_BIT函数,FLIP_BIT函数作用就是翻转指定位置的一个比特。
#define FLIP_BIT(_ar, _b) \
do { \
u8 *_arf = (u8 *)(_ar); \
u32 _bf = (_b); \
_arf[(_bf) >> 3] ^= (128 >> ((_bf)&7)); \
} while (0)
之后判断if (common_fuzz_stuff(afl, out_buf, len)) { goto abandon_entry; }
,
common_fuzz_stuff
函数返回1就说明是时候bail out了,所以直接进入退出程序。如果返回0,、那么之后进行恢复操作,再进行一次FLIP_BIT将之前翻转的比特再翻转回来,然后进入下一个位置的比特翻转。
在bitflip1/1中还有一部分是实现寻找token
自动检测token
在进行bitflip 1/1变异时,对于每个byte的最低位(least significant bit)翻转还进行了额外的处理:如果连续多个bytes的最低位被翻转后,程序的执行路径都未变化,而且与原始执行路径不一致(检测程序执行路径的方式可见上篇文章中“分支信息的分析”一节),那么就把这一段连续的bytes判断是一条token。这里用路径的hash来判断是否改变。
例如,PNG文件中用IHDR作为起始块的标识,那么就会存在类似于以下的内容
…IHDR…
在执行 bitflip 1/1 时,如果连续翻转多个字节后的用例,都能让程序走到新的代码路径,那么就称连续翻转的字节是一个 token。
当翻转到字符I的最高位时,因为IHDR被破坏,此时程序的执行路径肯定与处理正常文件的路径是不同的;随后,在翻转接下来3个字符的最高位时,IHDR标识同样被破坏,程序应该会采取同样的执行路径。由此,AFL就判断得到一个可能的token:IHDR,并将其记录下来为后面的变异提供备选。
AFL采取的这种方式是非常巧妙的:就本质而言,这实际上是对每个byte进行修改并检查执行路径;但集成到bitflip后,就不需要再浪费额外的执行资源了。此外,为了控制这样自动生成的token的大小和数量,AFL还在config.h中通过宏定义了限制.
#define MIN_AUTO_EXTRA 3
#define MAX_AUTO_EXTRA 32
对于一些文件来说,如果我们已知其格式中出现的token长度不会超过4,那么我们就可以修改MAX_AUTO_EXTRA为4并重新编译AFL,以排除一些明显不会是token的情况。遗憾的是,这些设置是通过宏定义来实现,所以不能做到运行时指定,每次修改后必须重新编译AFL。
这里有个maybe_add_auto
函数,该函数会将传入的token添加到数组中,如果数组还有空间则添加进来。
没有的话那就在数组的下半部分随机删除一个token,然后将新的添加进来。
bitflip2/1,4/1与1/1大同小异,只是在一次循环中调用2次或4次FLIP_BIT而已
FLIP_BIT(out_buf, afl->stage_cur);
FLIP_BIT(out_buf, afl->stage_cur + 1);
if (common_fuzz_stuff(afl, out_buf, len)) { goto abandon_entry; }
FLIP_BIT(out_buf, afl->stage_cur);
FLIP_BIT(out_buf, afl->stage_cur + 1);
在bitflip8/8时,代码里多了一个数据结构eff_map,eff_map的类型是u8(这是一个uint8_t = unsigned char 形,8比特(0~255)),并且u8类型只有这一个是map命名形式的,在后面的注释中如果出现Effector map,说的就是这个变量。主要的作用就是标记,用来标记每一个字节的变异是否有效,有效记为1,无效为0,为后面的变异节省时间。在初始化数组的地方有这么一段注释Initialize effector map for the next step (see comments below). Always flag first and last byte as doing something.把第一个和最后一个字节单独标记出来用作其他用途,这里其实就说明了,这个map是标记作用,那是标记什么呢,标记当前byte对应的map块是否需要进行阶段变异。如果是0意味着不需要变异,1就需要变异。
它的初始化代码如下
#define EFF_APOS(_p) ((_p) >> EFF_MAP_SCALE2)
#define EFF_REM(_x) ((_x) & ((1 << EFF_MAP_SCALE2) - 1))
#define EFF_ALEN(_l) (EFF_APOS(_l) + !!EFF_REM(_l))
#define EFF_SPAN_ALEN(_p, _l) (EFF_APOS((_p) + (_l)-1) - EFF_APOS(_p) + 1)
eff_map = afl_realloc(AFL_BUF_PARAM(eff), EFF_ALEN(len));
这里解释一下这几行代码
EFF_MAP_SCALE2
在config.h中定义为3,这里就是对传入的参数p右移3位并返回。
EFF_REM(_x)
求的是 _x 与 ((1 << EFF_MAP_SCALE2) - 1)
进行按位与,实际上就是跟 7 以二进制形式按位与,即返回 (_x & 7)
。
EFF_ALEN(_l)
:这里的 !! 是一个两次否,目的是归一化。举个例子,如果 r = !!a,如果a是整数0,则r=0,如果a是整数非0,则r=1。这里如果l不为0,那么返回EFF_APOS(_l) + 1
。
EFF_SPAN_ALEN(_p, _l)
就是返回(EFF_APOS((_p) + (_l) - 1) - EFF_APOS(_p) + 1)
。
最后为eff_map分配大小为EFF_ALEN(len)的内存,len来自于队列当前结点queue_cur的成员len,len = afl->queue_cur->len
,是input length(输入长度),所以这里分配给eff_map的大小是EFF_APOS(_l) + !!EFF_REM(_l)
,即 (文件大小/8) 向下取整再+1(文件大小不为0)。例如文件长度为17字节,那么此时EFF_ALEN(_l) = (EFF_APOS(_l) + !!EFF_REM(_l)
= 17/8 + 1 = 3。
eff_map数组元素只有 0/1 两种值,很明显是标记的意思,到底是标记什么呢?
要想明白 effector map 的原理需要了解三个点:
1.为什么是 8/8 的时候出现?因为这里设置 eff_map 是为了之后的启发式判断,而后面的文件数据变异都是 8/8 起步的,所以这里在 8/8 处进行判断。因为 8bit(比特)的时候是 1byte(字节),如果一个字节的翻转都无法带来路径变化,此byte极有可能是不会导致crash的数据,所以之后应该用一种思路避开无效byte。
2.标记是干什么用的?根据上面的分析,就很好理解了,标记好的数组可以为之后的变异服务,相当于提前“踩雷(踩掉无效byte的雷)”,相当于进行了启发式的判断。无效为0,有效为1。
3.达到了怎样的效果?要知道判断的时间开销,对不停循环的fuzzing过程来说是致命的,所以 eff_map 利用在这一次8/8的判断中,通过不大的空间开销,换取了可观的时间开销。(暂时是这样分析的,具体是否真的节约很多,不得而知)
bitflip8/8还是同样的使用这个for循环for (afl->stage_cur = 0; afl->stage_cur < afl->stage_max; ++afl->stage_cur)
,不同的是,这里的afl->stage_max = len
,即这里遍历的单位是每个字节,对每个在eff_map数组中标记为0的字节进行操作,并对没用的字节标记为1。
这里有个比较有意思的操作,如果百分之90以上的字节都是有效的,那么afl会认为所有字符都是有效的,直接全部标记为1。因为既然百分之就是以上的都是有效的,那么跳过其余的也节省不了多少时间,干脆全部变成有效的。
if (eff_cnt != (u32)EFF_ALEN(len) &&
eff_cnt * 100 / EFF_ALEN(len) > EFF_MAX_PERC) { //如果百分之九十以上都是,全部标记为1
memset(eff_map, 1, EFF_ALEN(len));
afl->blocks_eff_select += EFF_ALEN(len);
}
2.arithmetic,进行加减操作
bitflip 结束之后,就进入 arithmetic 阶段,目标大小和阶段与 bitflip 非常类似:
arith 8/8,每次8bit进行加减运算,8bit步长从头开始,即对每个byte进行整数加减变异;
arith 16/8,每次16bit进行加减运算,8bit步长从头开始,即对每个word进行整数加减变异;
arith 32/8,每次32bit进行加减运算,8bit步长从头开始,即对每个dword进行整数加减变异;
其中对于加减变异的上限,在 config.h 中有所定义:
#define ARITH_MAX 35
如果需要修改此值,在头文件中修改完之后,要进行编译才会生效。
这里以 arithmetic 的8/8变异这一段为例(16和32的变异大同小异),把重要部分做解释
afl->stage_name = "arith 8/8";//状态名
afl->stage_short = "arith8";
afl->stage_cur = 0;
afl->stage_max = 2 * len * ARITH_MAX;
afl->stage_val_type = STAGE_VAL_LE;
orig_hit_cnt = new_hit_cnt;
for (i = 0; i < (u32)len; ++i) {
u8 orig = out_buf[i];//out_buf = afl_realloc(AFL_BUF_PARAM(out), len); 应该是取出第i个字节
/* Let's consult the effector map... */
if (!eff_map[EFF_APOS(i)]) {
afl->stage_max -= 2 * ARITH_MAX;
continue;
}
afl->stage_cur_byte = i; //当前byte
for (j = 1; j <= ARITH_MAX; ++j) { //分别进行 +/- 操作 j = 1~35
u8 r = orig ^ (orig + j);
/* Do arithmetic operations only if the result couldn't be a product of a bitflip. */
//只有当arithmetic变异跟bitflip变异不重合时才会进行
if (!could_be_bitflip(r)) {
afl->stage_cur_val = j;
out_buf[i] = orig + j;
if (common_fuzz_stuff(afl, out_buf, len)) { goto abandon_entry; }
++afl->stage_cur;
} else {
--afl->stage_max; //如果没有进行变异,stage_max减一,因为这里属于无效操作
}
r = orig ^ (orig - j);
if (!could_be_bitflip(r)) {
afl->stage_cur_val = -j;
out_buf[i] = orig - j;
if (common_fuzz_stuff(afl, out_buf, len)) { goto abandon_entry; }
++afl->stage_cur;
} else {
--afl->stage_max;
}
out_buf[i] = orig; //复原
}
}
new_hit_cnt = afl->queued_paths + afl->unique_crashes;//如果8/8期间有新crash的话会加到这里
afl->stage_finds[STAGE_ARITH8] += new_hit_cnt - orig_hit_cnt;//这期间增加了的
afl->stage_cycles[STAGE_ARITH8] += afl->stage_max;//如果之前没有有效变异的话stage_max这里就已经变成0了
config.h中定义 #define ARITH_MAX 35 ARITH_MAX
就是加减变异的最大值限制35。那么这里的stage_max = 2 * len * ARITH_MAX
,即对每个字节的加减操作:文件大小len bytes,然后进行 +/- 操作乘以2,每个byte要进行的 +/- 操作各35次,所以这个stage_max意思就是将要进行多少次变异,但是之后要是没有进行有效变异就要给减去,即如果当前第i个字节在eff_map对应位置是0,那么就跳过此次循环,进入for循环的下一次,并且此字节对应的变异无效,所以要减 2*ARITH_MAX
。
if (!eff_map[EFF_APOS(i)]) {
afl->stage_max -= 2 * ARITH_MAX;
continue;
}
在这里对整数目标进行+1,+2,+3…+35,-1,-2,-3…-35的变异。由于整数存在大端序和小端序两种表示,AFL会对这两种表示方式都进行变异。
前面也提到过AFL设计的巧妙之处,AFL尽力不浪费每一个变异,也会尽力让变异不冗余,从而达到快速高效的目标。AFL会跳过某些arithmetic变异:
1.即前面提到的effector map:如果一个整数的所有bytes都被判断为“无效”,那么就跳过对整数的变异。
2.之前bitflip已经生成过的变异:如果加/减某个数后,其效果与之前的某种bitflip相同,那么这次变异肯定在上一个阶段已经执行过了,此次便不会再执行。这里的判断用的就是could_be_bitflip函数。
这里解释一下could_be_bitflip函数,该函数的作用是判断是否进行了bitflip变异。
could_be_bitfilp
static u8 could_be_bitflip(u32 xor_val) {
u32 sh = 0;
if (!xor_val) { return 1; }
/* Shift left until first bit set. 应该为右移?*/
while (!(xor_val & 1)) {
++sh;
xor_val >>= 1;
}
/* 1-, 2-, and 4-bit patterns are OK anywhere. */
if (xor_val == 1 || xor_val == 3 || xor_val == 15) { return 1; }
/* 8-, 16-, and 32-bit patterns are OK only if shift factor is
divisible by 8, since that's the stepover for these ops. */
if (sh & 7) { return 0; }
if (xor_val == 0xff || xor_val == 0xffff || xor_val == 0xffffffff) {
return 1;
}
return 0;
}
xor_val为旧的值和新的值的异或
首先,若是xor_val为0,说明旧的值和新的值是相同的,那么执行将会浪费时间,所以返回1。
之后进行一个while循环,若xor_val末位是0则进行右移操作,并记录下右移次数,直到末位为1为止。此时,进行判断if (xor_val == 1 || xor_val == 3 || xor_val == 15)
,即判断后4位是否为0001,0011,1111,对应的就是是否进行了1位,2位,4位的比特反转。以0011为例,异或值为0011,则说明旧的值和新的值在目前的最后两位是不同的,那么正是因为在这两位进行了bitflip,才会出现这样的结果,所以返回1。
之后进行判断if (sh & 7) { return 0; }
,将sh与7,当sh后四位为1000时,sh&7为0,此时sh为8的倍数,所以之后进行判断if (xor_val == 0xff || xor_val == 0xffff || xor_val == 0xffffffff) {return 1; }
,即判断是否进行了8位,16位,32位的bitflip。如果sh&7结果不为0,则说明没有进行bitflip操作,返回0。
类似的,还有could_be_arith函数和could_be_interest函数。
could_be_artih
static u8 could_be_arith(u32 old_val, u32 new_val, u8 blen) {
u32 i, ov = 0, nv = 0, diffs = 0;
if (old_val == new_val) { return 1; }
/* See if one-byte adjustments to any byte could produce this result. */
for (i = 0; i < blen; ++i) { //每次将旧的值和新的值右移一个字节,看调整后的数据是否相同
u8 a = old_val >> (8 * i), b = new_val >> (8 * i);
if (a != b) {
++diffs; //diffs必定>=1
ov = a;
nv = b;
}
}
/* If only one byte differs and the values are within range, return 1.
可能进行过bitflip?
*/
//只有一次diffs即未进行移位操作时的different,此时判断二者之差是否在范围内即可
if (diffs == 1) {
if ((u8)(ov - nv) <= ARITH_MAX || (u8)(nv - ov) <= ARITH_MAX) { return 1; }
}
//文件长度为一个字节并且新旧值之差大于arith的范围,则可以判定未进行过arithmetic变异
if (blen == 1) { return 0; }
/* See if two-byte adjustments to any byte would produce this result. */
diffs = 0;
//每次将旧的值和新的值右移两个字节,看调整后的数据是否相同
for (i = 0; i < blen / 2; ++i) {
u16 a = old_val >> (16 * i), b = new_val >> (16 * i);
if (a != b) {
++diffs;
ov = a;
nv = b;
}
}
/* If only one word differs and the values are within range, return 1. */
if (diffs == 1) { //同上
if ((u16)(ov - nv) <= ARITH_MAX || (u16)(nv - ov) <= ARITH_MAX) {
return 1;
}
ov = SWAP16(ov); //SWAP16交换前后八位
nv = SWAP16(nv);
//有可能在前八位进行了arithmetic变异,交换之后判断是否在范围内
if ((u16)(ov - nv) <= ARITH_MAX || (u16)(nv - ov) <= ARITH_MAX) {
return 1;
}
}
我们从头到尾捋一遍,该函数有三个参数old_val, new_val
,顾名思义就是旧的值和新的值,还有一个参数blen
应该就是以字节为单位的文件长度。
一开始判断新旧值是否相同,相同就返回1。
之后使用一个循环对每一个字节进行调整(因为arith只有三种情况8/8,16/8和32/8),看是否在该字节发生了arith变异,每次右移八位,第一次 i=0 不进行右移,此时必然有一次diffs,所以diffs必定是大于等于1的。如果之后右移全部相同,那么进入下一个if判断。
if (diffs == 1) {
if ((u8)(ov - nv) <= ARITH_MAX || (u8)(nv - ov) <= ARITH_MAX) { return 1; }
}
ARITH_MAX
是已经定义好的加减操作的上限。这里就是判断新旧值的绝对值之差是否在这个范围内,如果是就证明可能进行过arith变异。
之后重置diffs,进行两个字节的判断,依旧是通过循环每次右移16位,当diffs=1时进行绝对值之差的判断,这里跟之前都一样。不同之处在于16位的时候多了一个SWAP16的操作:
#define SWAP16(_x) \
({ \
u16 _ret = (_x); \
(u16)((_ret << 8) | (_ret >> 8)); \
})
作用就是将u16的数据前八位和后八位进行交换。即进行了大小端交换。
交换后再次进行范围判断,这里的目的就是看arith变异是否变异在前8位而不是后8位,因为arith的步长为8。
之后32位时使用SWAP32交换大小端,之后的判断与上面类似,就不再赘述。
could_be_interest
could_be_interest
函数判断方式与could_be_arith
大同小异,就不再详细解释,这里只说说一些区别。
could_be_interest
函数有四个参数,比could_be_arith
多了一个参数check_le
,目的是判断是否是小端方式,因为interest变异支持大端和小端操作。
之后的判断使用两层for循环分别遍历interesting_8,interesting_16,interesting_32中的每个元素对每个字节的插入情况。
3.interest
把一些“有意思”的特殊内容替换到原文件中。
interest的三个步骤跟arithmetic相同:
interest 8/8,每次8bit进行加减运算,8bit步长从头开始,即对每个byte进行替换;
interest 16/8,每次16bit进行加减运算,8bit步长从头开始,即对每个word进行替换;
interest 32/8,每次32bit进行加减运算,8bit步长从头开始,即对每个dword进行替换;
用于替换的叫做 interesting values ,是AFL预设的特殊数,在config.h中定义
static s8 interesting_8[] = { INTERESTING_8 };
static s16 interesting_16[] = { INTERESTING_8, INTERESTING_16 };
static s32 interesting_32[] = { INTERESTING_8, INTERESTING_16, INTERESTING_32 };
#define INTERESTING_8 \
-128, /* Overflow signed 8-bit when decremented 边界溢出条件*/ \
-1, /* */ \
0, /* */ \
1, /* */ \
16, /* One-off with common buffer size */ \
32, /* One-off with common buffer size */ \
64, /* One-off with common buffer size */ \
100, /* One-off with common buffer size */ \
127 /* Overflow signed 8-bit when incremented */
#define INTERESTING_16 \
-32768, /* Overflow signed 16-bit when decremented */ \
-129, /* Overflow signed 8-bit */ \
128, /* Overflow signed 8-bit */ \
255, /* Overflow unsig 8-bit when incremented */ \
256, /* Overflow unsig 8-bit */ \
512, /* One-off with common buffer size */ \
1000, /* One-off with common buffer size */ \
1024, /* One-off with common buffer size */ \
4096, /* One-off with common buffer size */ \
32767 /* Overflow signed 16-bit when incremented */
#define INTERESTING_32 \
-2147483648LL, /* Overflow signed 32-bit when decremented */ \
-100663046, /* Large negative number (endian-agnostic) */ \
-32769, /* Overflow signed 16-bit */ \
32768, /* Overflow signed 16-bit */ \
65535, /* Overflow unsig 16-bit when incremented */ \
65536, /* Overflow unsig 16 bit */ \
100663045, /* Large positive number (endian-agnostic) */ \
2147483647 /* Overflow signed 32-bit when incremented */
可以看到,用于替换的基本都是可能会造成溢出的数。
与之前类似,effector map仍然会用于判断是否需要变异;此外,如果某个interesting value,是可以通过bitflip或者arithmetic变异达到,那么这样的重复性变异也是会跳过的。
4.dictionary
用户提供的字典里有token,用来替换要进行变异的文件内容,如果用户没提供就使用 bitflip 自动生成的 token。
此时已是deterministic fuzzing 的结尾,有以下几个阶段:
1.user extras (over),从头开始,将用户提供的tokens依次替换到原文件中
2.user extras (insert),从头开始,将用户提供的tokens依次插入到原文件中
3.auto extras (over),从头开始,将自动检测的tokens依次替换到原文件中
其中,用户提供的tokens,是在词典文件中设置并通过-x选项指定的,如果没有则跳过相应的子阶段
afl->stage_max = afl->extras_cnt * len;
afl->extras_cnt
为tokens的总数
4.1.user extras (over)
对于用户提供的tokens,AFL先按照长度从小到大进行排序。这样做的好处是,只要按照顺序使用排序后的tokens,那么后面的token不会比之前的短,从而每次覆盖替换后不需要再恢复到原状。
随后,AFL会检查tokens的数量,如果数量大于预设的MAX_DET_EXTRAS(默认值为200),那么对每个token会根据概率来决定是否进行替换:
//检查tokens的数量,如果数量大于预设的MAX_DET_EXTRAS(默认值为200),那么对每个token会根据概率来决定是否进行替换
if ((afl->extras_cnt > afl->max_det_extras && //afl->max_det_extras = MAX_DET_EXTRAS (默认值为200)
//rand_below 函数生成一个0到afl->extras_cnt - 1 之间的随机数
rand_below(afl, afl->extras_cnt) >= afl->max_det_extras) ||afl->extras[j].len > len - i ||!memcmp(afl->extras[j].data, out_buf + i, afl->extras[j].len) ||!memchr(eff_map + FF_APOS(i), 1,EFF_SPAN_ALEN(i, afl->extras[j].len))) {
--afl->stage_max;
continue;
}
这里的 rand_below(afl, afl->extras_cnt)
是运行时生成的一个0到afl->extras_cnt - 1
之间的随机数。所以,如果用户词典中一共有400个tokens,那么每个token就有200/400=50%的概率执行替换变异。我们可以修改MAX_DET_EXTRAS
的大小来调整这一概率。
由上述代码也可以看到,effector map在这里同样被使用了:如果要替换的目标bytes全部是“无效”的,那么就跳过这一段,对下一段目标执行替换
4.2.user extras (insert)
user extras (insert)是对用户提供的tokens执行插入变异,与user extras (over)不同,由于原文件并未发生替换,effector map在这里不再被使用了。并且此时并没有对tokens数量的限制,所以全部tokens都会从原文件的第1个byte开始,依次向后插入。这一子阶段最特别的地方,就是变异不能简单地恢复。之前每次变异完,在变异位置处简单取逆即可,例如bitflip后,再进行一次同样的bitflip就恢复为原文件。正因为如此,之前的变异总体运算量并不大。
4.3.auto extras (over)
这一项与”user extras (over)”很类似,区别在于,这里的tokens是最开始bitflip阶段自动生成的。另外,自动生成的tokens总量会由USE_AUTO_EXTRAS限制(默认为128)。
之后就是非确定性变异
5.havoc
havoc,是对原文件的“大破坏”。具体来说,havoc包含了对原文件的多轮变异,每一轮都是将多种方式组合(stacked)而成。
havoc注释说明拼接文件时也会调用havoc阶段突变代码。
switch ((r = rand_below(afl, r_max)))
上文提到过rand_below函数生成0-r_max - 1之间的一个随机数,r_max的初始化代码如下
if (unlikely(afl->expand_havoc)) {
/* add expensive havoc cases here, they are activated after a full
cycle without finds happened */
r_max = 16 + ((afl->extras_cnt + afl->a_extras_cnt) ? 2 : 0);
} else {
r_max = 15 + ((afl->extras_cnt + afl->a_extras_cnt) ? 2 : 0);
}
havoc的方式有以下十几种:
随机选取某个bit进行翻转
随机选取某个byte,将其设置为随机的interesting value
随机选取某个word,并随机选取大、小端序,将其设置为随机的interesting value
随机选取某个dword,并随机选取大、小端序,将其设置为随机的interesting value
随机选取某个byte,对其减去一个随机数
随机选取某个byte,对其加上一个随机数
随机选取某个word,并随机选取大、小端序,对其减去一个随机数
随机选取某个word,并随机选取大、小端序,对其加上一个随机数
随机选取某个dword,并随机选取大、小端序,对其减去一个随机数
随机选取某个dword,并随机选取大、小端序,对其加上一个随机数
随机选取某个byte,将其设置为随机数
随机删除一段bytes
随机选取一个位置,插入一段随机长度的内容,其中75%的概率是插入原文中随机位置的内容,25%的概率是插入一段随机选取的数
随机选取一个位置,替换为一段随机长度的内容,其中75%的概率是替换成原文中随机位置的内容,25%的概率是替换成一段随机选取的数
随机选取一个位置,用随机选取的token(用户提供的或自动生成的)替换
随机选取一个位置,用随机选取的token(用户提供的或自动生成的)插入
之后,AFL会生成一个随机数,作为变异组合的数量,并根据这个数量,每次从上面那些方式中随机选取一个,依次作用到文件上。
6.splice
顾名思义,splice是将两个seed文件拼接得到新的文件,并对这个新文件继续执行havoc变异。
具体地,AFL在seed文件队列中随机选取一个,与当前的seed文件做对比。
if (f_diff < 0 || l_diff < 2 || f_diff == l_diff) { goto retry_splicing; }
如果两者差别不大,就再重新随机选一个;如果两者相差比较明显,那么就随机选取一个位置,split_at = f_diff + rand_below(afl, l_diff - f_diff);
将两者都分割为头部和尾部。最后,将当前文件的头部与随机文件的尾部拼接起来,就得到了新的文件。在这里,AFL还会过滤掉拼接文件未发生变化的情况。
上面的变异完成后,AFL会对文件队列的下一个进行变异处理。当队列中的全部文件都变异测试后,就完成了一个”cycle”,这个就是AFL状态栏右上角的”cycles done”。而正如cycle的意思所说,整个队列又会从第一个文件开始,再次进行变异,不过与第一次变异不同的是,这一次就不需要再进行deterministic fuzzing了。
当然,如果用户不停止AFL,那么seed文件将会一遍遍的变异下去。
本节补充内容参考https://blog.csdn.net/weixin_43820701/article/details/110427004
7)字典
插桩提供的反馈能够让它自动地识别出一些输入文件中的语法tokens,并且能够为测试器检测到一些组合,这些组合是由预定义的或自动检测到的字典项(dictionary terms)构成的合法语法(valid grammar)。
关于这些特点在afl-fuzz是如何实现的,可以看一下这个链接:
http://lcamtuf.blogspot.com/2015/01/afl-fuzz-making-up-grammar-with.html
大体上,当基本的、通常容易获得的语法标记以纯粹随机的方式组合在一起时,插桩和队列进化这两种方法共同提供了一种反馈机制,这种反馈机制能够区分无意义的变异和在插桩代码中触发新行为的变异。这样能增量地构建更复杂的句法。
这样构建的字典能够让fuzzer快速地重构非常详细且复杂的语法,比如JavaScript, SQL,XML。一些生成SQL语句的例子已经在之前提到的博客中给出了。
相关论文可以参考Token-Level Fuzzy
Salls C, Jindal C, Corina J, et al. {Token-Level} Fuzzing[C]//30th USENIX Security Symposium (USENIX Security 21). 2021: 2795-2809.
https://www.usenix.org/conference/usenixsecurity21/presentation/salls
有趣的是,AFL的插桩也允许fuzzer自动地隔离已经在输入文件中出现过的句法符号tokens。
它能通过变异策略中flipped字节,为程序执行路径生成一个一致的改变。这隐含着潜在的和代码中预定义值自动化对照的机制。详细过程参照上节。
8)崩溃去重
对于任何模糊测试工具来说,崩溃去重是重要的问题之一。 许多简单的方法都会遇到问题; 特别是,如果故障发生在公共库函数(例如,strcmp、strcpy)中,仅查看故障地址可能会导致完全不相关的问题分类在一起; 如果错误发生在一些不同的、可能递归的代码路径中,那么校验和调用栈回溯时可能导致极端的崩溃计数膨胀。
如果满足以下两个条件中的任何一个,afl-fuzz 中实现的解决方案会认为崩溃是唯一的:
-
这个崩溃的元组集合包括一个之前崩溃从未见到过的tuple,
-
崩溃的元组集合缺少一个始终存在于早期崩溃中的元组。
这种方式一开始容易受到数值膨胀的影响,但实验表明其有很强的自我限制效果。和执行路径分析一样,这种崩溃去重的方式是afl-fuzz的基石
9)崩溃调查
许多类型的崩溃的可利用性可能不明确; afl-fuzz 试图通过提供崩溃调查模式来解决这个问题,在这种模式下,对一个已知的出错测试用例,它被fuzz的方式和正常fuzz的操作没什么不同,但是不能导致崩溃的变异结果会被丢弃。
这种方法的意义在以下链接中会进一步讨论:
http://lcamtuf.blogspot.com/2014/11/afl-fuzz-crash-exploration-mode.html
这种方法使用插桩反馈的方式探索崩溃程序的状态,目的是通过不明确的错误条件,然后隔离出新发现的测试输入提供给人工复查。
关于崩溃的问题,值得注意的是,与正常的队列条目相比,崩溃输入并不会被修剪;它们将被完全保持被发现时的结构和大小,这样能使得更容易和队列中的父样本进行比较。所以后续可以使用afl-tmin工具对其进行缩减处理。
10)fork server
为了提高性能,afl-fuzz 使用了一个“fork server”,其中模糊的进程只进行一次 execve(),linking和libc initialization,然后通过利用写时拷贝技术从停止的fuzz进程镜像直接拷贝。此处更详细地描述了该实现:
http://lcamtuf.blogspot.com/2014/10/fuzzing-binaries-without-execve.html
fork server被集成在了instrumentation的程序下,在第一个instrument函数执行时,fork server就停止并等待afl-fuzz的命令。
对于需要快速发包的测试,fork server可以提供可观的性能提升,通常在 1.5 倍到 2 倍之间。此外还有:
-
在手动(“延迟”)模式下使用 fork server,跳过较大的、用户选择的初始化代码块。它需要对目标程序进行非常适度的代码更改,并且对于某些目标,可以产生 10 倍以上的性能提升。
-
启用“持久”模式,其中使用单个进程来尝试多个输入,极大地限制了重复 fork() 调用的开销。这通常需要对目标程序进行一些代码更改,但可以将快速目标的性能提高 5 倍或更多——这是近似于进程内模糊的好处,同时仍然保持模糊程序进程和目标二进制文件之间非常健壮的隔离。
11) 并行化
并行化机制依赖于定期检查由其他 CPU 内核或远程机器上独立运行的实例产生的队列,然后有选择地拉入测试用例,当在本地尝试时,会产生当前模糊器尚未看到的行为。
这使得模糊器设置具有极大的灵活性,包括针对通用数据格式的不同解析器运行同步实例,通常具有协同效应。
有关此设计的更多信息,请参阅afl 的 parallel_fuzzing.txt
12)二进制instrumentation
在“用户模拟”模式下,借助单独构建的 QEMU 版本,可以完成黑盒、仅二进制目标的检测。这也允许跨架构代码的执行 - 例如,x86 上的 ARM 二进制文件。
QEMU 使用基本块作为翻译单元;检测是在此之上实现的,并使用与编译时hooks大致类似的模型:
if (block_address > elf_text_start && block_address < elf_text_end) {
cur_location = (block_address >> 4) ^ (block_address << 8);
shared_mem[cur_location ^ prev_location]++;
prev_location = cur_location >> 1;
}
第二行中基于移位和异或的加扰用于屏蔽指令对齐的影响。
QEMU、DynamoRIO 和 PIN 等二进制转换器的启动相当慢;为了解决这个问题,QEMU 模式利用了一个类似于用于编译器检测代码的 fork server,过把一个已经初始化好的进程镜像,直接拷贝到新的进程中。
新基本块的首次转换也会导致大量延迟。为了消除这个问题,AFL fork server通过在运行的模拟器和父进程之间提供一个通道。该通道用于将任何新遇到的块的地址通知给父进程,并将它们添加到缓存中,以便直接复制到将来的子进程中。
由于这两个优化,QEMU 模式的开销大约是慢 2-5 倍,而 PIN 的开销是慢 100 倍以上。
13) afl分析工具
文件格式分析器是前面讨论的最小化算法的简单扩展;该工具不会尝试删除无操作块,而是执行一系列遍历字节翻转,然后对输入文件中的字节运行进行注释。
它使用以下分类方案:
-
“无操作块” - 位翻转不会导致控制流发生明显变化的段。常见的例子可能是注释部分、位图文件中的像素数据等。
-
“表面内容” - 部分(但不是全部)bitflip 产生一些控制流变化的片段。示例可能包括富文档(例如 XML、RTF)中的字符串。
-
“关键流” - 一个字节序列,其中所有位翻转以不同但相关的方式改变控制流。这可能是压缩的数据,非原子比较的关键字或魔术值,等等。
-
“可疑长度字段” - 小的原子整数,当以任何方式触摸时,会导致程序控制流的一致更改,提示长度检查失败。
-
“可疑的校验和或魔法 int” - 一个整数,其行为类似于长度字段,但其数值使得不太可能解释长度。这暗示了校验和或其他“魔术”整数。
-
“可疑校验和块” - 一个长数据块,其中任何更改总是触发相同的新执行路径。可能是由于在进行任何后续解析之前校验和或类似的完整性检查失败而引起的。
-
“魔法值部分” - 一个通用token,其中更改会导致前面概述的二进制行为类型,但不符合任何其他标准。可能是一个原子级比较的关键字。
标签:AFL,afl,val,变异,白皮书,bitflip,细节,stage 来源: https://blog.csdn.net/weixin_42110652/article/details/122093156