Skip to main content

a hitchhikers guide (helius)

https://www.helius.dev/blog/a-hitchhikers-guide-to-solana-program-security

https://www.helius.dev/blog/a-hitchhikers-guide-to-solana-program-security

Account Data Reallocation

improper use of realloc can lead to unintended consequences, including wasting compute units or potentially exposing stale data.

realloc方法有两个参数:

  • new_len:指定帐户数据新长度的usize
  • Zero_init:一个布尔值,确定新的内存空间是否应该为零初始化

realloc定义如下:


pub fn realloc(
&self,
new_len: usize,
zero_init: bool
) -> Result<(), ProgramError>
  • 为帐户数据分配的内存已在程序入口点初始化为零。这意味着当数据在单个事务中重新分配到更大的大小时,新的内存空间已经被清零。无需将此内存重新归零,并且会导致额外的计算单元消耗。
  • 相反,如果Zero_initfalse,则在同一事务中重新分配到较小的大小然后返回到较大的大小可能会暴露过时的数据。

漏洞示例


pub fn modify_todo_list(ctx: Context<ModifyTodoList>, modifications: Vec<TodoModification>) -> ProgramResult {
// Logic to process modifications
for modification in modifications {
match modification {
TodoModification::Add(entry) => {
// Add logic
},
TodoModification::Remove(index) => {
// Remove logic, potentially requiring data reallocation
},
TodoModification::Edit(index, new_entry) => {
// Edit logic
},
}
}

// Reallocation logic to adjust the data size based on modifications
let required_data_len = calculate_required_data_len(&modifications);
ctx.accounts.todo_list_data.realloc(required_data_len, false)?;

Ok(())
}

#[derive(Accounts)]
pub struct ModifyTodoList<'info> {
#[account(mut)]
todo_list_data: AccountInfo<'info>,
// Other relevant accounts
}

在这种情况下,modify_todo_list函数可能会多次重新分配to_do_list_data以适应修改所需的大小。

如果减小数据大小以删除待办事项条目,然后再次增加数据大小以在同一事务中添加新条目,则将Zero_init设置为false可能会暴露过时的数据。

Mitigation

  • 在同一事务调用中先前减少数据大小后增加数据大小时,将Zero_init设置为true 。这可以确保任何新的内存空间都是零初始化的,从而防止过时的数据被暴露
  • 当增加数据大小而没有事先减少同一事务调用时,将Zero_init设置为false,**因为内存已经被零初始化

Instead of reallocating data to meet specific size requirements, developers should use Address Lookup Tables (ALTs). ALTs allow developers to compress a transaction's data by storing up to 256 addresses in a single on-chain account. Each address within the table can then be referenced by a 1-byte index, significantly reducing the data needed for address references in a given transaction. ALTs are much more helpful for scenarios requiring dynamic account interactions without the need for frequent memory resizing. 开发人员不应重新分配数据以满足特定的大小要求,而应使用地址查找表 (ALT)。 ALT 允许开发人员通过在单个链上账户中存储最多 256 个地址来压缩交易数据。然后可以通过 1 字节索引来引用表中的每个地址,从而显着减少给定事务中地址引用所需的数据。 ALT 对于需要动态帐户交互且无需频繁调整内存大小的场景更有帮助。

Account Reloading

帐户重新加载是开发人员在执行 CPI 后未能更新反序列化帐户时出现的漏洞。

Anchor 不会在 CPI 后自动刷新反序列化帐户的状态。这可能会导致程序逻辑对陈旧数据进行操作的情况,从而导致逻辑错误或不正确的计算。

示例


pub fn update_rewards(ctx: Context<UpdateStakingRewards>, amount: u64) -> Result<()> {
let staking_seeds = &[b"stake", ctx.accounts.staker.key().as_ref(), &[ctx.accounts.staking_account.bump]];

let cpi_accounts = UpdateRewards {
staking_account: ctx.accounts.staking_account.to_account_info(),
};
let cpi_program = ctx.accounts.rewards_distribution_program.to_account_info();
let cpi_ctx = CpiContext::new_with_signer(cpi_program, cpi_accounts, staking_seeds);

rewards_distribution::cpi::update_rewards(cpi_ctx, amount)?;

// Attempt to log the "updated" reward balance
msg!("Rewards: {}", ctx.accounts.staking_account.rewards);

// Logic that uses the stale ctx.accounts.staking_account.rewards

Ok(())
}

#[derive(Accounts)]
pub struct UpdateStakingRewards<'info> {
#[account(mut)]
pub staker: Signer<'info>,
#[account(
mut,
seeds = [b"stake", staker.key().as_ref()],
bump,
)]
pub staking_account: Account<'info, StakingAccount>,
pub rewards_distribution_program: Program<'info, RewardsDistribution>,
}

#[account]
pub struct StakingAccount {
pub staker: Pubkey,
pub stake_amount: u64,
pub rewards: u64,
pub bump: u8,
}

update_rewards函数尝试通过对奖励分配程序的 CPI 调用来更新用户质押账户的奖励。最初,程序在 CPI 之后记录ctx.accounts.stake_account.rewards(即奖励余额),然后继续使用过时的ctx.accounts.stake_account.rewards数据的逻辑。

问题在于,质押账户的状态不会在 CPI 之后自动更新,这就是数据过时的原因。

