Solana 常见漏洞 — 签名验证缺失/PDA种子碰撞/整数溢出
### 1. Solana 安全模型 vs EVM 安全模型
日期: 2026-06-23 方向: Solana / 安全审计 阶段: 第四阶段:综合实战 (73-80) 标签: #solana #security #signer-check #pda #overflow #sealevel-attacks
今日目标
| 类型 | 内容 |
|---|---|
| 学习 | Solana 程序 6 大常见漏洞类型及其防护方法 |
| 实操 | 编写有漏洞的 Solana 程序 + 修复版本对比 |
| 产出 | Solana 安全检查清单 + Sealevel Attacks 分析笔记 |
核心概念
1. Solana 安全模型 vs EVM 安全模型
Solana 和 EVM 的安全模型有根本性差异:
| 维度 | EVM (Solidity) | Solana (Rust/Anchor) |
|---|---|---|
| 状态存储 | 合约内部 mapping | 外部账户 (Account) |
| 调用者验证 | msg.sender 自动可用 | 必须手动验证 signer |
| 数据所有权 | 合约拥有数据 | 账户有 owner 字段 |
| 重入风险 | 高(外部调用) | 低(无跨程序重入) |
| 溢出 | 0.8+ 自动检查 | debug 检查,release 不检查! |
| 账户模型 | 无需验证 | 必须验证每个传入账户 |
Solana 的核心安全挑战:所有账户从外部传入,程序必须验证每一个账户的合法性。
2. 漏洞 #1:缺失签名验证 (Missing Signer Check)
这是 Solana 最常见也最致命的漏洞——忘记检查某个账户是否真的签名了交易。
有漏洞的代码
// ❌ 有漏洞:缺失签名验证
use anchor_lang::prelude::*;
declare_id!("VuLn1111111111111111111111111111111111111111");
#[program]
pub mod vulnerable_bank {
use super::*;
/// 取款 - 任何人都可以从任何人的账户取款!
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
let vault = &mut ctx.accounts.vault;
// 漏洞:没有验证 authority 是否签名了交易!
// 攻击者可以传入任何人的 authority 账户(不签名)
// 然后从他们的 vault 中取钱
require!(vault.balance >= amount, ErrorCode::InsufficientFunds);
vault.balance -= amount;
// 转移 SOL
**vault.to_account_info().try_borrow_mut_lamports()? -= amount;
**ctx.accounts.authority.to_account_info().try_borrow_mut_lamports()? += amount;
Ok(())
}
}
#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(mut)]
pub vault: Account<'info, Vault>,
/// CHECK: 漏洞!没有 Signer 约束
#[account(mut)]
pub authority: AccountInfo<'info>,
// 应该是: pub authority: Signer<'info>,
}
#[account]
pub struct Vault {
pub authority: Pubkey,
pub balance: u64,
}
修复后的代码
// ✅ 修复:添加签名验证
#[derive(Accounts)]
pub struct WithdrawSecure<'info> {
#[account(
mut,
has_one = authority, // 验证 vault.authority == authority.key()
)]
pub vault: Account<'info, Vault>,
// Signer 类型自动验证该账户已签名交易
#[account(mut)]
pub authority: Signer<'info>,
}
// 或者手动验证(不推荐,但需要理解原理)
pub fn withdraw_manual_check(ctx: Context<WithdrawManual>, amount: u64) -> Result<()> {
let vault = &mut ctx.accounts.vault;
let authority = &ctx.accounts.authority;
// 手动检查1:是否签名
if !authority.is_signer {
return Err(ErrorCode::Unauthorized.into());
}
// 手动检查2:是否是正确的 authority
if vault.authority != authority.key() {
return Err(ErrorCode::Unauthorized.into());
}
// 安全的业务逻辑...
Ok(())
}
3. 漏洞 #2:缺失 Owner 验证 (Missing Owner Check)
Solana 中每个账户都有一个 owner 程序。如果不验证 owner,攻击者可以传入伪造的账户数据。
有漏洞的代码
// ❌ 有漏洞:未验证账户 owner
#[derive(Accounts)]
pub struct ProcessReward<'info> {
/// CHECK: 漏洞!没有验证这个账户的 owner 是本程序
pub user_stats: AccountInfo<'info>,
#[account(mut)]
pub reward_vault: Account<'info, RewardVault>,
pub authority: Signer<'info>,
}
pub fn process_reward(ctx: Context<ProcessReward>) -> Result<()> {
// 攻击者可以创建一个假的 user_stats 账户
// 其数据格式和真实的一样,但 total_staked 被设为极大值
let user_stats_data = ctx.accounts.user_stats.try_borrow_data()?;
let user_stats: UserStats = UserStats::try_deserialize(&mut &user_stats_data[..])?;
// 基于伪造的数据计算奖励
let reward = user_stats.total_staked * REWARD_RATE / 10000;
// 攻击者获得巨额奖励!
Ok(())
}
修复后的代码
// ✅ 修复:使用 Account<> 类型自动验证 owner
#[derive(Accounts)]
pub struct ProcessRewardSecure<'info> {
// Account<'info, UserStats> 会自动检查:
// 1. 账户的 owner == 当前程序 ID
// 2. 数据可以反序列化为 UserStats
// 3. discriminator 匹配(Anchor 特性)
#[account(
has_one = authority, // 额外检查 user_stats.authority == authority
)]
pub user_stats: Account<'info, UserStats>,
#[account(mut)]
pub reward_vault: Account<'info, RewardVault>,
pub authority: Signer<'info>,
}
#[account]
pub struct UserStats {
pub authority: Pubkey,
pub total_staked: u64,
pub last_claim_time: i64,
}
4. 漏洞 #3:PDA 种子碰撞 (PDA Seed Collision)
PDA (Program Derived Address) 用种子(seeds)推导确定性地址。如果种子设计不当,不同的逻辑账户可能映射到同一个 PDA。
有漏洞的代码
// ❌ 有漏洞:PDA 种子可碰撞
#[derive(Accounts)]
pub struct CreatePool<'info> {
#[account(
init,
// 漏洞:只用一个 token mint 作为种子
// 如果有两种不同类型的池(如 lending pool 和 staking pool)
// 它们会碰撞到同一个 PDA!
seeds = [token_mint.key().as_ref()],
bump,
payer = authority,
space = 8 + Pool::INIT_SPACE,
)]
pub pool: Account<'info, Pool>,
pub token_mint: Account<'info, Mint>,
#[account(mut)]
pub authority: Signer<'info>,
pub system_program: Program<'info, System>,
}
// 另一个程序也用相同的种子
// seeds = [token_mint.key().as_ref()]
// 如果在同一个程序中有不同类型的池,就会碰撞
修复后的代码
// ✅ 修复:使用唯一的种子前缀
#[derive(Accounts)]
pub struct CreateLendingPool<'info> {
#[account(
init,
// 添加类型前缀 + 所有相关参数作为种子
seeds = [
b"lending_pool", // 类型前缀
token_mint.key().as_ref(), // 相关 token
authority.key().as_ref(), // 创建者(如需要)
],
bump,
payer = authority,
space = 8 + LendingPool::INIT_SPACE,
)]
pub pool: Account<'info, LendingPool>,
pub token_mint: Account<'info, Mint>,
#[account(mut)]
pub authority: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct CreateStakingPool<'info> {
#[account(
init,
seeds = [
b"staking_pool", // 不同的类型前缀
token_mint.key().as_ref(),
],
bump,
payer = authority,
space = 8 + StakingPool::INIT_SPACE,
)]
pub pool: Account<'info, StakingPool>,
pub token_mint: Account<'info, Mint>,
#[account(mut)]
pub authority: Signer<'info>,
pub system_program: Program<'info, System>,
}
PDA 种子设计最佳实践
// ✅ 好的种子设计
seeds = [b"unique_prefix", related_account.key().as_ref(), &user_id.to_le_bytes()]
// ❌ 差的种子设计
seeds = [user.key().as_ref()] // 太短,容易碰撞
seeds = [b"pool"] // 全局唯一,无法创建多个
5. 漏洞 #4:整数溢出 (Integer Overflow in Rust)
Rust 在 debug 模式下检查整数溢出但在 release 模式下静默回绕!Solana 程序默认以 release 模式编译。
有漏洞的代码
// ❌ 有漏洞:release 模式下整数溢出不会 panic
pub fn transfer_tokens(ctx: Context<Transfer>, amount: u64) -> Result<()> {
let from = &mut ctx.accounts.from_account;
let to = &mut ctx.accounts.to_account;
// 在 release 模式下,如果 from.balance < amount
// from.balance - amount 不会 panic,而是会回绕!
// 例如: 100 - 200 = 18446744073709551516 (u64::MAX - 99)
from.balance -= amount;
to.balance += amount;
// to.balance 也可能溢出回绕为一个很小的值
Ok(())
}
修复后的代码
// ✅ 修复方案1:使用 checked_* 方法
pub fn transfer_tokens_safe(ctx: Context<Transfer>, amount: u64) -> Result<()> {
let from = &mut ctx.accounts.from_account;
let to = &mut ctx.accounts.to_account;
// checked_sub 在溢出时返回 None
from.balance = from.balance
.checked_sub(amount)
.ok_or(ErrorCode::InsufficientFunds)?;
// checked_add 在溢出时返回 None
to.balance = to.balance
.checked_add(amount)
.ok_or(ErrorCode::Overflow)?;
Ok(())
}
// ✅ 修复方案2:在 Cargo.toml 中启用溢出检查
// [profile.release]
// overflow-checks = true
// 注意:这会增加一些计算开销(Compute Units)
// ✅ 修复方案3:使用 require! 宏提前检查
pub fn transfer_with_require(ctx: Context<Transfer>, amount: u64) -> Result<()> {
let from = &mut ctx.accounts.from_account;
let to = &mut ctx.accounts.to_account;
require!(from.balance >= amount, ErrorCode::InsufficientFunds);
require!(
to.balance.checked_add(amount).is_some(),
ErrorCode::Overflow
);
from.balance -= amount;
to.balance += amount;
Ok(())
}
Rust 整数运算方法对比
// 标准运算符 - debug 检查,release 回绕
let a: u64 = 100;
let b: u64 = 200;
let c = a - b; // debug: panic! release: 18446744073709551516
// checked_* - 返回 Option,溢出返回 None
let c = a.checked_sub(b); // Some(result) 或 None
// saturating_* - 溢出时返回边界值
let c = a.saturating_sub(b); // 0(不会负数)
let d = u64::MAX.saturating_add(1); // u64::MAX
// wrapping_* - 显式回绕(几乎不应该在金融场景使用)
let c = a.wrapping_sub(b); // 18446744073709551516
// overflowing_* - 返回 (result, bool),bool 表示是否溢出
let (c, overflowed) = a.overflowing_sub(b);
6. 漏洞 #5:账户混淆 (Account Confusion / Type Cosplay)
当程序没有正确验证账户的类型时,攻击者可以传入格式兼容但类型不同的账户。
有漏洞的代码
// ❌ 原生 Solana(无 Anchor)- 缺少类型判别
// 假设有两种账户类型,内存布局类似
pub struct UserAccount {
pub authority: Pubkey, // 32 bytes
pub balance: u64, // 8 bytes
}
pub struct AdminAccount {
pub authority: Pubkey, // 32 bytes
pub privilege: u64, // 8 bytes,0=normal, 1=admin
}
// 如果程序只检查前 32 字节匹配 authority
// 攻击者可以创建一个 AdminAccount,将 privilege=1
// 然后作为 UserAccount 传入
// 程序读取 balance 字段时,实际读到的是 privilege=1
Anchor 如何解决
// ✅ Anchor 自动添加 8 字节 discriminator
// 每个 #[account] 结构体都有唯一的前缀
// UserAccount: discriminator = sha256("account:UserAccount")[..8]
// AdminAccount: discriminator = sha256("account:AdminAccount")[..8]
#[account]
pub struct UserAccount {
// 实际存储: [8 bytes discriminator][32 bytes authority][8 bytes balance]
pub authority: Pubkey,
pub balance: u64,
}
#[account]
pub struct AdminAccount {
// 实际存储: [8 bytes discriminator][32 bytes authority][8 bytes privilege]
pub authority: Pubkey,
pub privilege: u64,
}
// Account<'info, UserAccount> 会自动检查 discriminator
// 无法将 AdminAccount 当作 UserAccount 传入
7. 漏洞 #6:不安全的账户关闭 (Unsafe Account Closing)
关闭账户时如果处理不当,可能导致"复活攻击"。
// ❌ 有漏洞:只清零 lamports,没有清除数据
pub fn close_account_vulnerable(ctx: Context<CloseAccount>) -> Result<()> {
let account = &ctx.accounts.target_account;
let destination = &ctx.accounts.destination;
// 转移所有 lamports
**destination.to_account_info().try_borrow_mut_lamports()? +=
account.to_account_info().lamports();
**account.to_account_info().try_borrow_mut_lamports()? = 0;
// 漏洞:没有清除账户数据!
// 攻击者可以在同一交易中"复活"这个账户
// 因为 Solana 运行时只在交易结束时检查账户状态
Ok(())
}
// ✅ 修复:使用 Anchor 的 close 约束
#[derive(Accounts)]
pub struct CloseAccountSecure<'info> {
#[account(
mut,
close = destination, // Anchor 会:
// 1. 转移所有 lamports 到 destination
// 2. 将数据清零
// 3. 将 owner 设为系统程序
// 4. 设置 discriminator 为 CLOSED_ACCOUNT_DISCRIMINATOR
has_one = authority,
)]
pub target_account: Account<'info, UserAccount>,
#[account(mut)]
pub destination: SystemAccount<'info>,
pub authority: Signer<'info>,
}
// 或者手动安全关闭
pub fn close_account_manual(ctx: Context<CloseManual>) -> Result<()> {
let account_info = ctx.accounts.target_account.to_account_info();
let dest_info = ctx.accounts.destination.to_account_info();
// 步骤1:转移 lamports
let lamports = account_info.lamports();
**dest_info.try_borrow_mut_lamports()? += lamports;
**account_info.try_borrow_mut_lamports()? = 0;
// 步骤2:清除数据(关键!)
let mut data = account_info.try_borrow_mut_data()?;
// 写入关闭标记
data[0..8].copy_from_slice(&anchor_lang::__private::CLOSED_ACCOUNT_DISCRIMINATOR);
// 清零剩余数据
for byte in data[8..].iter_mut() {
*byte = 0;
}
// 步骤3:更改 owner 为系统程序
account_info.assign(&anchor_lang::system_program::ID);
Ok(())
}
代码实战
完整的安全 Vault 程序示例
use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount, Transfer};
declare_id!("SecR111111111111111111111111111111111111111");
#[program]
pub mod secure_vault {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let vault = &mut ctx.accounts.vault;
vault.authority = ctx.accounts.authority.key();
vault.total_deposited = 0;
vault.bump = ctx.bumps.vault;
Ok(())
}
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
require!(amount > 0, VaultError::ZeroAmount);
let vault = &mut ctx.accounts.vault;
// 安全的加法
vault.total_deposited = vault.total_deposited
.checked_add(amount)
.ok_or(VaultError::Overflow)?;
// SPL Token 转账
let transfer_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
Transfer {
from: ctx.accounts.user_token_account.to_account_info(),
to: ctx.accounts.vault_token_account.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
},
);
token::transfer(transfer_ctx, amount)?;
Ok(())
}
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
require!(amount > 0, VaultError::ZeroAmount);
let vault = &mut ctx.accounts.vault;
// 安全的减法
vault.total_deposited = vault.total_deposited
.checked_sub(amount)
.ok_or(VaultError::InsufficientFunds)?;
// PDA 签名的转账
let authority_key = ctx.accounts.authority.key();
let seeds = &[
b"vault".as_ref(),
authority_key.as_ref(),
&[vault.bump],
];
let signer_seeds = &[&seeds[..]];
let transfer_ctx = CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
Transfer {
from: ctx.accounts.vault_token_account.to_account_info(),
to: ctx.accounts.user_token_account.to_account_info(),
authority: ctx.accounts.vault.to_account_info(),
},
signer_seeds,
);
token::transfer(transfer_ctx, amount)?;
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(
init,
seeds = [b"vault", authority.key().as_ref()],
bump,
payer = authority,
space = 8 + Vault::INIT_SPACE,
)]
pub vault: Account<'info, Vault>,
#[account(mut)]
pub authority: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct Deposit<'info> {
#[account(
mut,
seeds = [b"vault", authority.key().as_ref()],
bump = vault.bump,
has_one = authority, // ✅ 验证 authority 匹配
)]
pub vault: Account<'info, Vault>,
#[account(mut)] // ✅ Signer 自动验证签名
pub authority: Signer<'info>,
#[account(
mut,
constraint = user_token_account.owner == authority.key()
@ VaultError::InvalidTokenOwner,
)]
pub user_token_account: Account<'info, TokenAccount>,
#[account(
mut,
constraint = vault_token_account.owner == vault.key()
@ VaultError::InvalidTokenOwner,
)]
pub vault_token_account: Account<'info, TokenAccount>,
pub token_program: Program<'info, Token>,
}
#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(
mut,
seeds = [b"vault", authority.key().as_ref()],
bump = vault.bump,
has_one = authority,
)]
pub vault: Account<'info, Vault>,
#[account(mut)]
pub authority: Signer<'info>,
#[account(mut)]
pub user_token_account: Account<'info, TokenAccount>,
#[account(
mut,
constraint = vault_token_account.owner == vault.key()
@ VaultError::InvalidTokenOwner,
)]
pub vault_token_account: Account<'info, TokenAccount>,
pub token_program: Program<'info, Token>,
}
#[account]
#[derive(InitSpace)]
pub struct Vault {
pub authority: Pubkey,
pub total_deposited: u64,
pub bump: u8,
}
#[error_code]
pub enum VaultError {
#[msg("Amount must be greater than zero")]
ZeroAmount,
#[msg("Insufficient funds in vault")]
InsufficientFunds,
#[msg("Arithmetic overflow")]
Overflow,
#[msg("Invalid token account owner")]
InvalidTokenOwner,
}
关键要点总结
| 漏洞类型 | 严重程度 | Anchor 防护 | 手动防护 |
|---|---|---|---|
| 缺失签名验证 | 致命 | Signer<'info> | is_signer 检查 |
| 缺失 Owner 验证 | 致命 | Account<'info, T> | owner 字段检查 |
| PDA 种子碰撞 | 高 | 好的种子设计 | 唯一前缀 + 充分种子 |
| 整数溢出 | 高 | overflow-checks = true | checked_* 方法 |
| 账户类型混淆 | 高 | Discriminator 自动检查 | 手动类型标记 |
| 不安全关闭 | 中 | close = dest 约束 | 清数据+改 owner |
常见误区
- "使用 Anchor 就不需要担心安全" — 错误!Anchor 防护了底层漏洞,但逻辑错误、经济攻击、权限设计缺陷仍需审计
- "Rust 的内存安全 = 程序安全" — 错误!Rust 防止内存错误,但不防止业务逻辑错误
- "Solana 不需要担心整数溢出" — 大错特错!release 模式默认不检查溢出
- "PDA 是安全的因为它是程序推导的" — PDA 推导是确定性的,但种子设计不当会导致碰撞
- "AccountInfo 和 Account 一样安全" — 不对!
AccountInfo不做任何验证,Account<T>验证 owner 和 discriminator
面试关联
面试题:Solana 程序开发中最常见的安全漏洞是什么?如何防护?
30 秒回答:
最常见的三类是:缺失签名验证(用 Signer 类型)、缺失 Owner 验证(用 Account<T> 类型)、整数溢出(用 checked_* 方法或启用 overflow-checks)。Anchor 框架可以自动防护大部分底层漏洞。
2 分钟回答:
Solana 程序的安全挑战与 EVM 有本质区别——因为所有账户从外部传入,程序必须验证每个账户的身份、所有权和类型。最常见的六类漏洞:一是缺失 Signer 检查,使用 Anchor 的 Signer<'info> 类型自动验证;二是缺失 Owner 检查,使用 Account<'info, T> 自动验证账户属于当前程序;三是 PDA 种子碰撞,通过唯一前缀和充分的种子参数避免;四是整数溢出,Rust release 模式默认不检查,必须使用 checked_* 方法;五是账户类型混淆,Anchor 的 discriminator 机制可以防护;六是不安全的账户关闭,需要清除数据并更改 owner。Anchor 框架解决了大部分底层问题,但业务逻辑安全和经济攻击仍需要专业审计。
追问准备
- Q: Anchor 的 discriminator 是怎么工作的? A: 对
account:TypeName做 SHA256 取前 8 字节,存在账户数据的开头。反序列化时自动校验。 - Q: Solana 有重入攻击风险吗? A: Solana 运行时层面防止了跨程序重入(同一程序不能在 CPI 中再次调用自己),但仍需注意同一交易中多条指令的状态依赖问题。
参考资源
| 资源 | 说明 |
|---|---|
| Sealevel Attacks | Solana 漏洞案例库(必读) |
| Anchor Book - Security | Anchor 安全章节 |
| Neodyme Blog | Solana 安全研究 |
| Soteria - Solana 审计工具 | 自动化漏洞扫描 |
| Solana Security Best Practices | 慢雾安全手册 |
| Sec3 Audit Reports | Solana 审计公司 |