利用模糊测试发现定价错误的操作码
模糊测试——一种通过重复执行测试用例并对其进行变异来发现漏洞的测试技术——传统上用于检测段错误、缓冲区溢出和其他可通过崩溃检测的内存破坏漏洞。但它还有您可能不知道的其他用途:给定正确的约束条件,我们可以用它来发现运行时错误和逻辑问题。
这篇博文解释了Trail of Bits如何为Fuel Labs开发模糊测试工具链,并用它识别在Fuel VM(Fuel智能合约运行的平台)中燃气收费过少的操作码。通过实施具有精心选择约束条件的类似模糊测试设置,您可以在智能合约平台中发现关键漏洞。
如何开发模糊测试工具链和种子语料库
Fuel VM已有使用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 VM燃气使用情况的分析包括三个步骤:
- 启动模糊测试活动。
- 对语料库执行
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,000ms,并将它们存储在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 VM的主要版本进行模糊测试,特别是在重大更改之后。模糊测试应作为开发过程的一部分集成,不应仅偶尔进行。
一旦模糊测试程序被调整到快速高效,它应该适当地集成到开发周期中以捕获漏洞。我们建议以下程序使用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可以帮助您进行模糊测试,请联系我们!
1 有关模糊驱动开发的更多信息,请参阅Google的Kostya Serebryany在CppCon 2017上的演讲。
如果您喜欢这篇文章,请分享: Twitter LinkedIn GitHub Mastodon Hacker News