文章详细解释了以太坊智能合约升级中使用的Proxy模式可能被恶意利用的漏洞,并介绍了如何通过函数选择器的冲突隐藏恶意代码,最后提出了解决方案。
我们最近对 ZeppelinOS 的初始版本进行了 审计,发现了 代理模式 中的一个漏洞,该模式被用于实现几乎所有可升级的智能合约。
这个漏洞使攻击者能够隐藏恶意代码,这些代码在没有深入理解 Solidity 和代理模式如何工作时很难被发现。这个问题已经在 ZeppelinOS 上 被修复。
一个用 Solidity 编写的简单智能合约
如果你是开发以以太坊为基础的应用程序的开发者,那么你很可能使用 Solidity 来编写和思考你的智能合约,但这并不是网络与它们交互的方式。
从网络的角度来看,智能合约是一个与之关联的一段代码的账户。如果任何其他账户向合约发送消息¹,这段代码将在 EVM 上执行。
那么,如果合约只有一段连续的代码,如何调用不同的函数呢?
以太坊定义了一种在其组件之间通信的标准方式,即 应用程序二进制接口 或简称 ABI。你可以把它想象成一个低级 API,不仅指定系统中可用的功能,还说明我们通常视为理所当然的事物是如何工作的。这些内容包括函数应如何被调用,如何传递参数以及如何返回值。
以太坊的 ABI 规定,你的交易的 data
参数必须以一个函数选择器开始,选择器用于识别你试图调用哪个方法。使用选择器,合约的代码就会跳转到自身实现你所尝试调用的函数的部分。
函数选择器就是函数签名的 sha3
哈希值的前四个字节。例如,get
的选择器计算为 sha3("get()")[0:4]
,结果是 0x6d4ce63c
。同样,set
的选择器是 sha3("set(uint256)")[0:4]
的结果。
唯一的例外是每个智能合约都存在的回退函数,它没有选择器。它有一个特殊的行为,当未提供 data
参数时,或者给定的选择器与合约的任何方法不匹配时,会被调用。
关于代理模式及其不同变体及其权衡,已有许多研究文献 被撰写。无论你选择哪个代理模式,其核心功能都是相同的:将它接收到的所有消息² 转发给合约的当前实现。
让我们看看这是如何工作的。
一个代理合约的实现
不用担心,你不需要理解那个可怕的汇编代码块是如何工作的。它将当前消息转发给实现,发送给它接收到的完全相同的 data
参数。
将转发逻辑置于回退函数中使我们可以将任何调用转发到 Proxy
,理论上是这样的。事实证明,这并不总是发生。
Proxy
也需要其自身的元功能,因为它需要是可升级的。因此像 implementation()
和 proxyOwner()
这样的函数不会被转发,因为它们存在并且回退函数没有被执行。
作为一个聪明的以太坊开发者,你可能已经意识到代理合约中的任何函数,如果其选择器与实现合约中的某一个函数的选择器匹配,将直接被调用,从而完全跳过实现代码。
由于函数选择器使用固定数量的字节,因此总会有冲突的可能性。这对日常开发没有问题,因为 Solidity 编译器会检测到合约内部的选择器冲突,但当选择器用于跨合约交互时,这就变得可被利用。冲突可以被滥用来创建一个看似良好的合约,而实际上它隐藏了一个后门。
我们利用一些 Rust 代码发现,clash550254402()
具有与 proxyOwner()
相同的选择器。用一台新的 Macbook Pro 找到它不到 15 分钟。一个有动力的黑客可以优化这一过程,并投入更多资源找到看似无害的函数名。
代理模式是当前在以太坊生态系统中用来实现智能合约可升级性的方式,而选择器冲突攻击允许任何使用它的项目——或者获得升级机制访问权限的攻击者——来部署隐藏恶意功能的代码。
例如,大多数可升级性的实现都有某种状态迁移的概念,这些是升级合约存储的函数。这些函数对于掩盖选择器冲突尤其有用,因为自动生成的字符串,例如提交编号,可能是这些函数的可接受名称,这使得选择器冲突攻击容易被掩盖。
在我们进行的 ZeppelinOS 的安全审计 的背景下,我们发现任何人都可以利用这一点,而不仅仅是 Proxy
所有者,因为他们意图让任何网络用户部署供其他用户使用的实现。例如,一个看似正常地移动资金的函数调用实际上可能根本没有被调用,从而窃取了某人的资金。
在我们发现这个漏洞之前,Zeppelin 的 Francisco Giordano 已经在研究 透明代理。这是一种改进的技术,旨在使实现合约能够与 Proxy
使用相同的函数名称而不可能发生选择器冲突。这消除了攻击的可能性。
这些新代理通过转发任何调用来工作,只要它们不是来自代理所有者。冲突仍然存在,但如果调用者是代理所有者以外的任何人,则调用将被转发。这使得代理所有者成为唯一可能发生冲突的账户,从而让用户不会受到隐瞒的影响。
唯一的缺点是其他用户无法使用其 ABI 读取 Proxy
自身的状态(即所有者和实现)。他们将需要改用 web3.eth.getStorageAt()
。这是一个相对较小的代价,以确保可升级的合约确实能够执行其实现源代码所显示的内容。
对于那些想深入了解如何利用这一漏洞的人,我们创建了一个小练习。你的任务是尝试窃取 这个合约 中的 ropsten-ETH,并弄清楚发生了什么。请记住,这也是一个 Proxy
合约,因此你也应该查看它的实现。
你可以随意对这些合约进行操作,只需不要把其余额完全提取出来,以便其他人也能参与。
delegatecall
执行实现的代码,就好像是通过代理的一样。从 Nomic Labs 获得高质量的智能合约审计.
- 原文链接: medium.com/nomic-foundat...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!