可升级智能合约系列:第二部分 - 智能合约常见漏洞与现实案例

本文是可升级智能合约系列文章的第二部分,主要探讨了可升级智能合约中常见的漏洞和故障模式,包括未初始化的代理/实现、重新初始化漏洞、存储布局冲突、未经授权或恶意的升级以及selfdestruct或任意delegatecall的危险使用。文章通过分析每个漏洞的性质、在可升级设置中产生的原因以及实际案例,旨在帮助开发者更好地避免在自己的合约中出现这些问题。

Image

可升级智能合约系列:第二部分 - 顶级智能合约漏洞与真实世界的攻击

简介

可升级性引入了传统不可变合约不会面临的一系列陷阱。在这一部分,我们将研究可升级智能合约中最常见的漏洞和失效模式。每个部分都涵盖了漏洞的性质、它为什么会在可升级设置中出现,以及利用它的真实世界攻击或事件(2021-2025 年)的例子。通过理解这些,你将更好地准备好在自己的合约中避免它们。

未初始化的代理/实现

Image

漏洞

未初始化的合约是指从未执行过初始化器(initializer)(或构造函数)的合约。在代理模式中,代理本身通常没有实现状态的构造函数逻辑,并且不使用实现合约的构造函数(因为它被独立部署)。相反,实现通常提供一个 initialize() 函数,代理的管理员必须在部署后精确地调用一次。如果忘记或错误地执行此步骤,合约的关键状态(如所有者角色或重要参数)可能仍处于默认的未设置状态。攻击者可以通过自己调用初始化器来利用这一点,从而控制合约。

在 Transparent 代理中,这通常意味着代理已部署并指向一个实现,但没有人通过代理调用初始化函数。在 UUPS 代理中,真正的危险是代理本身从未初始化。因为 initialize() 必须通过代理调用(因此状态被写入代理的存储中),所以忘记调用会将关键变量(如所有者或守护者)保留在其默认零值。然后,攻击者可以通过代理调用 initialize(),将自己设置为所有者,并立即调用 upgradeTo() 来完全控制。直接在实现合约上调用 initialize() 对攻击者没有帮助,因为这只会写入实现自己的存储,而代理永远不会读取该存储。例如,许多基于 OpenZeppelin 的合约都有一个初始化器,用于设置 owner 变量(对于 Ownable)并将合约标记为已初始化。如果攻击者可以在逻辑合约上运行它,他们可以将自己设置为实现的所有者。虽然这不能直接控制代理,但它可以成为更深层次利用的垫脚石(尤其是在 UUPS 中,我们将会看到)。

真实世界的例子,Wormhole (2022)

Wormhole 是一个主要的跨链桥,在 2022 年向一位白帽黑客支付了 1000 万美元的漏洞赏金,因为他发现了他们系统中一个未初始化的可升级合约。Wormhole Ethereum 桥有一个 UUPS 风格的代理。在一次例行更新后,其中一个核心合约最终未被初始化——他们升级脚本中的一个错误有效地“重置”了初始化。这打开了一个关键漏洞:攻击者(白帽)能够调用实现合约上的 initialize() 函数,使自己成为桥合约的守护者(管理员)。有了这个权限,他们随后调用升级函数将代理指向他们控制下的恶意实现。恶意实现的代码在被调用时,只是简单地执行了一个 selfdestruct。因此,下一步是攻击者调用 Wormhole 的例行升级入口点(submitContractUpgrade),该入口点委托调用到恶意实现中,从而在代理自身的执行上下文中触发 SELFDESTRUCT。因此,该操作码删除了代理合约本身,立即破坏了桥;实现合约保留在链上,但不再可访问。这有效地破坏了代理,它指向一个没有代码的地址。如果这是一个恶意黑客,它可能会永久冻结大量的用户资金。幸运的是,这是一个友好的黑客,他提醒了 Wormhole,他们迅速解决了这个问题(并支付了创纪录的赏金)。根本原因仅仅是忘记(或撤销)升级时的初始化调用,这说明了一个遗漏的函数调用是如何危及整个协议的。

另一个臭名昭著的案例是 Parity 多重签名钱包漏洞 (2017)。Parity 有一个库合约(充当共享逻辑),被许多多重签名钱包使用,这种设计与现代代理模式密切相关,即使 Solidity 的库链接(而不是显式代理合约)提供了间接层。该库合约在部署时未初始化所有者。2017 年,一名攻击者发现了这一点并调用了 init 函数,成为库合约的所有者,具有讽刺意味的是,这使他们能够调用一个自毁库的函数。这杀死了所有依赖钱包的逻辑,永久冻结了超过 500,000 ETH(没有资金被盗,但无法恢复)。虽然那个具体的案例不是现代意义上的代理(它是一个链接的库模式),但教训是相同的:任何未初始化的可升级或可重用合约都是一个待宰的羔羊。攻击者可以获得特权或破坏功能。