Mitigation

为了缓解此问题,请显式调用 Anchor 的**reload**方法从存储中重新加载给定帐户。在 CPI 之后重新加载帐户将准确反映其状态:


pub fn update_rewards(ctx: Context<UpdateStakingRewards>, amount: u64) -> Result<()> {
let staking_seeds = &[b"stake", ctx.accounts.staker.key().as_ref(), &[ctx.accounts.staking_account.bump]];

let cpi_accounts = UpdateRewards {
staking_account: ctx.accounts.staking_account.to_account_info(),
};
let cpi_program = ctx.accounts.rewards_distribution_program.to_account_info();
let cpi_ctx = CpiContext::new_with_signer(cpi_program, cpi_accounts, staking_seeds);

rewards_distribution::cpi::update_rewards(cpi_ctx, amount)?;

// Reload the staking account to reflect the updated reward balance
ctx.accounts.staking_account.reload()?;

// Log the updated reward balance
msg!("Rewards: {}", ctx.accounts.staking_account.rewards);

// Logic that uses ctx.accounts.staking_account.rewards

Ok(())
}

Authority Transfer Functionality

Solana 程序通常指定特定的公钥作为关键功能的授权机构,例如更新程序参数或提取资金。然而,无法将此权限转移到另一个地址可能会带来重大风险。在团队变更、协议销售或权限受到损害等情况下,这种限制会成为问题。

示例


pub fn set_params(ctx: Context<SetParams>, /* parameters to be set */) -> Result<()> {
require_keys_eq!(
ctx.accounts.current_admin.key(),
ctx.accounts.global_admin.authority,
);

// Logic to set parameters
}

在这里,权限是静态定义的,无法将其更新到新地址。

Mitigation

缓解此问题的一个安全方法是创建一个两步移交流程。此过程将允许当前权限提名一个新的未决权限,该权限必须明确接受该角色。这不仅可以提供权限转移功能,还可以防止意外转移或恶意接管。流程如下:

  • 当前权威机构提名:当前权威机构将通过调用nominate_new_authority提名一个新的****pending_authority ,这会在程序状态中设置pending_authority字段
  • 新权威机构的接受:指定的pending_authority调用accept_authority来承担新的角色,将权威从当前的权威机构转移到pending_authority

pub fn nominate_new_authority(ctx: Context<NominateAuthority>, new_authority: Pubkey) -> Result<()> {
let state = &mut ctx.accounts.state;
require_keys_eq!(
state.authority,
ctx.accounts.current_authority.key()
);

state.pending_authority = Some(new_authority);
Ok(())
}

pub fn accept_authority(ctx: Context<AcceptAuthority>) -> Result<()> {
let state = &mut ctx.accounts.state;
require_keys_eq!(
Some(ctx.accounts.new_authority.key()),
state.pending_authority
);

state.authority = ctx.accounts.new_authority.key();
state.pending_authority = None;
Ok(())
}

#[derive(Accounts)]
pub struct NominateAuthority<'info> {
#[account(
mut,
has_one = authority,
)]
pub state: Account<'info, ProgramState>,
pub current_authority: Signer<'info>,
pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct AcceptAuthority<'info> {
#[account(
mut,
constraint = state.pending_authority == Some(new_authority.key())
)]
pub state: Account<'info, ProgramState>,
pub new_authority: Signer<'info>,
}

#[account]
pub struct ProgramState {
pub authority: Pubkey,
pub pending_authority: Option<Pubkey>,
// Other relevant program state fields
}

ProgramState帐户结构保存当前权限和可选的ending_authority。 NominateAuthority上下文确保当前权威机构签署交易,从而允许他们提名新的权威机构。 AcceptAuthority上下文检查pending_authority是否与交易的签名者匹配,允许他们接受并成为新的权威。 此设置可确保程序内安全且受控的权力转移。

Bump Seed Canonicalization

Bump seed canonicalization refers to using the highest valid bump seed (i.e., canonical bump) when deriving PDAs. Using the canonical bump is a deterministic and secure way to find an address given a set of seeds. Failing to use the canonical bump can lead to vulnerabilities, such as malicious actors creating or manipulating PDAs that compromise program logic or data integrity. bump 种子规范化是指在派生 PDA 时使用最高有效bump种子(即规范bump)。使用规范碰撞是一种确定性且安全的方法,可以在给定一组种子的情况下查找地址。未能使用规范碰撞可能会导致漏洞,例如恶意行为者创建或操纵损害程序逻辑或数据完整性的 PDA。

示例


pub fn create_profile(ctx: Context<CreateProfile>, user_id: u64, attributes: Vec<u8>, bump: u8) -> Result<()> {
// Explicitly derive the PDA using create_program_address and a user-provided bump
let seeds: &[&[u8]] = &[b"profile", &user_id.to_le_bytes(),&[bump]];
let (derived_address, _bump) = Pubkey::create_program_address(seeds, &ctx.program_id)?;

if derived_address != ctx.accounts.profile.key() {
return Err(ProgramError::InvalidSeeds);
}

let profile_pda = &mut ctx.accounts.profile;
profile_pda.user_id = user_id;
profile_pda.attributes = attributes;

Ok(())
}

