使用模糊测试攻破Solidity编译器:Trail of Bits的五大关键策略

本文详细介绍了Trail of Bits团队通过改进的AFL模糊测试技术,在Solidity编译器中发现近20个新漏洞的实践经验,包括定制化变异策略、语料库优化和长期测试等关键要素。

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

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

这是一次非常成功的模糊测试活动,据我们所知,这是针对solc发起的最成功的活动之一。这并不是第一次用AFL对solc进行模糊测试;通过AFL对solc进行模糊测试是一种长期实践。该编译器自2019年1月起甚至在OSSFuzz上进行了测试。我们是如何发现这么多以前未发现的漏洞的——并且在大多数情况下,这些漏洞值得快速修复?以下是我们活动的五个重要要素。

1. 拥有秘密武器

幸运的是,新颖性并不需要真正保密,只需要真正新颖且有点“美味”!本质上,我们在此次模糊测试活动中使用了AFL,但不仅仅是任何现成的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源文件中可能看到的内容”的很大一部分。

当然,即使有这些现有工作,您也需要知道自己在做什么,或者至少知道如何在Google上查找。没有什么会告诉您,简单地用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 to handle the needed quoting.

即使我们触发漏洞的Solidity文件的未减少版本看起来也像Solidity源代码。这可能是因为我们的变异,受到AFL的 heavily favored,倾向于“保留源代码性”。似乎正在发生的大部分事情是 small changes that don’t make files too nonsensical plus combination (AFL splicing) of corpus examples that haven’t drifted too far from normal Solidity code. AFL on its own tends to reduce source code to uncompilable garbage that, even if merged with interesting code, won’t make it past initial compiler stages. But with more focused mutations, splicing can often get the job done, as in this input that triggers a bug that’s still open (as we write):

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表达式的实例。尝试在汇编中修改常量不太可能出现在合法代码中。我们想象手动产生如此奇怪但重要的输入是极其非平凡的。在这种情况下,正如模糊测试经常发生的那样,如果您能想到一个类似于触发漏洞的合约,您或其他人可能一开始就写出了正确的代码。

结论

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

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

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

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