隐藏在众目睽睽之下:Etherscan验证系统的0day漏洞利用

本文详细介绍了作者发现并利用Etherscan合约验证系统0day漏洞的全过程,通过精心构造的Solidity代码实现源码与实际行为不一致的合约,并基于此设计了创新的CTF挑战。

隐藏在众目睽睽之下

大多数人信任,但有多少人验证?

我喜欢挑战假设。我喜欢尝试不可能的事,发现别人错过的东西,用他们从未见过的事情震撼他们。去年,我基于一个非常晦涩的Solidity漏洞为Paradigm CTF 2021编写了一个挑战。虽然一个变体已被公开披露,但我利用的漏洞从未被真正讨论过。因此,几乎所有尝试这个挑战的人都被它看似不可能的性质难住了。

几周前,我们正在讨论Paradigm CTF 2022的计划,这时Georgios发了一条预告推文。我认为在启动电话会议的同一天发布一个预告挑战会非常酷。然而,它不能只是任何旧的预告挑战。我想要一些超乎寻常的东西,一些没有人会预料到的东西,一些推动人们想象极限的东西。我想编写第一个利用0day的以太坊CTF挑战。

制作过程:0days

作为安全研究人员,我们做出一些基本假设以优化时间。其中一个假设是我们阅读的源代码确实生成了我们正在分析的合约。当然,这个假设只有在我们从受信任的地方(如Etherscan)阅读源代码时才成立。因此,如果我能找到一种让Etherscan错误验证的方法,我就能够围绕它设计一个非常狡猾的谜题。

为了找出如何利用Etherscan的合约验证系统,我必须验证一些合约。我部署了几个合约到Ropsten进行试验,并尝试验证它们。立即,我看到了以下屏幕。

我选择了正确的设置并进入下一个屏幕。在这里,我被要求提供我的合约源代码。

我输入了源代码并点击验证。果然,我的源代码现在附加到了我的合约上。

既然我知道了事情的工作原理,我可以开始试验验证过程。我尝试的第一件事是部署一个将foo改为bar的新合约,并用原始源代码验证该合约。不出所料,Etherscan拒绝验证我的合约。

然而,当我手动比较两个字节码输出时,我注意到一些奇怪的事情。合约字节码应该是十六进制的,但那里显然有一些非十六进制的内容。

我知道Solidity将合约元数据附加到部署的字节码中,但我从未真正考虑过它如何影响合约验证。显然,Etherscan正在扫描字节码中的元数据,然后用一个标记替换它,该标记表示:“此区域中的任何内容允许不同,我们仍会将其视为相同的字节码。”

这似乎是一个潜在的0day的有希望的线索。如果我能欺骗Etherscan将非元数据解释为元数据,那么我就能够在标记为{ipfs}的区域中调整我的部署字节码,同时仍然使其验证为合法字节码。

我能想到的在创建交易中包含一些任意字节码的最简单方法是将它们编码为构造函数参数。Solidity通过将它们的ABI编码形式直接附加到创建交易数据中来编码构造函数参数。

然而,Etherscan太聪明了,并将构造函数参数排除在任何类型的元数据嗅探之外。您可以看到构造函数参数是斜体的,以表明它们与代码本身是分开的。

这意味着我需要以某种方式欺骗Solidity编译器发出我控制的字节序列,这样我就可以使其类似于嵌入的元数据。然而,这似乎是一个噩梦般的问题,因为如果不对编译器进行一些严重的操纵,我几乎无法控制Solidity选择的操作码或字节,之后源代码看起来会非常可疑。

我考虑这个问题一段时间,直到我突然想到:实际上让Solidity发出(几乎)任意字节是极其容易的。以下代码将导致Solidity发出32字节的0xAA。

1
bytes32 value = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;

受到激励,我迅速编写了一个小合约,该合约将以这样一种方式推出一系列常量,即Solidity将发出完全类似于嵌入元数据的字节码。

令我高兴的是,Etherscan在我的合约中间标记了一个IPFS哈希的存在,而那里不应该找到任何嵌入的元数据。

我迅速复制了预期的字节码,并用一些随机字节替换了IPFS哈希,然后部署了生成的合约。果然,Etherscan将不同的字节视为正常业务,并允许我的合约被验证。

使用这个合约,源代码建议在调用example()时应该返回一个简单的字节对象。然而,如果您实际尝试调用它,会发生这种情况。

1
2
3
4
$ seth call 0x3cd2138cabfb03c8ed9687561a0ef5c9a153923f 'example()'
seth-rpc: {"id":1,"jsonrpc":"2.0","method":"eth_call","params":[{"data":"0x54353f2f","to":"0x3CD2138CAbfB03c8eD9687561a0ef5C9a153923f"},"latest"]}
seth-rpc: error:   code       -32000
seth-rpc: error:   message    stack underflow (5 <=> 16)

我成功发现了Etherscan中的一个0day,现在我可以验证行为与源代码建议完全不同的合约。现在我只需要围绕它设计一个谜题。

错误的开始

显然,这个谜题将围绕Etherscan上看到的源代码不是合约实际行为的方式展开。我还想确保玩家不能简单地直接重放交易,因此解决方案必须是每个地址唯一的。最好的方法显然是要求签名。

