在我们之前的原生 Solana 教程中,为了保持示例简短并专注于核心主题,我们跳过了安全检查。
在本教程中,我们将介绍原生 Solana 程序的必要安全检查:验证账户所有权、验证 sysvar 和程序 ID、要求签名者、强制要求可写账户、在 CPI 之后重新加载状态,以及处理代币账户的灰尘攻击(dust attacks)。
验证账户所有权
在使用账户数据之前,请检查其所有者是否与预期的程序 ID 匹配。否则,攻击者可以传入一个由他们控制且带有恶意数据的账户。
在 Anchor 中,使用 Account<'info, T> 定义账户会自动检查该账户是否由你的程序拥有。对于外部账户,你可以添加 #[account(owner = <ID>)] 属性,以强制要求其由特定的程序 ID 拥有。
例如,假设我们有一个控制提款的 Config 账户。如果我们不验证该 config 是否由我们的程序拥有,攻击者就可以传入一个带有伪造数据的虚假账户,并在不该提款时进行提款。
use borsh::{BorshDeserialize, BorshSerialize};
use solana_program::{account_info::AccountInfo, program_error::ProgramError};
#[derive(BorshDeserialize, BorshSerialize)]
struct Config { withdraw_cap: u64 }
pub fn withdraw(config: &AccountInfo, amount: u64) -> Result<(), ProgramError> {
// Missing check: config.owner == program_id
let cfg = Config::try_from_slice(&config.try_borrow_data()?)?;
if amount <= cfg.withdraw_cap {
// proceed to transfer funds...
}
Ok(())
}
为了修复这个问题,我们在反序列化或使用其数据之前,需要检查该 config 账户是否由我们的程序拥有。
use solana_program::{account_info::AccountInfo, program_error::ProgramError, pubkey::Pubkey};
pub fn withdraw(config: &AccountInfo, amount: u64, program_id: &Pubkey) -> Result<(), ProgramError> {
// Check that the config account is owned by our program
if config.owner != program_id { return Err(ProgramError::IncorrectProgramId); }
// Rest of the function...
Ok(())
}
验证 Sysvar 和程序 ID
当你需要像 Clock 这样的 sysvar 或系统程序时,务必验证它是真实的。攻击者可以传入带有篡改数据的虚假账户。
在 Anchor 程序中,这是通过使用 Sysvar<'info, Clock> 或 Program<'info, System> 类型来强制执行的,它们会自动为你处理这些检查。但在原生程序中,我们必须手动检查这些 ID。
在这个示例中,传递给 withdraw_timelock 的 clock 账户预期应为 Clock sysvar,但如果没有进行验证,攻击者可以传入一个带有被篡改时间戳的虚假 clock 账户以提前提款。
// Vulnerable: fake sysvar allows time manipulation
use solana_program::{account_info::AccountInfo, program_error::ProgramError, clock::Clock};
#[derive(BorshDeserialize, BorshSerialize)]
struct TimeLock {
unlock_time: i64,
amount: u64,
}
pub fn withdraw_timelock(timelock: &AccountInfo, clock: &AccountInfo) -> Result<(), ProgramError> {
// Missing: verify clock.key == sysvar::clock::ID
let timelock = TimeLock::try_from_slice(&timelock.try_borrow_data()?)?;
// Attacker can pass fake clock account with manipulated timestamp
let clock = Clock::from_account_info(clock)?;
if clock.unix_timestamp >= timelock.unlock_time {
// Process early withdrawal with fake timestamp
}
Ok(())
}
作为修复方案,请始终确保该 sysvar 和程序账户的地址是匹配的。
use solana_program::{sysvar, program_error::ProgramError, account_info::AccountInfo};
// Rest of the code...
pub fn withdraw_timelock(timelock: &AccountInfo, clock: &AccountInfo) -> Result<(), ProgramError> {
// Fix: verify clock.key == sysvar::clock::ID
if clock.key != &sysvar::clock::ID {
return Err(ProgramError::InvalidAccountData);
}
// Rest of the function...
Ok(())
}
真实案例
发生在 2022 年 2 月的 Solana 上价值 3.2 亿美元的 Wormhole 跨链桥漏洞利用事件,正是因为程序没有确保所提供的系统程序地址与实际的 System Program 地址相匹配。攻击者传入了一个虚假的系统账户,绕过了签名检查,使他们能够在未经授权的情况下铸造 120,000 个 wETH(封装的 ETH)。
这就是为什么 Solana 程序必须始终验证系统账户和 sysvar 是否与其官方 ID 匹配的原因。你可以在这里阅读 CertiK 的完整分析。
要求签名者
当你的程序将某项操作限制为特定的权限(例如,admin)时,仅仅检查账户的公钥是否与预期匹配是不够的。攻击者可以将真实的 admin 账户作为非签名者包含在交易中,从而通过该检查。你必须同时验证该账户是否实际签署了交易(admin.is_signer),这能证明私钥所有者已批准该交易。在 Anchor 中,#[account(signer)] 属性会自动为你处理此事。
下面是一个存在漏洞的原生 Rust 示例,其中的函数更新了 config 账户中的 withdraw cap:
// Vulnerable: checks admin key but not that admin actually signed
#[derive(BorshDeserialize, BorshSerialize)]
struct Config { admin: Pubkey, withdraw_cap: u64 }
pub fn update_withdraw_cap(config: &AccountInfo, admin: &AccountInfo, new_cap: u64) -> Result<(), ProgramError> {
let mut cfg = Config::try_from_slice(&config.try_borrow_data()?)?;
// Missing check: require admin.is_signer
if *admin.key != cfg.admin { return Err(ProgramError::InvalidAccountData); }
cfg.withdraw_cap = new_cap;
cfg.serialize(&mut &mut config.try_borrow_mut_data()?[..])?;
Ok(())
}
为了修复这个问题,需要要求 admin 必须是签名者,并且与存储的 admin 密钥相匹配:
pub fn update_withdraw_cap(config: &AccountInfo, admin: &AccountInfo, new_cap: u64) -> Result<(), ProgramError> {
let mut cfg = Config::try_from_slice(&config.try_borrow_data()?)?;
// Ensure admin is a signer
if !admin.is_signer { return Err(ProgramError::MissingRequiredSignature); }
// Ensure admin is the expected admin
if *admin.key != cfg.admin { return Err(ProgramError::InvalidAccountData); }
cfg.withdraw_cap = new_cap;
cfg.serialize(&mut &mut config.try_borrow_mut_data()?[..])?;
Ok(())
}
对数据或 Lamport 修改强制要求可写账户
如果你的程序需要修改账户的 lamports 或数据,客户端必须在交易中将该账户标记为可写,并且你的程序也应该验证它是可写的。如果账户未被标记为可写,尝试修改它将导致交易失败并报错(例如,“Readonly account changed”)。
为了在 TypeScript 客户端中将账户标记为可写,我们在构建交易时将其 isWritable 标志设置为 true。
// web3.js: set isWritable = true for accounts you will modify
import { TransactionInstruction, PublicKey } from "@solana/web3.js";
const ix = new TransactionInstruction({
programId,
keys: [
{ pubkey: userAccount, isSigner: false, isWritable: true }, // needs mutation
{ pubkey: payer, isSigner: true, isWritable: false },
],
data: Buffer.from([]),
});
我们的程序可以使用 AccountInfo 结构体中的 is_writable 字段来检查账户是否可写。
pub fn update_user_balance(user_account: &AccountInfo) -> Result<(), ProgramError> {
// Check if supplied user account is writable before proceeding
if !user_account.is_writable {
return Err(ProgramError::InvalidAccountData);
}
// Rest of the function...
Ok(())
}
在 Anchor 中,你可以使用 #[account(mut)] 强制执行此操作,它会在你的函数运行之前检查 is_writable = true。
在每次 CPI 后重新加载账户状态
CPI 可以修改账户数据。你的程序必须在 CPI 调用之后重新读取该账户,然后再基于它做出决策。
以下是不重新加载可能发生的情况(使用原生 Rust 程序代码):
// Vulnerable: using stale account data after CPI
use solana_program::{
account_info::AccountInfo,
program::{invoke},
instruction::Instruction,
program_error::ProgramError
};
use borsh::BorshDeserialize;
#[derive(BorshDeserialize)]
struct VaultState {
balance: u64,
is_locked: bool,
}
pub fn withdraw_after_cpi_vulnerable(
vault: &AccountInfo,
external_program: &AccountInfo,
cpi_instruction: Instruction,
amount: u64
) -> Result<(), ProgramError> {
// Read vault state before CPI
let vault_state = VaultState::try_from_slice(&vault.try_borrow_data()?)?;
// Perform CPI that might modify the vault
invoke(&cpi_instruction, &[vault.clone(), external_program.clone()])?;
// Vulnerable: using stale vault_state after CPI
// The CPI might have changed vault.is_locked to true
if !vault_state.is_locked && vault_state.balance >= amount {
// Process withdrawal with stale data
}
Ok(())
}
在这段代码中,CPI 可能会修改 vault 的状态(例如,将 is_locked 设置为 true),但我们仍然在使用 CPI 之前读取的旧 vault_state。这会造成检查时与使用时(TOCTOU)的漏洞,意味着我们检查时的状态已经不再是我们执行操作时的状态了。
为了修复这个问题,请务必在 CPI 后重新加载账户数据:
use solana_program::{
account_info::AccountInfo,
program::invoke,
instruction::Instruction,
program_error::ProgramError
};
pub fn withdraw_after_cpi_safe(
vault: &AccountInfo,
external_program: &AccountInfo,
cpi_instruction: Instruction,
amount: u64
) -> Result<(), ProgramError> {
// Read vault state before CPI
let vault_state_before = VaultState::try_from_slice(&vault.try_borrow_data()?)?;
// Perform CPI
invoke(&cpi_instruction, &[vault.clone(), external_program.clone()])?;
// Reload vault state after CPI
let vault_state_after = VaultState::try_from_slice(&vault.try_borrow_data()?)?;
// Use the fresh data for decisions
if !vault_state_after.is_locked && vault_state_after.balance >= amount {
// Process withdrawal with current data
}
Ok(())
}
在关闭代币账户之前销毁剩余代币
想象一下你正在构建一个流动性质押协议。用户存入 SOL 并收到证明其质押份额的凭证代币。当他们取消质押时,他们将销毁其凭证代币以取回他们的 SOL。你的程序将每个用户的凭证代币存储在一个 PDA 代币账户(由你的程序拥有)中,并且在销毁凭证代币后,你将关闭该 PDA 代币账户以将租金退还给用户。
当程序假设 PDA 代币账户中仅包含用户合法的凭证代币时,问题就出现了。攻击者可以利用这一点,在受害者取消质押之前,直接向其 PDA 代币账户发送仅 1 个相同 mint 的凭证代币(即“灰尘攻击”)。
当合法用户尝试取消质押时,程序仅销毁用户在最初质押时收到的凭证代币数量。但由于攻击者的 1 个灰尘凭证代币仍在该账户中,余额并不为零。随后的关闭账户操作将会失败,因为 SPL Token Program 要求账户的余额在关闭前必须完全为零。由于我们的程序在关闭前没有检查或销毁任何剩余的代币,攻击者的灰尘代币就留在了那里。这会导致每次操作都失败,从而永久锁定用户质押的 SOL,并造成 DoS。
下面是一个示例:
// Vulnerable: assumes token account balance is always zero
use solana_program::{
account_info::AccountInfo,
entrypoint::ProgramResult,
program::invoke_signed,
pubkey::Pubkey,
};
use spl_token::instruction::{burn, close_account};
pub fn unstake_vulnerable(
program_id: &Pubkey,
accounts: &[AccountInfo],
amount: u64, // assume this to be the amount of receipt tokens the user originally received when they staked
) -> ProgramResult {
let account_info_iter = &mut accounts.iter();
let receipt_token_account = next_account_info(account_info_iter)?;
let receipt_mint = next_account_info(account_info_iter)?;
let vault_authority = next_account_info(account_info_iter)?;
let user = next_account_info(account_info_iter)?;
let token_program = next_account_info(account_info_iter)?;
// Burn the user's original receipt tokens
invoke_signed(
&burn(
token_program.key,
receipt_token_account.key,
receipt_mint.key,
vault_authority.key,
&[],
amount,
)?,
&[receipt_token_account.clone(), receipt_mint.clone(), vault_authority.clone()],
&[&vault_seeds],
)?;
// Missing: check if any dust tokens remain before closing
invoke_signed(
&close_account(
token_program.key,
receipt_token_account.key,
user.key,
vault_authority.key,
&[],
)?,
&[receipt_token_account.clone(), user.clone(), vault_authority.clone()],
&[&vault_seeds],
)?;
Ok(())
}
在这段代码中,程序仅销毁用户最初质押的 amount。如果攻击者事先发送了 1 个灰尘代币,它将残留在账户中。close_account 调用会失败,因为 SPL Token Program 要求余额必须为零,从而永久锁定了用户质押的 SOL。
这个漏洞在关闭 SPL 代币账户时,同样适用于原生 Solana 程序和 Anchor 程序。
为了修复这个问题,需获取实际的链上余额并销毁所有剩余代币:
use solana_program::{
account_info::AccountInfo,
entrypoint::ProgramResult,
program::invoke_signed,
program_pack::Pack,
pubkey::Pubkey,
};
use spl_token::{instruction::{burn, close_account}, state::Account as TokenAccount};
pub fn unstake_safe(
program_id: &Pubkey,
accounts: &[AccountInfo],
) -> ProgramResult {
let account_info_iter = &mut accounts.iter();
let receipt_token_account = next_account_info(account_info_iter)?;
let receipt_mint = next_account_info(account_info_iter)?;
let vault_authority = next_account_info(account_info_iter)?;
let user = next_account_info(account_info_iter)?;
let token_program = next_account_info(account_info_iter)?;
// Fetch actual on-chain balance
let token_account = TokenAccount::unpack(&receipt_token_account.try_borrow_data()?)?;
// Burn all tokens (user's original amount + any dust)
if token_account.amount > 0 {
invoke_signed(
&burn(
token_program.key,
receipt_token_account.key,
receipt_mint.key,
vault_authority.key,
&[],
token_account.amount, // Burns everything including dust
)?,
&[receipt_token_account.clone(), receipt_mint.clone(), vault_authority.clone()],
&[&vault_seeds],
)?;
}
// Safe to close now
invoke_signed(
&close_account(
token_program.key,
receipt_token_account.key,
user.key,
vault_authority.key,
&[],
)?,
&[receipt_token_account.clone(), user.clone(), vault_authority.clone()],
&[&vault_seeds],
)?;
Ok(())
}
抢跑代币账户或合约的创建
如果目标账户已经拥有 lamports,System Program 的 create_account 指令就会失败。由于 PDA 地址是确定性的(由已知种子衍生而来),攻击者可以在账户被创建之前计算出该地址,并向其发送 1 lamport。当程序稍后尝试在该地址创建账户时,create_account 会因为该账户已有余额而失败 —— 从而引发 DoS。
例如,假设我们有一个 vault 程序,每个用户都会获得一个从他们公钥衍生出的 PDA vault:
use solana_program::{
account_info::AccountInfo,
entrypoint::ProgramResult,
program::invoke_signed,
pubkey::Pubkey,
rent::Rent,
system_instruction,
sysvar::Sysvar,
};
pub fn initialize_vault(
program_id: &Pubkey,
accounts: &[AccountInfo],
) -> ProgramResult {
let payer = &accounts[0];
let vault = &accounts[1];
let system_program = &accounts[2];
let (vault_pda, bump) = Pubkey::find_program_address(
&[b"vault", payer.key.as_ref()],
program_id,
);
let rent = Rent::get()?;
let space = 48; // vault data size
let lamports = rent.minimum_balance(space);
// Vulnerable: fails if attacker sent lamports to vault_pda beforehand
invoke_signed(
&system_instruction::create_account(
payer.key,
&vault_pda,
lamports,
space as u64,
program_id,
),
&[payer.clone(), vault.clone(), system_program.clone()],
&[&[b"vault", payer.key.as_ref(), &[bump]]],
)?;
Ok(())
}
攻击者只需衍生出相同的 PDA 地址(seeds = ["vault", victim_pubkey]),通过普通的 SOL 转账向其发送 1 lamport,受害者就永远无法初始化他们的 vault 了。
为了修复这个问题,需要检查该账户是否已经拥有 lamports。如果有,则跳过 create_account,而是分别使用 transfer(用于补齐租金)、allocate(用于保留账户的数据空间)以及 assign(用于将账户所有者设置为你的程序)—— 这些指令在已有余额的账户上执行时不会失败:
use solana_program::{
account_info::AccountInfo,
entrypoint::ProgramResult,
program::invoke_signed,
pubkey::Pubkey,
rent::Rent,
system_instruction,
sysvar::Sysvar,
};
pub fn initialize_vault_safe(
program_id: &Pubkey,
accounts: &[AccountInfo],
) -> ProgramResult {
let payer = &accounts[0];
let vault = &accounts[1];
let system_program = &accounts[2];
let (vault_pda, bump) = Pubkey::find_program_address(
&[b"vault", payer.key.as_ref()],
program_id,
);
let signer_seeds = &[b"vault", payer.key.as_ref(), &[bump]];
let rent = Rent::get()?;
let space = 48;
let required_lamports = rent.minimum_balance(space);
if vault.lamports() > 0 {
// Account already has lamports (possibly from an attacker).
// Top up to rent-exempt minimum if needed.
let deficit = required_lamports.saturating_sub(vault.lamports());
if deficit > 0 {
invoke_signed(
&system_instruction::transfer(payer.key, &vault_pda, deficit),
&[payer.clone(), vault.clone()],
&[signer_seeds],
)?;
}
// Allocate space and assign ownership to our program
invoke_signed(
&system_instruction::allocate(&vault_pda, space as u64),
&[vault.clone()],
&[signer_seeds],
)?;
invoke_signed(
&system_instruction::assign(&vault_pda, program_id),
&[vault.clone()],
&[signer_seeds],
)?;
} else {
// No lamports — safe to use create_account
invoke_signed(
&system_instruction::create_account(
payer.key,
&vault_pda,
required_lamports,
space as u64,
program_id,
),
&[payer.clone(), vault.clone(), system_program.clone()],
&[signer_seeds],
)?;
}
// Rest of the code...
Ok(())
}
通过首先检查 vault.lamports() > 0,我们可以处理两种情况:正常创建(之前没有 lamports)以及抢跑场景(攻击者发送了 lamports)。allocate 和 assign 指令可以在已有余额的账户上正常运行,因此攻击者的恶意破坏尝试不会产生任何影响。
在 Anchor 中,使用 init_if_needed 代替 init 就可以处理这种场景。
本文是 Solana 开发 教程系列的一部分。