翻译afl-fuzz白皮书

前言

最近打算读一读afl(american fuzzy lop) 的源码,为研究生做fuzzing测试做相应的准备。在读源码之前我看了看官方文档(Technical "whitepaper" for afl-fuzz),然后萌生了翻译文档的想法,然后我就行动了。这是我第一次做翻译,希望是个好的开始吧。

网上的相关翻译工作有两篇:

afl-fuzz技术白皮书

使用Afl-fuzz (American Fuzzy Lop) 进行fuzzing测试(三)

这两篇工作没有翻译全,且许多翻译有误(有一些啼笑皆非的翻译,很有意思)。当然,本文有许多翻译借鉴这两篇文章,十分感谢作者们。

本文翻译自 Technical "whitepaper" for afl-fuzz

翻译正文

本文档提供了American Fuzzy Lop的简单的概述。想了解一般的使用说明,请参见README 。想了解AFL背后的动机和设计目标,请参见historical_notes.txt

0.设计说明(Design statement)

American Fuzzy Lop 不关注任何单一的操作规则(singular principle of
operation),也不是一个针对任何特定理论的概念验证(proof of concept)。这个工具可以被认为是一系列在实践中测试过的hacks行为,我们发现这个工具惊人的有效。我们用目前最simple且最robust的方法实现了这个工具。

..

1.覆盖率计算(Coverage measurements)

在编译程序中注入的插桩(instrumentation)能够捕获分支(边缘)覆盖率,并且还能检测到粗略的分支执行命中次数(branch-taken hit counts)。在分支点注入的代码大致如下:

cur_location = <COMPILE_TIME_RANDOM>;
shared_mem[cur_location ^ prev_location]++; 
prev_location = cur_location >> 1;

cur_location的值是随机产生的,为的是简化连接复杂对象的过程和保持XOR输出分布是均匀的。

shared_mem[] 数组是一个调用者 (caller) 传给被插桩的二进制程序的64kb的共享空间。其中的每一位可以理解成对于特别的(branch_src, branch_dst)式的tuple的一次命中(hit)。

选择这个数组大小的原因是让冲突(collisions)尽可能减少。这样通常能处理2k到10k的分支点。同时,它的大小也足以达到毫秒级的分析。

这种形式的覆盖率,相对于简单的基本块覆盖率来说,对程序运行路径提供了一个更好的描述(insight)。特别地,它能很好地区分以下两个执行路径:

A -> B -> C -> D -> E (tuples: AB, BC, CD, DE)
A -> B -> D -> C -> E (tuples: AB, BD, DC, CE)

这有助于发现底层代码的微小错误条件。因为安全漏洞通常是一些非预期(或不正确)的语句转移(一个tuple就是一个语句转移),而不是没覆盖到某块代码。

上边伪代码的最后一行移位操作是为了让tuple具有定向性(没有这一行的话,AB和BA就没区别了,同样,AA和BB也没区别了)。采用左移的原因跟Intel CPU的一些特性有关。

2.发现新路径(Detecting new behaviors)

AFL的fuzzer包括一个全局的Map来存储之前执行时看到的tuple。这些数据可以被用来对不同的trace进行快速对比,从而可以计算出是否新执行了一个dword指令/一个qword-wide指令/一个简单的循环。

当一个变异的输入产生了一个包含新tuple的执行路径时,对应的输入文件就被保存,然后被发送到下一过程(见第3部分)。对于那些没有产生新路径的输入,就算他们的路径是不同的,也会被抛弃掉。

这种算法考虑了一个非常细粒度的、长期的对程序状态的探索,同时它还不必执行复杂的计算,不必对整个复杂的执行流进行对比,也避免了路径爆炸的影响。