#[derive(Accounts)]
pub struct CreateProfile<'info> {
#[account(mut)]
pub user: Signer<'info>,
/// The profile account, expected to be a PDA derived with the user_id and a user-provided bump seed
#[account(mut)]
pub profile: Account<'info, UserProfile>,
pub system_program: Program<'info, System>,
}

#[account]
pub struct UserProfile {
pub user_id: u64,
pub attributes: Vec<u8>,
}

考虑一个旨在创建唯一用户配置文件的程序, 每个配置文件都具有使用create_program_address显式派生的关联 PDA 。

该程序允许通过用户提供的bump来创建配置文件。然而,这是有问题的,因为它引入了使用非规范bump的风险:

在这种情况下,程序使用create_program_address和包含用户提供的bump的seed派生出UserProfile PDA。使用用户提供的bump是有问题的,因为它无法确保使用规范bump。这将允许恶意行为者为同一用户 ID 创建多个具有不同bump的 PDA。

Mitigation


pub fn create_profile(ctx: Context<CreateProfile>, user_id: u64, attributes: Vec<u8>) -> Result<()> {
// Securely derive the PDA using find_program_address to ensure the canonical bump is used
let seeds: &[&[u8]] = &[b"profile", user_id.to_le_bytes()];
let (derived_address, bump) = Pubkey::find_program_address(seeds, &ctx.program_id);

// Store the canonical bump in the profile for future validations
let profile_pda = &mut ctx.accounts.profile;
profile_pda.user_id = user_id;
profile_pda.attributes = attributes;
profile_pda.bump = bump;

Ok(())
}

#[derive(Accounts)]
#[instruction(user_id: u64)]
pub struct CreateProfile<'info> {
#[account(
init,
payer = user,
space = 8 + 1024 + 1,
seeds = [b"profile", user_id.to_le_bytes().as_ref()],
bump
)]
pub profile: Account<'info, UserProfile>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}

#[account]
pub struct UserProfile {
pub user_id: u64,
pub attributes: Vec<u8>,
pub bump: u8,
}

find_program_address用于通过规范bump种子导出 PDA,以确保确定性且安全的 PDA 创建。

规范bump存储在UserProfile帐户中,以便在后续操作中进行高效、安全的验证。

我们更喜欢find_program_address而不是create_program_address

因为后者创建有效的 PDA_而不搜索bump seed。由于它不搜索bump seed,因此对于任何给定的种子集,它可能会意外地返回错误,并且通常不适合创建 PDA。

创建 PDA 时,find_program_address将_始终_使用规范bump。这是因为它会迭代各种create_program_address调用,从 255 的增量开始,并随着每次迭代递减。一旦找到有效地址,该函数就会返回派生的 PDA 以及用于派生它的规范bump。

注意 Anchor 通过其种子碰撞约束强制 PDA 派生的规范bump,简化整个过程以确保安全且确定的 PDA 创建和验证。

Closing Account

不正确地关闭程序中的帐户可能会导致多个漏洞,包括"已关闭"帐户可能被重新初始化或滥用。

该问题是由于未能正确地将帐户标记为关闭或未能防止其在后续交易中重复使用而引起的。

这种疏忽可能会导致恶意行为者利用给定帐户,从而导致程序内发生未经授权的操作或访问。

示例


pub fn close_account(ctx: Context<CloseAccount>) -> ProgramResult {
let account = ctx.accounts.data_account.to_account_info();
let destination = ctx.accounts.destination.to_account_info();

**destination.lamports.borrow_mut() = destination
.lamports()
.checked_add(account.lamports())
.unwrap();
**account.lamports.borrow_mut() = 0;

Ok(())
}

#[derive(Accounts)]
pub struct CloseAccount<'info> {
#[account(mut)]
pub data_account: Account<'info, Data>,
#[account(mut)]
pub destination: AccountInfo<'info>,
}

#[account]
pub struct Data {
data: u64,
}

这是有问题的,因为程序无法将帐户的数据清零或将其标记为关闭。

仅转出剩余的端口并不会关闭该帐户。

Mitigation

为了缓解这个问题,程序不仅应该转出所有Lamports,还应该将帐户的数据清零并用Discriminator标记它(即CLOSED_ACCOUNT_DISCRIMINATOR)。

该计划还应该实施检查,以防止关闭的帐户在未来的交易中被重复使用:


use anchor_lang::__private::CLOSED_ACCOUNT_DISCRIMINATOR;
use anchor_lang::prelude::*;
use std::io::Cursor;
use std::ops::DerefMut;

// Other code

pub fn close_account(ctx: Context<CloseAccount>) -> ProgramResult {
let account = ctx.accounts.data_account.to_account_info();
let destination = ctx.accounts.destination.to_account_info();

**destination.lamports.borrow_mut() = destination
.lamports()
.checked_add(account.lamports())
.unwrap();
**account.lamports.borrow_mut() = 0;

// Zero out the account data
let mut data = account.try_borrow_mut_data()?;
for byte in data.deref_mut().iter_mut() {
*byte = 0;
}

// Mark the account as closed
let dst: &mut [u8] = &mut data;
let mut cursor = Cursor::new(dst);
cursor.write_all(&CLOSED_ACCOUNT_DISCRIMINATOR).unwrap();

Ok(())
}

