使用Afl-fuzz (American Fuzzy Lop) 进行fuzzing测试(三)——技术白皮书(technical whitepaper)

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/youkawa/article/details/76615480

本文不完全译自:http://lcamtuf.coredump.cx/afl/technical_details.txt
转载请注明出处。

0. 设计声明(Design statement)

American Fuzzy Lop并不是集中某一规则或者某一理论的POC代码,而是集多种实用型的技术开发的高效、简单、健壮、易用的模糊测试工具。

1. 覆盖率衡量(Coverage measurements)

通过插桩的形式注入到被编译的程序中,实现对分支(branch、edge)覆盖率的捕获,以及分支节点计数。在分支节点处注入的代码基本上是一样的:

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

其中cur_location值随机生成,用于简化处理链接复杂项目,并保持XOR均匀输出。

shared_mem[]数组是一个被调用者传入插桩二进制的64Kb SHM大小的区域,每一个在输出映射(map)中的字节集能被认为对应着命中的特定元组(branch_src,branch_dst)。

映射区(map)的大小的选择是为了对几乎所有的特定目标而言,使得碰撞更加零散(sporadic)。可发现的分支数通常在2K到10K之间。

Branch cnt Colliding tuples Example targets
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% -

同时,映射区的足够小使得映射分析能在微秒级内完成,而且容易适用到L2级缓存。
这种形式的覆盖率能提供跟程序执行路径相关的更多的视野,而不是简单的块覆盖信息(block coverage)。特别的,这种策略能细微地鉴别以下两条执行路径:

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

这能帮助发现潜在程序中的细微的错误条件,因为安全漏洞更经常和非预期的或不正确的状态转换相关,而不仅仅是到达新的基本块。

在上面伪代码中最后一行的移位(shift)操作是为了保留元组的方向性(如果没有这一操作,将很难鉴别A ^ B和B ^ A),以及对比较紧凑的循环保留同一性(否则,A ^ A将明显地与B ^ B等价)。

Intel CPU中缺乏简单饱和的算术操作码(arithmetic opcodes),这意味着命中计数有时会趋近于0。但因为这种情况并不多见,所以相对于性能的折中,是可以接受的。

2. 新行为检测(Detecting new behaviors)

在程序执行过程中,fuzzer维护着一个全局的元组映射,这些数据能使用简单的dword或qword宽度的指令或者简单的循环(loop)迅速实现单次跟踪对比和更新。

当被变异后的测试输入执行跟踪,包含新的元组,相应的输入文件为后续的附加处理进行保留和routed。在执行跟踪过程中,对没有触发新的局部状态转换的测试输入将被丢弃,即使它们的总体控制流系列是唯一的。

这一方式在不需要对复杂执行跟踪时进行任意密集计算和脆弱的全局对比,以及避免路径爆炸问题的情况下,实现了非常细粒度和长期的程序状态探索。

