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