时间 2022 年 2 月 2 日
涉案 约 $3.26 亿(12 万 wETH)
结局 ⭐ 投资方注资补足缺口,用户资产未受损
平台 Solana ↔ Ethereum
类别 C7 密钥与签名流程

一、背景:守护者签名的验证

Wormhole 用一组"守护者"(guardian)为跨链消息背书:

① 源链上发生一件事(比如"有人锁定了 X ETH")
② ⭐ 19 个守护者节点观察到,各自签名
③ 攒够门槛数量的签名 ⟹ 组成一份 VAA(可验证行动批准)
④ 目标链上的合约验证这些签名 ⟹ 通过则铸出对应资产

在 Solana 上,签名验证有一个特殊的做法。 Solana 提供一个原生的 secp256k1 签名验证程序,用法是:

在一笔交易里放两条指令:
   指令 0:调用 secp256k1 程序,让它验证一组签名
   指令 1:⭐ 你的程序 —— 它去【回头读取指令 0】,
           确认"前面那条指令确实做了签名验证,而且通过了"

“回头读取前一条指令"这个动作,需要访问一个叫 Instructions 的系统变量账户(sysvar)。

二、⭐ 漏洞在哪:Solana 的账户模型

这里需要先讲清楚 Solana 和 EVM 的一个根本差异:

EVM:
   合约要读某个存储 ⟹ 自己去读,地址是自己代码里写死的
   ⟹ ⭐ 调用者【无法】决定你读的是哪个存储

Solana:
   ⚠️ 一笔交易必须【预先列出】它要访问的所有账户,
      由【调用者】把这些账户传给程序。
   ⟹ ⭐ 程序拿到的每一个账户,
        都是【调用者选的】。

⟹ 因此 Solana 程序有一条铁律:
   ⭐⭐【必须验证每一个传进来的账户,确实是你期望的那个。】

Wormhole 的代码用了一个 Solana SDK 里已被标记为 deprecated 的函数来读取前一条指令。

⚠️ 那个函数【不检查】传进来的账户是不是真正的 Instructions sysvar。
   它只是把这个账户里的字节按格式解析出来。

⟹ 攻击者传进一个【自己创建的、内容由自己填写的】账户,
   ⭐ 函数照样解析,并返回"前一条指令是一次成功的签名验证"。

⭐⭐ 签名验证的逻辑完全正确。 它验证的是"前一条指令说签名通过了”—— 而"前一条指令"是攻击者写的。

SDK 里存在一个 _checked 版本的同名函数,它会校验账户地址。Wormhole 用的是不带 _checked 的那个。

三、攻击复盘

① 攻击者构造一笔 Solana 交易,包含:
     · 一个【自己创建】的账户,
       内容伪装成"Instructions sysvar 的数据",
       里面写着"指令 0 是一次成功的 secp256k1 验证"
     · 调用 Wormhole 的 verify_signatures,
       ⭐ 把这个假账户当作 sysvar 传进去

② Wormhole 读取"前一条指令"
     └─ ⚠️ 用的是不校验账户的那个函数
     └─ 读到攻击者写的内容
     └─ ⟹ "签名验证通过了" ✅

③ 提交一份伪造的 VAA:"以太坊上有人锁定了 12 万 ETH"
     └─ 签名"已验证" ⟹ 通过
     └─ ⭐ 在 Solana 上铸出 12 万 wETH,【没有任何抵押】

④ 把其中一部分通过桥换回以太坊,取走真实的 ETH

结局

Wormhole 的支持方在事发后注资补足了缺口,用户资产未受损失。

⚠️ 但这同样不是协议的保证。

⭐ "有一个愿意且有能力补 3.26 亿窟窿的投资方"
   不是一条可以写进威胁模型的安全属性。

