| 时间 | 2022 年 10 月 6 日 |
| 名义增发 | 约 200 万 BNB(约 $5.7 亿) |
| 实际流出 | ⭐ 约 $1 亿——链被验证者暂停,其余资金没能转走 |
| 类别 | C3 数值/结构假设失效(证明验证) |
一、背景:桥怎么信任另一条链的事实
BNB Chain 由两条链组成:Beacon Chain(负责质押与治理)和 Smart Chain(EVM 兼容)。两者之间通过 Token Hub 跨链桥传递消息。
Beacon Chain 上发生了一件事(比如"有人存入了 X")
⟹ 这件事被写进 Beacon Chain 的状态树(IAVL 树)
⟹ 生成一份 Merkle 证明:"这条记录确实在那棵树里"
⟹ ⭐ Smart Chain 侧的桥合约验证这份证明
⟹ 验证通过 ⟹ 在 Smart Chain 上放款
⭐ 整座桥的安全性,最终压在"证明验证"这一段代码上。 它是唯一把"另一条链上的事实"变成"本链上可执行操作"的关口。
二、漏洞在哪:Merkle 证明必须绑定什么
先讲清楚一份合格的包含证明必须约束哪三样东西:
给定 (叶子 L, 路径 P, 根 R),验证器必须确认:
① ⭐ 用 L 和 P 算出来的根 == R (算得对)
② ⭐ L 确实是【一个叶子】,而不是内部节点 (类型对)
③ ⭐ L 在树中的【位置】是唯一确定的 (位置对)
⚠️ 只做第 ① 条是不够的 —— 而这是最常见的实现错误。
为什么只做第 ① 条不够
这是第 5 讲讲过的域分隔问题的一般形式:
如果叶子和内部节点用【同一个哈希函数、同一种编码】计算,
那么一个内部节点的哈希,可以被当成一个"叶子"来出示。
⟹ ⭐ 攻击者能构造出一条【算得出正确根】、
但并不对应任何真实数据的路径。
⟹ 这就是 CVE-2012-2459 一类漏洞的本质:
⚠️ 根哈希相同,不代表底下的数据相同。
BNB 桥的具体问题
公开分析指出,问题出在 IAVL 证明验证的实现上:验证逻辑没有完整约束证明的结构——攻击者可以在证明中塞入额外的、构造过的节点,使得最终算出的根仍然匹配,而被"证明"的那条叶子并不是树里真实存在的记录。
⭐⭐ 注意区分这三层:
IAVL 这个数据结构的设计 —— 没问题 Merkle 证明这个密码学原语 —— 没问题 ⚠️ 这一份具体的验证代码 —— 有问题
密码学从来不会因为数学被攻破而失效,它总是因为实现少了一个检查而失效。
三、攻击复盘
① 攻击者构造一条伪造的跨链消息:
"Beacon Chain 上有人存入了 100 万 BNB"
② 为这条消息构造一份【能通过验证但不对应真实数据】的证明
③ 提交给 Token Hub
└─ 验证器:算出来的根匹配 ✅
└─ ⚠️ 但它没有验证这条叶子的类型与位置
└─ 通过 ⟹ 在 Smart Chain 上铸出 100 万 BNB
④ 重复一次,共 200 万 BNB
止损:暂停整条链
攻击被发现后,BNB Chain 的验证者【协调暂停了出块】。
⟹ 约 200 万 BNB 中的大部分被冻在链上
⟹ ⭐ 实际转移到其他链的约 $1 亿,
而不是名义上的 $5.7 亿
⚠️ 这个止损非常有效,而它同时暴露了一件事:
⭐ 能被少数几个实体协调暂停的链,
意味着它的验证者集合足够小、足够协调。
⟹ 这次它救了用户,
⚠️ 但同一个能力也意味着:
这条链可以被同样一批人【拒绝服务】或【审查】。
⟹ 这不是道德判断,是一个必须被写进威胁模型的事实:
⭐【止损能力和审查能力,是同一个能力。】
这正是第 26 讲里 L2 严格定义要求"用户能单方面退出"的原因——如果你的资金安全依赖于"运营方会做正确的事",那它就不是密码学保证。
四、为什么没被发现
① 验证代码是"移植/改写"的
⚠️ 证明验证这类代码有一个共同特征:
它通常是从别处【移植】过来的
—— 从另一个语言、另一个链、另一份参考实现。
⭐ 而移植过程中最容易丢的,恰恰是那些
"看起来多余的检查" ——
因为移植者往往不知道那个检查是为了防什么。
② 正向测试全绿
用真实数据生成的证明 ⟹ 验证通过 ✅
随机乱改一个字节 ⟹ 验证失败 ✅
⭐ 看起来验证器工作得很好。
⚠️ 但它从来没有被【一个理解它内部结构的攻击者】测试过。
随机的错误证明会失败,
而【精心构造】的错误证明不会。
⭐ 这是第 7 篇那条"负向测试"的进阶版:负向测试也分两种,随机的负例和构造的负例,后者才是真正的测试。
③ 桥的价值和验证代码的行数不成比例
⭐ 一座锁着几十亿美元的桥,
它的安全性可能压在【几百行】证明验证代码上。
⚠️ 而审计的定价、时间分配,通常按"代码总行数"来,
而不是按"这几行控制着多少钱"来。
⟹ 应该反过来:
按【每一行代码控制的资金量】排序,
从高到低分配审计时间。
五、防御
① 不要自己实现 Merkle 证明验证
⭐ 如果必须实现,那份代码要:
□ 用【库】而不是手写(OpenZeppelin MerkleProof 等)
□ 与生成方使用【同一份实现的两侧】,并做交叉验证
□ 有独立的、专门的审计
□ ⭐ 有针对【构造的负例】的测试集
② 域分隔:让叶子和内部节点在类型上不可混淆
// ⭐ 叶子和内部节点加不同的前缀
bytes32 leafHash = keccak256(abi.encodePacked(bytes1(0x00), leafData));
bytes32 innerHash = keccak256(abi.encodePacked(bytes1(0x01), left, right));
⭐ 一行前缀,彻底消除"内部节点被当成叶子出示"这类攻击。 这是第 5 讲的核心结论。
③ 绑定位置
⚠️ 只证明"这条数据在树里"是不够的,
还要证明"它在【哪个位置】"。
⭐ 做法:把索引也纳入叶子的哈希
leaf = H(0x00 ‖ index ‖ data)
⟹ 于是同一份数据在不同位置有不同的叶子哈希,
证明无法被挪用到别的位置。
④ 双重验证与限额
⭐ 对高价值的桥,验证不应该是单点的:
□ 证明验证通过后,再检查【业务层面的合理性】
· 这笔金额是否超出历史区间?
· 源链上真的有对应的锁仓吗?(余额对账)
□ ⭐ 单位时间提款限额 —— 这是[第 7 篇](https://blog.ifcalm.org/posts/security/blockchain/07-nomad-bridge/)的结论:
它是唯一在"你还没反应过来"时起作用的机制
□ 大额提款走延时队列
⚠️ 如果 Token Hub 有"单次铸造不得超过历史锁仓量"这一条检查,200 万 BNB 的伪造会当场失败。
⑤ 按"每行代码控制的资金"分配审计资源
把系统里的代码按控制的资金量排序:
证明验证 ⟹ 控制全桥资产 ⭐⭐⭐ 最高优先级
提款逻辑 ⟹ 控制全桥资产 ⭐⭐⭐
权限/升级 ⟹ 控制一切 ⭐⭐⭐
费率计算 ⟹ 控制手续费 ⭐
前端展示逻辑 ⟹ 控制 0 —
六、⭐ 举一反三
核心命题
⭐⭐ 密码学从来不会因为数学被攻破而失效,它总是因为实现少了一个检查而失效。
"我们用了 Merkle 证明 / 多签 / ZK 证明" ——
⚠️ 这句话只描述了【用了什么原语】,
完全没有说明【那个原语被正确地约束了】。
⭐ 该问的永远是:
这个证明具体绑定了哪几样东西?
有哪样东西是它【没有】绑定的?
同一个盲区的其它形态
① 签名没绑定全部上下文
⚠️ 一个签名如果没有覆盖:
· nonce ⟹ 可重放
· chainId ⟹ 可跨链重放
· 合约地址 ⟹ 可跨合约重放
· 过期时间 ⟹ 永久有效
· ⭐ 调用哪个函数 ⟹ [第 6 篇](https://blog.ifcalm.org/posts/security/blockchain/06-poly-network/)的形态
⟹ EIP-712 的域分隔就是为这几条设计的。
② ZK 证明只证明了计算,没证明输入
⭐ ZK 能证明 "给定 x,我算对了 f(x)",
⚠️ 不能证明 "x 是真的"。
⟹ 见[第 14 篇](https://blog.ifcalm.org/posts/security/blockchain/14-compound-dai/)和第 31 讲。
③ 多签验证没检查签名者去重
⚠️ 一个"需要 5 个签名"的验证器,
如果不检查这 5 个签名来自 5 个【不同】的地址,
⟹ 一个人签 5 次就通过了。
⭐ 这是真实出现过的漏洞。检查方法:
要求签名者地址【严格递增】,
这样重复和乱序都会被自动拒绝。
④ 哈希拼接的歧义
// ⚠️ abi.encodePacked 会产生歧义
keccak256(abi.encodePacked("a", "bc")) == keccak256(abi.encodePacked("ab", "c"))
// ✅ 用 abi.encode(带长度前缀)或加分隔符
keccak256(abi.encode("a", "bc")) != keccak256(abi.encode("ab", "c"))
⭐ 凡是把多个变长字段拼起来哈希的地方,都要用 abi.encode 而不是 encodePacked。
关于"止损能力 = 审查能力"
⭐ 这条要写进威胁模型:
任何"出事时能救你"的机制,
都是一个"平时能控制你"的机制。
· 验证者能暂停链 ⟹ 也能审查你的交易
· 治理能冻结合约 ⟹ 也能冻结你的资金
· 团队能升级实现 ⟹ 也能把实现换成偷钱的版本
· 交易所能回滚 ⟹ 也能拒绝你提现
⟹ 这不意味着这些机制不该有,
⭐ 而意味着它们必须被【明确写出来】,
让用户知道自己在信任谁。
七、本案小结
- 伪造一份 Merkle 证明,凭空增发约 200 万 BNB。 密码学没被攻破——是那一份具体的验证代码没有完整约束证明的结构。
- ⭐⭐ 合格的包含证明必须绑定三样东西:① 算出的根匹配 ② 叶子确实是叶子(不是内部节点)③ 位置唯一确定。只做第 ① 条是最常见的实现错误。
- 根哈希相同不代表底下的数据相同——这是 CVE-2012-2459 一类漏洞的本质,解法是域分隔(叶子和内部节点加不同前缀)。 -⭐ 区分三层:数据结构的设计没问题、密码学原语没问题、这一份实现有问题。“我们用了 Merkle 证明"只说明用了什么原语,没说明它被正确约束了。
- ⚠️ 正向测试全绿:真证明通过、随机乱改的假证明失败——看起来验证器工作得很好。但随机的负例会失败,精心构造的负例不会。负向测试也分两种,构造的负例才是真正的测试。
- 审计资源应按"每行代码控制多少钱"分配,而不是按代码总行数。一座锁着几十亿的桥,安全性可能压在几百行验证代码上。
- 如果有"单次铸造不得超过历史锁仓量"这一条业务层检查,200 万 BNB 的伪造会当场失败。 -⭐ 止损能力 = 审查能力:验证者协调暂停整条链,把实际损失从 $5.7 亿压到约 $1 亿——同一个能力也意味着这条链可以被同一批人拒绝服务或审查。这不是道德判断,是必须写进威胁模型的事实。
- 多签验证要检查签名者去重(要求地址严格递增),变长字段拼接要用
abi.encode而非encodePacked。
思考题
- 写出一份合格的 Merkle 包含证明必须绑定的三样东西,并各举一个"漏掉它"会怎样的例子。
- 为什么"根哈希相同"不等于"数据相同”?请构造一个最小的例子说明内部节点如何被当成叶子出示。
- 域分隔(叶子加
0x00、内部节点加0x01)为什么能彻底解决上一题的问题?请给出论证。 - 把索引纳入叶子哈希(
leaf = H(0x00 ‖ index ‖ data))解决了什么问题?不加会怎样? - 一个多签验证器收到 5 个签名但不检查去重。写出攻击方法,并说明"要求地址严格递增"为什么同时解决了重复和乱序。
abi.encodePacked("a","bc")和abi.encodePacked("ab","c")为什么相同?在什么场景下这会变成一个可利用的漏洞?- 为本篇的证明验证器设计五个构造的负例(不是随机乱改)。你需要理解哪些内部细节才能构造它们?
- “单次铸造不得超过历史锁仓量"这条业务检查,会不会误伤正常用户?如果会,怎么设计才能兼顾?
- 论证"止损能力 = 审查能力”。请针对你熟悉的一个协议,列出所有具备"出事时能救你"能力的实体。
- 把你熟悉的系统里的代码按"每行控制多少资金"排序。排在最前面的那几行,上一次被专门审计是什么时候?