通过模糊测试发现定价错误的操作码
模糊测试(Fuzzing)是一种通过重复执行并变异测试用例来发现漏洞的测试技术,传统上用于检测段错误、缓冲区溢出和其他可通过崩溃检测的内存损坏漏洞。但它的用途远不止于此:在正确的约束条件下,我们还可以用它来发现运行时错误和逻辑问题。
本文介绍了Trail of Bits如何为Fuel Labs开发模糊测试工具链,并用它识别出Fuel虚拟机(Fuel智能合约的运行平台)中燃气收费过少的操作码。通过实施具有精心选择约束条件的类似模糊测试设置,您可以在智能合约平台中发现关键错误。
我们如何开发模糊测试工具链和种子语料库
Fuel虚拟机原本有一个使用cargo-fuzz和libFuzzer的模糊测试器,但它存在几个缺点:首先,它不调用内部合约;其次,速度较慢(约50次执行/秒);第三,它使用arbitrary crate生成仅由指令向量组成的随机程序。
我们开发了一个允许模糊测试器执行调用内部合约的脚本的工具链。该工具链仍使用cargo-fuzz执行,但我们将libFuzzer替换为LibAFL项目提供的shim。LibAFL运行时允许在多核上执行测试用例,并将模糊测试性能提升至八核机器上的约1,000次执行/秒。
分析Sway编译器的输出后,我们发现纯数据与编译器输出中的实际指令是交错的。因此,简单的指令向量不能准确表示Sway编译器的输出。更糟糕的是,Sway编译器输出无法用作种子语料库。
为了解决这些问题,必须重新设计模糊测试器的输入。模糊测试器的输入现在是一个字节向量,包含脚本汇编、脚本数据以及要调用的合约汇编。这些部分之间用任意选择的64位魔数值(0x00ADBEEF5566CEAA)分隔。通过这种重新设计,编译后的Sway程序可以用作种子语料库的输入(即作为初始测试用例)。我们使用Sway存储库中的示例作为初始输入,以加速模糊测试活动。
遇到的挑战
在审计过程中,我们必须克服许多挑战,包括:
-
secp256k1 0.27.0依赖项目前与cargo-fuzz不兼容,因为它自动启用了一种特殊的模糊测试模式,破坏了secp256k1的功能。我们在fuel-crypto/Cargo.toml中应用了以下依赖项声明:
图1:更新的依赖项声明
-
LibAFL shim不稳定,尚未包含在任何版本中。因此,预计会出现错误,但由于性能改进,仍然值得考虑使用它而不是默认的模糊测试器运行时。
-
我们寻找一种方法将脚本数据的偏移量传递给在模糊测试器中执行的程序。我们决定通过修补fuel-vm来实现。fuel-vm在执行实际程序之前将偏移量写入寄存器0x10。这样,程序可以可靠地访问脚本数据偏移量。此外,种子输入继续按预期执行。在fuel-vm/src/interpreter/executors/main.rs:523中进行了以下必要更改:
图2:将脚本数据偏移量写入寄存器0x10
此外,我们在种子语料库中添加了以下使用此行为的测试用例。
图3:使用现在可用的脚本数据偏移量的测试用例
使用模糊测试分析燃气使用情况
模糊测试活动创建的语料库可用于分析汇编程序的燃气使用情况。预计燃气使用量与执行时间高度相关(注意,执行时间是花费的CPU周期数的代理)。
我们对Fuel虚拟机燃气使用情况的分析包括三个步骤:
- 启动模糊测试活动。
- 对语料库执行
cargo run --bin collect <file/dir>
,生成gas_statistics.csv文件。 - 使用图4中的Python脚本检查并绘制收集到的数据结果。
- 识别异常值并在语料库中执行测试用例。在执行过程中,收集有关哪些指令被执行以及执行时间的数据。
- 通过按指令分组并将其简化为显示哪些指令导致高执行时间的表格来检查收集到的数据。
本节更详细地描述每个步骤。
步骤1:模糊测试
cargo-fuzz工具将在目录corpus/grammar_aware中输出语料库。模糊测试器试图找到增加覆盖率的输入。此外,LibAFL模糊测试器偏好产生长执行时间的短输入。这个目标很有趣,因为它可能发现消耗燃气不多但执行时间长的操作。
步骤2:收集数据和评估
图4中的Python脚本加载通过调用cargo run --bin collect <file/dir>
创建的CSV文件。然后绘制执行时间与燃气消耗的关系图。这已经揭示了一些异常值,这些测试用例在使用相同燃气量的情况下执行时间更长。
图4:确定发现的测试输入的燃气使用量与执行时间的Python脚本
图5:运行图4脚本的结果
步骤3:识别和分析异常值
图6中的Python脚本对数据执行线性回归。然后,我们确定哪些测试用例与回归线偏离超过1,000毫秒,并将它们存储在inspect变量中。结果如图7所示。
图6:对测试数据执行线性回归的Python脚本
图7:运行图6脚本的结果
最后,我们重新执行语料库,应用特定更改以收集有关哪些执行导致长时间执行的数据。更改如下:
- 在函数instruction_inner的开头添加
let start = Instant::now();
。 - 在函数末尾添加
println!("{:?}\t{:?}", instruction.opcode(), start.elapsed().as_nanos());
。
这些更改导致测试用例的执行打印出每个指令的操作码和执行时间。
图8:调查每个指令对执行时间的贡献
Fuel操作码的输出如下所示:
图9:运行图8脚本的结果
上述评估显示,操作码MCLI、SCWQ、K256、SWWQ和SRWQ可能定价错误。对于SCWQ、SWWQ和K256,结果是预期的,因为我们通过模糊测试已经发现了有问题的行为。这些问题似乎都已解决(参见FuelLabs/fuel-vm#537)。此分析还显示SRWQ可能存在定价问题。我们不确定为什么MCLI出现在我们的分析中。这可能是由于数据中的噪声,因为我们没有发现其实现和定价有直接问题。
经验教训
随着项目的发展,Fuel团队必须继续对引入新功能的代码或处理不可信数据的函数运行模糊测试活动。我们向Fuel团队建议了以下内容:
- 运行模糊测试器至少72小时(理想情况下为一周)。虽然目前没有工具确定理想的执行时间,但覆盖率数据提供了何时停止模糊测试的良好估计。在执行超过72小时后,我们没有看到模糊测试器有更多有价值的进展。
- 每当发现新问题时暂停模糊测试活动。开发人员应该对它们进行分类、修复,然后恢复模糊测试。这将减少分类和问题去重所需的工作量。
- 对Fuel虚拟机的主要版本进行模糊测试,特别是在重大更改之后。模糊测试应作为开发过程的一部分集成,而不应只是偶尔进行。
一旦模糊测试程序被调整到快速高效,它应该适当地集成到开发周期中以捕获错误。我们建议使用CI系统集成模糊测试的以下程序,例如使用ClusterFuzzLite(参见FuelLabs/fuel-vm#727):
- 在初始模糊测试活动之后,保存每个测试生成的语料库。
- 对于每个内部里程碑、新功能或公共版本,从每个测试的当前语料库开始重新运行模糊测试活动至少24小时。¹
- 用生成的新输入更新语料库。
请注意,随着时间的推移,语料库将代表数千CPU小时的改进,对于在模糊测试期间指导高效的代码覆盖率非常宝贵。攻击者也可能使用语料库快速识别易受攻击的代码;可以通过将模糊测试语料库保存在访问控制的存储位置而不是公共存储库中来避免这种额外风险。一些CI系统允许维护者保留缓存以加速构建和测试。如果语料库不是非常大,可以包含在此类缓存中。
未来工作
未来,我们建议Fuel扩展模糊测试工具链中使用的断言,特别是对于块的执行。例如,单元测试中的断言可以作为实施在模糊测试期间评估的额外检查的灵感。
此外,我们遇到了程序所需对齐的问题。Fuel VM的程序必须32位对齐。当前的模糊测试器不遵守这种对齐方式,因此容易产生无效程序,例如只插入一个字节而不是四个字节。未来可以通过使用基于语法的方法或添加遵守对齐的自定义变异来解决。
与其内部执行模糊测试,可以使用oss-fuzz项目,该项目使用Google广泛的测试基础设施执行自动模糊测试活动。oss-fuzz对广泛使用的开源软件免费。我们相信他们会接受Fuel作为另一个项目。
好处是,Google免费提供所有基础设施,并在源代码中的更改引入新问题时通知项目维护者。收到的报告包括基本重要信息,如最小化测试用例和回溯。
然而,也有一些缺点:如果oss-fuzz发现关键问题,Google员工将首先知道,甚至在Fuel项目自己的开发人员之前。Google政策还要求在90天后公开错误报告,这可能符合也可能不符合Fuel的最佳利益。在决定是否请求Google的免费模糊测试资源时,请权衡这些好处和风险。
如果Trail of Bits可以帮助您进行模糊测试,请联系我们!
¹ 有关模糊驱动开发的更多信息,请参见Google的Kostya Serebryany在CppCon 2017上的演讲。