即使在高调的黑客攻击之外,审计也经常在项目中发现未初始化的代理。这是一个非常常见的疏忽,特别是对于新的开发人员来说,他们可能会部署代理而忘记调用初始化器,或者错误地直接调用实现的 initialize()(而不是通过代理),这实际上不会设置代理的状态。始终确保初始化只完成一次,并且在部署后立即正确完成(通过代理)。

重新初始化 Bug

漏洞

与缺少初始化密切相关的是重新初始化的问题,即可以多次调用初始化函数(无论是错误地还是通过恶意设计)。OpenZeppelin 的 initializer 修饰符使用一个内部状态变量来防止重新执行。但是,复杂的继承或引入新初始化逻辑的升级可能会导致绕过或重置该保护的情况。如果攻击者能够以某种方式第二次触发初始化器(或旨在仅调用一次的函数),他们可能能够更改重要的状态变量或夺取控制权。

一个常见的场景是升级到具有自己的 initialize(或比如 initializeV2)以设置新变量的新实现。开发人员可能会使用 OpenZeppelin 的 reinitializer 来允许这个新的 init 运行一次。但如果不小心管理,它可能会重置某些东西,让原始的 init 再次运行,或者以其他方式混淆合约的初始化状态。另一种情况是,升级中的一个 Bug 无意中重置了内部的“已初始化”标志(例如,通过不正确地重用 Initializable 存储)。

真实世界的例子,AllianceBlock (2024)

2024 年 8 月,AllianceBlock(一个 DeFi 项目)升级了其一个 staking 合约以添加新的 Token 支持。在此过程中,开发人员犯了一个严重的错误:升级将合约的已初始化布尔值设置回 false(可能是通过部署一个新的实现而没有正确地转移标志)。这意味着合约认为它再次未初始化,因此攻击者可以第二次调用初始化函数。通过这样做,攻击者能够重置 staking 合约的关键参数,具体来说,他们将 rewardToken、stakingToken 和 rewardRate 更改为他们选择的值。他们设置了一个与治理 Token 相关的基本上无限的奖励率,并且可能已经将 staking Token设置为一个虚拟值。效果:如果完全执行,攻击者可以存入少量的假 staking Token,然后提取天文数字的奖励 Token,或者更阴险地,通过更改 staking Token 地址,他们可以锁定所有真正的已抵押资金(因为合约会认为抵押了不同的 Token)。本质上,重新初始化允许攻击者完全更改合约的核心变量,从而破坏协议的逻辑。

值得庆幸的是,这个漏洞很早就被发现了,并在资金损失之前被阻止了。AllianceBlock 的案例强调了重新初始化与根本不初始化一样危险。它可以通过覆盖状态来颠覆合约逻辑。另一个细微之处:一些项目有意允许为新模块使用“重新初始化器”,但如果版本(跟踪哪个 init 运行)没有得到正确管理,攻击者可能会两次调用旧的 init。最重要的是,初始化应该按预期版本发生一次,并且在使用后应该锁定自身。任何需要 init 函数的升级都必须小心处理,以避免重新打开这个攻击向量。

存储布局冲突

Image

漏洞

可升级合约最棘手的方面之一是在旧实现和新实现之间保持一致的存储布局。代理的存储是实际持久化的内容,它必须与实现中的变量对齐。如果状态变量的顺序或结构以不兼容的方式更改,你会遇到存储冲突或不匹配。这意味着新实现中的变量可能会从与预期不同的槽中读取或写入,从而可能损坏数据或启用漏洞利用。

这怎么会发生?如果你在合约中间插入一个新的状态变量或在升级之间更改现有变量的类型,则所有后续变量都会移动其位置。例如,如果合约的 V1 具有 uint256 a; bool b; 并且 V2 不小心在 a 之前插入了一个新变量,或者删除了 b,那么槽的含义就会错位。另一种冲突来源是当代理合约本身以正常方式(不在保留槽中)声明存储变量时。如前所述,EIP-1967 为代理元数据保留了三个哈希派生的槽,使得与实现存储或任何其他自定义命名空间的冲突实际上是不可能的。如果开发人员忽略了这一点并在代理中定义了一个状态变量,它可能会与实现的槽冲突。

