0.设计说明(Design statement)
American Fuzzy Lop 不关注任何单一的操作规则(singular principle of
operation),也不是一个针对任何特定理论的概念验证(proof of concept)。这个工具可以被认为是一系列在实践中测试过的hacks行为,我们发现这个工具惊人的有效。我们用目前最simple且最robust的方法实现了这个工具。
..
1.覆盖率计算(Coverage measurements)
在编译程序中注入的插桩(instrumentation)能够捕获分支(边缘)覆盖率,并且还能检测到粗略的分支执行命中次数(branch-taken hit counts)。在分支点注入的代码大致如下:
1 cur_location = <COMPILE_TIME_RANDOM>; 2 shared_mem[cur_location ^ prev_location]++; 3 prev_location = cur_location >> 1;
cur_location的值是随机产生的,为的是简化连接复杂对象的过程和保持XOR输出分布是均匀的。
shared_mem[] 数组是一个调用者 (caller) 传给被插桩的二进制程序的64kb的共享空间。其中的每一位可以理解成对于特别的(branch_src, branch_dst)式的tuple的一次命中(hit)。
选择这个数组大小的原因是让冲突(collisions)尽可能减少。这样通常能处理2k到10k的分支点。同时,它的大小也足以达到毫秒级的分析。
![](http://upload-images.jianshu.io/upload_images/11797651-d7a2d6db60dead92.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/333)
这种形式的覆盖率,相对于简单的基本块覆盖率来说,对程序运行路径提供了一个更好的描述(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):
![](http://upload-images.jianshu.io/upload_images/11797651-28825268f6c13c17.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/700)
这里有一些这种算法在实际情况下例子:
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:
![](http://upload-images.jianshu.io/upload_images/11797651-fc648b6ed1bd9596.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/513)
第一行的blind fuzzing (“S”)代表仅仅执行了一个回合的测试。第二行的Blind fuzzing ("L")表示执行了在一个循环(loop)中运行了多个执行周期(execution cycles),和插桩运行相比,后者需要更多时间全面处理增长队列。(<strong>译者没看懂</strong>)
在另一个独立的实验中也取得了大致相似的结果。在新实验中,fuzzer被修改成所有随机fuzzing 策略,只留下一系列基本、连续的操作,例如位反转(bit flips)。因为这种模式(mode)将不能改变输入文件的的大小,会话使用一个合法的合并格式(unified diff)作为种子。(<strong>译者没看懂</strong>)
![](http://upload-images.jianshu.io/upload_images/11797651-2fe0480805414d87.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/459)
在之前提到的基于遗传算法的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的具体操作如下:
首先,工具将自动选择操作模式。如果初始化输入崩溃了目标二进制,afl-tmin将以非插桩模式运行,简单保留任何能产生更简单的文件但仍然能够崩溃目标二进制的tweaks。如果目标没有崩溃,工具会使用插桩模式,只保留能精确产生相同路径的tweaks。
实际的最小化算法是:
- 尝试使用大的stepovers的方式对大的块进行归零处理。经验上来说,这主要是为了后续先取细粒度的方式减小execs的数量。
- 通过减小块大小和stepovers的方式对块进行删除;
- 通过计算唯一性字符,以及尝试使用0值(zero value)进行批量替换(bulk-place),达到进行字母标准化(alphabet normalization)的目的;
- 对非0字节进行逐字节的标准化(normalization)。
afl-tmin使用ASCII数字’0’而不是0x00对块进行归零处理。这样做是因为这种修改更不会影响到涉及到字符串相关的处理(0x00是字符串结束标志)。所以对text文件的最小化方式可能会更成功。
这里采用的算法比一些学术界采用的其他的测试用例最小化的方法要少一些,但应用到现实世界应用程序中是,我们只需要更少的执行开销,并具有相当好的效果。
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);它们可能会把剩下的确定性fuzzing,以及fuzzer可能直接处理随机tweaks,排除在外。特别的,人可读的数据格式,能在没有很大的降低覆盖率的前提下大约减小execs数量的10-40%。在极端情况下,通常块对齐的tar打包文件,能减小90%。
因为潜在的“effector maps”是局部每个队列entry,以及只有在确定性阶段没有改变潜在文件大小或者通用布局的前提下仍然有效,这一机制似乎工作地非常可靠,并且实现起来也非常简单。
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)。
它能通过寻找运行中跳转(flipped)的字节,为程序执行路径生成一个一致(consistent)的改变。这隐含着潜在的和代码中预定义值自动化对照的机制。
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
...这种方法使用插桩反馈的方式探索崩溃程序的状态,目的是通过(pass)不明确的(ambiguous)错误条件,然后隔离出(isolate)新发现的测试输入提供给人工复查。
关于崩溃的问题,值得注意的是,与正常的队列entries(normal queue entries)相比,崩溃输入并不会被修剪(trimmed);它们将被完全保持被发现时的结构和大小,这样能使得更容易和队列中的parent样本进行比较。所以后续可以使用afl-tmin工具对其进行缩减处理。
10.The fork server
为了提升性能,afl-fuzz使用了一个“fork server”,fuzz进程只进行一次execve(),linking和libc initialization,之后的fuzz进程通过写时拷贝技术从已经停止的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倍的性能。
11. 并行机制
实现并行的机制是,定期检查不同cpu core或不同机器产生的队列,然后有选择性的把队列中的条目放到test cases中。
详见: parallel_fuzzing.txt.
12. 二进制instrumentation
AFL-Fuzz对二进制黑盒目标程序的instrumentation是通过QEMU的“user emulation”模式实现的。
这样我们就可以允许跨架构的运行,比如ARM binaries运行在X86的架构上。QEMU使用basic blocks作为翻译单元,利用QEMU做instrumentation,再使用一个和编译期instrumentation类似的guided fuzz的模型。
像QEMU, DynamoRIO, and PIN这样的二进制翻译器,启动是很慢的QEMU mode同样使用了一个fork server,和编译期一样,通过把一个已经初始化好的进程镜像,直接拷贝到新的进程中。
当然第一次翻译一个新的basic block还是有必要的延迟,为了解决这个问题AFL fork server在emulator和父进程之间提供了一个频道。这个频道用来通知父进程新添加的blocks的地址,之后吧这些blocks放到一个缓存中,以便直接复制到将来的子进程中。这样优化之后,QEMU模式对目标程序造成2-5倍的减速,相比之下,PIN造成100倍以上的减速。
首先,该工具自动选择操作模式。如果是初始输入 崩溃目标二进制文件,afl-tmin将在非检测模式下运行,简单地说 保持产生更简单文件的任何调整但仍然使目标崩溃。如果 目标是非崩溃的,该工具使用检测模式并仅保留 产生完全相同的执行路径的调整。 实际的最小化算法是: 1)尝试将具有大步进的大块数据归零。根据经验, 这表明通过抢占更细粒度来减少高管人数 后来的努力。 2)执行块删除传递,减少块大小和步骤, 二进制搜索的风格。 3)通过计算唯一字符并尝试执行字母规范化 批量替换每个零值。 4)作为最后的结果,对非零字节执行逐字节标准化。 afl-tmin不使用0x00字节进行归零,而是使用ASCII数字“0”。这个 完成是因为这样的修改不太可能干扰 文本解析,因此更有可能导致成功最小化 文本文件。 这里使用的算法比其他一些测试案例更少涉及 学术工作中提出的最小化方法,但要求更少 执行并且往往在大多数现实世界中产生可比较的结果 应用。