时间 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 这个数字。
  • ⚠️ 六天后才被发现,因为一个用户提不出款。 这六天里桥的余额发生了巨大变化而没有任何东西在看,追踪与冻结的最佳窗口被完全浪费。 -⭐ 一个每分钟检查一次余额的脚本——几十行代码——就能把发现时间从六天缩短到一分钟。 对"资金总量"的监控是投入产出比最高的安全措施,而它几乎总是被排在最后。
  • 原则:让权限默认过期,续期需要主动操作。 反过来(默认永久、撤销靠自觉)在长期一定会积累出僵尸权限——因为撤销这件事不紧急、不可见、没有负责人。
  • ⚠️ 安全预算的分布与实际损失的分布恰好相反:大量投入合约审计,而反钓鱼培训、密钥流程审计、权限复核、资金监控几乎为零。
  • 检测时间是一个可以设计的安全参数:六天=追回无望,几小时=可联系交易所,几分钟=可以止损。

思考题

  1. 为什么说"Ronin 的实际门槛是 1/1"?请写出计算过程。
  2. 对你熟悉的一个多签,按"共享攻击面"分组,算出它的实际门槛。结果和名义门槛差多少?
  3. 那项代签授权在授予时是合理的。请设计一个机制,让这类临时授权不可能变成僵尸权限。
  4. “让权限默认过期"的代价是什么?如果续期操作本身出问题(比如负责人休假),会发生什么?怎么办?
  5. 写出那个"每分钟检查余额"脚本的伪代码。它需要哪些独立的数据源?告警怎么才能叫醒人?
  6. 从"发现攻击"到"资金停止流出”,请列出完整的响应链条,并估算每一环需要多久。
  7. 列出你系统里所有的"僵尸权限"候选。对每一条问"现在撤销它,谁会抱怨"。
  8. 设计一次反钓鱼演练:你会怎么测试团队?失败率多少算可接受?
  9. “假设你的开发者会被钓鱼成功——然后问那之后会发生什么。“请对你的团队认真回答这个问题。
  10. Unit 7 的损失超过前六个单元总和,而没有一起是代码写错。这对你分配安全预算有什么具体影响?请给出一个新的分配方案。