介绍 Fuzzomatic:使用AI从零开始自动为Rust项目进行模糊测试
2023年12月7日 Nils Amiet AI, 研究
引言
2023年8月,谷歌发布了他们关于AI驱动的模糊测试的研究。他们展示了通过AI可以自动提高已加入OSS-Fuzz的C/C++项目的模糊测试代码覆盖率。研究发现,在31个项目中的14个(即45%)上,生成的模糊测试目标成功构建。
这项研究启发了我们,我们认为可以扩展这项工作并带来一些新的东西。之前的研究是从已经进行模糊测试的项目开始的。实际上,所有加入OSS-Fuzz的项目至少有一个现有的工作模糊测试目标,这正是上述研究中用于构建提示的内容。我们想更进一步,能够在完全没有现有模糊测试目标示例的情况下,从零开始对项目进行模糊测试。
为了最大化成功率,我们决定专注于用Rust编写的项目。不仅仅是因为我们喜欢Rust,还因为用这种语言编写的项目通常具有高度一致的项目结构。实际上,大多数项目使用Cargo作为构建系统,这最小化了Rust生态系统中的碎片化。关于Rust代码库可以做出许多假设,因为很容易知道如何构建它、在哪里找到单元测试、示例及其依赖项。
考虑到这一点,是时候构建一些能够从零开始自动对Rust项目进行模糊测试的东西了,即使这些项目根本没有进行任何模糊测试。
我们的贡献
我们构建了Fuzzomatic,一个用Python编写的Rust项目自动化模糊测试目标生成器和错误查找工具。Fuzzomatic能够为Rust项目生成成功构建的模糊测试目标,完全从零开始。有时目标会构建但不会做任何有用的事情。我们通过评估运行模糊器时代码覆盖率是否显著增加来检测这些情况。在成功的情况下,目标会做一些有用的事情,比如用随机输入调用被测项目的函数。它有时还会自动在被测代码库中发现panic。该工具报告生成的目标是否成功构建以及是否发现了错误。
它是如何工作的?
Fuzzomatic依赖libFuzzer和cargo-fuzz作为后端。它还使用各种结合AI和确定性技术的方法来实现其目标。
我们在方法中使用了OpenAI API来生成和修复模糊测试目标。我们主要使用了gpt-3.5-turbo和gpt-3.5-turbo-16k模型。当我们的提示比前者支持的更长时,后者用作后备。
模糊测试目标和覆盖引导的模糊测试
第一步的输出是一个源代码文件:一个模糊测试目标。Rust中的libFuzzer模糊测试目标看起来像这样:
|
|
这个模糊测试目标需要编译成可执行文件。如你所见,这个程序依赖libFuzzer,也依赖被测库,这里是"mylib_under_test"。“fuzz_target!“宏使我们很容易只写需要调用的内容,前提是我们接收一个字节切片,即上例中的"data"变量。这里我们将这些字节转换为UTF-8字符串,并调用我们的目标函数,将该字符串作为参数传递。LibFuzzer负责用随机字节重复调用我们的模糊测试目标。它测量代码覆盖率以评估随机输入是否有助于覆盖更多代码。我们称之为覆盖引导的模糊测试。
使模糊测试目标有用
一旦我们生成了一个模糊测试目标,我们尝试编译它。如果编译成功,那很好,我们运行编译后的可执行文件最多10秒。我们捕获模糊测试目标的标准输出(stdout),并评估代码覆盖率是否显著变化。如果是这种情况,我们可以相当确信模糊测试目标正在做一些有用的事情。我们说在这种情况下目标是"有用的”。实际上,如果我们生成一个空的模糊测试目标,或者一个用常量调用函数或根本不使用随机输入的目标,代码覆盖率不应该变化太大。这可能不是100%准确,但我们发现这是一个足够好的指标。
我们还检测模糊器何时崩溃。当这种情况发生时,我们查看标准输出中的堆栈跟踪,看看崩溃是发生在模糊测试目标本身还是被测代码库中。如果是在被测代码库中,那么我们可能发现了一个错误。
修复编译错误
如果模糊测试目标没有构建,我们将编译错误和模糊测试目标提供给LLM,并要求它修复代码而不修改其行为。多次应用这种技术通常会产生成功构建的模糊测试目标。如果没有,我们转向下一个方法。
为了最小化LLM成本,我们关闭编译警告,只获取错误,因为警告并不能真正帮助修复错误,而且相当冗长。此外,编译错误越长,它们会变成的令牌越多,我们需要支付的费用就越多。实际上,OpenAI按处理的令牌收费。
修复依赖问题
起初,最常见的编译错误是由于不正确或缺失的依赖项。实际上,LLM可能生成一个从被测库导入模块的代码片段,但这个导入语句可能不正确。或者,一些导入语句可能缺失。为了最小化依赖问题并减少LLM调用次数,我们做两件事:
- 将被测代码库的所有依赖项添加到模糊测试项目的依赖项中
- 检测编译器输出中缺失的依赖项,并使用"cargo add"自动将crate添加到模糊测试项目的Cargo依赖项中
即使这样,由于不正确的导入,我们仍然有编译错误。通过添加一个新方法:“functions"方法,我们显著降低了这种错误率。但首先,让我们看看这些方法是什么。
方法
我们尝试的方法包括:
- 使用"examples"目录中的源文件
- 寻找单元测试作为示例
- 使用"benches"目录中的基准测试作为示例
- 识别所有公共函数及其参数,并调用其中一些函数
这些方法一个一个尝试,直到一个方法工作并给我们一个有用的模糊测试目标。
示例和基准测试方法
单元测试方法
我们使用semgrep来识别具有”#[test]“属性的代码片段,并将它们提供给LLM。我们还尝试捕获导入和可能在单元测试旁边定义的潜在辅助函数。我们任意限制这种方法最多尝试3个单元测试,而不是全部,以保持执行时间在合理水平。最短的单元测试首先使用,以最小化AI成本。
函数方法
我们首先获取被测代码库中所有公共函数的列表。这是通过运行"cargo rustdoc"并要求其输出JSON格式来实现的。这是一个不稳定功能,需要向"rustdoc"传递一些参数才能工作。然而,这是一个非常强大的功能,因为它直接从编译器提供信息,用静态分析准确获取这些信息要困难得多。然后我们可以解析生成的JSON文件并提取所有公共函数及其参数,包括它们的类型。除此之外,我们还获得已识别函数的路径。例如,如果我们有一个名为"mylib"的库,其中包含一个名为"mymod"的模块,该模块包含一个函数"func”,其路径将是"mylib::mymod::func”。Rustdoc使用编译器的程序内部表示来获取关于每个函数位置的准确信息,这总是正确的。有了这些信息,我们可以告别之前遇到的导入错误。
正确获取导入路径是一回事,但这本身不会产生有用的模糊测试目标。为了更接近这一点,我们根据每个函数接受的参数类型、参数数量以及函数本身的名称给每个函数打分。例如,名称中包含"parse"的函数可能是一个很好的模糊测试目标。此外,一个只接受字节数组作为输入的函数看起来像是一些可以自动调用的东西。我们根据分数选择前8个函数,对于每个这些函数,我们尝试使用模板生成调用该函数的模糊测试目标。如果构建失败,我们回退到要求LLM修复它。
另一个挑战是将随机输入字节转换为函数作为参数接受的适当类型。当一个函数有多个不同类型的参数时,这可能变得更加复杂。
在这种情况下,我们利用Arbitrary crate来分割这些字节,并自动将它们转换为函数作为参数接受的所需类型。这样,我们支持任何支持类型的参数组合。
这种方法出奇地有效,并且大多数时候能够产生有用的目标,只要至少有一个公共函数具有支持类型的参数。
结果
我们在GitHub上最受欢迎的50个用Rust编写的匹配搜索词"parser library"的项目上运行了Fuzzomatic。我们检测到6个项目已经在进行某种模糊测试,1个项目不是Cargo项目,所以我们跳过了这些。还有6个项目没有构建,所以这些也被丢弃了。这给我们留下了37个待处理的项目。
在这37个项目中,有2个项目没有任何方法工作。对于其他35个项目,每个都生成了至少一个成功编译的模糊测试目标。这是95%的成功率。
只有一个项目没有生成有用的模糊测试目标。但对于34/37个项目,生成了至少一个有用的模糊测试目标。这是92%的成功率。我们还在14个项目或38%的项目中发现了至少一个错误。
整个过程花了不到12小时。平均而言,处理一个至少生成一个成功模糊测试目标的项目需要18分钟。但我们看到有些只花了刚过两分钟,而其他花了长达57分钟。这些数字涵盖了整个过程,包括编译时间。
OpenAI API成本总计2.90美元,考虑到这在50个项目上运行,这是相当便宜的。通常,在一个项目上运行Fuzzomatic只需要几美分。
我们发现了什么样的错误?这14个错误包括:
- 1个字节切片到UTF-8字符串转换失败
- 2个调用panic!()
- 2个调用assert!()
- 1个创建过大的向量(vec![0, 12080808863958804234])
- 4个切片/数组索引错误
- 4个乘法、左移和减法期间的整数溢出
我们发现的大多数错误使被测软件崩溃。但是,在某些不同的条件下,这可能不成立,取决于项目如何构建。实际上,当Rust程序在发布模式下编译时,默认不检查整数溢出。在这种情况下,程序不会崩溃,并可能静默产生可能导致漏洞的意外行为。
经验教训
从零开始模糊测试代码库有要求
我们已经表明这是可能的,并且在大多数情况下工作,但这显然不是每个代码库都如此。以下是一些要求:
-
项目需要处于可构建状态
-
所有外部(非Rust)依赖项需要安装
-
项目必须至少有一样东西使其中一个方法成功:
- 单元测试或基准测试
- 设计用于测试的公共函数
如果该列表中的单个顶级项目未满足,那么这可能不会成功。
Fuzzomatic不替代手动模糊测试
我们获得的结果表明,对于解析器库,Fuzzomatic非常有效。当存在正确条件时,这种策略可以自动找到低挂果实,但不会像手动编写具有目标代码库深入知识的模糊测试目标那样有效。然而,当它成功发现错误时,它通常比熟悉代码库并手动为其编写模糊测试目标所需的时间快得多。
不成功的尝试仍然提供价值
即使生成的模糊测试目标没有构建,它仍然可以用作手动编写良好模糊测试目标的起点。也许自动生成构建的模糊测试目标没有工作,但修复可能对审查生成代码的人来说是显而易见的。能够从那里开始而不是完全从零开始可能为开发人员节省大量时间。自动生成一个草稿模糊测试目标,可能有导入问题但已经调用了一个有趣的函数,增加了一些价值并节省了时间。
不可利用的错误仍然有用
有时,发现了错误,但它们可能不可利用。即使发现的错误不能直接利用,这个结果本身提供了有用的信息。实际上,如果一些错误可以在代码库中自动发现,那么很可能在该代码库中有更多要发现的东西,它为更深入审查该代码库打开了道路。此外,错误可能根本不会导致任何安全问题,但修复它仍然是好的。
使用 Fuzzomatic
Fuzzomatic可以用作保护自己开源项目的防御措施。它非常容易运行。只有一些必须遵守的要求。
要求
Fuzzomatic专注于用Rust编写的项目,因此要求目标代码库用这种语言编写。
项目在运行"cargo build"时必须成功构建。不能构建的项目无法进行模糊测试。这可能是最重要的要求。有大量GitHub项目根本无法构建。
代码库中的函数应该设计用于测试。我们在许多库中看到的一个常见陷阱是暴露一个以文件路径作为参数的函数,但不暴露任何直接以文件内容作为参数的函数,例如,作为字符串或字节数组。这是一个需要停止的反模式。在设计库时,确保你的函数易于测试。这样,自动化模糊测试将更加有效。
最后,任何外部非Rust依赖项应该已经安装在运行Fuzzomatic的系统上。Fuzzomatic目前不支持安装非Rust依赖项,也不会自动安装。
警告:安全考虑
为了实现其目标,Fuzzomatic可能运行不受信任的代码,因此确保始终在隔离环境中运行它。实际上,它将构建不受信任的项目。构建不受信任的代码是一个安全问题,因为我们永远不知道构建脚本里面有什么。
此外,如果Fuzzomatic针对未知代码库运行,无法知道将被调用的函数会做什么。一个未知函数很可能删除文件或执行其他破坏性操作。
因此,始终确保在隔离环境中运行Fuzzomatic。
Fuzzomatic作为持续防御措施
Fuzzomatic可以在你的CI/CD管道中运行。它也可以用于一次引导根本不进行任何模糊测试的项目的模糊测试。它节省时间,并可以识别代码库的哪些函数应该被调用,并为你编写模糊测试目标。在某些情况下,它甚至会在你的代码中发现运行时错误,如panic或溢出。
Fuzzomatic作为进攻工具
Fuzzomatic也可以设置为在大量项目上运行,希望通过模糊测试自动发现错误。我们已经表明这种方法有效,并在50个解析器库中发现了14个错误。
保持源代码私有
如果发送部分源代码到OpenAI是一个问题,不要害怕。有很多替代方案。我们已经看到Mistral 7B instruct模型也工作,并且可以本地托管。使用像LM Studio这样的工具,只需点击几下就可以设置本地推理服务器。然后,由于该服务器与OpenAI API兼容,只需将OPENAI_API_BASE环境变量设置为http://localhost:1234/v1,然后Fuzzomatic可以使用你选择的模型在你自己的服务器上运行。你的代码保持私有。
进一步研究
还有其他我们没有尝试或实现的方法。一个例子是提取docstrings内部的代码片段。许多项目在多行注释中包含代码示例,这些注释记录模块或函数。这些示例可以提供给LLM生成模糊测试目标。
尽管Rust生态系统保证了大多数项目中的一些结构,但该结构可能很快变得复杂。Fuzzomatic确实支持相当多的Cargo功能,并且能够处理例如具有多个工作区成员crate的Cargo工作区项目。然而,我们还没有尝试支持所有Cargo功能,如[build-dependencies]、[patch.crates-io]或目标特定依赖项,仅举几例,并且仍然有项目可能因此失败。
外部非Rust依赖项是一个问题,并且是一些项目默认不构建的原因之一。自动安装这些将有助于从零开始自动化模糊测试。
存储库的默认分支可能不总是处于可构建状态。另一种策略可能是检出最新标签或发布,并以