pub fn force_defund(ctx: Context<ForceDefund>) -> ProgramResult {
let account = &ctx.accounts.account;
let data = account.try_borrow_data()?;

if data.len() < 8 || data[0..8] != CLOSED_ACCOUNT_DISCRIMINATOR {
return Err(ProgramError::InvalidAccountData);
}

let destination = ctx.accounts.destination.to_account_info();

**destination.lamports.borrow_mut() = destination
.lamports()
.checked_add(account.lamports())
.unwrap();
**account.lamports.borrow_mut() = 0;

Ok(())
}

#[derive(Accounts)]
pub struct ForceDefund<'info> {
#[account(mut)]
pub account: AccountInfo<'info>,
#[account(mut)]
pub destination: AccountInfo<'info>,
}

#[derive(Accounts)]
pub struct CloseAccount<'info> {
#[account(mut)]
pub data_account: Account<'info, Data>,
#[account(mut)]
pub destination: AccountInfo<'info>,
}

#[account]
pub struct Data {
data: u64,
}

然而,将数据归零并添加闭合Discriminator还不够。

用户可以通过在指令结束之前退还帐户的内存来防止帐户被垃圾收集(GC)。这将使该帐户处于一种奇怪的边缘状态,无法使用它或进行垃圾收集。

因此,我们添加了一个force_defund函数来解决这种边缘情况;现在任何人都可以对已关闭的账户进行退款。

Anchor 使用#[account(close = destination)] 约束简化了这一过程,通过传输 lamport、清零数据和设置关闭帐户鉴别器来自动安全关闭帐户,所有这些都在一个操作中完成。

Duplicate Mutable Accounts

重复的可变帐户是指同一帐户作为可变参数多次传递给指令的情况。当指令需要两个相同类型的可变帐户时,就会发生这种情况。

恶意行为者可能会两次传入同一个帐户,导致帐户以意想不到的方式发生变化(例如,覆盖数据)。该漏洞的严重程度根据具体场景而有所不同。

Example


pub fn distribute_rewards(ctx: Context<DistributeRewards>, reward_amount: u64, bonus_amount: u64) -> Result<()> {
let reward_account = &mut ctx.accounts.reward_account;
let bonus_reward = &mut ctx.accounts.bonus_account;

// Intended to increment the reward and bonus accounts separately
reward_account.balance += reward_amount;
bonus_account.balance += bonus_amount;

Ok(())
}

#[derive(Accounts)]
pub struct DistributeRewards<'info> {
#[account(mut)]
reward_account: Account<'info, RewardAccount>,
#[account(mut)]
bonus_account: Account<'info, RewardAccount>,
}

#[account]
pub struct RewardAccount {
pub balance: u64,
}

如果恶意行为者将同一个帐户传递给reward_accountbonus_account,则该帐户的余额将被错误地更新两次。

Mitigation


pub fn distribute_rewards(ctx: Context<DistributeRewards>, reward_amount: u64, bonus_amount: u64) -> Result<()> {
if ctx.accounts.reward_account.key() == ctx.accounts.bonus_account.key() {
return Err(ProgramError::InvalidArgument.into())
}

let reward_account = &mut ctx.accounts.reward_account;
let bonus_reward = &mut ctx.accounts.bonus_account;

// Intended to increment the reward and bonus accounts separately
reward_account.balance += reward_amount;
bonus_account.balance += bonus_amount;

Ok(())
}

为了缓解此问题,请在指令逻辑中添加检查以验证两个帐户的公钥不相同:

开发者可以利用Anchor的账户约束,对账户添加更明确的检查。这可以使用#[account] 属性和constraint关键字来完成:


pub fn distribute_rewards(ctx: Context<DistributeRewards>, reward_amount: u64, bonus_amount: u64) -> Result<()> {
let reward_account = &mut ctx.accounts.reward_account;
let bonus_reward = &mut ctx.accounts.bonus_account;

// Intended to increment the reward and bonus accounts separately
reward_account.balance += reward_amount;
bonus_account.balance += bonus_amount;

Ok(())
}

#[derive(Accounts)]
pub struct DistributeRewards<'info> {
#[account(
mut,
constraint = reward_account.key() != bonus_account.key()
)]
reward_account: Account<'info, RewardAccount>,
#[account(mut)]
bonus_account: Account<'info, RewardAccount>,
}

#[account]
pub struct RewardAccount {
pub balance: u64,
}

Frontrunning

随着交易捆绑器的日益普及,抢先交易是基于 Solana 的协议应该认真对待的一个问题。

随着 Jito 内存池的删除,我们在这里将抢先交易称为恶意行为者通过精心构造的交易操纵预期值与实际值的能力。

示例

#[derive(Accounts)]
pub struct SellProduct<'info> {
product_listing: Account<'info, ProductListing>,
sale_token_mint: Account<'info, Mint>,
sale_token_destination: Account<'info, TokenAccount>,
product_owner: Signer<'info>,
purchaser_token_source: Account<'info, TokenAccount>,
product: Account<info, Product>
}

#[derive(Accounts)]
pub struct PurchaseProduct<'info> {
product_listing: Account<'info, ProductListing>,
token_destination: Account<'info, TokenAccount>,
token_source: Account<'info, TokenAccount>,
buyer: Signer<'info>,
product_account: Account<'info, Product>,
token_mint_sale: Account<'info, Mint>,
}

#[account]
pub struct ProductListing {
sale_price: u64,
token_mint: Pubkey,
destination_token_account: Pubkey,
product_owner: Pubkey,
product: Pubkey,
}

