本文深入分析了 Harvest Finance 协议中 Uniswap V3 vault 代理合约中发现的未初始化实现合约漏洞。
软件开发是一个迭代的过程,任何时候都可能发生错误。这就是为什么在软件开发实践中,通常会有一个 QA 工程师团队——他们充当开发人员的第二双眼睛。在 Web2 中,这些错误通常会被快速修复,问题也会随之消失。然而,在 Web3 中,情况并非如此简单。
当你拥有一个不可变的区块链,并且有一个需要修复的错误时,会发生什么?与常规代码不同,智能合约没有内置的就地修补或更新的方法。
这为智能合约带来了一定的安全性,因为它们无法突然改变其属性。例如,一个允许你借出代币的合约不会突然清空你的帐户,因为该合约的代码无法被整体更改。
但当在已部署的智能合约中发现漏洞时,这也给开发者和用户带来了一个问题——在潜在的数百万美元面临风险的情况下,如何在不更改其代码的情况下修复合约?
为此设计的一种解决方案称为可升级代理模式(Upgradable Proxy Pattern),这是一种巧妙的方法,可以解决不可升级合约的最初问题,但同时也引入了一系列新问题——因此开发人员必须格外小心。如果执行不当,你最终可能会得到一个失效的合约和被困的用户余额。
幸运的是,Web3 中的审计和漏洞赏金鼓励了优秀的人们挺身而出,负责任地报告这些漏洞,比如 Dedaub 的那些大牛们,他们报告了这个有趣的漏洞。
Dedaub 团队,审计员和工具 https://contract-library.com/ 的创建者,通过 Immunefi 提交了一份关于 Uniswap V3 vault 代理的未初始化实现合约的报告,这些代理位于著名的以太坊协议 Harvest Finance 中。
这个关键的错误可能导致实现合约的自我销毁,这可能导致代理合约失效。这是因为使用了可升级代理模式:升级逻辑驻留在实现合约中,而不是代理中。
这与最近的 OpenZeppelin UUPS 漏洞 非常相似,但 Harvest 合约没有使用 OpenZeppelin 代码。事实上,检测到易受攻击合约的自动分析提出的正是这个问题:“我们如何概括 OpenZeppelin UUPS 未初始化代理错误的逻辑元素,以在不同代码中找到类似的问题?” 正如我们将看到的,代码非常不同,但具有启用相同攻击的元素。
在提交时,受影响的合约总共持有价值 640 万美元的 Uniswap V3 头寸。Harvest Finance 团队承认了这个错误并迅速修复了该问题。
Dedaub 从 Harvest Finance 那里获得了 10 万美元的报酬,另外从 Armor 那里获得了 10 万美元,因为 Harvest 参与了 Armor Finance 的 漏洞赏金匹配 计划。
在我们深入研究这个错误的细节之前,让我们快速回顾一下合约代理。
所有代码,甚至包括不可变的智能合约,最终可能都需要升级,这是合乎逻辑的。尤其是在防范新发现的漏洞以及向协议添加新功能方面更是如此。但是,对于哪种特定的升级机制模式是最佳的,开发人员之间存在一些分歧。
引入升级合约的能力会给这个过程增加很多复杂性,对于某些人来说,这违背了区块链的不变性或对智能合约控制的去中心化的目的。
智能合约升级可以简单地概括为:在特定地址上更改代码,同时保留先前代码的存储状态。
保留存储状态是必要的,因为我们希望能够访问之前发生的所有状态更改(即交互历史),但我们希望更改控制其交互逻辑的代码。另一种说法是,我们只是交换了实现,而不是合约的状态。
我们可以通过使用代理合约和委托调用来实现这一点。
在以太坊中,有三种主要的合约调用类型。常规的 CALL
,STATICCALL
和 DELEGATECALL
。
委托调用。图表 来源
当合约 A 通过调用 foo()
向合约 B 发出 CALL
时,函数执行依赖于合约 B 的存储,并且 msg.sender
设置为合约 A。
这是因为合约 A 调用了函数 foo()
,因此 msg.sender 将是合约 A 的地址,而 msg.value 将是随该函数调用一起发送的 ETH。在函数调用期间对状态所做的更改只能影响合约 B。
但是,当使用 DELEGATECALL
进行相同的调用时,将在合约 B 上下文中调用函数 foo()
,但该函数调用会发生在合约 A 中。这意味着将使用合约 B 的逻辑,但是函数 foo()
所做的任何状态更改都会影响合约 A 的存储。在这种情况下,msg.sender 将指向最初发出调用的 EOA。(参见示例 2)
delegatecall 使使用代理模式创建代理合约成为可能。这样,代理合约将其收到的所有调用重定向到实现合约,该合约的地址存储在其(合约 A 的)存储中。从用户的角度来看,代理合约将实现合约的代码作为自己的代码运行,从而修改了合约 A(代理合约)的存储和余额。(参见示例 3)
在这种情况下,进行升级非常简单,因为我们只需要更改代理中存储的实现合约地址,即可更改其智能合约逻辑。所有传入的调用都将重定向到新地址,并且从用户的角度来看没有任何变化。
我们需要考虑的另一件事是:我们如何处理构造函数逻辑?合约的构造函数在合约部署期间被自动调用。大多数开发人员会将初始化逻辑放在那里,以使智能合约能够正常运行。
但是当代理发挥作用时,这不再可能,因为构造函数只会更改实现合约的存储,而不是代理合约的存储,而代理合约的存储才是重要的。
因此,需要采取额外的步骤。我们需要将构造函数更改为常规函数。此函数通常称为 initialize 或 init。这些是添加到实现合约中的常规 Solidity 函数,并且当从代理调用时,会更改代理合约的存储。它们还需要特殊的逻辑来确保它们只能被调用一次,类似于构造函数。
有两种主要方法可以实现此代理和委托调用模式。我们使用现代 OpenZeppelin 的具体细节和术语来说明,尽管 Harvest finance 代码中的细节有所不同。
对于上述代理方法,存在一些主要问题。例如,当代理管理员想要调用与实现合约中的函数共享名称的代理合约函数 upgradeTo
时,将调用哪个函数?这种冲突可能导致意外的行为,甚至是恶意利用。
有一些解决方案可以避免这个问题。第一个称为 透明代理模式 (TPP)。此方法使用户的所有调用始终使用实现合约的逻辑而不是代理合约的逻辑来执行。管理员的调用始终使用代理合约的逻辑执行。
在用户调用一个函数 upgradeTo
的场景中,该函数在两个合约中都共享一个名称,她可以确保将执行实现的逻辑,而不是代理的逻辑。
但是代理的管理员呢?我们仍然希望能够在需要时调用代理的 upgradeTo
函数。解决整个问题的方法是分配一个地址作为所有者,以部署和管理代理。这也确保了当调用不是从管理员发出的时,将调用实现合约。下图显示了可能发生的场景示例。
但是,此解决方案并非没有缺点。透明代理仍然需要在代理合约中实现其他逻辑,以管理所有可升级性功能,以及识别调用者是否为管理员地址的能力。这涉及读取存储状态,以及执行增加合约执行成本的其他逻辑,并且效率不高。
尽管 TPP 仍然被广泛使用,但注意力已开始转向称为 UUPS 的替代方案。
两者之间的主要区别在于哪个合约包含升级逻辑。如我们所知,使用 TPP,升级逻辑位于代理合约本身中。但是对于 UUPS,该逻辑位于实现合约中。从代理调用 upgradeToAndCall()
到实现合约将导致实现地址的更改反映在代理本身中。这是因为 UUPS 实现可以访问代理的所有存储;它们可以覆盖代理的存储插槽,代理在其中存储实现的地址。
仅此简单的更改就可以使代理调用更便宜,因为我们仅在请求升级时才检查调用者是否为管理员。我们也不需要为存在两个具有相同名称的函数的情况提供逻辑。Solidity 在实现合约中自动生成的代码会为我们处理此事。所有可升级性的授权逻辑都位于实现合约中,以防止发生任何意外的调用。
另一个区别是升级逻辑的行为方式。为了确保将来也可以升级新的升级合约,upgradeTo()
函数还会执行“回滚”检查,以确保我们不会意外升级到无法进一步升级的合约。
免费加入 Medium 以获取此作者的更新。
有关 TPP 和 UUPS 之间差异的更深入分析,建议阅读 OpenZeppelin 对该主题的解释:https://docs.openzeppelin.com/contracts/4.x/api/proxy#transparent-vs-uups。
在我们研究 Harvest Finance 漏洞之前,我们先讨论一下最近的 OpenZeppelin UUPS 漏洞,该漏洞非常相关,但影响了更多已部署的合约。尽管代码不同,但 Harvest 漏洞是通过概括 OpenZeppelin UUPS 漏洞的模式来检测到的。
如前所述,当部署 UUPS 代理合约时,构造函数未在实现合约中实现。该实现提供了 initialize()
函数作为替代。在许多情况下,开发人员还使用标准 OpenZeppelin 合约的可升级版本,这些合约实现了自己的 initialize()
函数。
以下示例摘自 OpenZeppelin 的 安全公告。
// SPDX-License-Identifier: MITpragma solidity ^0.8.2;import “@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol”;
import “@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol”;
import “@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol”;
import “@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol”;contract MyToken is Initializable, ERC20Upgradeable, OwnableUpgradeable, UUPSUpgradeable {
function initialize() initializer public {
__ERC20_init(“MyToken”, “MTK”);
__Ownable_init();
__UUPSUpgradeable_init();
}function _authorizeUpgrade(address newImplementation)
internal
onlyOwner
override
{}}
我们可以看到 initialize()
函数调用了 __Ownable_init
,该函数将实现合约的所有者设置为第一个调用它的人。这是一个关键点。
作为 UUPS 实现合约的所有者意味着你可以控制升级功能。特别是,实现的所有者可以直接在实现合约上调用 upgradeToAndCall()
,而不是通过代理。
漏洞在于 upgradeToAndCall()
在内部的工作方式。除了将实现地址更改为新地址之外,它还使用 DELEGATECALL
原子地执行任何迁移/初始化函数以及传递给它的数据。如果新实现的初始化函数执行 SELFDESTRUCT
操作码,则 DELEGATECALL
调用者(实现合约)将被销毁。发生这种情况是因为 updateToAndCall()
正在使用 DELEGATECALL
,并且在直接调用此函数的情况下,SELFDESTRUCT
是在实现合约的上下文中执行的。
这将导致代理合约变得无用,因为它会将所有调用转发到一个空地址。升级将不再可能,你也无法切换升级机制来解决此问题,因为升级逻辑按 UUPS 模式的设计托管在实现合约上。
以下是如何执行假设攻击的分步指南:
initialize()
以成为所有者。selfdestruct()
函数的恶意合约。upgradeToAndCall()
,并将其指向恶意实现合约。upgradeToAndCall()
执行期间,从常规实现合约到恶意实现合约调用 DELEGATECALL
。SELFDESTRUCT
,销毁常规实现合约。10 月 20 日,Dedaub 团队向 Immunefi 提交了一份报告,详细说明了来自 Harvest Finance 的 3 个未初始化的代理实现。
有趣的是,这些实现合约没有发布源代码。这三个合约都没有在 Etherscan 上进行验证,并且只有每个合约的字节码是公开可用的。鉴于 Dedaub 在反编译和分析合约方面的专业知识,这只是一个小小的障碍。
以上实现用于持有大量资产的代理中。在提交时,总共有 640 万美元面临风险。例如,此代理持有价值 200 万美元的 Uniswap v3 头寸:
https://contract-library.com/contracts/Ethereum/1851A8FA2CA4D8FB8B5C56EAC1813FD890998EFC
与实现不同,代理确实具有公开可用的源代码。它再次是一个可升级的代理。与 OpenZeppelin UUPS 模式不同,升级逻辑不驻留在实现合约中。但是,在升级期间会咨询实现合约:
function upgrade() external {
(bool should, address newImplementation) = IUniVaultV1(address(this)).shouldUpgrade();
require(should, “Upgrade not scheduled”);
_upgradeTo(newImplementation);
…
调用 shouldUpgrade()
由实现合约处理。因此,我们再次遇到与 UUPS 模式相同的问题:如果实现自我销毁,则代理合约将丧失能力,并且无法更新其实现!
但是如何实现自我销毁呢?Harvest 代码没有 OpenZeppelin 代码的 upgradeToAndCall
功能。但是,如果查阅实现合约的反编译代码(由于没有发布的源代码),则明显的威胁是:Harvest 逻辑会委托调用一个“hard worker”合约,以执行各种任务,例如 doHardWork()
收益耕作功能。因此,攻击者要做的就是:
SELFDESTRUCT
的合约doHardWork()
这将破坏实现并使代理失效。
该攻击是由一种自动分析发现的,该分析试图概括 OpenZeppelin UUPS 未初始化实现漏洞的元素。该分析确定的通用元素是:
DELEGATECALL
调用者选择的地址。这是对合约代码的深入,复杂的静态分析。DELEGATECALL
ed X。额外的条件可以包括 Y 在其升级调用期间调用 X 以获得更高的精度,但在标记 Harvest 合约时甚至没有使用此条件。通过将代理重新指向无法在不受信任的调用者的命令下自我销毁的实现合约来实施修复。
我们要感谢 Dedaub 提交的非常有趣的报告,并祝贺他们拥有在未经验证的合约中发现此类问题的技能和奉献精神。
如果你想开始漏洞赏金活动,我们已经为你准备好了。查看 Web3 安全库,并在 Immunefi 上开始赚取奖励——Immunefi 是 web3 的领先漏洞赏金平台,提供世界上最大的支出。
我们还要感谢 Harvest Finance 团队的快速响应和对该问题的专家处理。要报告其他漏洞,请参阅 Harvest Finance 的 漏洞赏金计划。如果你有兴趣使用漏洞赏金保护你的项目,请访问 Immunefi 服务 页面并填写表格。
- 原文链接: medium.com/immunefi/harv...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!