为了证明算法的属性,在下列第二次跟踪时,将会潜在地认为是新的状态,因为出现了新的元组(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

为了发现新的元组,fuzzer同样会考虑语料元组命中计数。以下的序列会被分成若干buckers:

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

在一定程度上,bucket数目是人工实现的:依赖于fuzzer执行程序对每个元组的执行计数,它允许通过插桩到8个位置的位图(bitmap),实现8bit的计数器原地映射。

在单一bucket范围内的变动将会被忽略;从一个bucket过渡到另个bucket将被标记为程序控制流中感兴趣的变化。也会被routed进行进化处理(evolutionary process),这将会在后面提到。

这种命中计数(hit count)的行为潜在的感兴趣的控制流变化提供了一种鉴别方式,例如被执行了两次的代码块只会被命中一次。同时,对一些不是很显著的改变,会显得相当不敏感,例如从47轮循环到48轮循环。依赖于密集的跟踪映射,计数器同时还提供一定程度的元组碰撞的意外免疫(accidental immunity)。

通过内存和执行时间限制,程序执行将被相当严格地监控(policed)。默认情况下,timeout将被设置为初始校正的执行速度的5x,大概在20ms左右。设置aggressive的timeout值将能避免fuzzer性能的大幅度下降,改善代码1%覆盖率,但会有100x的速度下降。从实用角度讲,我们反对这样做,并希望fuzzer发现更轻量级的方式达到相同的效果。从测试经验看,更宽裕的时间限制所带来的开销并不值得,所以强烈建议不要这样做。

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

如果变异后的测试用例能在程序中产生新的状态转移,则会被添加到输入队列中,然后当成未来fuzzing轮数的起点。它们是作为补充,而不是自动替换已有的队列中的测试输入。

相比于更贪婪的遗传算法(genetic algorithms),这一方式允许fuzzer工具逐渐地探索潜在数据格式的各种互斥和可能互不相容的特征,如下图所示:
http://lcamtuf.coredump.cx/afl/afl_gzip.png
在以下链接中可以看到对这一算法相关的若干实用的例子及结果的讨论:
http://lcamtuf.blogspot.com/2014/11/pulling-jpegs-out-of-thin-air.html
http://lcamtuf.blogspot.com/2014/11/afl-fuzz-nobody-expects-cdata-sections.html

通过这种方式生成的综合语料(synthetic corpus)是非常紧凑(compact)的新的输入文件的收集。能被用做种子文件,用于其他测试处理。如对桌面app的手动指定的资源敏感型的压力测试。

大部分目标的队列增长到1k到10k之间,将近10-30%归因于发现的新元组,剩下的和命中计数的变化相关。

下表比较了使用若干种不同的方式引导fuzzing时,这些方式的发现文件语法和探索程序状态之间的相对能力。插桩目标是GNU patch 2.7.3,编译优化参数是-03,以dummy text 文件作为种子文件,会话使用afl-fuzz包括单一的通道提供给输入队列。

Fuzzer guidance strategy used Blocks reached Edges reached Edge hit cnt var Highest-coverage test case generated
(Initial file) 156 163 1.00 (none)
Blind fuzzing S 182 205 2.23 First 2 B of RCS diff
Blind fuzzing L 228 265 2.23 First 4 B of -c mode diff
Block coverage 855 1,130 1.57 Almost-valid RCS diff
Edge coverage 1,452 2,070 2.18 One-chunk -c mode diff
AFL model 1,765 2,597 4.99 Four-chunk -c mode diff

Blind fuzzing S 对应一轮测试的执行,Blind fuzzing L表示fuzzer在一个循环(loop)内运行若干执行周期(cycles)。和插桩运行相比,后者需要更多时间全面处理增长队列。

粗糙的相似结果已经在一个分开的实验中被获得,fuzzer被修改变异出所有随机fuzzing策略,只剩下一系列基本、连续的操作,如walking bit flips。因为这种模式(mode)将不能改变输入文件的的大小,会话使用一个合法的统一格式(unified diff)作为种子。

Queue extension strategy used Blocks reached Edges reached Edge hit cnt var Number of unique crashes found
(Initial file) 624 717 1.00 -
Blind fuzzing 1,101 1,409 1.60 0
Block coverage 1,255 1,649 1.48 0
Edge coverage 1,259 1,734 1.72 0
AFL model 1,452 2,040 3.16 1

另一个早期提到的,一些之前的遗传fuzzing的工作依赖于单一测试用例和包含最大化代码覆盖率的维护。但在上面提到的测试中,至少这种贪婪(greedy)方式似乎对盲fuzzing策略没有潜在的好处。

4. 语料筛选(Culling the corpus)

上面提到的渐进的状态探索方式意味着后续合成的一些测试用例的边覆盖(edge coverage)可能是之前测试用例的代码覆盖的超集。
为了优化fuzzing,AFL周期性的重新评估队列,使用一种快速算法选择仍然能覆盖每一个元组(覆盖率不变),但有更小的测试用例的子集,这一算法的特性使得对工具特别适用。
算法通过指定每一个队列入口,根据执行延迟(execution latency)和文件大小分配一个分值比例(score proportional)。然后为每一个元组选择最低分值作为候选。
然后使用简单的工作流(workflow)对元组进行一系列的处理:
1) 找到下一个还没有在临时工作集中的元组;
2) 为这一元组定位获胜队列(winning queue)入口;
3) 注册所有的元组(all tuples)到工作集中的entry trace;
4) 如果在集合set中有任何丢失的元组,跳到#1。