要购买列出的产品,买家必须输入与他们想要的产品相关的ProductListing帐户。但是,如果卖家可以更改其列表的sale_price呢?


pub fn change_sale_price(ctx: Context<ChangeSalePrice>, new_price: u64) -> Result<()> {...}

这将为卖方带来抢先交易的机会,特别是如果买方的购买交易不包括预期价格检查,以确保他们为所需产品支付的费用不超过预期。

如果购买者提交购买给定产品的交易,则卖方可以调用change_sale_price,并使用 Jito 确保此交易包含在购买者的交易之前。 恶意卖家可能会在买家不知情的情况下将ProductListing帐户中的价格更改为过高的金额,迫使他们为产品支付超出预期的费用!

Mitigation

一个简单的解决方案是在交易的购买方进行预期价格检查,防止买方为他们想要购买的产品支付超出预期的费用:


pub fn purchase_product(ctx: Context<PurchaseProduct>, expected_price: u64) -> Result<()> {
assert!(ctx.accounts.product_listing.sale_price <= expected_price);
...
}

Insecure Initialization

与部署到 EVM 的合约不同,Solana 程序不使用构造函数来部署来设置状态变量。相反,它们是手动初始化的(通常通过称为初始化或类似的函数)。

初始化函数通常设置数据,例如程序的权限或创建构成正在部署的程序的基础的帐户(即中央状态帐户或类似帐户)。

由于初始化函数是手动调用的,而不是在程序部署时自动调用的,因此该指令必须在程序开发团队的控制下由已知地址调用。否则,攻击者可能会抢先进行初始化,并可能使用攻击者控制下的帐户来设置程序。

如果程序有升级权限,通常的做法是使用程序的upgrade_authority作为调用initialize函数的授权地址。

示例


pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
ctx.accounts.central_state.authority = authority.key();
...
}

#[derive(Accounts)]
pub struct Initialize<'info> {
authority: Signer<'info>,
#[account(mut,
init,
payer = authority,
space = CentralState::SIZE,
seeds = [b"central_state"],
bump
)]
central_state: Account<'info, CentralState>,
...
}

#[account]
pub struct CentralState {
authority: Pubkey,
...
}

上面的示例是一个精简的初始化函数,它为指令调用者设置CentralState帐户的权限。但是,这可以是任何调用初始化的帐户!如前所述,保护初始化函数的常见方法是使用程序的upgrade_authority(在部署时已知)。

Mitigation

下面是Anchor文档中的一个例子,它使用约束来保证只有程序的升级权限才能调用initialize:


use anchor_lang::prelude::*;
use crate::program::MyProgram;

declare_id!("Cum9tTyj5HwcEiAmhgaS7Bbj4UczCwsucrCkxRECzM4e");

#[program]
pub mod my_program {
use super::*;

pub fn set_initial_admin(
ctx: Context<SetInitialAdmin>,
admin_key: Pubkey
) -> Result<()> {
ctx.accounts.admin_settings.admin_key = admin_key;
Ok(())
}

pub fn set_admin(...){...}

pub fn set_settings(...){...}
}

#[account]
#[derive(Default, Debug)]
pub struct AdminSettings {
admin_key: Pubkey
}

#[derive(Accounts)]
pub struct SetInitialAdmin<'info> {
#[account(init, payer = authority, seeds = [b"admin"], bump)]
pub admin_settings: Account<'info, AdminSettings>,
#[account(mut)]
pub authority: Signer<'info>,
#[account(constraint = program.programdata_address()? == Some(program_data.key()))]
pub program: Program<'info, MyProgram>,
#[account(constraint = program_data.upgrade_authority_address == Some(authority.key()))]
pub program_data: Account<'info, ProgramData>,
pub system_program: Program<'info, System>,
}

Loss of Precision

精度损失虽然看起来很小,但可能对程序构成重大威胁。它可能导致错误的计算、套利机会和意外的程序行为。

算术运算中的精度损失是常见的错误来源。对于 Solana 程序,建议尽可能使用定点运算。这是因为程序仅支持Rust 浮点运算的有限子集。如果程序尝试使用不受支持的浮点运算,运行时将返回未解析的符号错误。此外,与整数运算相比,浮点运算需要更多指令。定点算术的使用以及准确处理大量标记和小数的需要可能会加剧精度损失。

除法后乘法

虽然关联性适用于大多数数学运算,但其在计算机算术中的应用可能会导致意外的精度损失。

精度损失的一个典型例子发生在除法之后执行乘法时,这可能会产生与除法之前执行乘法不同的结果。

例如,考虑以下表达式:(a / c) * b和**(a * b) / c**。从数学上讲,这些表达式是关联的 - 它们_应该_产生相同的结果。

然而,在 Solana 和定点算术的上下文中,运算顺序非常重要。如果商在乘以b之前向下舍入,则首先执行除法(a / c)可能会导致精度损失。这可能会导致结果小于预期。

相反,在除以c之前先乘以(a * b),可以保留更多的原始精度。

这种差异可能会导致计算不正确,从而产生意外的程序行为和/或套利机会。

saturating_*算术函数

