| 时间 | 2017 年 11 月 6 日 |
| 涉及 | 约 51.3 万 ETH 永久冻结(含某知名项目 ICO 募得的约 30 万 ETH) |
| 结局 | ⚠️ 至今未恢复。社区提案(EIP-999)讨论后被否决 |
| 类别 | C2 权限与身份假设失效 |
一、背景:修复之后的第二个洞
第 4 篇的事故发生在 2017 年 7 月。Parity 随后修复并部署了新版的 WalletLibrary,给初始化函数加上了保护。
修复本身是对的。 但部署时漏了一件事:
⭐ 新的库合约被部署到链上之后,
它【自己】从来没有被初始化过。
团队的心智模型:
"库只是一段被 delegatecall 借用的代码,
它自己不是一个钱包,不需要初始化。"
⚠️ 链上的现实:
库是一个【独立存在的合约】,
有自己的地址、自己的存储、自己的 public 函数。
⟹ 它可以被直接调用。
⟹ 而它此刻【无主】。
二、漏洞在哪
修复后的库里,初始化函数大致是这样:
contract WalletLibrary {
address[] m_owners;
modifier only_uninitialized {
require(m_numOwners == 0); // ⭐ 保护有了
_;
}
function initWallet(address[] _owners, uint _required, uint _daylimit)
only_uninitialized
{
initDaylimit(_daylimit);
initMultiowned(_owners, _required);
}
// 只有 owner 能销毁这个合约
function kill(address _to) onlymanyowners(...) external {
selfdestruct(_to);
}
}
逐个钱包看,这个保护是有效的:钱包在部署时已经被初始化,m_numOwners != 0,所以没人能再次初始化它。
⚠️ 但库合约自己的 m_numOwners 是 0——因为从来没人初始化过它。
⟹ 对【库合约本身】调用 initWallet:
only_uninitialized 检查通过 ✅
⟹ 调用者成为库合约的 owner
⟹ 而库合约的 owner 可以调用 kill()
三、攻击复盘
⭐ 这次严格来说不是"攻击"。 事后的记录显示,操作者是在探索这些合约时误触发的,事后自己也表示是意外。
第 1 笔交易
直接对【库合约地址】调用 initWallet([自己], 1, ...)
└─ only_uninitialized 通过(库的 m_numOwners == 0)
└─ ⭐ 自己成为库合约的唯一 owner
第 2 笔交易
对【库合约地址】调用 kill(自己)
└─ onlymanyowners 检查:门槛是 1,自己就是 owner ✅
└─ selfdestruct
└─ ⚠️ 库合约的【代码从链上被删除】
然后,所有钱包同时变砖:
┌──────────────────────────────┐
│ WalletLibrary │
│ ⚠️ 代码已被 selfdestruct 删除 │
│ (地址还在,但里面是空的) │
└──────────────────────────────┘
▲
│ delegatecall 到一个【没有代码】的地址
┌───────────┴───────────┬────────────┐
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Wallet A │ │ Wallet B │ │ Wallet C │
│ 有 ETH │ │ 有 ETH │ │ 有 ETH │
│ ⚠️ 取不出 │ │ ⚠️ 取不出 │ │ ⚠️ 取不出 │
└─────────┘ └─────────┘ └─────────┘
⚠️ delegatecall 到一个空地址不会 revert,它会"成功"并返回空。 于是钱包的 fallback 正常返回,但什么也没发生——提款请求被静默地吞掉。
约 51.3 万 ETH 从此停在链上,所有人都能看见,谁也拿不走。
四、⭐ 为什么"冻结"比"被盗"更糟
这一节是本篇存在的理由。
资金被盗:
· 攻击者是一个【有动机的人】
· ⟹ 可以谈判(Poly Network 全额归还,见第 6 篇)
· ⟹ 可以追踪、可以施压、可以设赏金
· ⟹ 交易所可以冻结、可以配合
⭐ 存在一条通往"钱回来"的路径
资金冻结:
· 对手是【EVM 的确定性】
· ⚠️ 没有人可以谈判 —— 没有"人"在另一头
· ⚠️ 唯一的出路是【修改协议本身】
⟹ 而那需要全网共识
Parity 之后确实有过恢复提案(EIP-999,提议通过一次状态修改恢复这些资金)。社区讨论后没有通过。
⭐ 反对的核心理由是一致的:
"如果为一个项目的失误改状态,
那么【不可变性】这条性质本身就不存在了。"
⟹ 而不可变性正是这些资金当初愿意放上链的理由。
⭐⭐ 这构成一个无解的取舍: 让链能救你,就等于让链能改你。 The DAO 那次社区选择了救(代价是分裂成 ETH/ETC),这次选择了不救。两次都没有"正确答案",只有不同的代价。
五、为什么没被发现
① 实现合约被当成了"代码"而不是"合约"
⭐ 心智模型的错误:
"库 = 一段代码 = 一个 .sol 文件"
链上的现实:
"库 = 一个部署好的、有地址、有存储、
有全部 public 函数入口的【活合约】"
⟹ 你为代理写的每一条访问控制,
都必须【同时】适用于实现合约自己。
② 修复只针对了"已知的那个用法"
7 月的教训被总结成:"钱包不能被重复初始化"。
⟹ 于是加了 only_uninitialized 检查。
⚠️ 但这条检查的语义是"这个【合约实例】还没被初始化",
而库合约恰好就是一个【没被初始化的实例】。
⭐ 修复是对的,只是修复者没意识到
受保护对象的集合里【多了一个成员】。
③ selfdestruct 的破坏面被低估
当时对 selfdestruct 的普遍认知:
"它销毁一个合约、把余额转走。影响范围 = 这一个合约。"
⚠️ 在 delegatecall 架构下:
影响范围 = 【所有指向它的代理】。
⟹ 一次 selfdestruct 摧毁了成百上千个钱包。
六、防御
① 实现合约自身必须在部署时被锁死
contract WalletImplementation is Initializable {
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers(); // ⭐ 让实现合约永远无法被初始化
}
function initialize(address[] calldata owners_) external initializer {
...
}
}
⭐ _disableInitializers() 做的事:在构造函数里把初始化版本号直接拉到最大,于是实现合约自己永远进不了 initializer。而代理是通过 delegatecall 使用它的代码,代理自己的存储不受影响,仍能正常初始化一次。
② 实现合约里不要有 selfdestruct 和任意 delegatecall
⚠️ 实现合约中的这两个操作码,在代理语境下是【核弹】:
selfdestruct ⟹ 摧毁所有代理(本案)
delegatecall(任意) ⟹ 代理可被诱导 delegatecall 到攻击者的代码
⟹ 等价于交出全部存储
⭐ 部署前用工具扫一遍实现合约的字节码,
确认这两个操作码不存在。
OpenZeppelin Upgrades 插件默认就会拦这一条。
③ 代理必须校验实现地址上确实有代码
function _setImplementation(address newImpl) private {
require(newImpl.code.length > 0, "impl has no code"); // ⭐
...
}
⚠️ 这挡不住"设置之后对方才被销毁",但能挡住相当一部分误操作。ERC-1967 的标准实现包含了这条检查。
④ 把"可用性"当成一等的安全属性
⭐ 做威胁建模时,不要只问"钱会不会被偷",
还要问"钱会不会【取不出来】":
· 依赖的外部合约被销毁 / 暂停 / 升级成别的东西?
· 预言机停止更新 ⟹ 清算路径卡死?
· 某个 owner 私钥丢失 ⟹ 多签永远凑不齐?
· 治理门槛设得太高 ⟹ 永远无法通过任何提案?
· ⚠️ 依赖的代币被列入黑名单 ⟹ 转账 revert ⟹ 提款函数永久失败?
七、⭐ 一个重要的更新:Cancun 之后
这个攻击在今天已经打不成了,但原因和你想的可能不一样。
EIP-6780(2024 年 Cancun 升级)大幅削弱了 SELFDESTRUCT:
⭐ 现在它【只转走余额】,不再删除合约代码 ——
除非 selfdestruct 与合约创建发生在【同一笔交易】里。
⟹ 对一个早已部署的库合约调用 selfdestruct,
代码会留在链上,代理仍然能正常工作。
⚠️ 但不要因此认为这一课过期了:
① "夺取无主实现合约的所有权"这一步【完全没变】。
夺到之后能做什么,取决于实现合约里还有什么危险函数
—— selfdestruct 只是其中一种。
② ⭐ 如果实现合约里有【任意 delegatecall】,
夺取所有权后仍然可以把所有代理的存储改成任意值。
这比销毁更糟:不是变砖,是【被接管】。
③ 更一般的教训 ——"可用性也是安全属性"——
与任何具体操作码都无关。
⭐ 协议升级会关掉某一条具体的攻击路径,但很少关掉产生它的那个思维盲区。
八、⭐ 举一反三
核心命题
⭐⭐ 你依赖的每一个外部地址,都要问三个问题: ① 它会不会变成别的东西?(升级) ② 它会不会消失?(销毁 / 停服) ③ 它会不会拒绝服务?(暂停 / 黑名单 / 耗尽)
大多数威胁建模只想了"它会不会骗我",漏掉了后面这三条。
同一个盲区的其它形态
① 提款函数会 revert 的一切理由
⚠️ 一个"取不出来"的提款函数,效果等同于资金被没收:
· 转账目标是合约,且它的 fallback 会 revert
· 代币有黑名单,收款方被拉黑(USDC/USDT 都有)
· 转账目标 gas 不够(用了 transfer 的 2300 限制)
· 循环给 N 个人发钱,其中一个 revert ⟹ ⭐ 全部人都收不到
· 依赖的预言机返回 0 或 revert ⟹ 价格算不出 ⟹ 函数挂掉
⭐ 通用解法:把"推送"改成"拉取"(pull payment)——
合约只记账,每个人自己来领,一个人失败不影响别人。
② 治理把自己锁死
· 门槛设成 "需要 100% 代币投票",而部分代币已永久丢失
· 提案只能由 owner 发起,而 owner 私钥丢了
· 升级需要旧版合约签名,而旧版已经被停用
⟹ ⭐ 每一条都会让协议进入"活着但动不了"的状态。
③ 依赖一个可以被别人关掉的东西
· 中心化预言机停止推送
· 桥的中继方停止运营
· L2 排序器停机 ⟹ 你的合约在 L2 上无法被调用
⚠️ 这就是[第 26 讲](https://blog.ifcalm.org/posts/blockchain/26-scaling-constraints/)里
L2 严格定义要求"能单方面退出"的原因
一条检查
把你合约里所有【外部地址】列出来(代币、预言机、实现合约、
多签、桥、任何 immutable address)。
对每一个填:
⭐ 它消失了,我的用户还能取回钱吗?
⚠️ 只要有一行答案是"不能",
那个地址就是你系统的单点故障 ——
而它多半没有出现在任何一份审计报告的"高危"栏里。
九、本案小结
- 不是"攻击",是意外。 操作者在探索合约时,对库合约本身调用了初始化,成为其 owner,随后销毁了它。
- ⭐⭐ 根因:实现合约被当成"代码",而它其实是一个有地址、有存储、有全部 public 入口的活合约——而且当时无主(从没被初始化过)。
- 7 月的修复是对的,只是修复者没意识到受保护对象的集合里多了一个成员:
only_uninitialized对每个钱包都有效,唯独对库自己失效,因为库确实没被初始化过。 - ⚠️
delegatecall到空地址不会 revert,它"成功"并返回空——提款请求被静默吞掉,成百上千个钱包同时变砖。 -⭐ 冻结比被盗更糟:被盗的对手是有动机的人(可谈判、可追踪、可施压),冻结的对手是 EVM 的确定性——没有人在另一头。唯一出路是改协议,而那需要全网共识(EIP-999 被否决)。 - 无解的取舍:让链能救你,就等于让链能改你。The DAO 那次选择了救(代价是 ETH/ETC 分裂),这次选择了不救。两次都没有正确答案。
- 防御:实现合约构造函数里
_disableInitializers();实现合约里禁止selfdestruct和任意delegatecall;代理设置实现时校验code.length > 0。 - ⚠️ EIP-6780(Cancun)之后这个具体攻击打不成了(
selfdestruct不再删代码),但"夺取无主实现合约"这一步完全没变——夺到之后如果实现里有任意delegatecall,后果是被接管,比变砖更糟。 -⭐ 一般化:对每一个你依赖的外部地址问三件事——它会不会变成别的东西、会不会消失、会不会拒绝服务。 大多数威胁建模只问了"它会不会骗我"。
思考题
- 为什么
only_uninitialized对每个钱包都有效,唯独对库合约自己失效?请精确地说出这个检查的语义。 delegatecall到一个没有代码的地址会发生什么?为什么它"成功"比 revert 更糟?- 解释
_disableInitializers()的原理:为什么它锁死了实现合约,却不影响代理正常初始化一次? - 为什么说"冻结比被盗更糟"?请分别列出两种情况下资金回来的可能路径。
- EIP-999 被否决的核心理由是什么?请从"这些资金当初为什么愿意上链"的角度论证这个否决的合理性。
- EIP-6780 之后,如果一个实现合约仍然无主,攻击者能做什么?请说明"被接管"为什么比"变砖"更糟。
- 一个合约用循环给 100 个地址发奖励,其中第 37 个是一个 fallback 会 revert 的合约。会发生什么?用 pull payment 重写它。
- USDC 有黑名单功能。如果你的清算逻辑必须把 USDC 转给被清算人,而他被拉黑了,会怎样?给出两种设计上的应对。
- 把你熟悉的一个协议的所有外部依赖地址列出来,逐个回答"它消失了,用户还能取回钱吗"。有几行答案是"不能"?
- 本篇说"协议升级会关掉某一条具体的攻击路径,但很少关掉产生它的思维盲区"。请再举一个符合这个规律的例子(提示:可以看第 1 篇里关于
.transfer()的那一段)。