本文详细介绍了Solana原生程序开发中的多项关键安全检查,包括验证账户所有权、系统变量和程序ID、要求签名者、强制可写账户、跨程序调用后状态重载以及防范代币账户粉尘攻击和PDA账户抢占。文章通过具体代码示例和真实案例(如Wormhole漏洞)深入剖析了潜在的安全风险及其修复方法,旨在帮助开发者构建更安全的Solana程序。
在我们之前的原生 Solana 教程中,我们为了保持示例简洁并专注于核心主题而省略了安全检查。
在本教程中,我们将介绍原生 Solana 程序的基本安全检查:验证账户所有权、验证系统变量 (sysvar) 和程序 ID、要求签名者、强制账户可写、CPI 后重新加载状态以及处理代币账户“灰尘攻击”。
在使用账户数据之前,请检查其所有者是否与预期的程序 ID 匹配。否则,攻击者可能会传递一个由他们控制的带有恶意数据的账户。
在 Anchor 中,使用 Account<'info, T> 定义账户会自动检查该账户是否由你的程序拥有。对于外部账户,你可以添加 #[account(owner = <ID>)] 属性来强制特定程序 ID 拥有该账户。
例如,假设我们有一个 Config 账户,用于控制提款。如果我们不验证 Config 是否由我们的程序拥有,攻击者可以传递一个带有虚假数据的假账户,并在不应该提款时进行提款。
Copyuse 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 账户的数据之前,检查它是否由我们的程序拥有。
Copyuse 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(())
}
当你需要像 Clock 系统变量或系统程序这样的系统变量时,务必验证它是真实的。攻击者可以传递带有被操纵数据的虚假账户。
在 Anchor 程序中,这通过使用 Sysvar<'info, Clock> 或 Program<'info, System> 类型来强制执行,这些类型会为你处理检查。但我们必须在原生程序中手动检查 ID。
在这个例子中,传递给 withdraw_timelock 的 clock 账户预期是 Clock 系统变量,但如果没有验证,攻击者可以传递一个带有被操纵时间戳的虚假 clock 账户,从而提前提款。
Copy// 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(())
}
作为修复,请务必确保系统变量和程序账户地址匹配。
Copyuse 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 桥漏洞利用事件的发生,是因为程序没有确保提供的系统程序地址与实际的系统程序地址匹配。攻击者传入了一个虚假的系统账户,绕过了签名检查,使他们未经授权地铸造了 120,000 wETH(封装的 ETH)。
这就是为什么 Solana 程序必须始终验证系统账户和系统变量是否与其官方 ID 匹配。 你可以阅读 CertiK 对此的完整分析。
当你的程序将某个操作限制为特定权限(例如,管理员)时,仅仅检查账户的公钥是否与预期匹配是不够的。攻击者可以将真实的管理员账户作为非签名者包含在交易中,从而通过该检查。你还必须验证该账户是否实际签署了交易(admin.is_signer),这证明私钥所有者已批准该操作。在 Anchor 中,#[account(signer)] 属性为你处理了这一点。
下面是一个易受攻击的原生 Rust 示例,其中一个函数更新 config 账户中的提款上限:
Copy// 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(())
}
为了解决这个问题,要求管理员是签名者,并且与存储的管理员密钥匹配:
Copypub 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 或数据,客户端必须在交易中将该账户标记为可写,并且你的程序应验证其是否可写。如果账户未标记为可写,尝试修改它将导致交易失败并出现错误(例如,“Readonly account changed”)。
要在 TypeScript 客户端中将账户标记为可写,我们在交易构建期间将其 isWritable 标志设置为 true。
Copy// 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 字段检查账户是否可写。
Copypub 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 调用之后,你的程序必须重新读取账户,然后才能根据其做出决策。
下面是未重新加载时可能发生的情况(使用原生 Rust 程序代码):
Copy// 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
}... 如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!