虽然saturating_*算术函数通过将值限制在最大或最小可能值来防止溢出和下溢,但如果意外达到此上限,则可能会导致微妙的错误和精度损失。当程序逻辑假设仅饱和就能保证准确的结果并忽略处理潜在的精度或准确度损失时,就会发生这种情况。

例如,想象一个程序,旨在根据用户在特定时期内交易的代币数量来计算并向用户分配奖励:


pub fn calculate_reward(transaction_amount: u64, reward_multiplier: u64) -> u64 {
transaction_amount.saturating_mul(reward_multiplier)
}

考虑这样的场景:transaction_amount为 100,000 个代币,reward_multiplier为每笔交易 100 个代币。两者相乘将超过u64可以容纳的最大值。这意味着他们的产品将受到限制,从而因奖励用户不足而导致精度大幅下降。

舍入误差

舍入操作是编程中常见的精度损失。舍入方法的选择会显着影响计算的准确性和 Solana 程序的行为。 try_round_u64 ()函数将十进制值四舍五入到最接近的整数。

四舍五入是有问题的,因为它可能会人为地夸大值,导致实际计算与预期计算之间存在差异。

考虑一个 Solana 计划,该计划根据市场状况将抵押品转换为流动性。该程序使用try_round_u64()对除法运算的结果进行舍入:


pub fn collateral_to_liquidity(&self, collateral_amount: u64) -> Result<u64, ProgramError> {
Decimal::from(collateral_amount)
.try_div(self.0)?
.try_round_u64()
}

在这种情况下,四舍五入可能会导致发行比抵押品金额合理的更多的流动性代币。

恶意行为者可以利用这种差异进行套利攻击,通过有利影响的舍入结果从协议中提取价值。

为了缓解这种情况,请使用try_floor_u64向下舍入到最接近的整数。这种方法最大限度地降低了人为夸大值的风险,并确保任何舍入不会以牺牲系统为代价给用户带来优势。

或者,实现逻辑来处理舍入可能明确影响结果的情况。这可能包括为舍入决策设置特定阈值或根据所涉及值的大小应用不同的逻辑。

Overflow and Underflow

Rust 包括在调试模式下编译时对整数上溢和下溢的检查。如果检测到这种情况,这些检查将导致程序在运行时出现_恐慌_。然而,Rust 不包括在使用 --release 标志在发布模式下编译时对整数溢出和下溢产生恐慌的检查。

当溢出或下溢悄无声息地发生时,此行为可能会引入微妙的漏洞。

示例


pub fn process_instruction(
_program_id: & Pubkey,
accounts: [&AccountInfo],
_instruction_data: &[u8],
) -> ProgramResult {
let account_info_iter = &mut accounts.iter();
let account = next_account_info(account_info_iter)?;

let mut balance: u8 = account.data.borrow()[0];
let tokens_to_subtract: u8 = 100;

balance = balance - tokens_to_subtract;

account.data.borrow_mut()[0] = balance;
msg!("Updated balance to {}", balance);

Ok(())
}

为了简单起见,该函数假设余额存储在第一个字节中。它获取账户余额并从中减去tokens_to_subtract 。如果用户的余额小于tokens_to_subtract,则会导致下溢。例如,拥有 10 个代币的用户将下溢到总余额 165 个代币

Mitigation

溢出检查

缓解此漏洞的最简单方法是在项目的Cargo.toml文件中将key overflow-checks设置为true

在这里,Rust 将在编译器中添加溢出和下溢检查。然而,添加上溢和下溢检查会增加事务的计算成本。在需要优化计算的情况下,将溢出检查设置为false可能更有利。

check_* 算术

对每个整数类型使用 Rust 的check_* 算术函数来策略性地检查整个程序中的上溢和下溢。如果发生上溢或下溢,这些函数将返回None 。这允许程序优雅地处理错误。例如,您可以将之前的代码重构为:


pub fn process_instruction(
_program_id: & Pubkey,
accounts: [&AccountInfo],
_instruction_data: &[u8],
) -> ProgramResult {
let account_info_iter = &mut accounts.iter();
let account = next_account_info(account_info_iter)?;

let mut balance: u8 = account.data.borrow()[0];
let tokens_to_subtract: u8 = 100;

match balance.checked_sub(tokens_to_subtract) {
Some(new_balance) => {
account.data.borrow_mut()[0] = new_balance;
msg!("Updated balance to {}", new_balance);
},
None => {
return Err(ProgramErrorr::InsufficientFunds);
}
}

Ok(())
}

在修改后的示例中,checked_sub用于从balance中减去tokens_to_subtract。因此,如果余额足以支付减法,checked_sub将返回Some(new_balance)。该程序继续安全地更新帐户余额并记录它。但是,如果减法会导致下溢,checked_sub返回None,我们可以通过返回错误来处理。

Checked Math Macro

Checked Math是一个过程宏,用于更改检查数学表达式的属性,而在大多数情况下无需更改这些表达式。check_*算术函数的问题是数学符号的丢失。相反,必须使用像a.checked_add(b).unwrap() 这样麻烦的方法而不是 a + b

例如,如果我们想使用检查算术函数编写**(x * y) + z ,我们会编写x.checked_mul(y).unwrap().checked_add(z).unwrap()

相反,使用 Checked Math 宏的以下表达式将如下所示:


use checked_math::checked_math as cm;

cm!((x * y) + z).unwrap()

