时间 2017 年 7 月 19 日
涉案 约 15 万 ETH(当时约 $3000 万)
结局 未追回;白帽抢救出另一批资金
类别 C2 权限与身份假设失效

一、背景:为省 Gas 而生的代理模式

2017 年,多签钱包是存放团队资金的标准做法。但一个功能完整的多签钱包合约有几百行代码,每部署一个就要付一次全部字节码的部署 Gas

Parity 的做法是把代码和数据分开:

        ┌──────────────────────────────┐
        │  WalletLibrary(部署一次)    │
        │  ⭐ 所有逻辑都在这里            │
        │  initWallet / execute / ...   │
        └──────────────────────────────┘
                    ▲
                    │ delegatecall
        ┌───────────┴───────────┐
        │                       │
   ┌─────────┐            ┌─────────┐
   │ Wallet A │            │ Wallet B │   ← 每个用户一个,极小
   │ 只存数据 │            │ 只存数据 │      只有一个 fallback
   └─────────┘            └─────────┘

每个用户的钱包合约小到只有一个 fallback 函数,收到任何调用就 delegatecall 转发给共享的库。

⭐ delegatecall 的语义必须先讲清楚

这是理解本案的前提:

普通 call:
   在【被调用合约】的上下文里执行
   ⟹ 改的是【它的】存储,msg.sender 是【你】

delegatecall:
   ⭐ 借来【别人的代码】,在【自己的】上下文里执行
   ⟹ 改的是【自己的】存储
   ⟹ msg.sender 和 msg.value 【原封不动地传递】

⟹ 所以:库合约里写的 `m_owners[...] = ...`,
   实际改的是【调用方钱包】的存储槽。

二、漏洞在哪

钱包的初始化逻辑长这样(简化):

contract WalletLibrary {
    address[] m_owners;
    uint      m_required;

    // 设置多签成员与门槛
    function initMultiowned(address[] _owners, uint _required) {
        //  ⚠️ 没有任何访问控制
        //  ⚠️ 没有 "只能调用一次" 的标记
        m_owners   = _owners;
        m_required = _required;
    }

    function initWallet(address[] _owners, uint _required, uint _daylimit) {
        initDaylimit(_daylimit);
        initMultiowned(_owners, _required);   // ⚠️ 同样没有保护
    }
}

本意是:钱包合约在构造时调用一次 initWallet,之后再也不碰。

⚠️ 但"本意"不是访问控制。 实际情况是:

① initWallet 是 public 的
② 它没有 onlyOwner 之类的修饰符
③ 它没有 "已初始化" 的布尔标记
④ ⭐ 而钱包的 fallback 会把【任何】调用 delegatecall 到库

⟹ 任何人向任何一个 Parity 钱包发送一笔
   调用 initWallet 的交易,就能把自己写进 m_owners。

注意 delegatecall 在这里的放大作用:因为是 delegatecallm_owners 改的是受害者钱包自己的存储。攻击者不需要碰库,只要对着每一个钱包各发一笔交易。

三、攻击复盘

对每一个目标钱包 W:

  第 1 笔交易 ── 夺取所有权
     调用 W.initWallet([攻击者地址], 1, 极大值)
       │
       └─ W 的 fallback ⟹ delegatecall 到 WalletLibrary
            └─ 在 W 的存储上执行:
                 m_owners   = [攻击者]        ⭐ 覆盖了原来的 owner
                 m_required = 1               ⭐ 单签即可
                 m_daylimit = 极大值          ⭐ 日限额形同虚设

  第 2 笔交易 ── 提款
     调用 W.execute(攻击者地址, 全部余额, "")
       └─ 权限检查:msg.sender 在 m_owners 里吗? ✅(刚写进去的)
       └─ 转账成功

两笔普通交易,没有任何复杂技巧。 攻击者对当时链上存在的 Parity 多签钱包批量执行,取走约 15 万 ETH。

白帽的反向操作

事件被发现后,一群白帽用完全相同的漏洞抢先接管了尚未被攻击的钱包,把资金转移到安全地址,事后归还给原主。

⭐ 这件事本身说明了漏洞有多简单:
   防守方和攻击方用的是同一段代码、同一个方法。

四、为什么没被发现

① “只会被调用一次"是一个假设,不是一个机制

写代码的人脑子里:
   "initWallet 是在部署时调用的,之后没人会再调它。"