真实世界的例子,Audius 治理攻击 (2022)

Audius 是一个去中心化的音乐协议,在 2022 年 7 月遭受了一次攻击,攻击者从其社区金库中窃取了资金。根本原因追溯到升级期间引入的存储冲突。Audius 有一个可升级的治理合约(代理 + 逻辑)。在一次升级中,开发人员向代理合约自己的存储中添加了一个新的变量 proxyAdmin,旨在存储管理地址。但是,他们没有意识到实现(逻辑)合约的布局现在不再对齐:实现具有代理现在部分阴影的变量(包括一个已初始化标志)。具体来说,当代理尝试读取已初始化标志(以检查合约是否已经初始化)时,它反而获得了新的 proxyAdmin 地址的值(因为两者都在槽 0 或相关位置)。由于该地址是非零的,逻辑认为它从未被初始化,允许攻击者再次调用 initialize() 函数。通过重新初始化治理,攻击者将自己分配为治理者。从那里,这很简单:他们使用治理权限从金库中转移出价值约 600 万美元的 Token 到他们自己的帐户。所有这些都发生是因为一个看似无害的更改,添加一个 proxyAdmin 变量,导致代理和实现存储之间发生灾难性的不对齐。

Audius 事件说明了存储冲突可能有多么危险。旨在代表一件事(一个标志)的数据被解释为其他东西(一个地址),绕过了安全检查。冲突也可能发生在不涉及代理自身存储的情况下,例如,如果在升级中你忘记包含继承的基合约的存储。想象一下,V1 继承 A(带有一个变量)并有自己的变量。如果 V2 删除了 A 的继承但没有考虑到该槽,那么一切都会向上移动一个。更微妙的是,如果你使用多重继承,则基合约的初始化顺序(在线性化中)对于存储顺序很重要;更改这一点也会重新排序槽。第 3 部分将讨论使用存储“间隙”来减轻这些风险的策略。但显然,存储布局错误可能是致命的,它可能不会立即显现出来,但它可以像 Audius 所学的那样创建一个后门。

未经授权或恶意的升级

Image

漏洞

可升级合约添加了一个“紧急出口”来更改代码,但这个出口必须受到保护。如果升级的权限(代理管理员或 UUPS 实现中的 _authorizeUpgrade)落入坏人之手,攻击者可以立即破坏整个系统。通过部署他们自己的恶意实现并将代理升级到指向它,他们可以使合约做任何事情:耗尽资金、铸造 Token、更改余额或简单地破坏合约。这不是一个理论上的风险,许多漏洞利用甚至项目 rug-pull 都是通过被破坏的升级路径发生的。

此漏洞的常见表现形式:

  • 控制代理管理员的私钥被黑客入侵或网络钓鱼。

  • 访问控制中的一个 Bug 允许其他人调用升级函数(例如,忘记 UUPS upgradeTo 上的 onlyOwner)。

  • 内部人员的故意滥用:具有升级权限的开发人员可能会恶意升级到后门的合约(内部人员攻击或“rug pull”)。

真实世界的例子,PAID Network (2021)

PAID Network 在 2021 年 3 月遭到攻击,攻击者获得了代理管理员的私钥(报告显示可能是通过泄露的密钥或网络钓鱼)。通过控制管理员,攻击者将 PAID Token合约(一个可升级的代理)升级到他们创建的新实现。这个恶意实现包括向攻击者铸造大量 PAID Token 和从其他人那里销毁 Token 的函数。本质上,攻击者给了自己大量的余额并使其他人的 Token 无效,然后出售非法 Token 以获取利润。原始 PAID Token 的代码没有缺陷,唯一的缺陷是它的升级密钥被破坏了。一旦发生这种情况,不变性就会被设计破坏:攻击者可以重新定义合约的作用。PAID 的 Token 因此失去了大部分价值和信任。这强调了可升级合约的安全性仅取决于围绕升级的治理。如果攻击者可以随意用不同的逻辑替换它,那么一个完全安全的逻辑合约毫无意义。

还有许多其他事件:

  • 2022 年,Ankr 协议的攻击者窃取了一个部署者密钥,并使用它来升级 Ankr 的 staking Token 合约的实现,插入一个函数来为自己铸造 6 千万亿个 Token(基本上是恶性通货膨胀),从中获得了约 500 万美元的流动性。同样,代码很好;管理员密钥丢失是致命的一点。

  • 2023 年 4 月,SushiSwap 路由合约从一个漏洞中被拯救出来,当时白帽发现部署者(具有升级能力)仍然是一个可以被定位的 EOA 密钥;在发生相关事件后,他们敦促迅速迁移到多重签名。

  • Rug pulls:可悲的是,一些项目创建者故意留下一个升级路径,然后自己利用它。他们将部署一个通过审计的无害合约,然后在以后将其升级为窃取用户资金的恶意代码。这发生在各种 yield farming 骗局中。具有由单个密钥控制的升级功能通常是评估新项目时的危险信号(除非通过时间锁或治理来缓解)。