生成的“favored”entries的语料与初始数据集相比,在体积上通常要小5-10x倍。Non-favored entries将不会被丢弃,但当在队列中遇到的时候,它们会被变化的(不同的)概率(可能性)跳过:

  • 如果在队列中有新的优先的条目(favored
    entries),为了到达这些条目,99%的non-favored条目(entries)将被跳过;
  • 如果没有new favorites:

    • 如果当前non-favored entry是之前被fuzzed过得,它将被以95%的概率跳过;
    • 如果它还没有通过任何fuzzing rounds,概率将被下降到75%。

    通过实验检验,这在队列周期速度(queue cycling)和测试用例的多样性之间提供了合理的平衡。

    稍微更复杂但是速度要慢得多的筛选(culling)方法可以使用afl-min工具对输入或输出的语料进行处理。这一工具将永久丢弃冗余entries,产生适用于afl-fuzz或者外部工具的更小的语料库。

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

文件的大小对fuzzing性能有非常大(dramatic)的影响,因为大的文件使得目标二进制运行变慢,同时因为有太多冗余数据块,降低了对重要格式控制够变异的可能性(likelihood)。这些在perf_tips.txt中有更详细的讨论。

用户可能提供低质量初始语料,而一些类型的变异能对迭代增加生成文件大小有影响,所以把这种趋向计算在内很重要。

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

afl-fuzz内置(built-in)的修剪器使用变量长度(variable)和stepover尝试连续地删除数据块,任何删除将不会影响提交到disk的trace map的校验和。trimmer并没有经过特别周密(thorough)的设计,它尝试致力获得精确性(precision)和过程中execve()调用之间的平衡,选择块大小和stepover进行监控。每个文件的大小将增大大约5-20%。

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

首先,工具将自动选择操作模式。如果初始化输入崩溃了目标二进制,afl-tmin将以非插桩模式运行,简单保留任何能产生更简单的文件但仍然能够崩溃目标二进制的tweaks。如果目标没有崩溃,工具会使用插桩模式,只保留能精确产生相同路径的tweaks。
实际的最小化算法是:

  1. 尝试使用大的stepovers的方式对大的块进行归零处理。经验上来说,这主要是为了后续先取细粒度的方式减小execs的数量。
  2. 通过减小块大小和stepovers的方式对块进行删除;
  3. 通过计算唯一性字符,以及尝试使用0值(zero value)进行批量替换(bulk-place),达到进行字母标准化(alphabet normalization)的目的;
  4. 对非0字节进行逐字节的标准化(normalization)。

afl-tmin使用ASCII数字’0’而不是0x00对块进行归零处理。这样做是因为这种修改更不会影响到涉及到字符串相关的处理(0x00是字符串结束标志)。所以对text文件的最小化方式可能会更成功。

这里采用的算法比一些学术界采用的其他的测试用例最小化的方法要少一些,但应用到现实世界应用程序中是,我们只需要更少的执行开销,并具有相当好的效果。

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

通过插桩的反馈使得更容易理解各种fuzzin策略的价值,并优化她们参数,这样它们对不同的文件类型都能同等地工作。afl-fuzz使用的策略通常是格式不可知(format-agnostic)的,这个在以下链接中有详细介绍:

http://lcamtuf.blogspot.com/2014/08/binary-fuzzing-strategies-what-works.html

需要稍微注意的是,afl-fuzz的大部分工作事实上都是高度确定性的,随机堆放的更改和测试用例拼接(splicing)只在后续的步骤中有所涉及。确定性的步骤包括:

  • 使用变化的长度(lengths)和跨度(stepovers)进行连续的位翻转(Sequential bit flips);
  • 使用小整数进行连续的增加和删减;
  • 使已知的有意思的整数(如0, 1, INT_MAX, 等)进行连续插入。
  • 使用确定性的步骤的目的是为了产生紧凑的测试用例,以及生成崩溃和非崩溃输入之间小的diffs。

使用确定性的fuzzing策略是比较少见的,非确定性(non-deterministic)的步骤包括对不同测试用例采用的堆积的比特翻转(stacked bit flips),插入、删除、算术以及拼接。

在这些所有的策略中,相关的yields和execve()代价已经在之前研究和讨论过。

正如在historical_notes.txt所给出的原因(性能、简单性、可靠性),AFL通常不会尝试推导特定变异和程序状态之间的关系;Fuzzing步骤是名义上盲目的(nominally blind),只被输入队列进化的设计(evolutionary design)引导。

