什么是Fuzz?
嘛这篇blog 还是会是一篇和计算机相关的Blog, 希望有机会我也能写一些和上面图里的Fuzzer相关的文章hhhhh
如何保证程序的正确性?
相信每一个程序员都会因为这个问题而感到困扰。我在雪城学到最多的一点就是,正确是需要代价的
这一点其实在生活中或者工作中被我们常常忽视, 我们用大量的时间去学习开发程序但是大多数人并没有认识如何向别人证明你的程序是正确的。 而在计算机科学中,这种昂贵的正确性给了很多研究者funding。
程序无非是符号运算, 有一种做法是,使用逻辑学数学工具,抽象出我们使用的编程语言的语义,然后通过数学的方式去证明你的程序是正确的, 这是一种非常严谨的方式, 被称作 形式验证 。 这种方式听起来很美好,但是这种正确却是有极其巨大的代价的, 通常,证明一个程序正确需要的时间,人数是编写这个程序的10倍以上。 这对于很多开发者和团队来说往往是不能接受的,而且世界上可能也并没有这么多的Proof Engineer 给你去招聘。
还有一种做法,使用一些简化的语义, 或者说,把你程序中的一些重要的性质单独拿出来研究,比如我们只关注和用户输入输相关的性质(taint analysis)。 这种做法的本质(在一些流派看来)是把程序的执行通过某种数学映射,映射到一比较简单,并且具有单调性质的执行上。这种方法叫做 程序分析。
不论是程序分析还是形式验证都是非常严谨的方法, 他们本质都是使用数学的性质去证明和验证分析一个程序。也就是说他们非常非常的注重Soundness.
但是上面说的方法都有一个致命的缺点:他们都需要在你知道程序源代码。这么说甚至还不准确。你不能只是“知道”,你必须对这段代码的理解非常非常的深刻。事实情况是,对大多数人来说,自己的代码往往都无法做到彻底清晰,时常过几天就不知道自己之前写的啥玩意,对于自己使用的程序语言的性质往往都一知半解,更不要说抽象出程序的语义了。
这些方法和其他的一些方法比如符号执行(symbolic execution) 本质上都属于白盒方法。
如果我们对程序一无所知,那么其实也是有办法的。比如绝大多数的研发团队都会有配套的测试团队。对于测试工程师来说, 他们并不编写程序,也不了解程序,他们只关心程序的输入和输出, 这种方法属于黑盒方法。传统测试是一种非常不严谨的方法,即使是通过了测试,你的程序大概率依然是千疮百孔的。 这也是为上程序员都要花大量的时间在debug上。。。。
上面提到的这些验证程序正确的方法想必对于大多数人都是听说过或者使用过的。 那么有没有一种能够介于白盒和黑盒之间的方法呢?
当然是有的
同时对源代码和程序的输入动手脚!今天我要介绍的灰盒Fuzz就是这种思想的一种体现。Fuzzer通过修改源代码/二进制程序的方式,插入一些探针,通过这些探针来了解程序运行过程中一些性质,比如程序的控制流/数据流信息。其实可以说是一些动态分析的手段。在获取了这些信息后,以此来评价测试用例的“好坏”, 通过这种反馈机制, 再对输入的测试用例进行变异(mutation), 进而不断的自动生成大量的测试用例,尽可能的去触发程序崩溃。以此来找到藏在程序中的bug。
Fuzz测试器
一个Fuzz测试器从总体结构上可以分为前端,后端,Fuzz算法三个部分。前端部分包括和测试用例相关的部分, 而后端部分则是包含和被测试程序源代码相关的部分。
先从后端部分开始说吧。
Fuzzer后端的主要工作是插入指令(instrumentation). 之前说了灰盒fuzzer是需要知道程序的源代码的,并且会修改程序的代码,但是我们又不希望在测试的过程中去深入了解代码本身写了什么。 一种很直接的想法就是在编译器上动手脚。 比如在编译的时候增加一道llvm pass,或者修改as汇编器, 在生成汇编的时候插入一些我们需要用的和内存,控制流相关的指令,收集我们要的信息然后输出到文件或者共享内存之类的地方,让fuzzer程序能够阅读到这些信息。
fuzzer的前端部分一般主要包含3个重要的组成部分(有的fuzz器可能只有其中的两个)。 让fuzzer能够不断的生成新测试用例的核心部分变异器(mutator), 控制生成的测试用例长度的修剪器(trimer), 和生成初始测试用例的seed/cropus生成器,(cropus只是在fuzzer界比较常用的一个称呼seed初始测试用例的名词)。其中最最重要的就是mutator,mutator一般来说是一个随机程序, 根据一定的规则对测试用例进行随机变异。比如,每6个bit取一次反,插入一些奇怪的int值之类的。
Fuzz算法部分其实反而是一个没有那么多变化的部分。总的来说,fuzz算法都是一些类似遗传算法的启发式的算法。他们一般会对测试用例和程序之间的关系给出一个基本的假设,然后fuzzer在这种方向上,根据后端给出的反馈数据,给前端的每一次mutation进行打分,保留优秀的变异, 去除不良的变异,尝试的去学习什么是一个更加容易crash程序的mutation。一般来说这种打分机制会会越来越接近真实的程序输入。比如对于一个jpg处理程序, 初始的seed很可能只有一和hello world字符串,但是fuzz算法可以通过不断学习最后让测试用例变异成真正的jpg格式.
有人是不是很想说这个和神经网络,人工智能很像可以用AI方法啥的。
然而遗憾的是。Fuzz算法和AI风马牛不相及,甚至我觉得是完全相反的追求。Fuzz算法的策略是固定的,也就是说我们预先已经假设好了什么样的mutation是好的,而现在的AI大部分是希望能去学习得到什么样的是好的策略,AI往往需要办法去学习得到一个非常复杂的规则,比如一个巨大的特征矩阵。但是一个优秀的fuzz算法必须是非常简洁,甚至是非常粗糙的。因为fuzz器的算法部分非常追求极致的效率,甚至对于每个bit比较的性能都非常的计较。因为程序的逻辑的复杂性往往远超程序员的想象,如果你做过SE,或者使用过程序分析的工具,你就会很明白,稍微精度高一些的工具对时间复杂度的变化根本不是几倍或者几十倍的区别,是直接从多项式P时间退化到NP的差距。所以Fuzz的哲学就是,彻底放弃精度,用海量的测试来来代替。一个Fuzzer迭代周期很可能在12小时以上,一次常规的fuzz测试往往需要10个以上的迭代周期,稍微复杂一点就会把几天的工作变成几个周。。。
覆盖率导向Fuzz(Coverage Guided Fuzz)
目前最流行的Fuzz工具毫无疑问是AFL, 全名叫American fuzzy lop。
反正是很草的一个名字.
当然LLVM宇宙肯定是有自己的fuzz工具的叫libFuzzer.
这两种非常流行的fuzz测试工具都属于覆盖率导向Fuzz。他们的Fuzz算法部分和后端部分本质都是一样的,即认为,如果想要生成crash程序的变异,那生成的变异就要尽可能的去覆盖新的程序Control Flow Path。这样从总体来说,Fuzzer就可以不断的提高程序测试用例对于程序控制流的覆盖率。
这是一种非常直接并且被证明异常高效的Fuzz策略。下面我会主要介绍AFL的后续机型AFL++。
AFL本体是成熟,具有工业强度,在各大厂被广泛应用的测试工具,FireFox等大型项目都在使用。所有没有任何理由不把他加入你的CI/CD工具链!!!
AFL的后端部分主要是两个程序,一个是一个被修改过的as
汇编器,一个是afl-clang-fast
。其实还有一个gcc的wrapper但是实际上afl-gcc
只是设置一下编译参数,真正的instrument的部分都在as中完成。afl-clang-fast
没什么好多说的,对llvm ir进行instrument. 其实这部分比我想象中的简单,原理是。 在共享内存里预先会由afl本体生成一个bitmap,每一个index对应程序中的一个或者多个控制流节点,为什么不是1对1呢?因为程序的控制流可能非常复杂,节点非常多,为了效率这里就只能近似一下了。在每一个cfg的block前面都插入一个随机生成的id, 如果程序执行到这个block就把id对应的Bitmap位置置1。最终所有被标记1的位置就是一条程序的执行path。这个方法其实很粗糙,第一是节点映射不是单射,第二对于path经过节点的先后顺序其实没法准确的区分。
前端部分,AFL对于seed/cropus没有太多的处理,只是会trim, 也不会管是不是一个比较优质的seed。 他的mutator主要是依赖bitfliper策略和插值策略。 bitfliper会在input 测试用例上每隔几个bit对bit进行一次反转,这种策略很蠢但是很快,数量和启发式算法可以弥补这一点。 插值就是afl会插入一些interesting value,这些value主要有两个来源:
- 一个用户指定的字典
- Fuzz算法在打分过程中发现的比较容易产生变异的数据。
简单的来说就是这样,当然AFL里有很多东西远比我想象的复杂,比如如何提升效率,如何处理error的程序等等。
AFL++
AFL++是我目前在使用的Fuzzer, 这个东西是AFL的后续型号, 我主要是为了他的Custom Mutator 功能。 在AFL++中用户可以定义自己的mutator来提高变异的质量。因为对于一些程序,比如编译器,他的输入总是一个程序代码,在变异的过程中比较有效的变异肯定是起码还是要让他是个代码。但是原生AFL的变异比如bitfliper,插入int值之类的,一变代码就不再是代码了。。。。对于seed program简直是反向变异,就算是找到啦bug很可能也是一些比较无聊的bug。
最后放个图。。。怎么fuzz编译器是个更加复杂的问题,下次得单独开blog讲啦。