⟹ 和[第 6 篇](https://blog.ifcalm.org/posts/security/blockchain/06-poly-network/)、[第 18 篇](https://blog.ifcalm.org/posts/security/blockchain/18-euler-finance/)一样:
   ⚠️ 好的结局掩盖了同样严重的漏洞。

四、为什么没被发现

① 用了 deprecated 的 API

⭐ 一个函数被标记为 deprecated,通常有具体原因。
   在安全敏感的代码里,这个原因往往【就是安全问题】。

⟹ 而 deprecated 只是一个编译警告,
   ⚠️ 在几千行的构建输出里几乎不可见。

⭐ 实践:CI 里把 deprecation 警告设为【错误】。
   至少要求每一处都有显式的豁免注释说明理由。

② 心智模型是从 EVM 迁移过来的

⚠️ 一个熟悉 EVM 的开发者,会自然地认为:
   "sysvar 就是一个系统提供的东西,它当然是真的。"

⭐ 而在 Solana 上,"系统提供"和"调用者传进来"
   在类型上没有区别 —— 都是一个 AccountInfo。

⟹ 跨链/跨生态开发时,最危险的不是"不会写",
   ⭐ 是【把上一个平台的安全直觉原样带过来】。

③ 审计的注意力在密码学上

"签名验证"这四个字会让审计员去看:
   □ 门槛对不对?          ✅
   □ 有没有重放保护?      ✅
   □ 签名者集合怎么轮换?  ✅
   □ 曲线和哈希用对了吗?  ✅

⚠️ 而漏洞在【签名验证的结果是从哪读来的】——
   ⭐ 这一步在代码里只是一行"读取账户",
      看起来是数据获取,不是安全逻辑。

五、防御

① 验证每一个账户/地址输入

// ⚠️ 危险:不校验账户
let ix = load_instruction_at(index, &instruction_sysvar_account.data.borrow())?;

// ✅ 安全:校验账户确实是 Instructions sysvar
let ix = load_instruction_at_checked(index, instruction_sysvar_account)?;

// ✅ 或者显式断言
require!(
    instruction_sysvar_account.key() == sysvar::instructions::ID,
    ErrorCode::InvalidSysvar
);

在 Anchor 框架里,用类型系统表达这个约束,让编译器替你检查:

#[derive(Accounts)]
pub struct VerifySignatures<'info> {
    /// CHECK: ⭐ 由类型保证它是真正的 Instructions sysvar
    #[account(address = sysvar::instructions::ID)]
    pub instruction_sysvar: AccountInfo<'info>,
}

一般原则:让"验证过的输入"和"未验证的输入"在类型上不同。 这样"忘记验证"会变成编译错误,而不是运行时漏洞。

② 把 deprecated 当作错误

# ⭐ CI 配置:把警告变成构建失败
[build]
rustflags = ["-D", "deprecated"]

③ 铸造要有独立的上限校验

⭐ 即使签名验证被绕过,还应该有一道业务层防线:

   □ 铸造量 ≤ 源链上实际锁定的量(跨链余额对账)
   □ 单笔铸造上限
   □ ⭐ 单位时间铸造总量上限
   □ 铸造量超过某阈值 ⟹ 需要额外的人工确认或延迟

⟹ 12 万 wETH 是一个极端异常的数字,
   ⚠️ 任何一条限额都会拦住它。

这是本档案里反复出现的同一条结论第 7 篇第 10 篇):限额是唯一在"你的验证逻辑已经失守"之后还起作用的机制。

④ 跨生态开发要重新学一遍安全模型

⭐ 从 EVM 到 Solana / Move / Cosmos,
   必须重新回答的几个问题:

   □ 谁决定我的程序能访问哪些数据?
   □ ⚠️ 调用者能控制什么?(Solana:几乎所有账户)
   □ 什么是"自动"验证的,什么必须我自己验证?
   □ 重入的形态是什么?(Solana 有 CPI 重入)
   □ ⭐ 这个平台上,最常见的漏洞类型是什么?
      —— 去读这个生态自己的事故清单,
         而不是照搬 EVM 的清单。

六、⭐ 举一反三

核心命题:地址也是输入

⭐⭐ 人们习惯验证"数据"(金额、数量、时间),却常常忘记验证"引用"(地址、账户、合约、索引)。

而一个错误的引用,比一个错误的数值危险得多——它会让你从错误的地方读,或者往错误的地方写。

⚠️ 这条在每个平台上都有对应形态:

Solana:
   · 未验证的 sysvar 账户            ⟹ 本篇
   · 未验证的 token account owner
   · ⭐ 未验证的 PDA 派生种子
   · 未检查账户是否可写/是否签名

