时间 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

思考题

  1. 写出一份合格的 Merkle 包含证明必须绑定的三样东西,并各举一个"漏掉它"会怎样的例子。
  2. 为什么"根哈希相同"不等于"数据相同”?请构造一个最小的例子说明内部节点如何被当成叶子出示。
  3. 域分隔(叶子加 0x00、内部节点加 0x01)为什么能彻底解决上一题的问题?请给出论证。
  4. 把索引纳入叶子哈希(leaf = H(0x00 ‖ index ‖ data))解决了什么问题?不加会怎样?
  5. 一个多签验证器收到 5 个签名但不检查去重。写出攻击方法,并说明"要求地址严格递增"为什么同时解决了重复和乱序。
  6. abi.encodePacked("a","bc")abi.encodePacked("ab","c") 为什么相同?在什么场景下这会变成一个可利用的漏洞?
  7. 为本篇的证明验证器设计五个构造的负例(不是随机乱改)。你需要理解哪些内部细节才能构造它们?
  8. “单次铸造不得超过历史锁仓量"这条业务检查,会不会误伤正常用户?如果会,怎么设计才能兼顾?
  9. 论证"止损能力 = 审查能力”。请针对你熟悉的一个协议,列出所有具备"出事时能救你"能力的实体。
  10. 把你熟悉的系统里的代码按"每行控制多少资金"排序。排在最前面的那几行,上一次被专门审计是什么时候?