与传统区块链不同,Solana 的并行处理和基于账户的架构带来了独特的安全挑战。程序的无状态特性意味着所有验证都必须明确,而账户所有权模型需要对授权模式给予特别关注。
最安全的程序实施了多层验证:
前面我们探索了 Solana 并行处理架构和基于账户模型所特有的基本安全挑战。本节,我们将学习影响 Solana 程序的关键安全漏洞以及防御这些漏洞的实用策略。
我们主要介绍这些高级攻击向量:
重复的可变账户攻击利用了程序接受多个相同类型的可变账户的漏洞,通过传递同一个账户两次,导致程序在不知情的情况下覆盖了自己的更改。这在单个指令中创建了一个竞争条件,其中后续的更改可能会悄悄地取消之前的更改。
此漏洞主要影响修改程序拥有账户中数据的指令,而不是像 lamport 转账这样的系统操作。攻击之所以成功,是因为 Solana 的运行时不会阻止同一个账户被多次传递给不同的参数;检测和处理重复账户是程序的责任。
危险在于指令执行的顺序性。当同一个账户被传递两次时,程序会先执行第一次更改,然后立即用第二次更改覆盖它,导致账户处于一个意外的状态,这可能无法反映用户的意图或程序的逻辑。
请考虑以下这个更新两个程序账户所有权字段的易受攻击的指令:
#[program]
pub mod unsafe_update_account{
use super::*;
//..
pub fn update_account(ctx: Context<UpdateAccount>, pubkey_a: Pubkey, pubkey_b: Pubkey) -> Result<()> {
ctx.accounts.program_account_1.owner = pubkey_a;
ctx.accounts.program_account_2.owner = pubkey_b;
Ok(())
}
//..
}
#[derive(Accounts)]
pub struct UpdateAccount<'info> {
#[account(mut)]
pub program_account_1: Account<'info, ProgramAccount>,
#[account(mut)]
pub program_account_2: Account<'info, ProgramAccount>,
}
#[account]
pub struct ProgramAccount {
owner: Pubkey,
}
此代码存在一个关键缺陷:它从未验证 program_account_1 和 program_account_2 是否是不同的账户。
攻击者可以通过为两个参数传递同一个账户来利用这一点。以下是会发生的情况:
program_account_1.owner = pubkey_aprogram_account_2.owner = pubkey_b 覆盖它最终结果:账户的所有者被设置为 pubkey_b,完全忽略了 pubkey_a
这看起来可能无害,但请考虑其影响。一个期望为两个不同账户分配特定所有权的用户会发现只有一个账户被修改了,而且修改的方式并非他们所期望。在复杂的协议中,这可能导致状态不一致、多步操作失败,甚至是财务损失。
解决方案很简单。您只需在继续之前验证账户是否唯一:
pub fn update_account(ctx: Context<UpdateAccount>, pubkey_a: Pubkey, pubkey_b: Pubkey) -> Result<()> {
if ctx.accounts.program_account_1.key() == ctx.accounts.program_account_2.key() {
return Err(ProgramError::InvalidArgument)
}
ctx.accounts.program_account_1.owner = pubkey_a;
ctx.accounts.program_account_2.owner = pubkey_b;
Ok(())
}
在 Pinocchio 中,同样的验证模式适用:
if pubkey_eq(self.accounts.program_account_1.key(), self.accounts.program_account_2.key()) {
return Err(ProgramError::InvalidArgument)
}
重新初始化攻击利用了未检查账户是否已初始化的程序漏洞,使攻击者能够覆盖现有数据并劫持对有价值账户的控制权。
虽然初始化是为了首次使用而合法设置新账户,但重新初始化则是恶意地将现有账户重置为攻击者控制的状态。
如果没有正确的初始化验证,攻击者可以对已在使用中的账户调用初始化函数,从而有效地对已建立的程序状态进行恶意接管。这在托管、金库或任何账户所有权决定对有价值资产控制权的系统中尤为严重。
初始化是首次为新账户设置数据。必须检查账户是否已被初始化,以防止覆盖现有数据。
请考虑以下存在漏洞的指令,它用于初始化一个程序账户:
#[program]
pub mod unsafe_initialize_account{
use super::*;
//..
pub fn unsafe_initialize_account(ctx: Context<InitializeAccount>) -> Result<()> {
let mut writer: Vec<u8> = vec![];
let program_account = ProgramAccount {
owner: ctx.accounts.owner.key()
}.try_serialize(&mut writer)?;
sol_memcpy(&mut data, &writer, writer.len());
Ok(())
}
//..
}
#[derive(Accounts)]
pub struct InitializeAccount<'info> {
pub owner: Signer<'info>,
#[account(mut)]
/// CHECK: This account will not be checked by Anchor
pub program_account: UncheckedAccount<'info>,
}
#[account]
pub struct ProgramAccount {
owner: Pubkey,
}
此代码存在一个致命缺陷:它从未检查账户是否已被初始化。每次调用此指令时,它都会无条件地覆盖账户数据,并将调用者设置为新所有者,而不考虑账户的先前状态。
攻击者可以通过以下方式利用此漏洞:
unsafe_initialize_account这种攻击在托管场景中尤为严重。想象一个托管 PDA 拥有包含数千美元资产的 token account。最初的托管初始化正确地设置了账户并包含了合法参与者。但如果攻击者能够调用重新初始化函数,他们可以覆盖托管数据,将自己设置为所有者,并获得对所有托管 token 的控制权。
幸运的是,Anchor 使得直接在账户结构中执行此检查变得非常简单,只需在初始化账户时使用 init 约束,如下所示:
#[derive(Accounts)]
pub struct InitializeAccount<'info> {
pub owner: Signer<'info>,
#[account(
init,
payer = owner,
space = 8 + ProgramAccount::INIT_SPACE
)]
pub program_account: Account<'info, ProgramAccount>,
}
#[account]
#[derive(InitSpace)]
pub struct ProgramAccount {
owner: Pubkey,
}
或者,您也可以在指令中使用 ctx.accounts.program_account.is_initialized 检查,验证账户是否已被初始化,如下所示:
pub fn update_ownership(ctx: Context<UpdateOwnership>) -> Result<()> {
if ctx.accounts.program_account.is_initialized {
return Err(ProgramError::AccountAlreadyInitialized.into());
}
Ok(())
}
⚠️ Anchor 的
init_if_needed约束(受功能标志保护)应谨慎使用。虽然它方便地仅在账户尚未初始化时初始化账户,但它也带来了一个危险的陷阱:如果账户已经初始化,指令处理程序会继续正常执行。这意味着您的程序可能会在不知情的情况下操作现有账户,可能覆盖关键数据或允许未经授权的访问。
在 Pinocchio 中,由于我们无法直接在账户结构中添加安全检查,因此我们被迫在指令逻辑中进行检查。
我们可以通过检查账户是否具有正确的标识符来实现:
let account_data = self.accounts.program_account.try_borrow_data()?;
if account_data[0] == DISCRIMINATOR {
return Err(ProgramError::AccountAlreadyInitialized.into());
}
复活攻击利用了 Solana 的账户关闭机制,通过在同一笔交易中将“死亡”账户重新激活。
当您通过转移账户中的 lamports 来关闭账户时,Solana 并不会立即对其进行垃圾回收;账户只有在交易完成后才会被清理。这种延迟会产生一个危险的窗口,攻击者可以通过将 lamports 发送回这些账户来“复活”已关闭的账户,从而留下可能仍被您的程序信任的僵尸账户和过时数据。
这种攻击之所以成功,是因为对账户生命周期的基本误解。开发者通常认为关闭账户会使其立即不可用,但实际上,账户在交易结束之前仍然可以访问。攻击者可以在您的关闭指令前后插入一条转账指令,将账户的租金豁免金额退回,从而阻止垃圾回收并使账户保持在可被利用的状态。
在某些协议中,这种攻击尤其具有破坏性,例如账户关闭代表最终操作的场景:完成托管、解决争议或销毁资产。被复活的账户可能会欺骗您的程序,使其认为这些操作从未完成,从而可能导致双重支付、未经授权的访问或协议操控。
请考虑以下易受攻击的指令,该指令用于关闭一个程序账户:
#[program]
pub mod insecure_close{
use super::*;
//..
pub fn close(ctx: Context<Close>) -> Result<()> {
let dest_starting_lamports = ctx.accounts.destination.lamports();
**ctx.accounts.destination.lamports.borrow_mut() = dest_starting_lamports
.checked_add(ctx.accounts.account_to_close.to_account_info().lamports())
.unwrap();
**ctx.accounts.account_to_close.to_account_info().lamports.borrow_mut() = 0;
Ok(())
}
//..
}
#[derive(Accounts)]
pub struct Close<'info> {
/// CHECK: This account will not be checked by Anchor
pub owner: UncheckedAccount<'info>,
#[account(
mut,
has_one = owner
)]
pub program_account: Account<'info, ProgramAccount>,
}
这段代码看起来是正确的:它将账户中的所有 lamports 转移到目标地址,这应该会触发垃圾回收。然而,账户的数据仍然保持不变,并且在同一笔交易中仍然可以访问该账户。
攻击者可以通过创建包含多个指令的交易来利用这一点:
结果是一个僵尸账户,从程序逻辑上看似已关闭,但实际上仍然可以正常运行,并保留其所有原始数据。这可能导致:
最安全的解决方案是使用 Anchor 的 close 约束,它可以自动处理安全关闭:
#[derive(Accounts)]
pub struct Close<'info> {
#[account(mut)]
pub owner: Signer<'info>,
#[account(
mut,
close = owner,
has_one = owner
)]
pub program_account: Account<'info, ProgramAccount>,
}
或者,您可以像这样添加 signer 账户约束:
#[derive(Accounts)]
pub struct Close<'info> {
#[account(signer)]
/// CHECK: This account will not be checked by Anchor
pub owner: UncheckedAccount<'info>,
/// CHECK: This account will not be checked by Anchor
pub new_owner: UncheckedAccount<'info>,
#[account(
mut,
has_one = owner
)]
pub program_account: Account<'info, ProgramAccount>,
}
对于自定义关闭逻辑,请实现完整的安全关闭模式:
pub fn update_ownership(ctx: Context<UpdateOwnership>) -> Result<()> {
let account = ctx.accounts.account.to_account_info();
let dest_starting_lamports = ctx.accounts.destination.lamports();
**ctx.accounts.destination.lamports.borrow_mut() = dest_starting_lamports
.checked_add(account.lamports())
.unwrap();
**account.lamports.borrow_mut() = 0;
let mut data = account.try_borrow_mut_data()?;
for byte in data.deref_mut().iter_mut() {
*byte = 0;
}
let dst: &mut [u8] = &mut data;
let mut cursor = std::io::Cursor::new(dst);
cursor
.write_all(&anchor_lang::__private::CLOSED_ACCOUNT_DISCRIMINATOR)
.unwrap();
Ok(())
}
在 Pinocchio 中,手动实现关闭模式:
self.program_account.realloc(0, true)?;
self.program_account.close()?;
let mut data_ref = self.program_account.try_borrow_mut_data()?;
data_ref[0] = 0xff;
任意 Cross Program Invocation (CPI) 攻击发生在程序盲目调用作为参数传入的任何程序,而不是验证它们是否调用了预期的程序。
这会将您的安全程序转变为恶意代码的启动器,使攻击者能够劫持您的程序权限,并以您的程序身份执行未经授权的操作。
危险在于 Solana 灵活的账户模型。由于调用者可以将任何程序 ID 传入指令的账户列表中,如果未能验证程序地址,您的程序就会成为任意代码执行的代理。
攻击者可以替换一个恶意程序,该程序模仿预期的接口,但执行完全不同的操作——例如逆转转账、清空账户或以意想不到的方式操纵状态。
这种攻击特别隐蔽的地方在于,即使所有其他安全检查都通过,攻击仍然可能成功。您的程序可能正确验证了账户所有权、检查了签名并验证了数据结构,但仍然可能调用恶意代码,因为它从未确认与之交互的是正确的程序。
请考虑以下执行代币转移的易受攻击的指令:
#[program]
pub mod insecure_cpi{
use super::*;
//..
pub fn send_tokens(ctx: Context<SendTokens>, amount: u64) -> Result<()> {
solana_program::program::invoke(
&spl_token::instruction::transfer(
ctx.accounts.token_program.key,
ctx.accounts.source.key,
ctx.accounts.destination.key,
ctx.accounts.authority.key,
&[],
amount,
)?,
&[
ctx.accounts.source.clone(),
ctx.accounts.destination.clone(),
ctx.accounts.authority.clone(),
],
)?;
Ok(())
}
//..
}
#[derive(Accounts)]
pub struct SendTokens<'info> {
authority: Signer<'info>,
source: Account<'info, Token>,
destination: Account<'info, Token>,
/// CHECK: This account will not be checked by Anchor
pub token_program: UncheckedAccount<'info>,
}
这段代码乍一看似乎是安全的。源和目标已正确验证为 token account,并且授权必须签署交易。然而,token_program 字段是一个 UncheckedAccount,这意味着 Anchor 对其完全不进行验证。
攻击者可以通过以下方式利用这一点:
token_program 参数传入攻击之所以成功,是因为虽然 token account 是合法的,但执行操作的程序却不是合法的。恶意程序可能会将代币转移到错误的方向,将账户资金转移到攻击者的钱包,或者执行任何传递的账户允许的操作。
幸运的是,Anchor 使得可以直接在账户结构中轻松执行此检查,只需将 UncheckedAccount 更改为 Program 并传递 Token 类型,该类型会自动验证程序 ID:
#[derive(Accounts)]
pub struct SendTokens<'info> {
authority: Signer<'info>,
source: Account<'info, Token>,
destination: Account<'info, Token>,
pub token_program: Program<'info, Token>,
}
更好的是,使用 Anchor 的 CPI 辅助工具,它会自动处理程序验证:
pub fn send_tokens(ctx: Context<SendTokens>, amount: u64) -> Result<()> {
transfer(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
Transfer {
from: ctx.accounts.from_token_account.to_account_info(),
to: ctx.accounts.to_token_account.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
},
),
&amount,
)?;
Ok(())
}
对于自定义验证,在进行 CPI 之前显式检查程序 ID:
pub fn send_tokens(ctx: Context<SendTokens>, amount: u64) -> Result<()> {
if &spl_token::ID != ctx.accounts.token_program.key {
return Err(ProgramError::IncorrectProgramId);
}
solana_program::program::invoke(
&spl_token::instruction::transfer(
ctx.accounts.token_program.key,
ctx.accounts.source.key,
ctx.accounts.destination.key,
ctx.accounts.authority.key,
&[],
amount,
)?,
&[
ctx.accounts.source.clone(),
ctx.accounts.destination.clone(),
ctx.accounts.authority.clone(),
],
)?;
Ok(())
}
在 Pinocchio 中,由于没有自动程序检查,因此需要手动验证:
if self.accounts.token_program.pubkey() != &spl_token::ID {
return Err(ProgramError::MissingRequiredSignature.into());
}
类型伪装攻击利用程序未能验证账户类型的漏洞,允许攻击者替换具有相同数据结构但用途不同的账户。由于 Solana 将所有账户数据存储为原始字节,一个未检查账户类型的程序可能会被欺骗,将 VaultConfig 视为 AdminSettings,从而导致潜在的灾难性后果。
该漏洞源于结构上的模糊性。当多个账户类型共享相同的数据布局(例如都具有 owner: Pubkey 字段)时,仅依靠所有者检查和数据验证不足以区分它们。控制一种账户类型的攻击者可以伪装成完全不同账户类型的所有者,从而绕过围绕特定账户用途设计的授权逻辑。
如果没有区分符(用于区分账户类型的唯一标识符),您的程序将容易受到复杂的伪装攻击,恶意行为者可以利用结构相似性与逻辑意图之间的差距。
请考虑以下基于账户所有权执行管理员操作的易受攻击的指令:
#[program]
pub mod insecure_check{
use super::*;
//..
pub fn instruction(ctx: Context<Instruction>) -> Result<()> {
let program_account_one = ctx.accounts.program_account_one.to_account_info();
if program_account_one.owner != ctx.program_id {
return Err(ProgramError::IllegalOwner.into());
}
if ctx.accounts.program_account_one.owner != ctx.accounts.admin.key() {
return Err(ProgramError::InvalidAccountData.into());
}
//..do something
Ok(())
}
//..
}
#[derive(Accounts)]
pub struct Instruction<'info> {
pub admin: Signer<'info>,
#[account(mut)]
/// CHECK: This account will not be checked by Anchor
pub program_account_one: UncheckedAccount<'info>,
#[account(mut)]
/// CHECK: This account will not be checked by Anchor
pub program_account_two: UncheckedAccount<'info>,
}
#[derive(AnchorSerialize, AnchorDeserialize, InitSpace)]
pub struct ProgramAccountOne {
owner: Pubkey,
}
#[derive(AnchorSerialize, AnchorDeserialize, InitSpace)]
pub struct ProgramAccountTwo {
owner: Pubkey,
}
这段代码看起来很安全:它检查了程序所有权并验证了管理员权限。但存在一个致命的缺陷:它从未验证 program_account_one 是否确实是一个 ProgramAccountOne,而不是其他具有相同数据结构的账户类型。
攻击者可以通过以下方式利用这一点:
ProgramAccountTwo 账户ProgramAccountTwo 作为 program_account_one 参数传递owner: Pubkey 结构,反序列化成功ProgramAccountOne 所有者设计的操作的“管理员”Solana 使用判别器来解决此问题:
0.31.0,可以实现“自定义”判别器)最简单的解决方法是使用 Anchor 的内置类型验证:
#[derive(Accounts)]
pub struct Instruction<'info> {
pub admin: Signer<'info>,
#[account(mut)]
/// CHECK: This account will not be checked by Anchor
pub program_account_one: Account<'info, ProgramAccountOne>,
#[account(mut)]
/// CHECK: This account will not be checked by Anchor
pub program_account_two: Account<'info, ProgramAccountTwo>,
}
#[account]
pub struct ProgramAccountOne {
owner: Pubkey,
}
#[account]
pub struct ProgramAccountTwo {
owner: Pubkey,
}
对于自定义验证,可以添加显式判别器检查:
pub fn instruction(ctx: Context<Instruction>) -> Result<()> {
let program_account_one = ctx.accounts.program_account_one.to_account_info();
if program_account_one.owner != ctx.program_id {
return Err(ProgramError::IllegalOwner.into());
}
if ctx.accounts.program_account_one.owner != ctx.accounts.admin.key() {
return Err(ProgramError::InvalidAccountData.into());
}
let data = program_account_one.data.borrow();
// Assume ProgramAccountOne has a discriminator of 8 bytes
let discriminator = &data[..8];
if discriminator != ProgramAccountOne::DISCRIMINATOR {
return Err(ProgramError::InvalidAccountData.into());
}
//..do something
Ok(())
}
在 Pinocchio 中,手动实现判别器检查:
let account_data = self.accounts.program_account.try_borrow_data()?;
if account_data[0] != DISCRIMINATOR {
return Err(ProgramError::AccountAlreadyInitialized.into());
}
PDA 共享攻击利用了在多个用户或域之间使用相同 Program Derived Address (PDA) 的程序,从而使攻击者能够访问不属于他们的资金、数据或权限。虽然使用全局 PDA 进行程序范围的操作看起来很优雅,但它会导致危险的交叉污染,使一个用户的操作可能影响到另一个用户的资产。
漏洞的根源在于派生 PDA 时种子(seed)特异性不足。当多个账户共享相同的 PDA 权限时,程序就无法区分合法和非法的访问尝试。攻击者可以创建引用相同共享 PDA 的账户,然后利用该 PDA 的签名权限操控其他用户的资产。
在 DeFi 协议中,这种情况尤其具有破坏性,因为 PDA 控制着代币金库、用户余额或提款权限。共享 PDA 本质上创建了一把主密钥,可以解锁多个用户的资产,将单个用户的操作变成对整个协议的潜在攻击。
以下是一个使用基于 mint 的 PDA 进行签名的易受攻击的提款系统:
#[program]
pub mod insecure_withdraw{
use super::*;
//..
pub fn withdraw(ctx: Context<WithdrawTokens>) -> Result<()> {
//..
// other conditions/actions...
//..
let amount = ctx.accounts.vault.amount;
let seeds = &[
ctx.accounts.pool.mint.as_ref(),
&[ctx.accounts.pool.bump],
];
transfer(
CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
Transfer {
from: ctx.accounts.vault.to_account_info(),
to: ctx.accounts.withdraw_destination.to_account_info(),
authority: ctx.accounts.pool.to_account_info(),
},
),
&amount,
seeds,
)?;
Ok(())
}
//..
}
#[derive(Accounts)]
pub struct WithdrawTokens<'info> {
#[account(
seeds = [b"pool", pool.mint.as_ref()],
bump = pool.bump,
)]
pool: Account<'info, TokenPool>,
vault: Account<'info, TokenAccount>,
withdraw_destination: Account<'info, TokenAccount>,
//..
// other accounts..
//..
token_program: Program<'info, Token>,
}
#[account]
#[derive(InitSpace)]
pub struct TokenPool {
pub mint: Pubkey,
pub bump: u8,
}
此代码存在一个关键缺陷:PDA 仅使用 mint 地址派生。这意味着同一代币类型的所有池共享相同的签名权限,从而形成了一个危险的攻击向量。
攻击者可以通过以下方式利用这一点:
withdraw_destination攻击之所以成功,是因为 PDA 权限没有区分不同的资金池实例,它只关注代币类型,而不是应该访问这些资金的特定用户或资金池。
第一个改进是使 PDA 特定于个人用户或目标,并使用 Anchor 的种子和 bump 约束来验证 PDA 的推导:
#[derive(Accounts)]
pub struct WithdrawTokens<'info> {
#[account(
has_one = vault,
has_one = withdraw_destination,
seeds = [b"pool", vault.key().as_ref(), withdraw_destination.key().as_ref()],
bump = pool.bump,
)]
pool: Account<'info, TokenPool>, // Authority for the vault
#[account(mut)]
vault: Account<'info, TokenAccount>,
#[account(mut)]
withdraw_destination: Account<'info, TokenAccount>,
//..
// other accounts..
//..
token_program: Program<'info, Token>,
}
#[account]
#[derive(InitSpace)]
pub struct TokenPool{
pub vault:Pubkey,
pub withdraw_destination:Pubkey,
pub bump:u8
}
对指令处理程序也进行了相同的更改,其中一个可能的情况是杠杆交易程序允许用户的交易在亏损达到一定金额时被清算。例如,用户自己设置止损,代码会检查是否达到该金额的条件,然后允许任何人停止交易并将剩余资金提取到目标账户,即提款账户。
一个单一的PDA(程序派生地址)控制特定代币的所有资金会导致一种情况:如果多个用户同时满足条件,例如许多用户接近止损/清算,那么任何单个用户都可以为所有这些用户提取资金,可能通过一个或多个包含针对不同用户的多条指令的交易来完成。