EVM:
   · 未白名单的调用目标              ⟹ [第 6 篇](https://blog.ifcalm.org/posts/security/blockchain/06-poly-network/) Poly Network
   · ⭐ 用户可指定的代币地址          ⟹ [第 2 篇](https://blog.ifcalm.org/posts/security/blockchain/02-lendfme-erc777/) Lendf.Me
   · 未校验的预言机地址
   · delegatecall 到用户控制的地址
   · ⚠️ 未验证的 ERC-20 是否真的是那个代币

通用:
   · 未验证的数组索引
   · 未验证的 chainId / 域分隔符
   · ⭐ 未验证的"上一步的结果"从哪来      ⟹ 本篇的本质

一条可执行的检查

⭐ 把函数的所有参数分成两类:

   【数值类】amount、deadline、id、比例
      ⟹ 检查范围、检查溢出

   【引用类】address、account、index、selector、路径
      ⟹ ⭐⭐ 检查它【是不是你以为的那一个】

⚠️ 第二类的检查经常被完全遗忘,
   因为它"看起来不是一个需要校验的数"。

关于"结果从哪来"

⭐ 本案最深的一层:
   代码信任的不是签名,是【"签名已验证"这个结论】。

⟹ 一般化:
   任何时候你的代码依赖"某个检查已经做过了",
   ⚠️ 都必须验证【那个检查确实是由可信的一方做的】。

同类形态:
   · 相信调用者传来的"已验证"标志位
   · 相信一个外部合约返回的 isValid = true
     ⚠️ 而没验证那个合约是不是你以为的合约
   · ⭐ 相信前端传来的"用户已确认" ⟹ [第 23 篇](https://blog.ifcalm.org/posts/security/blockchain/23-wazirx/)
   · 相信 msg.sender 是某个合约,
     而没检查它是不是被 delegatecall 进来的

七、本案小结

  • 签名验证的逻辑完全正确、密码学也是对的——缺的是一步:它没有确认"用来读取验证结果的那个系统账户"是真的
  • ⭐⭐ Solana 的账户模型是关键:一笔交易必须预先列出要访问的所有账户,由调用者传给程序——因此 Solana 程序有一条铁律:必须验证每一个传进来的账户确实是你期望的那个
  • 代码信任的不是签名,是"签名已验证"这个结论——而这个结论来自攻击者自己写的账户。
  • ⚠️ 用了 deprecated 的 API,而 SDK 里存在一个 _checked 版本。deprecated 在安全敏感代码里通常意味着安全问题,但它只是一个编译警告,在几千行构建输出里几乎不可见
  • 跨生态开发最危险的不是"不会写",是把上一个平台的安全直觉原样带过来——EVM 开发者会自然认为"sysvar 是系统提供的所以当然是真的",而在 Solana 上"系统提供"和"调用者传进来"在类型上没有区别。
  • 审计注意力在密码学上(门槛、重放、轮换、曲线),而漏洞在"验证结果从哪读来"——这一步在代码里只是一行读取账户,看起来是数据获取而不是安全逻辑
  • 防御:用类型系统表达"这个账户必须是某个特定地址"(Anchor 的 #[account(address = ...)]),让"忘记验证"变成编译错误;CI 里把 deprecation 警告设为错误。
  • 12 万 wETH 是极端异常的数字,任何一条限额都会拦住它——限额是唯一在"验证逻辑已经失守"之后还起作用的机制。 -⭐ 一般化:人们习惯验证"数据",却常常忘记验证"引用"。 一个错误的引用比一个错误的数值危险得多——它会让你从错误的地方读,或往错误的地方写。
  • ⚠️ 投资方补足缺口不是协议的保证——好的结局掩盖了同样严重的漏洞。

思考题

  1. 用自己的话说清 EVM 和 Solana 在"程序能访问哪些数据"上的根本差异。这个差异如何直接导致了本案?
  2. 为什么说"代码信任的不是签名,是’签名已验证’这个结论"?请精确指出信任链在哪一环断掉。
  3. load_instruction_atload_instruction_at_checked 的区别是什么?为什么前者会存在?
  4. 用 Anchor 的类型约束重写这段验证。为什么"让忘记验证变成编译错误"比"记得写 require"更可靠?
  5. 把函数参数分成"数值类"和"引用类",对你熟悉的一个合约做一遍分类。有几个引用类参数没有被验证?
  6. 列出 EVM 上五种"未验证的引用"漏洞形态,各举一个本档案里的对应案例。
  7. “任何时候你的代码依赖’某个检查已经做过了’,都必须验证那个检查是由可信的一方做的。“请在你的代码里找出一处这样的依赖。
  8. 设计一套跨链桥的铸造限额方案,说明它为什么能拦住 12 万 wETH,又不会误伤正常业务。
  9. 从 EVM 转到一个新生态时,你会列出哪五个必须重新回答的安全问题?
  10. 对比本篇和第 6 篇:一个是"没验证账户”,一个是"没验证调用目标”。它们在结构上是同一类错误吗?请论证。