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