使用模糊测试攻破Solidity编译器:Trail of Bits的五大成功要素

本文详细介绍了Trail of Bits团队通过改进的AFL模糊测试技术,在Solidity编译器中发现近20个新漏洞的过程。文章涵盖定制化变异策略、语料库优化、持续测试等重要技术要素,展示了如何在高 visibility 软件中挖掘深层漏洞。

使用模糊测试攻破Solidity编译器

在过去的几个月中,我们一直在对 solc(标准 Solidity 智能合约编译器)进行模糊测试,并成功发现了近 20 个新漏洞(目前大部分已修复)。其中少数是现有漏洞的变体,症状或触发条件略有不同,但绝大多数是编译器之前未报告的漏洞。

这是一次非常成功的模糊测试活动,据我们所知,这是针对 solc 最成功的一次。虽然之前也有人用 AFL 对 solc 进行过模糊测试(甚至从 2019 年 1 月起就在 OSSFuzz 上测试过),但我们是如何发现这么多以前未知的漏洞——并且在大多数情况下快速得到修复的呢?以下是本次活动的五个关键要素。

1. 拥有“秘制酱料”

幸运的是,并不需要真正保密,只需是真正新颖且有效!本质上,我们在这次活动中使用了 AFL,但不是现成的普通 AFL,而是一种新变体,专门为开发者模糊测试类 C 语言工具而设计,无需太多额外工作。

与标准 AFL 的差异并不大;这个模糊测试器只是增加了一些新的 AFL havoc 变异操作,类似于朴素的基于文本的源代码变异测试工具(例如 universalmutator)。新方法只需不到 500 行代码即可实现,大部分代码简单且重复。

这个 AFL 变体是与 Sourcegraph 的 Rijnard van Tonder、CMU 的 Claire Le Goues 和犹他大学的 John Regehr 的联合研究项目的一部分。在初步实验中,与普通旧版 AFL 相比,该方法对 solc 和 Tiny C 编译器(tcc)的效果很好。作为科学研究,该方法还需要进一步开发和验证;我们正在努力。但在实践中,这种新方法几乎肯定帮助我们找到了 solc 中的许多新漏洞。

我们在实验比较中使用普通旧版 AFL 发现了一些早期漏洞,而一些用新方法轻松发现的漏洞最终也能用无新方法的 AFL 复现——但大多数漏洞在“正常”AFL 中无法复现。下图显示我们在 GitHub 上提交的问题数量,并突显了 AFL 更改的重要性:

二月底漏洞发现数量的大幅跃升发生在我们向 AFL 版本添加了几个更智能的变异操作之后。可能是巧合,但我们对此表示怀疑;我们手动检查了生成的文件,发现 AFL 模糊测试队列内容发生了质的变化。此外,AFL 生成的文件中实际可编译的 Solidity 文件比例跃升了 10% 以上。

2. 站在巨人的肩膀上

模糊测试一个从未被测试过的系统当然有效;系统对模糊测试器生成的输入类型的“抵抗力”可能极低。但测试一个曾被测试过的系统也有优势。如前所述,我们不是第一个用 AFL 测试 solc 的。之前的工作也并非完全自由随意;编译器团队参与了 solc 的模糊测试,并构建了我们可以使用的工具来简化工作。

Solidity 构建包含一个名为 solfuzzer 的可执行文件,它接受 Solidity 源文件作为输入,并使用多种选项(有优化和无优化等)进行编译,寻找各种不变量违反和崩溃类型。我们发现的几个漏洞在正常 solc 可执行文件中不会出现,除非使用特定命令行选项(尤其是优化)或以其他相当不寻常的方式运行 solc;solfuzzer 找到了所有这些漏洞。我们还从他人的经验中了解到,AFL 模糊测试的一个良好起始语料库位于 test/libsolidity/syntaxTests 目录树中。这是其他人正在使用的,它肯定覆盖了“在 Solidity 源文件中可能看到的内容”的大部分。

当然,即使有这些现有工作,你仍需知道自己在做什么,或至少知道如何在谷歌上查找。没有什么会告诉你,仅仅用 AFL 编译 solc 实际上不会产生良好的模糊测试效果。首先,你需要注意到模糊测试会导致非常高的映射密度,这衡量了你“填充”AFL 覆盖哈希的程度。然后,你需要知道 AFL 用户指南中给出的建议,或搜索“afl map density”并看到你需要用 AFL_INST_RATIO 设置为 10 重新编译整个系统,以使模糊测试器更容易识别新路径。根据 AFL 文档,这只有在“你正在测试极其复杂的软件”时才会发生。所以,如果你习惯测试编译器,可能以前见过这个,否则你可能没遇到过映射密度问题。

3. 玩弄语料库

你可能注意到,提交漏洞的最后一个高峰发生在我们对 AFL-compiler-fuzzer 仓库的最后一次提交之后很久。我们是否有尚未公开的本地更改?不,我们只是更改了用于模糊测试的语料库。特别是,我们超越了语法测试,添加了所有在 test/libsolidity 下能找到的 Solidity 源文件。这样做最重要的成就是让我们找到了 SMT 检查器漏洞,因为它引入了使用 SMTChecker 编译指示的文件。没有使用该编译指示的语料库示例,AFL 基本上没有机会探索 SMT 检查器行为。

我们发现的其他后期漏洞(当似乎不可能找到任何新漏洞时)主要来自构建一个“主”语料库,包括我们之前所有模糊测试运行产生的每个有趣路径,然后让模糊测试器探索它一个多月。