⚠️ 而链上的现实:
   任何人、任何时刻、都可以调用任何 public 函数。
   ⭐ "没人会这么做" 从来不是安全属性。

② 构造函数的直觉被 delegatecall 破坏了

在普通合约里,初始化逻辑写在 constructor 里,
⭐ 而 constructor 【天然只能执行一次】—— 语言帮你保证。

但代理模式下:
   ⚠️ 库合约的 constructor 对钱包【毫无作用】
      (delegatecall 不会执行对方的构造函数)
   ⟹ 初始化必须改写成一个【普通函数】
   ⟹ 而普通函数没有 "只执行一次" 这个天然属性

⭐ 一旦把 constructor 改写成普通函数,
   你就必须【自己】把 constructor 的两个保证补回来:
      ① 只能执行一次
      ② 只能由部署者执行
   Parity 一个都没补。

⭐⭐ 这是代理模式最经典的陷阱:把一个由语言保证的性质,降级成了一个需要程序员手写的约定——而降级这件事本身没有任何提示。

③ 审计范围与"库合约不持有资产"的错觉

库合约自己没有钱,看起来不重要。⚠️ 但在 delegatecall 模式下,库的代码就是每一个钱包的代码——它的每一个 public 函数,都是每一个钱包的 public 函数。

五、防御

① 初始化函数必须同时具备三重保护

contract Wallet {
    bool private _initialized;
    address[] private _owners;

    function initialize(address[] calldata owners_, uint required_) external {
        // ① 只能一次
        require(!_initialized, "already initialized");
        _initialized = true;

        // ② 参数校验(不要漏,见第 7 篇)
        require(owners_.length > 0 && required_ > 0, "bad params");
        require(required_ <= owners_.length, "bad threshold");

        _owners = owners_;
        _required = required_;
    }
}

现成的实现:OpenZeppelin 的 Initializable 提供了 initializer 修饰符,处理了重入初始化、继承链上多次初始化等边角情况。自己手写容易漏。

② 部署与初始化必须原子

⚠️ 分两笔交易做(先部署,再初始化)会留下一个窗口:
   在这两笔之间,任何人都能抢先调用 initialize。
   ⟹ 这类"抢跑初始化"在真实世界发生过多次。

⭐ 正确做法:
   用工厂合约在【同一笔交易】里完成部署 + 初始化,
   或者在代理的构造函数里直接 delegatecall 初始化。

③ 实现合约自身必须被锁死

这是第 5 篇的主题,也是 Parity 第二次事故的成因。 简单说:

constructor() {
    _disableInitializers();   // ⭐ 让实现合约自己永远无法被初始化
}

④ 权限变更必须有事件与延时

⭐ owner 变更、门槛变更、升级 —— 这三件事都应该:
   ① 触发事件(监控能看到)
   ② 走时间锁(有人能反应)

⚠️ Parity 的 owner 被改写时,没有任何东西响。

六、⭐ 举一反三

核心命题

⭐⭐ 凡是"本该只发生一次"的事,都必须有一个显式的状态位来保证它只发生一次。

“本该”、“设计上”、“正常流程下”——这些词出现在安全论证里时,它们标记的正是漏洞的位置。

同一个洞的其它形态

① 忘了 onlyOwner

最朴素的形态:一个改关键状态的函数忘了加修饰符。
⚠️ 在有几十个函数的合约里,这比想象中常见得多。

⭐ 检查方法:把所有 external / public 函数列成一张表,
   逐个写出"谁能调"。写不出来的就是漏了。

tx.origin 当身份用

require(tx.origin == owner);   // ⚠️ 错

⚠️ 用户被诱导调用了攻击者的合约,tx.origin 仍然是用户——攻击者借你的手过了权限检查。永远用 msg.sender

③ 签名没有绑定上下文

一个用来授权的签名,如果没有绑定:
   · nonce      ⟹ ⚠️ 可以被重放
   · chainId    ⟹ ⚠️ 可以跨链重放
   · 合约地址   ⟹ ⚠️ 可以在另一个合约上重放
   · 过期时间   ⟹ ⚠️ 永久有效

⭐ EIP-712 的域分隔就是为了这四条而存在的。

④ 初始化参数没校验

require 只写了 "还没初始化",没写 "参数合理":
   · owners 为空数组     ⟹ 钱包永远无法操作
   · required = 0        ⟹ ⚠️ 任何人都能通过"多签"
   · required > 人数     ⟹ 永远凑不齐,资金冻死
   · owner 是零地址      ⟹ 权限落入无人区