为了说明这个算法是怎么工作的,考虑下面的两个路径,第二个路径出现了新的tuples(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

除了检测新的tuple之外,AFL的fuzzer也会粗略地记录tuple的命中数(hit counts)。这些被分割成几个buckets:

1, 2, 3, 4-7, 8-15, 16-31, 32-127, 128+

从某种意义来说,buckets里边的数目是有实际意义的:它是一个8-bit counter和一个8-position bitmap的映射。8-bit counter是由桩生成的,8-position bitmap则依赖于每个fuzzer记录的已执行的tuple的命中数。

单个bucket的改变会被忽略掉:在程序流中,bucket的转换会被标记成一个interesting change,传入evolutionary(见第三部分)进行处理。

通过命中次数(hit count),我们能够分辨控制流是否发生变化。例如一个代码块被执行了两次,但只命中了一次。并且这种方法对循环的次数不敏感(循环47次和48次没区别)。

这种算法通过限制内存和运行时间来保证效率。

3.输入队列的进化(Evolving the input queue)

变异测试用例(Mutated test cases)是能够产生新的语句转移(即新的tuple)的测试用例。这种变异测试用例会被加入到输入队列(input queue)中,当做下一次fuzz的起点。它们作为已有测试用例的补充,但并不替换掉已有测试用例。

与遗传算法相比,(上述)算法能让工具渐进地探索不同的目标程序,即使目标程序的底层数据格式可能是不同的。如图所示(图很大,将近500KB):

这里有一些这种算法在实际情况下例子:

pulling-jpegs-out-of-thin-air

afl-fuzz-nobody-expects-cdata-sections

在(使用算法)过程中生成的语料库是那些“有用的”输入的集合,这个语料库可以直接给其他测试过程当做seed(例如,手动对一些desktop apps进行压力测试)。

使用这种算法,大多数目标程序的输入队列会到1k到10k。其中,大约10-30%是发现的新tuple,剩下的都是和命中次数(hit count)的改变有关。

下表比较了不同fuzzing方法在发现文件句法(file syntax)和探索程序执行路径的能力。插桩的目标程序是 GNU patch 2.7.3 compiled with -O3 and seeded with a dummy text file:

第一行的blind fuzzing (“S”)代表仅仅执行了一个回合的测试。第二行的Blind fuzzing ("L")表示执行了在一个循环(loop)中运行了多个执行周期(execution cycles),和插桩运行相比,后者需要更多时间全面处理增长队列。(<strong>译者没看懂</strong>)

在另一个独立的实验中也取得了大致相似的结果。在新实验中,fuzzer被修改成所有随机fuzzing 策略,只留下一系列基本、连续的操作,例如位反转(bit flips)。因为这种模式(mode)将不能改变输入文件的的大小,会话使用一个合法的合并格式(unified diff)作为种子。(<strong>译者没看懂</strong>)

在之前提到的基于遗传算法的fuzzing,是通过一个test case的进化(这里指的是用遗传算法进行变异)来实现最大覆盖。在上述实验看来,这种“贪婪”的方法似乎没有为盲目的模糊策略带来实质性的好处。

4.语料筛选(Culling the corpus)

上文提到的渐进式语句探索路径的方法意味着:假设A和B是测试用例(test cases),且B是由A变异产生的。那么测试用例B达到的边缘覆盖率(edge coverage)是测试用例A达到的边缘覆盖率的严格超集(superset)。(<strong>这里的例子是译者自造的</strong>)。

为了优化fuzzing,AFL会用一个快速算法周期性的重新评估(re-evaluates)队列,这种算法会选择队列的一个更小的子集,并且这个子集仍能覆盖所有的tuple。算法的这个特性对这个工具特别有利(favorable)。

算法通过指定每一个队列入口(queue entry),根据执行时延(execution latency)和文件大小分配一个分值比例(score proportional)。然后为每一个tuple选择最低分值的entry。

这些tuples按下述流程进行处理:

1)找到下一个还没有在temporary working set中存在的tuple,

2)对这个tuple,定位到winning queue entry(<strong>用之前说的为tuple找最低分entry的方法</strong>)

3)把当前所有的tuples注册到队列中(<strong>译者没看懂</strong>)

4)如果还有不在set中的tuple,返回1)继续处理

"favored" entries生成的语料库通常比初始数据集小5-10倍。Non-favored entries也没有被扔掉,当遇到下列队列时,他们有一定的几率被略过(skip):

-如果有没有被fuzz的favored entries出现在队列里,则99%的non-favored entries将被略过。

-如果没有新的favored entries:

●如果当前的non-favored entry 在之前被fuzz过,则有95%的几率被略过。

●如果当前的non-favored entry 在之前没有被fuzz过,则它被略过的几率下降到75%。

基于以往的实验经验,这种方法能够在队列周期速度(queue cycling speed)和测试用例多样性(test case diversity)之间达到一个合理的平衡。

使用afl-cmin工具能够对输入或输出的语料库进行稍微复杂但慢得多的的处理。这一工具将永久丢弃冗余entries,产生适用于afl-fuzz或者外部工具的更小的语料库。

5.输入文件修剪(Trimming input files)

文件的大小对fuzzing的性能有着重大影响(dramatic impact)。因为大文件会让目标二进制文件运行变慢;大文件还会减少变异触及重要格式控制结构(format control structures)的可能性(<strong>我们希望的是变异要触及冗余代码块(redundant data blocks)</strong>)。这个问题将在perf_tips.txt细说。

用户可能提供低质量初始语料(starting corpus),某些类型的变异会迭代地增加生成文件的大小。所以要抑制这种趋势(counter this trend)。

幸运的是,插桩反馈(instrumentation feedback)提供了一种简单的方式自动削减(trim down)输入文件,并确保这些改变能使得文件对执行路径没有影响。

afl-fuzz内置的修剪器(trimmer)使用变化的长度和步距(variable length and stepover)来连续地(sequentially)删除数据块;任何不影响trace map的校验和(checksum)的删除块将被提交到disk。(<strong>译者没看懂</strong>)。这个修剪器的设计并不算特别地周密(thorough),相反地,它试着在精确度(precision)和进程调用execve()的次数之间选取一个平衡,找到一个合适的block size和stepover。平均每个文件将增大约5-20%。

