时间 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,后果是被接管,比变砖更糟。 -⭐ 一般化:对每一个你依赖的外部地址问三件事——它会不会变成别的东西、会不会消失、会不会拒绝服务。 大多数威胁建模只问了"它会不会骗我"。

思考题

  1. 为什么 only_uninitialized 对每个钱包都有效,唯独对库合约自己失效?请精确地说出这个检查的语义。
  2. delegatecall 到一个没有代码的地址会发生什么?为什么它"成功"比 revert 更糟?
  3. 解释 _disableInitializers() 的原理:为什么它锁死了实现合约,却不影响代理正常初始化一次?
  4. 为什么说"冻结比被盗更糟"?请分别列出两种情况下资金回来的可能路径。
  5. EIP-999 被否决的核心理由是什么?请从"这些资金当初为什么愿意上链"的角度论证这个否决的合理性。
  6. EIP-6780 之后,如果一个实现合约仍然无主,攻击者能做什么?请说明"被接管"为什么比"变砖"更糟。
  7. 一个合约用循环给 100 个地址发奖励,其中第 37 个是一个 fallback 会 revert 的合约。会发生什么?用 pull payment 重写它。
  8. USDC 有黑名单功能。如果你的清算逻辑必须把 USDC 转给被清算人,而他被拉黑了,会怎样?给出两种设计上的应对。
  9. 把你熟悉的一个协议的所有外部依赖地址列出来,逐个回答"它消失了,用户还能取回钱吗"。有几行答案是"不能"?
  10. 本篇说"协议升级会关掉某一条具体的攻击路径,但很少关掉产生它的思维盲区"。请再举一个符合这个规律的例子(提示:可以看第 1 篇里关于 .transfer() 的那一段)。