这样写起来比较方便,保留了表达式的数学符号,而且只需要一个.unwrap()。这是因为如果任何检查的步骤返回None ,宏会将普通数学表达式转换为返回 None的表达式。如果成功,则返回Some(_),这就是我们最后解开表达式的原因。

Casting

在没有适当检查的情况下使用as关键字在整数类型之间进行转换可能会引入整数溢出或下溢漏洞。

这是因为转换可能会以意想不到的方式截断或扩展值。

  • 当从较大的整数类型转换为较小的整数类型(例如,u64u32)时,Rust 会截断原始值中不适合目标类型的高位。
    • 当原始值超过目标类型可以存储的最大值时,这就会出现问题。
  • 当从较小的整数类型转换为较大的整数类型(例如,i16i32)时,Rust 会扩展该值。
    • 这对于无符号类型来说很简单。
    • 但是,这可能会导致带符号整数的符号扩展,从而引入意外的负值。

Mitigation

使用 Rust 的安全转换方法来缓解此漏洞。这包括**try_from** 和**from**等方法。

使用try_from返回Result类型,允许显式处理值不适合目标类型的情况。 使用 Rust 的from方法可以用于保证无损转换的安全、隐式转换(例如,u8u32)。

例如,假设程序需要将u64令牌金额安全地转换为u32类型进行处理。在这种情况下,它可以执行以下操作:


pub fn convert_token_amount(amount: u64) -> Result<u32, ProgramError> {
u32::try_from(amount).map_err(|_| ProgramError::InvalidArgument)
}

在此示例中,如果amount超过u32可以容纳的最大值(即 4 294 967 295),则转换失败,并且程序返回错误。这可以防止发生潜在的上溢/下溢。

PDA Sharing

PDA 共享是一个常见的漏洞,当跨multiple authority domains or roles使用同一个 PDA 时就会出现这种漏洞。

这可能允许恶意行为者在没有适当的访问控制检查的情况下,通过滥用 PDA 作为签名者来访问不属于他们的数据或资金。

示例


pub fn stake_tokens(ctx: Context<StakeTokens>, amount: u64) -> ProgramResult {
// Logic to stake tokens
Ok(())
}

pub fn withdraw_rewards(ctx: Context<WithdrawRewards>, amount: u64) -> ProgramResult {
// Logic to withdraw rewards
Ok(())
}

#[derive(Accounts)]
pub struct StakeTokens<'info> {
#[account(
mut,
seeds = [b"staking_pool_pda"],
bump
)]
staking_pool: AccountInfo<'info>,
// Other staking-related accounts
}

#[derive(Accounts)]
pub struct WithdrawRewards<'info> {
#[account(
mut,
seeds = [b"staking_pool_pda"],
bump
)]
rewards_pool: AccountInfo<'info>,
// Other rewards withdrawal-related accounts
}

这是有问题的,因为质押和奖励提取功能依赖于从stake_pool_pda派生的相同 PDA 。这可能允许用户操纵合约进行未经授权的奖励提取或质押操纵。

Mitigation

为了缓解此漏洞,请针对不同的功能使用不同的 PDA。确保每个 PDA 服务于特定的上下文,并使用独特的、特定于操作的种子派生:


pub fn stake_tokens(ctx: Context<StakeTokens>, amount: u64) -> ProgramResult {
// Logic to stake tokens
Ok(())
}

pub fn withdraw_rewards(ctx: Context<WithdrawRewards>, amount: u64) -> ProgramResult {
// Logic to withdraw rewards
Ok(())
}

#[derive(Accounts)]
pub struct StakeTokens<'info> {
#[account(
mut,
seeds = [b"staking_pool", &staking_pool.key().as_ref()],
bump
)]
staking_pool: AccountInfo<'info>,
// Other staking-related accounts
}

#[derive(Accounts)]
pub struct WithdrawRewards<'info> {
#[account(
mut,
seeds = [b"rewards_pool", &rewards_pool.key().as_ref()],
bump
)]
rewards_pool: AccountInfo<'info>,
// Other rewards withdrawal-related accounts
}

在上面的示例中,用于质押代币和提取奖励的 PDA 是使用不同的种子(分别是stake_poolrewards_pool)与特定帐户的密钥相结合而派生的。

这确保了 PDA 与其预期功能唯一相关,从而降低了未经授权操作的风险。

Remaining Accounts

ctx.remaining_accounts提供了一种将其他帐户传递到最初未在Accounts结构中指定的函数的方法。这为开发人员提供了更大的灵活性,使他们能够处理需要动态帐户数量的场景(即处理可变数量的用户或与不同的程序交互)。

但是,这种灵活性的提高伴随着一个警告:通过ctx.remaining_accounts传递的帐户不要对Accounts结构中定义的帐户进行相同的验证,因为ctx.remaining_accounts不会验证传入的帐户,因此恶意行为者可以通过传入程序不打算与之交互的帐户来利用此漏洞,从而导致未经授权的操作或访问。

示例


pub fn calculate_rewards(ctx: Context<CalculateRewards>) -> Result<()> {
let rewards_account = &ctx.accounts.rewards_account;
let authority = &ctx.accounts.authority;

// Iterate over accounts passed in via ctx.remaining_accounts
for user_pda_info in ctx.remaining_accounts.iter() {
// logic to check user activity and calculate rewards
}

// Logic to distribute calculated rewards

Ok(())
}

