返回 SC 笔记
SC Day 74

Solana 常见漏洞 — 签名验证缺失/PDA种子碰撞/整数溢出

### 1. Solana 安全模型 vs EVM 安全模型

2026-06-23
第四阶段:综合实战 (73-80)
solanasecuritysigner-checkpdaoverflowsealevel-attacks

日期: 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 = truechecked_* 方法
账户类型混淆Discriminator 自动检查手动类型标记
不安全关闭close = dest 约束清数据+改 owner

常见误区

  1. "使用 Anchor 就不需要担心安全" — 错误!Anchor 防护了底层漏洞,但逻辑错误、经济攻击、权限设计缺陷仍需审计
  2. "Rust 的内存安全 = 程序安全" — 错误!Rust 防止内存错误,但不防止业务逻辑错误
  3. "Solana 不需要担心整数溢出" — 大错特错!release 模式默认不检查溢出
  4. "PDA 是安全的因为它是程序推导的" — PDA 推导是确定性的,但种子设计不当会导致碰撞
  5. "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 AttacksSolana 漏洞案例库(必读)
Anchor Book - SecurityAnchor 安全章节
Neodyme BlogSolana 安全研究
Soteria - Solana 审计工具自动化漏洞扫描
Solana Security Best Practices慢雾安全手册
Sec3 Audit ReportsSolana 审计公司