独立的afl-tmin工具使用更完整(exhaustive)、迭代次数更多(iteractive)的算法,并尝试对被修剪的文件采用字母标准化的方式处理。afl-tmin的具体操作如下:

...

6.模糊测试策略(Fuzzing strategies)

插桩提供的反馈(feedback)使得我们更容易理解各种不同fuzzing策略的价值,从而优化(optimize)他们的参数。使得他们对不同的文件类型都能同等地进行工作。afl-fuzz用的策略通常是format-agnostic,详细说明在下边的连接中:

binary-fuzzing-strategies-what-works

值得注意的一点是,afl-fuzz大部分的(尤其是前期的)工作都是高度确定的(highly deterministic),random stacked
modifications和test case splicing只在后期的部分进行。确定性的策略包括:

-使用变化的长度和步距(lengths and stepovers)来连续(sequential)进行位反转。

-对小的整型数(small integers)来连续进行加法和减法。

-对已知的interesting integers(例如 0,1,INT_MAX等)连续地插入。

使用这些确定步骤的目的在于,生成紧凑的(compact)测试用例,以及在产生non-crashing的输入和产生crashing的输入之间,有很小的差异(small diffs)。

非确定性(non-deterministic)策略的步骤包括:stacked bit flips、插入(insertions)、删除(deletions)、算数(arithmetics)和不同测试用例之间的接片(splicing)。

在这些所有的策略中,相关的yields和execve()代价已经在之前提到的博客中相似说明了。

由于在historical_notes.txt 中提到的原因(性能、简易性、可靠性),AFL通常不试图去推断某个特定的变异(specific mutations)和程序状态(program states)的关系。fuzzing的步骤名义上来说是盲目的(nominally blind),只被输入队列的进化方式的设计(<strong>见第三部分</strong>)所影响。

对上述的规则,有一个不太重要的特例:当一个通过确定性fuzzing步骤的产生的新的队列入口(queue entry),调整(tweak)到文件中对执行路径校验和没有影响一些区域(region),

...

7.字典(Dictionaries)

插桩提供的反馈能够让它自动地识别出一些输入文件中的局法(syntax)符号(tokens),并且能够为测试器(tested parser)检测到一些组合,这些组合是由预定义(predefined)的或自动检测到的(auto-detected)字典项(dictionary terms)构成的合法语法(valid grammar)。

关于这些特点在afl-fuzz是如何实现的,可以看一下这个链接:

afl-fuzz-making-up-grammar-with

大体上,当基本的(basic, typically easily-obtained)句法(syntax)符号(tokens)以纯粹随机的方式组合在一起时,插桩和队列进化这两种方法共同提供了一种反馈机制,这种反馈机制能够区分无意义的变异和在插桩代码中触发新行为的变异。这样能增量地构建更复杂的句法(syntax)。

这样构建的字典能够让fuzzer快速地重构非常详细(highly verbose)且复杂的(complex)语法,比如JavaScript, SQL,XML。一些生成SQL语句的例子已经在之前提到的博客中给出了。

有趣的是,AFL的插桩也允许fuzzer自动地隔离(isolate)已经在输入文件中出现过的句法(syntax)符号(tokens)。

...

8.崩溃去重(De-duping crashes)

崩溃去重是fuzzing工具里很重要的问题之一。很多naive的解决方式都会有这样的问题:如果这个错误发生在一个普通的库函数中(如say, strcmp, strcpy),只关注出错地址(faulting address)的话,那么可能导致一些完全不相关的问题被分在一类(clustered together)。如果错误发生在一些不同的、可能递归的代码路径中,那么校验和(checksumming)调用栈回溯(call stack backtraces)时可能导致crash count inflation。

afl-fuzz的解决方案认为满足一下两个条件,那么这个crash就是唯一的(unique):

-这个crash的路径包括一个之前crash从未见到过的tuple。

-这个crash的路径不包含一个总(always)在之前crash中出现的tuple。

这种方式一开始容易受到count inflation的影响,但实验表明其有很强的自我限制效果。和执行路径分析一样,这种崩溃去重的方式是afl-fuzz的基石(cornerstone)。

9.崩溃调查(Investigating crashes)

不同的crash的可用性(exploitability)是不同的。afl-fuzz提供一个crash的探索模式(exploration mode)来解决这个问题。对一个已知的出错测试用例,它被fuzz的方式和正常fuzz的操作没什么不同,但是有一个限制能让任何non-crashing 的变异(mutations)会被丢弃(thrown away)。

这种方法的意义在以下链接中会进一步讨论:

afl-fuzz-crash-exploration-mode

...

10.The fork server

为了提升性能,afl-fuzz使用了一个"fork server",fuzz的进程只进行一次execve(), 连接(linking), 库初始化(libc initialization)。fuzz进程通过copy-on-write的方式从已停止的fuzz进程中clone下来。实现细节在以下链接中:

fuzzing-binaries-without-execve

(未完待续)

推荐阅读更多精彩内容