但玩家在什么情况下需要签名一些数据?我的第一个设计是一个具有单一公共函数的简单谜题。玩家将使用几个输入调用该函数,签名数据以证明他们想出了解决方案,如果输入通过了所有各种检查,他们将被标记为解决者。然而,当我在接下来的几个小时里充实这个设计时,我很快对结果感到不满意。它开始变得非常笨拙和不优雅,我无法忍受将如此棒的0day浪费在如此糟糕设计的谜题上。

我接受了无法在周五前完成这个事实,决定先睡一觉。

弹球

我周末继续尝试迭代我的初始设计,但没有取得更多进展。就像我当前的 approach 碰壁了,尽管我不想承认,但我知道如果我想要满意的东西,可能必须重新开始。

最终,我发现自己从第一原理重新审视这个问题。我想要的是一个玩家必须完成某种知识检查的谜题。然而,没有要求完成知识检查本身就是获胜条件。相反,它可以是玩家被允许采取的众多路径之一。也许玩家可以在整个谜题中积累分数,漏洞利用提供某种奖励。获胜条件将只是最高分,因此间接鼓励使用漏洞利用。

我回想起去年设计的一个挑战,Lockbox,它迫使玩家构建一个单一的数据块,该数据块将满足六个不同合约施加的要求。合约将对相同的字节应用不同的约束,迫使玩家在构建有效载荷时变得聪明。我意识到我想在这里做类似的事情,我将要求玩家提交一个单一的数据块,并根据数据的某些部分满足特定要求来奖励分数。

就在这时,我意识到我基本上是在描述pinboooll,一个我在DEFCON CTF 2020决赛期间工作的挑战。pinboooll的噱头是,当您执行二进制文件时,执行将在控制流图中反弹,类似于球在弹球机中反弹。通过正确构建输入,您将能够击中代码的特定部分并积累分数。当然,也涉及一个漏洞利用,但坦率地说,我已经忘记了它是什么,并且我不打算再次尝试找到它。此外,我已经有自己的漏洞利用想要使用。

由于我在这里处理一个活的0day,我决定我想尽快发布这个谜题,即使这意味着在我复制别人工作的多少方面做出妥协。最终,我花了几个小时复习pinboooll的工作原理,并花了几天时间在Solidity中重新实现它。这处理了谜题的脚手架,现在我只需要集成漏洞利用。

武器化0day

我让Solidity输出正确字节的方法一直是加载几个常量并让Solidity发出相应的PUSH指令。然而,这样的任意常量可能是一个巨大的红旗,我想要一些能稍微更好地融入的东西。我还必须连续加载所有常量,这在实际代码中很难解释。

因为我真的只需要硬编码两个魔术字节序列(0xa264…1220和0x6473…0033),我决定看看我是否可以在它们之间夹入代码,而不是第三个常量。在部署的合约中,我将用一些其他指令交换夹入的代码。

1
2
3
4
5
address a = 0xa264...1220;

uint x = 1 + 1 + 1 + ... + 1;

address b = 0x6473...0033;

经过一些实验,我发现这是可能的,但只有在优化器启用的情况下。否则,Solidity会发出太多的值清理代码。这是可以接受的,所以我继续完善代码本身。

我将只能修改两个地址之间的代码,但在末尾看到一个悬空的地址会很奇怪,所以我决定在条件语句中使用它们。我还必须证明第二个条件语句的需要,所以最后我加入了一个小分数奖励。我让第一个条件语句检查tx.origin是否匹配一个硬编码值,给人们最初的印象,即没有必要进一步追求这个代码路径。

1
2
3
4
5
6
7
8
if (tx.origin != 0x13378bd7CacfCAb2909Fa2646970667358221220) return true;

state.rand = 0x40;
state.location = 0x60;

if (msg.sender != 0x64736F6c6343A0FB380033c82951b4126BD95042) return true;

state.baseScore += 1500;

现在源代码都准备好了,我必须编写实际的后门。我的后门需要验证玩家是否正确触发了漏洞利用,如果没有则静默失败,如果触发了则奖励他们一个奖励。我想确保漏洞利用不能被轻易重放,所以我决定简单地要求玩家签名自己的地址并在交易中提交签名。为了额外的乐趣,我决定要求签名位于交易数据中的偏移量0x44处,球通常从这里开始。这将要求玩家理解ABI编码的工作原理并手动将球数据重新定位到其他地方。

然而,在这里我遇到了一个大问题:根本不可能将所有这逻辑放入31字节的手写汇编中。幸运的是,经过一些考虑,我意识到我还有另外31字节可以玩。毕竟,真正的嵌入元数据包含另一个Etherscan也会忽略的IPFS哈希。

经过一些代码高尔夫,我得到了一个工作的后门。在第一个IPFS哈希中,我将立即弹出刚刚推送的地址,然后跳转到第二个IPFS哈希。在那里,我将哈希调用者并为调用ecrecover部分设置内存/堆栈。然后我跳回第一个IPFS哈希,在那里我完成堆栈设置并执行调用。最后,我将分数乘数设置为等于(msg.sender == ecrecover()) * 0x40 + 1,这意味着不需要额外的分支。

将后门代码高尔夫到合适的大小后,我在Twitter上发布我的Rinkeby地址,以便从水龙头获取一些测试网ETH,并向任何关注Twitter的人 drop 一个 subtle 的暗示,表明可能有事情要发生。然后,我部署了合约并验证了它。

现在剩下的就是等待有人发现隐藏在众目睽睽之下的后门。

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