| 时间 | 2022 年 3 月 23 日 |
| 涉案 | 约 $6.25 亿 |
| 发现 | ⚠️ 六天后,因为一个用户提不出款 |
| 合约漏洞 | ⭐ 没有 |
| 类别 | C7 密钥与签名流程 |
一、背景:一座 5/9 的桥
Ronin 是 Axie Infinity 的专用侧链。它和以太坊之间的桥由 9 个验证者共同守护:
⭐ 一笔提款需要 9 个验证者中的 5 个签名。
设计上的推理:
"攻破 5 个独立机构的密钥?那太难了。"
这个推理有两个隐含前提,而两个都不成立:
① 这 9 个验证者是【独立的】
② 权限一旦授予就【一直是那个人在用】
二、漏洞在哪:不在合约里
⭐ Ronin 的桥合约没有任何已知漏洞。 攻击者做的事,在合约看来完全合法:他提交了一笔带有 5 个有效签名的提款请求。
⚠️ 漏洞在【谁持有那 5 把私钥】这个问题上。
第 1 到 4 把:社会工程
⭐ 攻击者针对一名高级工程师发起了长期的社会工程:
伪装成一家(并不存在的)公司的招聘方,
经过多轮"面试"建立信任,
最后发来一份【薪酬条件的文档】。
⟹ 打开文档 ⟹ 恶意代码在其机器上执行
⟹ 攻击者进入内网
⟹ ⚠️ 拿到该公司运行的四个验证者节点的签名密钥
⭐ 注意这里的第一个结构性问题:四个"独立"的验证者,实际上跑在同一家公司的同一套基础设施上。
⚠️ 于是 "9 个验证者中的 4 个" 在攻击面上等于 "1 个"。
⟹ 名义门槛:5/9
⭐ 实际门槛:攻破 1 个组织 + 再拿 1 票
第 5 把:一项忘了撤销的权限
这是本案最值得记住的部分。
2021 年 11 月,Axie 用户量激增,
提款请求积压,Axie DAO 的验证者处理不过来。
⟹ 应急措施:Axie DAO 授权 Sky Mavis
⭐ 【代表它签名】,帮忙分担负载。
⟹ 流量高峰过去后,这项安排在实际操作上停止了,
⚠️⚠️ 但【链上的授权从未被撤销】。
⭐ 于是四个月后,攻击者控制了 Sky Mavis 的基础设施,也就自动获得了第 5 把签名权。
⟹ 一项【几个月前的、临时的、早已不再使用的】授权,
在所有人都忘记它的时候,
⭐ 成了压垮 6.25 亿美元的最后一票。
三、⭐ 六天
2022-03-23 攻击发生,资金分两笔转出
⚠️ 没有任何告警
2022-03-29 ⭐ 一个用户尝试提款失败并上报
⟹ 团队这才发现桥已经空了
六天。 这个数字比 6.25 亿更值得研究:
⚠️ 在这六天里:
· 桥的余额发生了巨大变化 —— ⭐ 没有任何东西在看
· 资金在链上完成了多次转移和混币
· 追踪与冻结的最佳窗口被完全浪费
⟹ ⭐ 一个每分钟检查一次桥余额的脚本 ——
——【几十行代码】——
就能把发现时间从六天缩短到一分钟。
⭐⭐ 这一条几乎适用于本档案里的每一起事故: 对"资金总量"的监控,是投入产出比最高的安全措施,而它几乎总是被排在最后。
四、为什么没被发现
① “多签"这个词掩盖了实际的独立性
⭐ 5/9 听起来很安全,但真正的问题是:
这 9 个密钥:
□ 在几个【不同的组织】手里?
□ 在几个【不同的物理位置】?
□ 用几种【不同的密钥管理方案】?
□ ⚠️ 有几个人能同时接触到多把?
⟹ Ronin 的答案:4 把在同一家公司、
⭐ 而第 5 把也在同一家公司(通过那项授权)。
⟹ ⭐⭐ 实际的门槛是 1/1,不是 5/9。
② 临时权限没有过期机制
⚠️ 授予权限时,人们想的是"现在需要";
⭐ 几乎没有人在同一时刻想"什么时候不再需要"。
⟹ 而"撤销权限"这件事:
· 不紧急(系统正常运行)
· 不可见(没有任何东西提醒你)
· 没有负责人(当初授权的人可能已经离职)
⟹ ⭐ 于是它永远不会发生。
③ 攻击面在链下,而安全预算在链上
⭐ 一个典型项目的安全投入分布:
智能合约审计 ██████████ 大量预算
形式化验证 ████
漏洞赏金 ███
⚠️ 员工反钓鱼培训 ▏
⚠️ 密钥管理流程审计 ▏
⚠️ 权限定期复核 ▏
⚠️ 资金监控告警 ▏
⟹ 而实际损失的分布恰好【相反】。
五、防御
① 所有授权必须带过期时间
struct Delegation {
address delegate;
uint256 expiresAt; // ⭐ 强制字段,不能为 0(永久)
}
function delegate(address to, uint256 duration) external onlyOwner {
require(duration <= MAX_DELEGATION, "too long"); // ⭐ 上限
delegations[msg.sender] = Delegation(to, block.timestamp + duration);
}
function isAuthorized(address signer) public view returns (bool) {
Delegation memory d = delegations[signer];
return d.delegate == signer && block.timestamp < d.expiresAt; // ⭐ 自动失效
}
⭐⭐ 原则:让权限【默认过期】,续期需要主动操作。 反过来(默认永久,撤销需要主动操作)在长期一定会积累出僵尸权限。
这是第 18 篇“约定 vs 机制"的另一个应用:不要依赖"有人会记得撤销”。
② 定期的权限审计
⭐ 每季度做一次,输出一张表:
谁 / 什么合约 | 有什么权限 | 何时授予 | 何时过期 | ⭐ 现在还需要吗?
⚠️ 最后一列必须有人签字。
凡是"不确定"的,一律撤销 —— 需要时再授予的成本,
远低于一个僵尸权限的代价。
③ 多签成员的真实独立性
⭐ 上线前必须回答的问题清单:
□ N 个密钥分布在几个【法律实体】?
□ 几个【地理位置】?几个【司法辖区】?
□ 用了几种【不同的硬件/软件】方案?
⚠️ 全部用同一款钱包 ⟹ 一个 CVE 全灭
□ ⭐ 有没有任何一个人/一台机器能接触到 ≥ 门槛数量的密钥?
□ 密钥持有人之间是否互相知道身份?
(知道 ⟹ 可被逐个针对;不知道 ⟹ 难以协调)
□ ⚠️ 有没有"代签""委托""备用"之类的旁路?
⟹ ⭐ 只要有一个答案让"实际门槛"低于"名义门槛",
那个名义门槛就是虚的。
④ 资金监控:最高性价比的那一条
⭐ 一个几十行的脚本:
每 N 秒:
读取桥/金库的余额
与上一次比较
变化超过阈值 ⟹ 告警(电话、短信,不是邮件)
变化超过更高阈值 ⟹ ⭐ 自动触发暂停
⚠️ 关键点:
· 数据源要独立(不要只信自己的 RPC)
· 告警要能【叫醒人】
· ⭐ 要有演练 —— 没演练过的应急流程等于不存在
⑤ 把人当作攻击面来防御
□ 全员反钓鱼培训,且【定期演练】(发假钓鱼邮件测试)
□ ⭐ 密钥操作必须在【专用的、隔离的】设备上进行
—— 那台机器不上网、不收邮件、不装别的软件
□ 生产权限与日常办公环境完全分离
□ 关键操作需要带外确认(打电话核对)
□ ⚠️ 假设你的开发者【会】被钓鱼成功 ——
然后问:"那之后会发生什么?"
六、⭐ 举一反三
核心命题一:名义门槛 vs 实际门槛
⭐⭐
N/M多签的实际安全性,取决于"攻破 N 把密钥需要攻破几个独立的东西”,而不是 N 这个数字。
计算方法:
把 M 个密钥按【共享的攻击面】分组:
同一家公司 / 同一套基础设施 / 同一款硬件 /
同一个云账号 / 同一个人能接触
⭐ 实际门槛 = 覆盖 N 把密钥所需的【最少组数】
⟹ Ronin:M=9,N=5,⚠️ 而实际门槛 = 1
对你的多签算一遍这个数。 大多数团队算完会吓一跳。
核心命题二:僵尸权限
⚠️ 每一个系统都在积累"当初有理由、现在没人记得"的权限:
· 临时代签授权(本篇)
· 已离职员工的密钥/账号
· 调试用的后门函数
· ⭐ 无限额度的 ERC-20 approve
· 早期部署的、忘了废弃的合约仍持有权限
· CI/CD 的部署密钥
· "临时"提高的限额
⭐ 检查方法:列出所有权限,对每一条问
"如果现在撤销它,谁会来抱怨?"
⚠️ 答不上来 ⟹ 撤销。
核心命题三:检测时间是一个安全参数
⭐ 把"从攻击发生到被发现的时间"当成一个可以设计的指标:
六天 ⟹ 资金已被彻底洗白,追回无望(本篇)
几小时 ⟹ 可以联系交易所冻结
几分钟 ⟹ ⭐ 可以按下暂停键,止损
几秒 ⟹ 自动熔断可能来得及
⟹ 而把它从"六天"降到"一分钟",
⚠️ 需要的往往只是几十行代码 ——
⭐ 这是整个安全预算里回报率最高的一笔投入。
关于"没有合约漏洞"的事故
⭐ 本档案 Unit 7 的六篇,加起来的损失
超过前面六个单元的总和。
⚠️ 而它们没有一起是"代码写错了"。
⟹ 推论:
⭐ 当你把合约写对之后,
剩下的风险几乎全部集中在
【谁能操作它】和【操作时看到的是什么】上。
⟹ 一份只审计了 Solidity 的报告,
覆盖的是这个档案里较小的那一半。
七、本案小结
- ⭐ 合约没有任何漏洞。 攻击者提交了一笔带 5 个有效签名的提款请求——在合约看来完全合法。
- 前四把私钥来自社会工程:伪装成招聘方,多轮"面试"建立信任,最后发来一份薪酬文档,打开即中招。
- ⭐四个"独立"的验证者跑在同一家公司的同一套基础设施上——于是"9 个里的 4 个"在攻击面上等于"1 个"。
-⭐ 第五把来自一项 2021 年 11 月为应付流量高峰临时授予的代签权限。高峰过去后实际操作上停止了,但链上的授权从未被撤销。四个月后它成了压垮 6.25 亿的最后一票。
-⭐ 名义门槛 5/9,实际门槛 1/1。
N/M多签的实际安全性取决于"攻破 N 把密钥需要攻破几个独立的东西",而不是 N 这个数字。 - ⚠️ 六天后才被发现,因为一个用户提不出款。 这六天里桥的余额发生了巨大变化而没有任何东西在看,追踪与冻结的最佳窗口被完全浪费。 -⭐ 一个每分钟检查一次余额的脚本——几十行代码——就能把发现时间从六天缩短到一分钟。 对"资金总量"的监控是投入产出比最高的安全措施,而它几乎总是被排在最后。
- 原则:让权限默认过期,续期需要主动操作。 反过来(默认永久、撤销靠自觉)在长期一定会积累出僵尸权限——因为撤销这件事不紧急、不可见、没有负责人。
- ⚠️ 安全预算的分布与实际损失的分布恰好相反:大量投入合约审计,而反钓鱼培训、密钥流程审计、权限复核、资金监控几乎为零。
- 检测时间是一个可以设计的安全参数:六天=追回无望,几小时=可联系交易所,几分钟=可以止损。
思考题
- 为什么说"Ronin 的实际门槛是 1/1"?请写出计算过程。
- 对你熟悉的一个多签,按"共享攻击面"分组,算出它的实际门槛。结果和名义门槛差多少?
- 那项代签授权在授予时是合理的。请设计一个机制,让这类临时授权不可能变成僵尸权限。
- “让权限默认过期"的代价是什么?如果续期操作本身出问题(比如负责人休假),会发生什么?怎么办?
- 写出那个"每分钟检查余额"脚本的伪代码。它需要哪些独立的数据源?告警怎么才能叫醒人?
- 从"发现攻击"到"资金停止流出”,请列出完整的响应链条,并估算每一环需要多久。
- 列出你系统里所有的"僵尸权限"候选。对每一条问"现在撤销它,谁会抱怨"。
- 设计一次反钓鱼演练:你会怎么测试团队?失败率多少算可接受?
- “假设你的开发者会被钓鱼成功——然后问那之后会发生什么。“请对你的团队认真回答这个问题。
- Unit 7 的损失超过前六个单元总和,而没有一起是代码写错。这对你分配安全预算有什么具体影响?请给出一个新的分配方案。