陈鹏:大家好,今天我给大家分享一下怎么从零开始实现Rust Fuzzer,又叫模糊测试工具。首先介绍一下我自己,我叫陈鹏,来自腾讯安全的安全大数据实验室,我们实验室主要以人工智能和大数据来解决安全问题为目标,在这个过程中我们也在尝试把Rust和安全与大数据这两个核心要素结合在一块,包括两个方面。
首先我们用Rust来处理、分析海量的安全数据,在这个过程中我们搭建了一个安全日志平台,在这个平台上我们实现了一个高性能的时序数据库,以及一套安全威胁检测与响应的分析引擎。通过这个平台我们在未来会支撑腾讯安全的安全检测算法与产品。
另一方面,我们也尝试用Rust来生成大量的数据来发现安全问题,这也是一个自动化挖掘的工作,也是我们今天本次分享要分享的模糊测试工具的方向之一。我本人之前也实现过很多的模糊测试工具,包括开源的Angora Fuzzer,这可能是第一个用Rust实现的开源Fuzzer,也为后来大量的Fuzzer用Rust实现奠定了一个基础。如果大家对我们的工作感兴趣,或者想加入我们的话,可以联系我。
我们这次的分享会包括三个部分,第一个部分是模糊测试的背景介绍。另外一个就是讲如何用Rust来生成高质量的输入。还有一个就是在针对Rust我们怎么去设计一个简单易用的Fuzzer。
然后首先我们来讲讲什么是模糊测试。模糊测试是一种能够自动化的帮你发现软件缺陷的方法,它在商业软件和开源软件被广泛地使用。经过实践证明,它能够发现大量的安全缺陷,它的想法看起来有点简单,通过给软件构造大量的随机的输入,然后丢给这个被测试的软件进行执行,看看发生了什么。如果这个软件崩溃了,那么恭喜您可能发现了一个软件BUG。虽然它很简单,但是它也带来了很多的好处,它可以被大规模的使用起来,并且因为它提供了输入,我们就可以来复现这个漏洞,并且很少误报。
回到Rust本身,Rust需要Fuzzing吗?Rust是一门针对安全而设计的一个语言,它能够保证我们的内存安全和线程安全,但这并不能保证我们的代码是完全安全的。我们的代码里面还是会存在Panic,还有迫不得已去引入一些Unsafe code,同时它也无法解一些逻辑的缺陷。下图展示了在实际过程中Fuzzer可以找到的一些实际的Rust代码里面的漏洞,也对应着刚才我举的那三个问题。
对于Rust开发者而言,他们选择Rust的原因就是,他们想要让自己的代码非常安全,如果我们Fuzzing能把这个问题做得更好,那我觉得就是对Rust语言而言,它可能更需要我们的Fuzzing。
Rust设计的开发者很早就意识到了这个问题,并且已经在很早就把市面上一些比较热门的Fuzzer,比如说LibFuzzer和AFL引入进来做了支持,同时在Angora之后也有一系列的Fuzzer选择用Rust来实现它们的功能。下图是一个cargo-fuzz的一个例子,比如说我们要测试parse这个函数,它可以生成一系列的随机的bytes,然后我们可以把它转成我们需要的这个数值类型,然后把它喂给这个输入。
回到我们的问题本身,怎么去实现一个模糊测试工具呢,一个模糊测试工具需要什么呢?最简单的来说就是我们可能需要一个生成器来生成大量随机的输入,然后还需要一个执行,把这些输入拿给这些被测试的程序来执行,然后看看它是不是会Crash。
在Rust的生态中有一个我们常见的库叫rand,它提供了在无状态分布下的常见数值类型的随机生成。通过它我们可以在不到十行的代码内去实现一个最简单的模糊测试工具,可以看看下面的这个例子。
我们通过构造一个循环,在这个循环里面去用rand来生成一个随机长度的数组,并且往里面塞一些随机的数值,然后把它喂给我们被测试的parse函数,就实现了这么一个模糊测试工具。但是像这种撞大运似的随机生成其实是不是很高效的,我们其实需要一个更好的Fuzzer。为了做一个更好的Fuzzer,我们其实需要两方面的工作,一个是我们需要用我们的算法能力来生成一个高质量的输入,来更有可能地触发漏洞。像随机生成可能你得跑个一年几百万次才能碰撞出来的一个东西,然后我们高质量的生成可能它在10秒之内就可以去找到这个需要的值,并且把它生成出来。
从另外一方面,对于我们刚才的例子,如果我们每测一个函数就得去写一些这种比较繁琐的代码,像刚才是一个比较简单的例子,这其实有很多的人工成本,我们怎么去做一个开箱即用的模糊测试工具。对Rust而言,我们可以通过一行代码或者一个标注就把这个模糊测试工具把它启用起来,这也是我们想要的。
首先我们就来讲讲我们怎么来生成高质量的输入。
我们先来看看史上最成功的模糊测试工具AFL,它是怎么做的。AFL将路径反馈引入到了模糊测试之中,可以看看这个图,它会去观察被执行程序的一个路径反馈,如果它这个路径反馈又触发新的覆盖,那么就会把这个输入加入到一个种子队列里面。每次生成我们是从这个种子队列里面选择一个输入,并且对它进行变异,来生成一个新的输入。
通过AFL启发我们可以看到,之前这种完全无状态的随机分布是不够用的,在不同的场景下我们可能是需要不同的分布。也就是说我们可能对不同类型,需要不同的分布。或者对不同状态需要不同的分布。也就是说我们可能需要的是一个有状态的分布。为了做到这一点我们可能就去得做一个有状态的生成,其实AFL本身也是一种有状态的生成,它去维护了这种覆盖、路径这些反馈,然后根据这些反馈和它维护的状态来判断这个输入是不是触发了新的状态。
另外一点就是,我们的输入生成就可以像AFL一样,可以在之前的有效输入上进行变异,但我们把这个概念拓展开来就是,我们可以根据反馈来调整我们下一次输入的生成。
最后一点,我们还是再强调一下,就是我们会对这个不同的类型去做不同的生成,也就是我们可能对Rust中不同类型都得去实现它对应的生成方法。
这样我们就有了一个新的生成方式,我们定义了两个Trait,一个叫FuzzGenerate,一个叫FuzzMutate。这两个方法在之前用rand函数来生成之外,多了一个state参数。然后Mutate和Generate的区别是,Mutate是对已有的输入进行变异,而Generate是从零到1进行生成。然后在这我们的state的可能包含什么呢?就可能是一些,就是我们刚才提到的反馈得到的信息。比如就是我们的程序执行的控制流和数据流等,它们可以帮助我们确定下一次输入的值。或者我们也可以去维护就是我们对这个输入的生成次数,然后通过它来确定我们现在所要采用的一些生成策略等等。
然后定义了这些Trait之后,我们现在的这个测试回路就变得有状态了,我们在外面会去维护这个种子和一个state的状态。然后在这个回路里面我们会去选取种子,并且依赖这个状态信息来做生成。然后在这个生成的输入被执行之后,我们会去拿反馈,并且来把这些反馈的内容加入到我们的状态信息里面。然后后面我们来讲讲就是我们怎么来具体的对这种不同类型做实现它们的生成方式的。
这边是先讲一讲Generate的生成,就是我们可能会有四种策略,一个就是我们可能是会引入一些确定性的策略。比如说第一次生成我们必须要它为0,为0是一个比较容易触发BUG的值。另外一个就是我们会更大概率地使用一些预先设定的语料值,比如说它的最小值,这个类型的最小值或者最大值或者最大值+1,这样可能会触发一些overflow的漏洞。第三点就是我们刚才提到了我们会根据它的反馈来计算我们输入下一次可能的值,并且在这个生成回路里面我们会把它拿出来,采用它。比如Angora就是通过反馈中的控制流和数字流,来决定输入是该为哪个值,能够让这个约束更有可能地去被解决。第四个方法就是我们回顾最简单的完全随机的生成,右图就是u32类型对应的这四种策略的一个实现。
除了生成之外我们也引入了变异行为。变异行为也是对不同类型有不同的实现,这边我们的例子是一个数值类型,还是u32。对这种数值类型我们会在它的位或者字节上进行替换或者尝试加上或减去一个比较小的数值。但是如果对于序列类型我们就会去尝试对这个序列长度进行变换,包括删掉或者增加里面的一些元素。如果它是复合类型的话,比如说结构体,我们可能就是把它中间的几个field拆开,然后跟另外一个结构体进行拼接。
然后介绍完了我们怎么去生成和变异后,我们来简单介绍一下我们怎么去定义覆盖反馈以及怎么去收集判断反馈是不是触发一个新的状态。
覆盖反馈我们其实就是把它定义为我们访问过的一个输入访问过的路径的集合。然后一个路径就是可以把它抽象成就是一个代码块到另外一个代码块的行为。如果用Rust的代码来表示的话,像右边就是如果你想要表现一个路径的话,它就是一个BasicBlock的type,然后这个BasicBlock,我们可能就给它会设一个unique的ID,那这个我们的覆盖那就是一个edge,就是路径的一个集合。那怎么去判断我们当前的这个覆盖有没有触发一个新的状态?我们可以去维护一个累积的覆盖反馈的集合。如果我们当前的覆盖反馈的集合并不被我们这个累计的覆盖包含,那我们可以说明,它触发了新的覆盖,并且把这个新的覆盖合并到这个累积的覆盖中,右边也是一个实现的例子。
在Rust中也是已经引入了就叫LLVM SanCov的parse,它可以帮助我们很好地来收集程序本身的控制流或者数据流相关的信息,来当我们的反馈。这边Sancov它提供了很多个不同的钩子,下面举了两个,有一个叫trace_pc的钩子,还有一个叫trace_cmp的钩子,它可以分别帮助我们收集一个每条路径被触发行为,触发之后的一个行为,或者就是比如说比较指令被触发的一个行为,然后帮助我们收集它们的信息。底下也是一个收集覆盖反馈的例子,在这个钩子里面我们会把这个guard,其实就是你可以把它抽象成当前的一个路径的ID,然后我们就把这个ID所指向的我们这个Set里面的bucket把它置1,就证明这条路径已经被访问过。
然后另外一个问题就是我们刚才提到的,我们怎么把刚才这套有状态的生成,在我们的Rust里面把它变得很简单易用。
首先我们再看一下一开始cargo-fuzz提供的生成Fuzzing的方式。我们其实需要指定,我们这个类型是一个什么,我们的输入是一个什么类型的,其实它只能生成这种byte类型的一个数据,它对我们要测试的这些参数其实没有感知的。如果我们想做到开箱即用的话,其实我们肯定不想要每次都得指定它是什么类型,我们可能就是像底下这样,我们只需要一行,对这个函数只要一行代码调用它的Fuzz函数,或者在它上面加一个Fuzz的标签,那就可以启用它。这样我们就可以不管输入是什么类型,不用怎么担心你怎么去处理错误维护这个状态,然后我们就完全是一个你要不要用的问题。为了做到这种程度,我们其实需要做的一件事情就是对所有的Rust中的函数来实现Fuzz这个trait。
因此我们有了一个新的测试回路,在这个新的测试回路中,我们要做三件事情,第一就是我们需要对所有的函数,就像第一行的这个例子上,这是对两个参数的一个函数,然后来实现Fuzz这个Trait。另外一点就是我们需要对所有的输入类型,也就是它参数所包含的所有的类型实现我们的Generate和Mutate的Trait。另外一点我们需要就是一个包装得更好的执行区,这个执行区可以帮我们处理反馈状态等等一些比较琐碎的东西。
由于函数本身也是一种类型,所以我们可以把它的参数类型的抽象抽取出来,就比如说它的参数都是Mutate、Generate的,这样我们就可以像刚才一样,通过一个实现对所有的,比如说参数长度为1或者为2的,进行实现它的trait,然后接着借助宏的能力,我们可以对不同长度,比如说长度为1到12的函数类型来实现这个trait,可能就只需要非常少的代码,我们就可以把对所有函数实现Fuzz这个trait的事情解决。然后解决了这个问题之后,我们剩下的问题就是对所有的输入参数的类型,也就是所有我们在Rust可能见到的类型来实现,它的Generate、Mutate的trait。为了做这件事情我们可以用,还是继续用宏和抽象的能力来帮我们做这件事情。对于一些比如说它们的类型功能比较统一的,比如说数值类型,我们也可以用宏把它包起来,做一个统一的实现一些功能。然后或者就是它们有一些比较相同的抽象能力,比如说num trait给所有数值类型提供了cast等等的抽象,那我们也可以用这些能力来给数值类型实现它们的一些变异的操作。比如说这边也举了一个怎么去实现bit_flip的例子,这样我们就可以对所有的数值类型实现这个功能。
然后对于刚才讲的一些原生类型的实现,我们可以通过我们的能力把它一个一个来实现,当然我也有一些非原生类型它是未知的,我们来怎么做呢?有一个思路就是我们可以像Cargo-fuzz一样,我们只生成字符串,然后交给你来做反序列化的工作,然后把它反序列化成具体的类型。但是这样的话其实我们就会丢失它的类型信息的,我们还是需要这个类型信息来帮我们做更高效的生成。我们就可能还是需要去对这些类型实现对应的Generate trait或者Mutate trait。对于容器类型这也是一个比较简单的问题,其实我们也可以用它内部这个成年的抽象能力,然后给它做一个统一的实现。包括Vec和数组我们都可以把它实现,包括const N: usize也是最近进入到stable中了。
另外一个比较麻烦的问题就是对于结构体我们要怎么生成,我们没办法像之前一样用它的抽象能力。那我们就可能不是抽象就是宏了,这边我们就可以用一些过程宏的方法,把这个像我们常见的derive的或者clone这种derive的实现一样,把它用过程宏展开来,对它的每个字段我们都去分别调用它的生成的或者变异的一些函数。然后最后把这些字段拼到这个结构体上,我们就可以得到这些结构体的Generate、Mutate的一些实现了。这边注意了一下,是我们对这些里面的每个字段,我们也是会去分别给它们维护一个state。
经过这样的处理,我们就几乎对所有的可以见到的Rust中的类型实现了Mutate、Generate,这样我们也相应地对所有的函数都实现了它的Fuzz的方法。这样我们刚才一开始提到了这种用一行代码或者一个开关来使用Fuzzing,并且是一个类型感知的高效的Fuzz的方法就可以完成了。
最后一点我们来讲讲我们执行还需要做的一些事情,除了刚才在最前面提到的,我们可能需要去做一些反馈的处理之外,其实还有两个很重要的东西是在执行时需要去做的。一个是我们需要去捕获Rust代码中的异常,包括Panic或者Abort或者Unsafe代码中的异常。然后除了这些之外,其实安全研究员还提供了各种Sanitizers工具,提供我们来捕获一种可能会潜在代码中的一些异常现象,或者您也可以去制定一些规则。
另外一点我们需要就是保证我们的可复现性,就是在我们发现异常的时候,我们要保留上下文。这点如果我们简单地实现可能就是直接把这个输入做一下序列化保存到文本中。如果我们使用那个Serde trait其实就很简单地帮我们实现了这种东西,然后这些被保存的上下文我们就可以用它来做回归分析,然后来分析我们这个漏洞的成因等等。
在Rust中如果你只是想捕获Panic这种类型的错误,其实它已经提供了一个很简单的方法,我们直接叫用catch_unwind,就可以把它捕获住。但是它对abort或者unsafe code里面的一些异常其实是没法处理的。所以为了处理这些事情,我们可能需要去做一些Hook来捕获这些信号量。或者如果我们的代码中有一些副作用,比如它会改一些前级的一些变量或者有一些内存的泄露,可能就会使用Fuzzing中比较常用的一个叫Fork Server的技术来解决这个问题。由于篇幅有限,我们这边就不会展开。
Rust设计其实也很早地就引入了一些Sanitizers,包括一些内存的错误的检查器,一些线程竞争的检查器等等等等。如果大家感兴趣,可以去看看这些Sanitizers怎么使用。但是想强调的一点就是,我觉得Sanitizers是对Fuzzing是非常重要的,因为它们定义的这些规则能够在你生成输入的时候,帮你更快地准确地发现你的漏洞,而不是让这个可能就是你触发这个漏洞,但是你却把它忽略了等等。
最后我们总结一下,一个好的模糊测试工具其实需要三点,一个就是它的生成需要足够高质量、高效。另外一点就是它的执行要足够快,因为我们这个跑的时间其实需要蛮久的。然后最后一点就是,它要足够的简单易用,因为我们可以Fuzz的点是很多的,如果你要不断地去给不同的点去实现,其实这会耗费很大的人力。对这三个点,其实Rust可以帮助我们做很多东西,第一就是它的抽象能力,还有过程宏可以帮助我们在做生成的时候简单许多。并且它这些能力可以帮助我们去做一个简单易用的模糊测试工具。另外一点就是Rust本身可以做一些比较底层的优化,可以让我们也去让这个执行更加高效。最后一点就是,Rust本身的安全性能够保证我们自身的Fuzzing的工具没有漏洞,这也是一个安全员的执着吧。如果大家对我们的工作有兴趣的话,可以加:wechat: chenpeng0146 联系我,谢谢大家。