时间 2022 年 8 月 1 日
涉案 约 $1.9 亿
结局 部分白帽归还
类别 C2 权限与身份假设失效(零值/默认值)
参与者 ⚠️ 数百个独立地址

一、背景:桥怎么验证一条消息

Nomad 是一个乐观式跨链桥。以太坊侧的 Replica 合约负责验证来自其他链的消息:

① 源链上的消息被打包进一棵 Merkle 树
② 树根被提交到目标链,经过一段等待期后成为"可信根"
③ 用户提交 (消息 + Merkle 证明) 来兑现
④ ⭐ Replica 校验:这个证明算出来的根,是不是一个【可信根】?

代码大致是这样:

mapping(bytes32 => uint256) public confirmAt;      // 根 => 可信时间
mapping(bytes32 => bytes32) public messages;       // 消息哈希 => 它所属的根

function acceptableRoot(bytes32 _root) public view returns (bool) {
    uint256 _time = confirmAt[_root];
    if (_time == 0) return false;                  // ⭐ 未知的根 ⟹ 不可信
    return block.timestamp >= _time;
}

function prove(bytes32 _leaf, bytes32[32] calldata _proof, uint256 _index) public {
    bytes32 _calculatedRoot = branchRoot(_leaf, _proof, _index);
    require(acceptableRoot(_calculatedRoot), "!proof");   // 证明必须算出可信根
    messages[_leaf] = _calculatedRoot;                    // 记下来
}

function process(bytes32 _messageHash) public {
    // ⭐ 只有被证明过的消息才能执行
    require(acceptableRoot(messages[_messageHash]), "!proven");
    ...执行消息,放款...
}

逻辑本身是对的:没被证明过的消息,messages[hash]0x00confirmAt[0x00] 也是 0,acceptableRoot 返回 false

二、漏洞在哪:一次升级把 0 变成了合法值

2022 年 6 月,团队做了一次例行升级,在初始化时把 committedRoot 设成了 0x00。而初始化流程会把这个值登记为一个可信根:

confirmAt[0x00] = 1      ⚠️ 零根被标记为"可信"

这一个赋值,让上面那条逻辑整个翻转:

攻击者调用 process(任意消息哈希)
   │
   ├─ messages[任意哈希]  ⟹ ⚠️ 从未证明过,Solidity 的 mapping
   │                          对不存在的 key 返回【零值】= 0x00
   │
   └─ acceptableRoot(0x00)
        ├─ confirmAt[0x00] = 1   ⟹ 不是 0,继续
        └─ block.timestamp >= 1  ⟹ ✅ 通过

⟹ ⭐ 任何消息、不需要任何证明,直接通过验证。

注意这里两个"零"的相遇:

① Solidity 的 mapping 对不存在的 key 返回零值——这是语言语义。 ② 而零值被配置成了一个合法的可信根——这是一个配置错误。

单独看,第 ① 条是所有人都知道的常识,第 ② 条只是一个初始化参数。 合起来,桥的全部密码学验证形同虚设。

三、攻击复盘

第一个攻击者构造了一笔 process() 调用,消息内容是"把 Nomad 金库里的 WBTC 转给我"。交易成功。

然后发生了这次事故真正独特的事情:

⭐ 这笔交易【公开在链上】,而且它不需要任何前置条件 ——
   不需要闪电贷、不需要部署合约、不需要理解漏洞原理。

   任何人只要:
     ① 在区块浏览器上复制那笔成功交易的 calldata
     ② 把里面的收款地址改成自己的
     ③ 提交

⟹ 就能分一杯羹。

结果是:数百个独立地址在几个小时内反复执行同一套操作,把桥里的资产按各种代币、各种金额提空。

参与者的构成非常混杂:
   · 真正的攻击者(最早的几个,取走大头)
   · 抢救资金的白帽(事后归还)
   · ⚠️ 大量围观者 —— 看到消息后临时加入的普通用户
   · 抢跑机器人 —— 监控内存池,复制别人的调用并提高 Gas 抢先执行

⚠️ 这让事后的定性变得极其困难:链上是几百个地址,其中谁是攻击者、谁是白帽、谁只是"顺手拿了一点",无法从链上数据区分。

为什么这件事重要

⭐ 传统的漏洞利用有一道天然门槛:
   要理解漏洞、要写攻击合约、要准备资金。
   ⟹ 这道门槛限制了参与人数,也就限制了单位时间的损失速度。

⚠️ Nomad 的漏洞【没有这道门槛】。
   利用它的成本 = 复制粘贴 + 改一个地址。

⟹ 于是损失速度不再受"有多少人懂这个漏洞"限制,
   而是受【有多少人看到了推特】限制。

四、为什么没被发现

① 它不在代码审计的范围里

