| 时间 | 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 是极端异常的数字,任何一条限额都会拦住它——限额是唯一在"验证逻辑已经失守"之后还起作用的机制。 -⭐ 一般化:人们习惯验证"数据",却常常忘记验证"引用"。 一个错误的引用比一个错误的数值危险得多——它会让你从错误的地方读,或往错误的地方写。
- ⚠️ 投资方补足缺口不是协议的保证——好的结局掩盖了同样严重的漏洞。
思考题
- 用自己的话说清 EVM 和 Solana 在"程序能访问哪些数据"上的根本差异。这个差异如何直接导致了本案?
- 为什么说"代码信任的不是签名,是’签名已验证’这个结论"?请精确指出信任链在哪一环断掉。
load_instruction_at和load_instruction_at_checked的区别是什么?为什么前者会存在?- 用 Anchor 的类型约束重写这段验证。为什么"让忘记验证变成编译错误"比"记得写 require"更可靠?
- 把函数参数分成"数值类"和"引用类",对你熟悉的一个合约做一遍分类。有几个引用类参数没有被验证?
- 列出 EVM 上五种"未验证的引用"漏洞形态,各举一个本档案里的对应案例。
- “任何时候你的代码依赖’某个检查已经做过了’,都必须验证那个检查是由可信的一方做的。“请在你的代码里找出一处这样的依赖。
- 设计一套跨链桥的铸造限额方案,说明它为什么能拦住 12 万 wETH,又不会误伤正常业务。
- 从 EVM 转到一个新生态时,你会列出哪五个必须重新回答的安全问题?
- 对比本篇和第 6 篇:一个是"没验证账户”,一个是"没验证调用目标”。它们在结构上是同一类错误吗?请论证。