#[derive(Accounts)]
pub struct CalculateRewards<'info> {
#[account(mut)]
pub rewards_account: Account<'info, RewardsAccount>,
pub authority : Signer<'info>,
}

#[account]
pub struct RewardsAccount {
pub total_rewards: u64,
// Other relevant fields
}

这里的问题是,没有任何明确的检查来验证通过ctx.remaining_accounts传入的帐户,这意味着它无法确保在奖励计算和分配中仅处理有效且符合条件的用户帐户。

因此,恶意行为者可以传入不属于他们的帐户或他们自己创建的帐户,以获得比他们实际应得的更多的奖励。

Mitigation

为了缓解此漏洞,开发人员应在函数内手动验证每个帐户的有效性。

这包括检查帐户的所有者,以确保其与预期用户的所有者相匹配,并验证帐户内的任何相关数据。

通过合并这些手动检查,开发人员可以利用ctx.remaining_acounts的灵活性,同时降低未经授权的访问或操纵的风险。

Rust-Specific Errors

unsafe

Rust 因其内存安全保证而闻名,这是通过严格的所有权和借用系统实现的。然而,这些保证有时会产生阻碍,因此 Rust 提供了**unsafe**关键字来绕过安全检查。不安全的Rust 用于四个主要环境:

  • unsafe function:执行可能违反 Rust 安全保证的操作的函数必须使用unsafe关键字进行标记。例如,unsafe fn anger_function()
  • unsafe block:允许unsafe 操作的代码块。例如,unsafe { // 不安全操作 }
  • unsafe trait:暗示编译器无法验证的某些不变量的特征。例如,unsafe trait BadTrait
  • impl unsafe trait: unsaft特征的实现也必须标记为unsafe。例如,unsafe impl UnsafeTrait for UnsafeType

使用unsafe关键字,开发人员可以:

  • 取消引用原始指针:允许直接内存访问可以指向任何内存位置的原始指针,该位置可能不包含有效数据
  • 调用不安全函数:这些函数可能不遵守 Rust 的安全保证,并可能导致潜在的未定义行为
  • 访问可变静态变量:全局可变状态可能导致数据争用

缓解不安全 Rust 的最佳方法是尽量减少unsafe块的使用。如果出于某种原因绝对需要unsafe代码,请确保其有详细记录、定期审核,并且如果可能的话,封装在可以提供给程序其余部分的安全抽象中。

Panics and Error Management

当发生panic时,Rust 开始展开堆栈并清理它。这将返回堆栈跟踪,其中包括有关所涉及错误的详细信息。这可以为攻击者提供有关底层文件结构的信息。

虽然这并不直接适用于 Solana 程序,但程序使用的依赖项可能容易受到此类攻击。确保依赖项保持最新并使用不包含已知漏洞的版本。

常见的panic场景包括:

  • 除以零:Rust 在尝试除以零时会出现恐慌。因此,在执行除法之前始终检查零除数
  • 数组索引越界:访问索引超出界限的数组将导致恐慌。为了缓解这种情况,请使用返回Option类型的方法(例如get)来安全地访问数组元素
  • 展开 None 值:在包含None值的 选项上调用.unwrap()将导致恐慌。始终使用模式匹配或unwrap_orunwrap_or_else或**?等方法。返回结果的函数中的运算符

为了缓解与恐慌相关的问题,必须避免导致恐慌的操作,验证可能导致有问题的操作的所有输入和条件,并使用结果选项类型进行错误处理。此外,编写全面的程序测试将有助于在部署之前发现并解决潜在的恐慌场景。

Seed Collisions

示例


// Creating a Voting Session PDA
#[derive(Accounts)]
#[instruction(session_id: String)]
pub struct CreateVotingSession<'info> {
#[account(mut)]
pub organizer: Signer<'info>,
#[account(
init,
payer = organizer,
space = 8 + Product::SIZE,
seeds = [b"session", session_id.as_bytes()],
)]
pub voting_session: Account<'info, VotingSession>,
pub system_program: Program<'info, System>,
}

// Submitting a Vote PDA
#[derive(Accounts)]
#[instruction(session_id: String)]
pub struct SubmitVote<'info> {
#[account(mut)]
pub voter: Signer<'info>,
#[account(
init,
payer = voter,
space = 8 + Vote::SIZE,
seeds = [session_id.as_bytes(), voter.key().as_ref()]
)]
pub vote: Account<'info, Vote>,
pub system_program: Program<'info, System>,
}

在这种情况下,攻击者会尝试精心设计一个VotingSession,当与静态种子 "session" 结合时,将导致 PDA 与为不同VotingSession生成的 PDA 恰好匹配。故意创建与另一个投票会话的 PDA 冲突的 PDA 可能会扰乱平台的运行,例如,由于 Solana 的运行时无法区分冲突的 PDA,因此会阻止对提案进行合法投票或拒绝将新倡议添加到平台中。

Mitigation

为了降低seed collisions的风险,开发人员可以:

  • 同一程序中不同 PDA 之间的种子使用唯一的前缀。这种方法将有助于确保 PDA 保持独特
  • 使用唯一标识符(例如时间戳、用户 ID、随机数值)来保证每次生成唯一的 PDA
  • 以编程方式验证生成的 PDA 不会与现有 PDA 发生冲突