⚠️ Replica 的验证逻辑【本身是正确的】。
   审计它一百遍也审不出问题 ——
   问题是【运行时的一个状态变量的值】。

⭐ 审计看的是代码,
   而这个漏洞存在于"代码 + 部署参数"的组合里。

② 升级评审的注意力在"改了什么",而不是"设成了什么"

一次升级的 review 通常问:
   · 新增/修改了哪些函数?    ✅ 看了
   · 存储布局兼容吗?          ✅ 看了
   · 权限有没有变?            ✅ 看了

⚠️ 很少有人逐项问:
   ⭐ "初始化把每一个状态变量设成了什么值?
      这些值分别意味着什么?"

③ 零值的"哑失败"

⭐ 这类 bug 的可怕之处在于它【不会报错】。

   升级之后,桥看起来一切正常:
      · 正常的跨链转账照常工作 ✅
      · 监控指标全绿 ✅
      · 没有任何异常日志 ✅

⚠️ 唯一的变化是"一扇本该锁着的门开了"——
   而没有任何东西会告诉你这件事,
   直到有人走进来。

五、防御

① 零值永远不能是有效值

function acceptableRoot(bytes32 _root) public view returns (bool) {
    if (_root == bytes32(0)) return false;    // ⭐ 显式排除零值
    uint256 _time = confirmAt[_root];
    if (_time == 0) return false;
    return block.timestamp >= _time;
}

这一行就能完全阻止本次事故。 更一般地:

凡是用 mapping 做"存在性判断"的地方,
⚠️ 都要记住:mapping 对任何未设置的 key 都返回零值。

⟹ 因此【零值必须永远表示"不存在"】。
   如果你的业务里零值有别的含义,
   ⭐ 就必须用一个额外的 bool 或哨兵值来区分。

② 用结构体而不是裸值

struct RootStatus {
    bool    exists;      // ⭐ 显式的存在位
    uint256 confirmAt;
}
mapping(bytes32 => RootStatus) public roots;

function acceptableRoot(bytes32 _root) public view returns (bool) {
    RootStatus memory s = roots[_root];
    return s.exists && block.timestamp >= s.confirmAt;   // ⭐ 零值天然为 false
}

③ 初始化参数逐项校验

function initialize(bytes32 _committedRoot, ...) external initializer {
    require(_committedRoot != bytes32(0), "zero root");     // ⭐
    require(_threshold > 0 && _threshold <= _owners.length, "bad threshold");
    require(_updater != address(0), "zero updater");
    ...
}

这与第 4 篇的"初始化参数没校验"是同一条——把两篇放在一起看,会发现初始化是整个合约生命周期里最危险的一次函数调用

④ 升级后做"负向测试"

⭐ 部署到主网之后,不要只测"正常流程还能跑",
   要主动测【本该失败的操作是不是真的失败】:

   □ 提交一条【没有证明】的消息 ⟹ 必须 revert
   □ 提交一条【伪造根】的消息   ⟹ 必须 revert
   □ 用【零值】作为各种参数     ⟹ 必须 revert
   □ 用【别人的】证明兑现自己的消息 ⟹ 必须 revert

⚠️ 正向测试只能证明功能没坏,
   ⭐ 只有负向测试能证明【防线还在】。

这是本篇最可操作的一条:Nomad 的正向测试全绿,而一条负向测试就能发现问题

⑤ 为"公开哄抢"准备预案

⭐ 假设漏洞被发现的那一刻,全世界同时知道。你有什么?

   □ 暂停开关(pause)——谁能按?多快能按到?
      ⚠️ 需要多签的暂停 ≈ 没有暂停
   □ 提款限额 —— 单位时间的最大流出量
      ⭐ 这是唯一在"你还没反应过来"时起作用的机制
   □ 异常监控 —— 单笔超过阈值 / 短时间内多笔 ⟹ 告警
   □ 事先写好的沟通预案 —— 归还地址、赏金政策、联系方式

六、⭐ 举一反三

核心命题一:默认值是一个合法值

⭐⭐ 在 Solidity 里,你从来没有"未设置"这个状态——只有"值恰好是 0"。

受影响的不只是 mapping:

   · uint 的默认值 0    ⟹ 可能是合法金额、合法 ID、合法时间戳
   · address 的默认值   ⟹ 零地址,可能通过"不是某某地址"的检查
   · bool 的默认值      ⟹ false,通常安全;⚠️ 但反过来写成
                          "isBlocked" 时,默认就是"没被封禁"
   · 数组越界 / 空数组  ⟹ 循环体一次都不执行,函数"成功"返回
   · enum 的默认值      ⟹ ⚠️ 第 0 个成员!设计枚举时
                          第 0 位应该留给 "Unknown / Invalid"

一条可以随身带的检查

把合约里每一个 mapping 和每一个状态变量列出来,问:

   ① 它的零值意味着什么?
   ② 零值会不会通过某个检查?
   ③ ⭐ 如果会,把零值显式排除掉。

