本文探讨了可升级智能合约中存在的关键风险,包括未初始化的代理、存储冲突和恶意升级。通过分析Wormhole、Parity Multisig Wallet、AllianceBlock和Audius Governance等真实案例,强调了在升级过程中可能出现的安全漏洞以及由此可能造成的严重后果,并为开发者提供了避免这些常见陷阱的建议。
了解可升级合约中最大的风险:未初始化的代理、存储冲突和恶意升级。 避免这些常见的陷阱。
可升级性引入了传统不可变合约不会面临的大量陷阱。 在本部分中,我们将检查可升级智能合约中最常见的漏洞和故障模式。 每个部分都涵盖了漏洞的性质、为什么它会在可升级设置中出现,以及利用它的真实世界的黑客攻击或事件(2021-2025 年)的例子。 通过理解这些,你将更好地准备在自己的合约中避免它们。
未初始化的合约是指从未执行过初始化程序(或构造函数)的合约。 在代理模式中,代理本身通常没有用于实现的构造函数逻辑,并且不使用实现合约的构造函数(因为它独立部署)。 相反,实现通常提供一个 initialize()
函数,代理的管理必须在部署后精确地调用一次。 如果忘记或错误地执行此步骤,则合约的关键状态(如所有者角色或重要参数)可能仍处于默认的未设置状态。 攻击者可以通过自己调用初始化程序来利用这一点,从而控制合约。
在透明代理中,这通常意味着代理已部署并指向实现,但没有人通过代理调用初始化函数。 在 UUPS 代理中,真正的危险是代理本身从未初始化。 因为 initialize()
必须通过代理调用(因此状态被写入代理的存储),所以忘记的调用会将所有者或守护者等关键变量保留在其默认零值。 然后,攻击者可以通过代理调用 initialize()
,将自己设置为所有者,并立即调用 upgradeTo()
以获取完全控制权。 直接在实现合约上调用 initialize()
不会帮助攻击者,因为这只会写入到实现自己的存储,而代理永远不会读取该存储。 例如,许多基于 OpenZeppelin 的合约都有一个 initializer
,它设置一个 owner
变量(对于 Ownable)并将合约标记为已初始化。 如果攻击者可以在逻辑合约上运行它,他们可以将自己设置为实现的所有者。 虽然这不能直接控制代理,但它可以成为更深层攻击的垫脚石(特别是在 UUPS 中,我们将会看到)。
Wormhole 是一个主要的跨链桥,在 2022 年支付了 1000 万美元的错误赏金,当时一位白帽黑客发现其系统中存在一个未初始化的可升级合约。 Wormhole 以太坊桥有一个 UUPS 风格的代理。 在一次例行更新之后,其中一个核心合约最终未初始化——他们的升级脚本中的一个错误有效地“重置”了初始化。 这打开了一个关键漏洞:攻击者(白帽)能够在实现合约上调用 initialize()
函数,使自己成为桥合约的守护者(管理员)。 凭借该权限,他们然后调用升级函数以将代理指向受其控制的恶意实现。 当被调用时,恶意实现的代碼只会执行 selfdestruct
。 所以下一步是攻击者调用 Wormhole 的例行升级入口点 ( submitContractUpgrade
),它委托调用到恶意实现中,从而在代理自己的执行上下文中触发 SELFDESTRUCT
。 因此,该操作碼删除了代理合约本身,立即破坏了桥; 实现合约仍然在链上,但不再可访问。 这有效地破坏了代理,它指向一个没有代码的地址。 如果这是一个恶意的黑客,它可能永久冻结大量的用户资金。 幸运的是,这是一个友好的黑客,他提醒了 Wormhole,他们立即修复了这个问题(并支付了创纪录的赏金)。 根本原因仅仅是忘记(或撤消)升级时的初始化调用,这说明了一个遗漏的函数调用如何危及整个协议。
另一个臭名昭著的案例是 Parity 多重签名钱包漏洞(2017 年)。 Parity 有一个库合约(充当共享逻辑),许多多重签名钱包使用它——即使 Solidity 的库链接(而不是显式代理合约)提供了间接层,这种设计也与现代代理模式密切相关。 该库合约在部署时没有初始化的所有者。 2017 年,一名攻击者注意到了这一点并调用了 init 函数,成为库合约的所有者,具有讽刺意味的是,这使他们能够调用一个自我销毁库的函数。 这杀死了所有依赖钱包的逻辑,永久冻结了超过 500,000 个 ETH(没有资金被盗,但无法恢复)。 虽然该特定案例不是现代意义上的代理(它是链接的库模式),但教训是:任何未初始化的可升级或可重用合约都是一个随时可能发生的漏洞。 攻击者可以获得特权或破坏功能。
即使在备受瞩目的黑客攻击之外,审计也经常在项目中发现未初始化的代理。 这是一个非常常见的疏忽,特别是对于可能部署代理并忘记调用初始化程序的新开发人员,或者错误地直接调用实现的 initialize()
(而不是通过代理),这实际上不会设置代理的状态。 始终确保初始化只完成一次,并且在部署后立即正确完成(通过代理)。
与缺少初始化密切相关的是重新初始化问题,即初始化程序函数可以被多次调用(无论是错误还是恶意设计)。 OpenZeppelin 的 initializer
修饰符使用内部状态变量来防止重新执行。 但是,复杂的继承或引入新初始化逻辑的升级可能会导致绕过或重置该保护的情况。 如果攻击者以某种方式第二次触发初始化程序(或旨在仅调用一次的函数),他们可能会更改重要的状态变量或获取控制权。
一个常见的场景是升级到具有自己的 initialize
(或假设 initializeV2
)的新实现以设置新变量。 开发人员可能会使用 OpenZeppelin 的 reinitializer
来允许这个新的 init 运行一次。 但是,如果没有仔细管理,它可能会重置一些允许原始 init 再次运行的内容,或者以其他方式混淆合约的初始化状态。 另一种情况是升级中的错误无意中重置了内部“已初始化”标志(例如,通过错误地重用 Initializable
存储)。
在 2024 年 8 月,AllianceBlock(一个 DeFi 项目)升级了其一个 staking 合约以添加新的代币支持。 在此过程中,开发人员犯了一个严重的错误:升级将合约的 initialized
布尔值设置回 false(可能是因为部署了一个新的实现而没有正确地传递该标志)。 这意味着合约认为它再次未初始化,因此攻击者可以第二次调用初始化程序函数。 通过这样做,攻击者能够重置 staking 合约的关键参数,具体来说,他们将 rewardToken
、stakingToken
和 rewardRate
更改为他们选择的值。 他们设置了一个与治理代币相关的基本上无限的奖励率,并且可能已将 staking 代币设置为虚拟值。 效果:如果完全执行,攻击者可以存入少量的虚假 staking 代币,然后提取天文数字的奖励代币,或者更阴险的是,通过更改 staking 代币地址,他们可能会锁定所有真实的已 staking 资金(因为合约会认为已 staking 了不同的代币)。 本质上,重新初始化允许攻击者完全更改合约的核心变量,从而破坏协议的逻辑。
值得庆幸的是,这个漏洞很早就被发现并阻止了,并且没有造成资金损失。 AllianceBlock 的案例强调,重新初始化与根本不初始化一样危险。 它可以覆盖状态,从而颠倒合约逻辑。 另一个细微差别:一些项目有意允许为新模块“重新初始化”,但如果版本(跟踪运行了哪个 init)没有得到正确管理,攻击者可能会两次调用旧的 init。 最重要的是,初始化应该在每个预期版本中发生一次,并且在使用后应该锁定自己。 任何需要 init 函数的升级都必须小心处理,以避免重新打开此攻击向量。
可升级合约最棘手的方面之一是在旧实现和新实现之间保持一致的存储布局。 代理的存储是实际持久化的东西,它必须与实现中的变量对齐。 如果状态变量的顺序或结构以不兼容的方式更改,则会发生存储冲突或不匹配。 这意味着新实现中的变量可能会从不同的槽读取或写入,从而可能损坏数据或启用漏洞利用。
这种情况是如何发生的? 如果你在合约中间插入一个新的状态变量或在升级之间更改现有变量的类型,则所有后续变量都会将其位置移位。 例如,如果合约的 V1 具有 uint256 a; bool b;
,并且 V2 意外地在 a
之前插入了一个新变量,或者删除了 b
,那么槽的含义就会错位。 另一个冲突来源是代理合约本身以正常方式声明存储变量(不在保留槽中)。 如前所述,EIP-1967 为代理元数据保留了三个哈希派生的槽,这使得与实现存储或任何其他自定义命名空间的冲突实际上是不可能的。 如果开发人员忽略了这一点并在代理中定义了一个状态变量,则它可能会与实现的槽冲突。
Audius 是一个去中心化的音乐协议,在 2022 年 7 月遭受了一次黑客攻击,攻击者从其社区金库中窃取了资金。 根本原因追溯到升级期间引入的存储冲突。 Audius 有一个可升级的治理合约(代理 + 逻辑)。 在一次升级中,开发人员向代理合约自己的存储添加了一个新变量 proxyAdmin
,旨在存储管理员地址。 但是,他们没有意识到实现(逻辑)合约的布局现在不再对齐:实现具有代理现在部分 shadowing 的变量(包括 initialized
标志)。 具体来说,当代理尝试读取 initialized
标志(以检查合约是否已初始化)时,它反而得到了新 proxyAdmin
地址的值(因为两者都在槽 0 或相关位置)。 由于该地址是非零的,因此逻辑认为它从未初始化,从而允许攻击者再次调用 initialize()
函数。 通过重新初始化治理,攻击者将自己分配为管理者。 从那里开始,就很简单了:他们使用治理权限从金库中转移了价值约 600 万美元的代币到他们自己的账户。 所有这一切都是因为一个看似无害的更改,即添加了一个 proxyAdmin
变量,导致代理和实现存储之间发生灾难性的错位。
Audius 事件说明了存储冲突的危险性。 旨在代表一件事(标志)的数据被解释为其他东西(地址),从而绕过了安全检查。 冲突也可能发生在不涉及代理自身存储 的情况下,例如,如果在升级中你忘记包含继承的基础合约的存储。 想象一下,V1 继承了 A(带有一个变量)并具有自己的变量。 如果 V2 删除了 A 的继承,但没有考虑该槽,则所有内容都会向上移动一个。 更微妙的是,如果使用多重继承,则基础合约的初始化顺序(在线性化中)对于存储顺序很重要; 更改该顺序也会重新排序槽。 第 3 部分将讨论使用存储“间隙”来减轻这些风险的策略。 但很明显,存储布局错误可能是致命的,它可能不会立即显现出来,但它可以创建一个后门,正如 Audius 所了解的那样。
可升级合约添加了一个“紧急出口”来更改代码,但必须保护好这个出口。 如果升级权限(代理管理员或 UUPS 实现中的 _authorizeUpgrade
)落入坏人之手,攻击者可以立即破坏整个系统。 通过部署他们自己的恶意实现并将代理升级为指向它,他们可以使合约做任何事情:耗尽资金、铸造代币、更改余额或仅仅破坏合约。 这不是一个理论上的风险,许多漏洞利用甚至项目 rug-pull 都是通过受损的升级路径发生的。
此漏洞的常见表现方式:
upgradeTo
上的 onlyOwner
)。PAID Network 在 2021 年 3 月遭到攻击,当时一名攻击者获得了代理管理员的私钥(报告表明可能是通过泄露的密钥或网络钓鱼)。 通过控制管理员,攻击者将 PAID 代币合约(可升级的代理)升级到他们创建的新实现。 此恶意实现包括将大量 PAID 代币铸造给攻击者并从其他人那里销毁代币的函数。 本质上,攻击者给了自己大量的余额并使其他人的代币无效,然后出售非法代币以获取利润。 原始 PAID 代币的代码没有缺陷,唯一的缺陷是其升级密钥遭到破坏。 一旦发生这种情况,不可变性就会被设计破坏:攻击者可以重新定义合约的功能。 PAID 的代币因此失去了大部分价值和信任。 这强调了可升级合约的安全性仅取决于围绕升级的治理。 如果攻击者可以随意用不同的逻辑替换它,那么一个完全安全的逻辑合约毫无意义。
还有许多其他事件:
selfdestruct
或任意 delegatecall
Solidity 中的一些低级操作在可升级上下文中尤其危险。 其中最主要的是:
selfdestruct
(或 SELFDESTRUCT
操作碼):如果逻辑合约自我销毁,它不会直接杀死代理(因为代理是一个单独的合约)。 但是,如果代理尝试委托调用到被销毁的实现,它将找不到代码,并且调用将失败。 本质上,实现中的自我销毁可以通过清除代理指向的代码来“破坏”系统。 这可能是恶意的(如前面描述的 Wormhole 方案中,攻击者升级到自我销毁的实现)或意外的(允许管理员在“完成时”自我销毁的逻辑函数在可升级设置中将是一个设计错误)。delegatecall
(或 call
):如果实现合约具有一个执行 delegatecall
到调用者提供的地址的函数,这将非常危险。 它实际上让调用者在代理状态的上下文中执行代码,为各种漏洞利用打开了大门。 类似地,无限制的 address.call(bytes)
允许用户提供数据,可用于调用其他合约上的任意函数,可能使用代理的资金或权限。 虽然不是可升级合约独有的,但当这种函数存在于可升级逻辑中时,攻击者甚至可以将它与升级攻击结合使用(例如,升级到具有后门委托调用的逻辑)。Furucombo 是一个允许用户批量操作的 DeFi 工具。 它不是一个可升级的代理,但它具有一种机制,可以 delegatecall
到不同协议的处理程序中。 关键错误是它允许将外部地址作为委托调用的目标传入,并假设它是一个有效的处理程序。 在 2021 年,攻击者制作了一个恶意合约,当 Furucombo 委托调用时,使攻击者可以控制 Furucombo 自己的存储(包括批准)。 攻击者使用它来使 Furucombo 将用户的代币拉入攻击者的地址,窃取了超过 1400 万美元。
虽然 Furucombo 的场景不是关于可升级的本身,但它说明了为什么未经检查的委托调用是致命的。 在一个可升级合约中,人们可能会试图在逻辑中编写类似于通用代理的东西(可能将调用转发到“插件”)。 但是,如果没有受到严格的限制,它会引入与 Furucombo 相同的风险。 关于 selfdestruct
:上面的 Wormhole 案例表明,攻击者如何将其武器化作为升级漏洞利用的一部分。 另一个小例子:一些可升级合约开发人员曾经考虑使用自我销毁来删除旧的实现(以节省 gas 或避免混淆)。 这是不必要的和危险的,你应该简单地单独保留旧的实现(并可能通过禁用初始化程序来撤销其升级权限)。
总之,应避免或严格限制逻辑合约中的危险操作碼。 selfdestruct
应该永远不在可升级合约的业务逻辑中。 委托调用应仅在受控的内部模式中使用(例如,代理本身委托给实现,或者钻石委托给 facet,这些都是经过深思熟虑和审查的用途)。 开放的委托调用类似于将你房屋的钥匙交给攻击者。 第 3 部分将重申这一点:除非你绝对知道自己在做什么并已考虑安全影响,否则不要试图在实现中使用元编程。
许多这些陷阱都归结为人为错误和升级的复杂性。 它们强调了对稳健流程和工具的需求。 在野外,一些协议几乎遭受了损失,其中升级的执行不正确,但运气或快速行动阻止了漏洞利用。
在第 3 部分中,我们将把这些惨痛的教训转化为最佳实践,演示如何正确编写初始化程序、保护升级函数、使用工具捕获存储问题以及实施安全治理。 目标是,通过遵循这些指导原则,你的项目不会成为此列表中的下一个案例研究。 可升级的合约可以安全地使用,但正如我们所见,错误可能是可怕的。 谨慎、知识和正确的工具进行操作。
- 原文链接: threesigma.xyz/blog/web3...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!