可升级合约杀链:未初始化代理如何成为 DeFi 的 $200M+ 循环噩梦
Source: Dev.to
请提供您希望翻译的正文内容,我将为您翻译成简体中文并保留原有的格式、Markdown 语法以及代码块和链接。
为什么可升级性是一把双刃剑
每个拥有显著 TVL 的 DeFi 协议都使用可升级合约。这不是可选的——你需要能够修补漏洞、添加功能以及应对紧急情况。
但可升级性是一把上膛的枪,安全关闭开关被比任何人愿意承认的次数都要多地翻动。
最危险的单一模式
这并不是新颖的漏洞。它是一个缺失的函数调用——具体来说,就是忘记初始化 UUPS 或 Transparent 代理背后的实现合约。
仅此一个疏忽自 2017 年以来直接导致了超过 2 亿美元的损失和险些失误。
如何运作 Kill Chain
The proxy holds all state (storage, balances).
The implementation holds all logic.
When you call the proxy, it delegatecalls into the implementation,
executing its code in the proxy’s storage context.关键细节
- 实现合约不能使用构造函数(构造函数会在实现合约的存储上设置状态,而不是在代理合约上)。
- 相反,它们使用
initialize()函数——但与构造函数不同,如果没有适当的保护,任何人都可以调用initialize()。
模式概览
| 代理类型 | 升级逻辑所在位置 | 未初始化时的风险 |
|---|---|---|
| Transparent | 代理合约 | 攻击者无法通过实现合约进行升级 |
| UUPS | 实现合约 | 攻击者在实现合约上调用 upgradeToAndCall() → 游戏结束 |
UUPS 更加省 gas,且被广泛采用,但在实现合约未初始化时风险更大。由于升级函数位于实现合约中,获得实现合约所有权的攻击者可以将其升级为任意代码。
利用未初始化的 UUPS 代理
步骤 1:部署代理 + 实现(实现保持未初始化)
步骤 2:攻击者直接在实现上调用 initialize()
步骤 3:攻击者在实现上调用 upgradeToAndCall()
步骤 4:实现自毁
步骤 5:(可选)攻击者升级到恶意实现原始灾难 – Parity
开发者在 Parity 的库合约上调用 initialize(),成为其所有者,然后调用 kill()。库合约自毁,导致所有依赖它的多签钱包失效。
- 513,774 ETH 永久冻结——至今仍未解冻。
function initWallet(address[] _owners, uint _required) {
// No protection — called directly on the library
owner = _owners[0];
}
function kill(address _to) onlyOwner {
selfdestruct(_to);
}OpenZeppelin的UUPS漏洞 (v4.1.0–v4.3.1)
安全研究人员发现这些版本默认使实现合约 不可初始化。任何使用它们的项目都有可被利用的实现合约。
修复方案 – 一行代码
// In the implementation’s constructor
_disableInitializers();像 KeeperDAO 和 Rivermen NFT 这样的项目在被利用之前已经修补,但并非所有人都收到通知。
实际案例
| 项目 | 发生了什么 | 结果 |
|---|---|---|
| Wormhole(修复后) | 未初始化的 UUPS 实现未关闭 | 攻击者可能会: 1️⃣ 调用 initialize()2️⃣ 设置他们自己的 Guardian 集合 3️⃣ 授权恶意升级 4️⃣ 抽干所有桥接资产 |
| Ronin Bridge | 未初始化的代理参数 | $12 M 抽走 |
| Parity | 未初始化的库合约 | 513,774 ETH 冻结 |
Wormhole 事件导致了 $10 M 赏金——当时最高的漏洞赏金。
代码中最重要的一行
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract MyProtocol is UUPSUpgradeable, OwnableUpgradeable {
@custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers(); // ) -> Result {
// …
Ok()
}
}
// In tests, ensure the upgrade authority is the expected multisig在安全测试中验证升级权限,就像在 EVM 上验证代理管理员一样。
TL;DR 检查清单
- 永远不要让实现合约保持未初始化。
- 在实现合约的构造函数中调用
_disableInitializers()。 - 原子化部署代理 + 实现(单笔交易)。
- 部署后验证实现合约无法再次初始化。
- 使用 OpenZeppelin 的升级工具检查存储布局兼容性。
- 用多签或时间锁保护
_authorizeUpgrade。 - 对于非 EVM 链,同样严格对待升级权限。
fn test_upgrade_authority_is_multisig() {
// test body...
}三个原因导致此 Bug 类不会消失
- 实现看起来安全 — 它位于代理后面,开发者认为没有人会直接与其交互。
- 测试盲点 — 团队测试代理路径,但从不直接测试调用实现。
- 升级失忆 — 第一次部署是安全的,但在 v3、v4、v5 升级后,有人忘记在新实现上调用
_disableInitializers()。
解决方案很无聊: 检查清单、自动化,并且永远不要假设上一次部署是正确的。
DreamWork Security 每周发布 DeFi 安全研究。关注我们获取漏洞分析、审计工具指南以及 Solana 和 EVM 生态系统的安全最佳实践。