Caloogy 文章
English

可升级合约与管理员私钥

日期
2025 年 9 月 9 日
系列
链上
阅读
4 分钟
主题
OnchainSmart contractProxyUpgradeable

一句话版本

区块链上的合约代码一旦部署就不能修改,有 bug 也改不了。所以很多项目使用代理模式:用户和一个固定地址的"代理合约"打交道,代理把调用转发给一个可以替换的"实现合约"。这样就能升级了。 代价是:谁能升级,谁就能拿走一切。管理员私钥被盗、升级时存储布局写错、实现合约没初始化,都曾造成巨额损失。防御靠多签、时间锁和严格的升级审查。

全文分三层:

  1. 通俗理解:换芯不换壳
  2. 实际用处:Parity 多签冻结、Audius、管理员私钥被盗
  3. 原理与工程要点:代理的工作原理、存储冲突、初始化、升级治理

第一层:通俗理解

1.1 换芯不换壳

一台自动售货机,外壳(地址、里面存的钱和库存)固定不动,但里面的"大脑芯片"(逻辑代码)可以拔出来换一块新的。

1.2 用户其实在信任管理员

很多用户以为"智能合约 = 代码说了算,谁也改不了"。但对于可升级合约,真实情况是"代码说了算,直到管理员决定换代码"。用户实际信任的是管理员的私钥和他的诚信。


第二层:实际用处

事件 经过
Parity 多签钱包(2017 年 11 月) 很多多签钱包共用一个"库合约"提供逻辑。这个库合约本身没有初始化,一名用户调用初始化函数成了它的所有者,随后(据称是误操作)调用了自毁函数。所有依赖它的钱包失去逻辑代码,约 51 万 ETH 被永久冻结
Audius(2022) 代理合约和实现合约的存储布局发生冲突,导致"是否已初始化"的标志被错误地读取,攻击者得以重新初始化治理合约、把自己设成管理员,转走约 600 万美元的代币
管理员私钥被盗(屡见不鲜) 项目方的部署私钥或管理员私钥被盗,攻击者直接把合约升级成恶意版本,或调用"紧急提取"等管理函数转走资金

第三层:原理与工程要点

3.1 代理是怎么工作的

调用

delegatecall

升级后改为

用户

代理合约
(固定地址,存所有数据)

实现合约 v1
(只有代码)

实现合约 v2

关键是 delegatecall:代理借用实现合约的代码,但在代理自己的存储上运行。所以数据都存在代理里,换实现合约时数据不丢。(delegatecall 的危险另见《EOA与智能合约钱包》。)

3.2 存储冲突

合约的状态变量按声明顺序放在编号为 0、1、2……的存储槽里。代理和实现合约共用代理的存储,如果:

那么修改总供应量就会改掉管理员地址,反之亦然。

对策:

3.3 初始化

代理模式下,实现合约的构造函数不会在代理的存储上运行,所以改用一个 initialize 函数。常见错误:

错误 后果
部署后忘了调用 initialize 任何人都可以抢先调用,成为所有者
initialize 可以被调用多次 攻击者重新初始化,夺走权限
实现合约本身没有初始化 攻击者直接对实现合约初始化,再做破坏(Parity 事件)

对策:初始化函数加"只能调用一次"的保护;实现合约的构造函数里调用 _disableInitializers() 锁死它自己;部署和初始化放在同一笔交易里完成。

3.4 升级治理

措施 作用
多签管理员 一把私钥被盗不足以升级(见《EOA与智能合约钱包》)
时间锁 升级提案公布后要等一段时间(如 48 小时)才能执行,用户和安全研究者有时间审查,不同意的可以先撤出资金(链上的冷静期)
公开升级内容 新实现合约的源码在区块浏览器上验证并公开
最终放弃升级权 协议成熟后,把管理员权限转给治理合约或直接放弃,让合约真正不可变

3.5 用户怎么判断

在区块浏览器上查看合约:

如果一个持有大量用户资金的合约,管理员只是一个普通地址,而且没有时间锁,那就意味着一把私钥决定了所有人的资金。


总结

层次 要点
直觉 换芯不换壳;谁能换芯,谁就能拿走一切
原理 代理用 delegatecall 借代码、用自己的存储
坑 存储冲突(Audius)、未初始化(Parity)、管理员私钥被盗
防御 EIP-1967 槽位、只追加变量、锁死初始化、多签 + 时间锁