对上述描述的策略,有一个比较小的特例,当新的队列节点(queue entry)通过确定性模糊测试步骤的初始集,以及tweaks到文件中对执行路径校验和没有影响一些区域;它们可能会把剩下的确定性fuzzing,以及fuzzer可能直接处理随机tweaks,排除在外。特别的,人可读的数据格式,能在没有很大的降低覆盖率的前提下大约减小execs数量的10-40%。在极端情况下,通常块对齐的tar打包文件,能减小90%。

因为潜在的“effector maps”是局部每个队列entry,以及只有在确定性阶段没有改变潜在文件大小或者通用布局的前提下仍然有效,这一机制似乎工作地非常可靠,并且实现起来也非常简单。

7. 字典(Dictionaries)

通过对被测试的解析程序的插桩反馈,容易在一些输入文件中自动化鉴别语法(syntax)符号(token),并检测出某些预定义的组合,或者自动检测包含一个合法的grammar的字典字段。

关于这些特征(feature)如何在afl-fuzz中实现的细节,可以查看:
http://lcamtuf.blogspot.com/2015/01/afl-fuzz-making-up-grammar-with.html

本质来说,当基本的、典型的、容易获得的语法符号(syntax tokens)是以纯粹随机的方式组合在一起的。插桩和进化(evolutionary)设计,提供了一种能区分无意义变异和在插桩代码中触发新的行为的变异的反馈机制,以及在这些发现的基础上增量建立更复杂的语法(syntax)。

字典方式已经被证明能够使得fuzzer快速重建高度冗长(verbose)和复杂语言(如JavaScript、SQL以及XML等)的grammar。一些生成SQL声明的实例已经在之前的blog中提到。

有意思的是,AFL插桩同样使得fuzzer能自动隔离(isolate)已经在输入文件中出现过得语法符号(syntax tokens)。它能通过寻找运行中跳转(flipped)的字节,为程序执行路径生成一个一致(consistent)的改变。这隐含着潜在的和代码中预定义值自动化对照的机制。

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

崩溃去重(De-duping crashes)是任何具有竞争力的模糊测试工具需要解决的一个更重要的问题。许多比较原始的方式是,如果错误发生在一个通常lib函数中(如strcmp、strcpy),通过查找可能导致完全不相关问题的错误地址;如果错误通过一些不同的、可能循环(递归)的代码路径时,校验和调用栈回溯跟踪时能导致极大地崩溃计数膨胀(crash count inflation)。

在afl-fuzz中的解决方案是,如果任意两个条件满足以下条件,则认为这个崩溃是唯一的(unique):

  • 崩溃跟踪包括一个之前崩溃中没有遇见过的元组;
  • 崩溃跟踪缺少之前错误中总是会出现的元组。

这种方式对一些早期的路径计数膨胀是非常有用的,但也显现出一个非常强的自我限制的效果。和执行路径分析逻辑相似,这是afl-fuzz的基石(cornerstone)。

9. 崩溃审查(Investigating crashes)

许多类型的崩溃的可利用性可能会具有不确定性,afl-fuzz尝试通过提供一个crash exploration模式缓解这一问题。对能触发已知错误的测试用例,将以一种和正常操作非常相似的方式fuzz,但任意不会导致crashing的变异将会被丢弃。

关于这种方式的价值在以下链接中有详细讨论:
http://lcamtuf.blogspot.com/2014/11/afl-fuzz-crash-exploration-mode.html

这种方法使用插桩反馈的方式探索崩溃程序的状态,目的是通过(pass)不明确的(ambiguous)错误条件,然后隔离出(isolate)新发现的测试输入提供给人工复查。

关于崩溃的问题,值得注意的是,与正常的队列entries(normal queue entries)相比,崩溃输入并不会被修剪(trimmed);它们将被完全保持被发现时的结构和大小,这样能使得更容易和队列中的parent样本进行比较。所以后续可以使用afl-tmin工具对其进行缩减处理。

10. The fork server

11. 并行化Parallelization

12. 二进制插桩Binary-only instrumentation

13. afl分析工具The afl-analyze tool

猜你喜欢

转载自blog.csdn.net/youkawa/article/details/76615480