⭐ 第 7 篇(Nomad)就死在"零值被当成有效值"上。

⑤ 角色可以被自己授予

一个 grantRole 函数,如果它的权限检查是
   "调用者必须拥有 ADMIN_ROLE"
而 ADMIN_ROLE 的初始持有者又是通过同一个函数设定的
   ⟹ ⭐ 检查一下有没有环。

⑥ 升级本身就是最大的权限

⭐ 一个可升级的合约,它的安全上限 =【谁能升级它】的安全性。
   审计了一万行业务代码,
   而升级权在一个单签的 EOA 手里 ——
   ⟹ 那一万行的安全结论全部作废。

⟹ 这是 Unit 7 的主题:合约之外的那一半。

一份可以直接用的权限表

上线前把这张表填完,填不完就不能上线:

函数名          谁能调用        改了什么状态       出错的最坏后果
──────────────────────────────────────────────────────────────
initialize      ⭐ 仅一次       owners/threshold   资金全失
execute         owners         余额               资金全失
addOwner        ⭐ 多签         owners             资金全失
upgrade         ⭐ 时间锁+多签  实现地址           ⚠️ 一切
pause           guardian       暂停位             DoS
setFee          治理           费率               经济参数
──────────────────────────────────────────────────────────────

这张表的价值在于它会逼你发现"这一行我填不出来”——那就是漏掉的访问控制。

七、本案小结

  • 漏洞极其朴素initWallet 是 public 的、没有访问控制、没有"已初始化"标记。任何人发一笔交易就能把自己设成 owner。
  • ⭐⭐ delegatecall 放大了它:库的代码在每个钱包自己的存储上执行,所以攻击者对着每个钱包各发一笔即可,不需要碰库。
  • 两笔普通交易完成攻击:一笔改 owner 和门槛,一笔提款。没有任何复杂技巧——白帽用同一段代码做了反向操作,这本身说明了它有多简单。 -⭐ 根因是代理模式的经典陷阱constructor 由语言保证"只执行一次 + 只有部署者能执行",改写成普通初始化函数后这两个保证同时消失,而降级这件事没有任何提示。
  • ⚠️ “没人会再调它"从来不是安全属性。 链上任何人、任何时刻都能调用任何 public 函数。
  • ⚠️ 库合约看起来"没有资产所以不重要"是错觉:delegatecall 模式下,库的每个 public 函数就是每个钱包的 public 函数。
  • 防御四件套:一次性标记(用 OpenZeppelin Initializable)、部署与初始化原子完成、实现合约自身 _disableInitializers()、权限变更有事件和时间锁。 -⭐ 一般化:凡是"本该只发生一次"的事,都必须有显式状态位保证。“本该”、“设计上”、“正常流程下"这些词出现在安全论证里时,它们标记的正是漏洞的位置。
  • 升级权是最大的权限:业务代码审计一万行,而升级权在一个单签 EOA 手里,那一万行的结论全部作废。

思考题

  1. 用自己的话说清 calldelegatecall 在存储、msg.sendermsg.value 三方面的区别。为什么本案必须是 delegatecall 才成立?
  2. 为什么库合约的 constructor 对钱包毫无作用?这个事实如何直接导致了本案?
  3. 如果 initWallet 加了 require(m_owners.length == 0),攻击还能成功吗?这个写法有什么隐患?
  4. “先部署、再初始化"会留下一个抢跑窗口。请写出攻击者的具体操作,并给出两种消除窗口的方案。
  5. 为什么 tx.origin 不能用作身份检查?构造一个完整的钓鱼场景。
  6. 一个授权签名如果不绑定 chainId,会怎样被滥用?如果不绑定合约地址呢?
  7. 初始化时 required = 0 会导致什么?请检查你熟悉的任意一个多签实现,看它有没有校验这一条。
  8. 把你写过(或读过)的一个合约的所有 external/public 函数列成本篇最后那张权限表。哪一行你填不出来?
  9. 白帽用同样的漏洞抢救资金。请从法律与伦理之外的角度讨论:这个做法在技术上有什么风险?
  10. 本案是"权限被夺取”,而第 5 篇是"权限被销毁”。在读第 5 篇之前,先自己推测一下:同一个漏洞怎么会导致资金被永久冻结而不是被偷?