selfdestruct 或任意 delegatecall 的危险使用

漏洞

Solidity 中的一些底层操作在可升级的上下文中尤其危险。其中最主要的是:

  • selfdestruct (或 SELFDESTRUCT 操作码):如果一个逻辑合约自我销毁,它不会直接杀死代理(因为代理是一个单独的合约)。但是,如果代理尝试委托调用已销毁的实现,它将找不到代码,并且调用将失败。本质上,实现中的 selfdestruct 可以通过清除代理指向的代码来“破坏”系统。这可能会恶意发生(如前文描述的 Wormhole 场景中,攻击者升级到 self-destruct 的实现)或意外发生(允许管理员 selfdestruct “完成后”的逻辑函数在可升级设置中将是一个设计错误)。

  • 对外部地址的无限制 delegatecall(或 call):如果实现合约有一个函数,该函数对调用者提供的地址执行委托调用,这是非常危险的。它实际上让调用者在代理状态的上下文中执行代码,为各种漏洞利用打开了大门。类似地,一个无限制的 address.call (bytes) 允许用户提供数据,可用于调用其他合约上的任意函数,可能会使用代理的资金或权力。虽然这并非可升级合约独有,但当此类函数存在于可升级逻辑中时,攻击者甚至可以将其与升级攻击结合起来(例如,升级到具有后门委托调用的逻辑)。

真实世界的例子,Furucombo (2021)

Furucombo 是一个允许用户批量操作的 DeFi 工具。它不是一个可升级的代理,但它有一种机制,可以委托调用到不同协议的处理程序中。最关键的错误是它允许将外部地址作为委托调用的目标传入,并假设它是一个有效的处理程序。2021 年,一名攻击者制作了一个恶意合约,当 Furucombo 委托调用该合约时,该合约使攻击者能够控制 Furucombo 自己的存储(包括批准)。攻击者使用它来使 Furucombo 将用户的 Token 拉入攻击者的地址,窃取了超过 1400 万美元。

虽然 Furucombo 的情景与可升级性本身无关,但它例证了为什么未经检查的委托调用是致命的。在可升级合约中,人们可能会想在逻辑中编写类似通用代理的东西(可能将调用转发到“插件”)。但如果不对其进行严格限制,就会引入与 Furucombo 相同的风险。关于 selfdestruct:上面的 Wormhole 案例展示了攻击者如何将其作为升级攻击的一部分来武器化它。另一个小例子:一些可升级合约的开发人员曾经想过使用 selfdestruct 来删除旧的实现(以节省 gas 或避免混淆)。这是不必要的且危险的,你应该简单地让旧的实现保持原样(并可能通过禁用初始化器来撤销它们的升级权限)。

总而言之,应避免或严格限制逻辑合约中的危险操作码。selfdestruct 永远不应出现在可升级合约的业务逻辑中。委托调用应仅用于受控的内部模式中(例如代理本身委托给实现,或者 diamond 委托给 facets,这些都是经过慎重考虑和审查的用法)。一个开放的委托调用类似于将你房子的钥匙交给攻击者。第 3 部分将重申这一点:不要试图在你的实现中对元编程进行花哨的操作,除非你绝对清楚自己在做什么,并且已经考虑了安全影响。

结论

许多这些陷阱都归结为人为错误和升级的复杂性。它们强调了对强大的流程和工具的需求。在现实中,一些协议有过险情,其中升级执行不正确,但运气或快速行动阻止了漏洞利用。

在第 3 部分中,我们将把这些惨痛的教训转化为最佳实践,演示如何正确编写初始化器、保护升级函数、使用工具来发现存储问题以及实施安全治理。目标是,通过遵循这些指南,你的项目不会成为此列表中的下一个案例研究。可升级合约可以安全使用,但正如我们所见,错误可能是可怕的。请谨慎、有知识和使用正确的工具进行操作。

  • 原文链接: x.com/threesigmaxyz/stat...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Three Sigma
Three Sigma
Three Sigma is a blockchain engineering and auditing firm focused on improving Web3 by working closely with projects in the space.