核心命题二:漏洞的"利用门槛"决定了损失速度

⭐ 做威胁建模时,除了"能不能被利用",还要评估
   【利用它需要什么能力】:

   需要偷到私钥            ⟹ 参与者:少数有能力的团伙
   需要写攻击合约 + 闪电贷 ⟹ 参与者:懂 DeFi 的开发者
   需要理解漏洞原理        ⟹ 参与者:读得懂代码的人
   ⚠️ 只需要复制粘贴改地址 ⟹ 参与者:【所有人】

⟹ 最后一档意味着:
   ① 损失速度不受能力限制,只受传播速度限制
   ② ⭐ 无法通过谈判解决 —— 你面对的不是一个对手,是几百个
   ③ 白帽和攻击者在链上无法区分

推论:你的应急预案必须能在"几百人同时利用"的情况下起作用。 而这基本上排除了一切需要人工决策的机制——只剩下事先设好的自动限流。

核心命题三:审计的边界

把[第 2 篇](https://blog.ifcalm.org/posts/security/blockchain/02-lendfme-erc777/)、[第 3 篇](https://blog.ifcalm.org/posts/security/blockchain/03-readonly-reentrancy/)和本篇放在一起看:

   第 2 篇:漏洞在 "合约 × 上架的代币" 里
   第 3 篇:漏洞在 "合约 A × 合约 B" 里
   本  篇:漏洞在 "合约 × 部署参数" 里

⭐ 三次都不在【任何单份合约代码】里。

⟹ "审计通过"这句话的完整版应该是:
   "在【审计当时的代码 + 当时的参数 + 当时的外部依赖】下通过。"
   ⚠️ 而后两项在上线后每天都在变。

七、本案小结

  • 技术原因只有一行:一次例行升级把 committedRoot 初始化成 0x00,而初始化流程把它登记成了可信根。
  • ⭐⭐ 两个"零"相遇:Solidity 的 mapping 对不存在的 key 返回零值(语言语义,人人皆知)+ 零值被配置成合法可信根(一个初始化参数)。单独看都不是问题,合起来让桥的全部密码学验证形同虚设。
  • 验证逻辑本身完全正确,审计一百遍也审不出来——⚠️ 漏洞存在于"代码 + 部署参数"的组合里
  • 零值 bug 不会报错:升级后正常转账照常工作、监控全绿、无异常日志。唯一的变化是"一扇本该锁着的门开了",而没有任何东西会告诉你。 -⭐ 第二重灾难是利用门槛为零:攻击交易公开后,复制 calldata、改一个收款地址就能参与。数百个地址在几小时内哄抢,其中攻击者、白帽、围观者在链上无法区分
  • 损失速度不再受"有多少人懂这个漏洞"限制,而是受"有多少人看到了推特"限制。
  • 防御第一条:零值永远不能是有效值。 if (_root == bytes32(0)) return false; 这一行就能完全阻止本次事故。用带 exists 位的结构体是更彻底的写法。
  • 最可操作的一条是负向测试:Nomad 的正向测试全绿,一条"提交未证明的消息必须 revert"就能发现问题。正向测试只证明功能没坏,只有负向测试证明防线还在。
  • ⚠️ 应急预案必须能在"几百人同时利用"下起作用——这排除了一切需要人工决策的机制,只剩下事先设好的自动限流。需要多签才能按的暂停开关 ≈ 没有暂停。

思考题

  1. 精确写出 process(任意哈希) 的完整执行路径,指出每一步为什么通过。
  2. 为什么说"Solidity 里没有’未设置’这个状态"?这对用 mapping 做存在性判断意味着什么?
  3. 加上 if (_root == bytes32(0)) return false; 之后,攻击为什么完全失效?还有没有别的绕过方式?
  4. 用带 exists 位的结构体重写这段逻辑。它比显式排除零值好在哪?
  5. 设计一个 enum Status,说明为什么第 0 个成员应该是 Invalid 而不是某个有意义的状态。
  6. 写出五条针对跨链桥的负向测试。为什么正向测试无法覆盖它们?
  7. “需要多签才能按的暂停开关 ≈ 没有暂停”——请论证这句话,并给出一个兼顾"能快速暂停"和"不能被滥用"的设计。
  8. 提款限流是唯一在"你还没反应过来"时起作用的机制。请设计一个具体的限流方案,并说明它对正常大额用户的影响。
  9. 按"利用门槛"给本档案前七篇排序(需要偷私钥 / 需要写攻击合约 / 需要懂原理 / 只需复制粘贴)。这个排序和损失金额相关吗?
  10. “审计通过"的完整版要补上哪两个下标?请针对你熟悉的一个协议,列出上线后这两项各自发生过哪些变化。