4. 保持耐心

是的,我们说的是一个多月(在两个核心上)。我们运行了超过十亿次编译,以触达一些更隐蔽的漏洞。这些漏洞在原始语料库的派生树中非常深。我们在 Vyper 编译器中发现的漏洞同样需要一些非常长的运行才能发现。当然,如果你的模糊测试工作不仅仅涉及尝试新技术,你可能想用机器(也就是金钱)来解决问题。但根据一篇重要的新论文,如果这是你唯一的方法,你可能需要以指数级更多的机器来解决问题。

此外,对于基于反馈的模糊测试器,仅仅使用更多机器可能无法产生一些需要长时间才能找到的隐蔽漏洞;对于需要原始语料库路径的变异再变异的漏洞,并不总是有捷径。启动一百万个“clusterfuzz”实例会产生很大的广度,但不一定能达到深度,即使这些实例定期共享它们的新路径。

5. 做明显必要的事情

在提交之前减少触发漏洞的源文件,或尝试遵循你报告漏洞项目的实际问题提交指南,并没有什么秘密。当然,即使指南中没有提到,执行快速搜索以避免提交重复项是标准做法。我们做了这些事情。它们没有增加我们的漏洞数量,但它们肯定加快了识别提交问题为真实漏洞并修复的过程。

有趣的是,通常不需要太多减少。在大多数情况下,只需删除 5-10 行代码(不到文件的一半)就产生了“足够好”的输入。这部分是由于语料库,部分(我们认为)是由于我们的自定义变异倾向于保持输入小,甚至超越了 AFL 的内置启发式方法。

我们发现了什么?

一些漏洞是非常简单的问题。例如,这个合约曾经导致编译器崩溃,显示消息“编译期间未知异常:std::bad_cast”:

1
2
3
4
5
6
7
contract C {
    function f() public returns (uint, uint) {
        try this() {
        } catch Error(string memory) {
        }
    }
}

通过将 typeError 更改为 fatalTypeError 轻松修复了该问题,这防止编译器在不良状态下继续。修复该问题的提交只有一行代码(尽管有许多新测试代码行)。

另一方面,这个问题促使了漏洞赏金奖励,并进入了 0.6.8 编译器版本的重要漏洞修复列表,可能为某些字符串文字产生不正确代码。它还需要 substantially more code 来处理所需的引用。

即使我们未减少的触发漏洞的 Solidity 文件看起来也像 Solidity 源代码。这可能是因为我们的变异(受到 AFL 的强烈青睐)倾向于“保留源代码性”。大部分似乎发生的是小变化的混合,这些变化不会使文件太荒谬,加上语料库示例的组合(AFL 拼接),这些示例没有偏离正常 Solidity 代码太远。AFL 本身倾向于将源代码减少为不可编译的垃圾,即使与有趣代码合并,也无法通过初始编译器阶段。但通过更集中的变异,拼接通常可以完成工作,如在这个触发仍然开放漏洞的输入中(在我们写作时):

1
2
3
4
5
6
7
8
9
contract C {
    function o (int256) public returns (int256) {
    assembly {
        c:=shl(1,a)
    }
    }

    int constant c=2 szabo+1 seconds+3 finney*3 hours;
}

触发输入结合了汇编和常量,但我们使用的语料库中没有文件同时包含两者并且看起来很像这个。最接近的是:

1
2
3
4
5
6
7
8
contract C {
  bool constant c = this;
  function f() public {
    assembly {
        let t := c
    }
  }
}

同时,最接近的包含汇编和 shl 的文件是:

1
2
3
4
contract C {
    function f(uint x) public returns (uint y) {
        assembly { y := shl(2, x) }
    }

像这样组合合约并非易事;在语料库中甚至没有出现任何类似于暴露漏洞合约中特定 shl 表达式的实例。尝试在汇编中修改常量不太可能出现在合法代码中。我们想象手动产生如此奇怪但重要的输入极其非 trivial。在这种情况下,正如模糊测试经常发生的那样,如果你能想到任何类似于触发漏洞的合约,你或其他人可能一开始就写出了正确代码。

结论

在已经测试过的高 visibility 软件中寻找重要漏洞比在从未测试过的软件中更难。然而,通过方法上的新颖性、基于先前模糊测试活动的智能引导(特别是对于预言机、基础设施和语料库内容),加上经验和专业知识,可以在复杂软件系统中找到许多从未发现的漏洞,即使它们托管在 OSSFuzz 上。最终,即使我们最激进的模糊测试也只是触及了真正复杂软件(如现代生产编译器)的表面——因此除了暴力之外,还需要技巧。

我们一直在开发工具来帮助你更快更聪明地工作。需要帮助你的下一个项目吗?联系我们!

如果你喜欢这篇文章,请分享: Twitter LinkedIn GitHub Mastodon Hacker News

页面内容

  1. 拥有“秘制酱料”
  2. 站在巨人的肩膀上
  3. 玩弄语料库
  4. 保持耐心
  5. 做明显必要的事情 我们发现了什么? 结论

最近文章

  • 使用 Deptective 调查你的依赖项
  • 系好安全带,Buttercup,AIxCC 的评分回合正在进行中!
  • 使你的智能合约超越私钥风险成熟
  • Go 解析器中意想不到的安全隐患
  • 我们审查首批 DKL 学到的内容
  • Silence Laboratories 的 23 个库

© 2025 Trail of Bits。 使用 Hugo 和 Mainroad 主题生成。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计