隐藏在众目睽睽之下
大多数人信任,但有多少人验证?
我喜欢挑战假设。我喜欢尝试不可能的事情,发现别人遗漏的东西,用人们从未预料到的事情让他们震惊。去年,我基于一个非常晦涩的Solidity漏洞为Paradigm CTF 2021编写了一个挑战。虽然一个变体已被公开披露,但我利用的漏洞从未被真正讨论过。结果,几乎所有尝试这个挑战的人都被它看似不可能的性质难住了。
几周前,我们正在讨论Paradigm CTF 2022的计划,这时Georgios发了一条预告推文。我认为在启动电话会议的同一天发布一个预告挑战会非常酷。然而,它不能只是任何一个旧的预告挑战。我想要一些超乎寻常的东西,一些没有人会预料到的东西,一些突破人们想象极限的东西。我想编写第一个利用零日漏洞的以太坊CTF挑战。
制作方法:零日漏洞
作为安全研究人员,我们做出一些基本假设以优化时间。其中一个假设是我们阅读的源代码确实生成了我们正在分析的合约。当然,这个假设只有在我们从可信来源(如Etherscan)阅读源代码时才成立。因此,如果我能找到一种让Etherscan错误验证的方法,我就能够围绕它设计一个非常狡猾的谜题。
为了找出利用Etherscan合约验证系统的方法,我必须验证一些合约。我部署了几个合约到Ropsten测试网进行试验,并尝试验证它们。立即,我看到了以下屏幕。
我选择了正确的设置,然后进入下一个屏幕。在这里,我被要求提供我的合约源代码。
我输入了源代码并点击验证。果然,我的源代码现在附加到了我的合约上。
既然我知道了工作原理,我就可以开始试验验证过程了。我尝试的第一件事是部署一个将foo改为bar的新合约,并使用原始源代码验证该合约。不出所料,Etherscan拒绝验证我的合约。
然而,当我手动比较两个字节码输出时,我注意到一些奇怪的事情。合约字节码应该是十六进制的,但那里显然有一些非十六进制的内容。
我知道Solidity会将合约元数据附加到部署的字节码中,但我从未真正考虑过它如何影响合约验证。显然,Etherscan正在扫描字节码中的元数据,然后用一个标记替换它,该标记表示:“此区域中的任何内容允许不同,我们仍会将其视为相同的字节码。”
这似乎是潜在零日漏洞的一个有希望的线索。如果我能欺骗Etherscan将非元数据解释为元数据,那么我就能够在标记为{ipfs}的区域中调整我部署的字节码,同时仍然使其验证为合法字节码。
我能想到的在创建交易中包含一些任意字节码的最简单方法是将其编码为构造函数参数。Solidity通过将其ABI编码形式直接附加到创建交易数据中来编码构造函数参数。
然而,Etherscan太聪明了,它将构造函数参数排除在任何类型的元数据嗅探之外。你可以看到构造函数参数是斜体的,表示它们与代码本身是分开的。
这意味着我需要以某种方式欺骗Solidity编译器发出我控制的字节序列,这样我就可以使其类似于嵌入的元数据。然而,这似乎是一个噩梦般的问题,因为如果不对编译器进行一些严肃的操作,我几乎无法控制Solidity选择使用的操作码或字节,之后源代码看起来会非常可疑。
我思考了这个问题一段时间,直到我突然想到:实际上让Solidity发出(几乎)任意字节是极其容易的。以下代码将导致Solidity发出32字节的0xAA。
|
|
受到激励,我迅速编写了一个小合约,该合约将以这样一种方式推出一系列常量,使Solidity发出的字节码完全类似于嵌入的元数据。
令我高兴的是,Etherscan在我的合约中间标记了一个IPFS哈希的存在,而那里本不应找到任何嵌入的元数据。
我迅速复制了预期的字节码,并将IPFS哈希替换为一些随机字节,然后部署了生成的合约。果然,Etherscan将不同的字节视为正常业务,并允许我的合约通过验证。
使用这个合约,源代码表明在调用example()时应返回一个简单的bytes对象。然而,如果你实际尝试调用它,会发生以下情况。
|
|
我成功发现了Etherscan中的一个零日漏洞,现在我可以验证行为与源代码建议完全不同的合约了。现在我只需要围绕它设计一个谜题。
错误的开始
显然,这个谜题将围绕Etherscan上看到的源代码并非合约实际行为这一理念展开。我还想确保玩家不能简单地直接重放交易,因此解决方案必须是每个地址唯一的。最好的方法显然是要求签名。
但玩家在什么情况下需要签名一些数据?我的第一个设计是一个具有单一公共函数的简单谜题。玩家将使用几个输入调用该函数,签名数据以证明他们想出了解决方案,如果输入通过了所有各种检查,他们将被标记为解谜者。然而,当我在接下来的几个小时里充实这个设计时,我很快对结果感到不满意。它开始变得非常笨拙和不优雅,我无法忍受将如此棒的零日漏洞浪费在如此糟糕设计的谜题上。
我接受了无法在周五前完成这个事实,决定先睡一觉再说。
弹球游戏
我在周末继续尝试迭代我的初始设计,但没有取得更多进展。就像我当前的 approach 碰壁了,尽管我不想承认,但我知道如果我想得到满意的东西,可能必须重新开始。
最终,我发现自己从第一性原理重新审视这个问题。我想要的是一个玩家必须完成某种知识检查的谜题。然而,并没有要求完成知识检查本身就是获胜条件。相反,它可以是玩家被允许采取的众多路径之一。也许玩家可以在整个谜题中积累分数,而漏洞利用提供某种奖励。获胜条件将只是最高分,从而间接鼓励利用漏洞。
我回想起去年设计的一个挑战Lockbox,它迫使玩家构建一个单一的数据块,该数据块满足六个不同合约施加的要求。这些合约会对相同的字节应用不同的约束,迫使玩家在构建有效载荷时变得聪明。我意识到我想在这里做类似的事情,我会要求玩家提交一个单一的数据块,并根据数据的某些部分满足特定要求来奖励分数。
就在这时,我意识到我基本上是在描述pinboooll,这是我在DEFCON CTF 2020决赛期间研究的一个挑战。pinboooll的噱头是,当你执行二进制文件时,执行会在控制流图中反弹,类似于球在弹球机中反弹。通过正确构建输入,你将能够命中代码的特定部分并积累分数。当然,也涉及一个漏洞利用,但坦率地说,我已经忘记了它是什么,并且我不打算再次尝试找到它。此外,我已经有自己的漏洞利用想使用。
由于我在这里处理的是一个活跃的零日漏洞,我决定尽快发布这个谜题,即使这意味着在我复制别人工作的程度上妥协。最终,我花了几个小时复习pinboooll的工作原理,并花了几天时间在Solidity中重新实现它。这处理了谜题的脚手架,现在我只需要集成漏洞利用。
武器化零日漏洞
我让Solidity输出正确字节的方法一直是只加载几个常量,并让Solidity发出相应的PUSH指令。然而,这样的任意常量可能是一个巨大的危险信号,我想要一些能稍微更好地融入的东西。我还必须连续加载所有常量,这在实际代码中很难解释。
因为我真的只需要硬编码两个魔术字节序列(0xa264...1220和0x6473...0033),我决定看看我是否可以在它们之间夹入代码,而不是第三个常量。在部署的合约中,我会将夹入的代码换掉一些其他指令。
|
|
经过一些实验,我发现这是可能的,但只有在优化器启用的情况下。否则,Solidity会发出太多的值清理代码。这是可以接受的,所以我继续改进代码本身。
我只能修改两个地址之间的代码,但在末尾看到一个悬空的地址会很奇怪,所以我决定在条件语句中使用它们。我还必须证明第二个条件语句的必要性,所以最后我加入了一个小分数奖励。我让第一个条件语句检查tx.origin是否匹配一个硬编码值,给人们最初的印象是进一步追求这个代码路径没有意义。
|
|
现在源代码都准备好了,我必须编写实际的后门。我的后门需要验证玩家是否正确触发了漏洞利用,如果没有则静默失败,如果触发了则奖励他们一个奖励。我想确保漏洞利用不能被轻易重放,所以我决定简单地要求玩家签名自己的地址并在交易中提交签名。为了额外的乐趣,我决定要求签名位于交易数据中的偏移量0x44处,球通常从这里开始。这将要求玩家理解ABI编码的工作原理,并手动将球数据重新定位到其他地方。
然而,在这里我遇到了一个大问题:根本不可能将所有这些逻辑放入31字节的手写汇编中。幸运的是,经过一些考虑,我意识到我还有另外31字节可以玩。毕竟,真实的嵌入元数据包含另一个Etherscan也会忽略的IPFS哈希。
经过一些代码高尔夫,我得到了一个可用的后门。在第一个IPFS哈希中,我会立即弹出刚刚推送的地址,然后跳转到第二个IPFS哈希。在那里,我会哈希调用者,并为调用ecrecover部分设置内存/堆栈。然后我会跳回第一个IPFS哈希,在那里完成堆栈设置并执行调用。最后,我将分数乘数设置为等于(msg.sender == ecrecover()) * 0x40 + 1,这意味着不需要额外的分支。
将后门代码高尔夫到合适的大小后,我在Twitter上发布了我的Rinkeby地址,以便从水龙头获取一些测试网ETH,并向任何关注Twitter的人暗示可能有事要发生。然后,我部署了合约并验证了它。
现在剩下的就是等待有人发现这个隐藏在众目睽睽之下的后门。