
Solana 程序安全漫游指南
目录
- 引言
- 利用 Solana 程序时的攻击者思维
- Solana 的编程模型
- Solana 受攻击者控制
- 潜在攻击向量
- 缓解策略
- 账户数据匹配
- 漏洞
- 示例场景
- 建议的缓解措施
- 账户数据重新分配
- 漏洞
- 示例场景
- 建议的缓解措施
- 账户重新加载
- 漏洞
- 示例场景
- 建议的缓解措施
- 任意 CPI
- 漏洞
- 示例场景
- 建议的缓解措施
- 权限转移功能
- 漏洞
- 示例场景
- 建议的缓解措施
- Bump 种子规范化
- 漏洞
- 示例场景
- 建议的缓解措施
- 关闭账户
- 漏洞
- 示例场景
- 推荐的缓解措施
- 重复的可变账户
- 漏洞
- 示例场景
- 推荐的缓解措施
- 抢跑
- 漏洞
- 示例场景
- 推荐的缓解措施
- 不安全的初始化
- 不安全示例及缓解方法
- 精度损失
- 漏洞
- 除法后执行乘法
- 舍入误差
- 缺少所有权检查
- 漏洞
- 示例场景
- 推荐的缓解措施
- 只读账户
- 缺少签名者检查
- 漏洞
- 示例场景
- 推荐的缓解措施
- 溢出与下溢
- 漏洞
- 示例场景
- 建议的缓解措施
- 类型转换
- PDA 共享
- 漏洞
- 示例场景
- 建议的缓解措施
- 剩余账户
- 漏洞
- 示例场景
- 建议的缓解措施
- Rust 特有错误
- 不安全 Rust
- Panic 与错误管理
- 种子冲突
- 漏洞
- 示例场景
- 建议的缓解措施
- 类型伪装
- 漏洞
- 示例场景
- 建议的缓解措施
- 结语
- 其他资源
本文由 bl0ckpain 共同撰写。他是一名安全研究员和智能合约开发者,曾就职于 Kudelski Security 和 Halborn
引言
Solana 程序安全不只是防止黑客窃取项目资金,还要确保程序按预期运行,并符合项目规范和用户预期。Solana 程序安全可能影响 dApp 的性能、可扩展性和互操作性。因此,开发者在构建面向消费者的应用之前,必须了解潜在的攻击向量和常见漏洞。
本文探讨开发者创建 Solana 程序时会遇到的常见漏洞。首先,我们将介绍攻击者利用 Solana 程序时的思维方式,涵盖 Solana 的编程模型、Solana 的设计如何在本质上受攻击者控制、潜在攻击向量以及常见缓解策略。然后,我们将介绍各种不同的漏洞,解释漏洞原理,并在适用时提供不安全和安全的代码示例。
请注意,本文面向中级或高级读者,并假定你已了解 Solana 的编程模型和程序开发。
本文不会介绍构建程序的流程或 Solana 特有的概念,而是重点分析常见漏洞并学习如何缓解这些漏洞。如果你刚接触 Solana,建议先阅读以下博客文章,再继续阅读本文:
利用 Solana 程序时的攻击者思维
Solana 的编程模型
Solana 的编程模型决定了其网络上所构建应用的安全格局。在 Solana 上,账户充当数据容器,类似于计算机上的文件。账户大体可以分为两类:可执行账户和不可执行账户。可执行账户,也称为程序,是能够运行代码的账户。不可执行账户用于存储数据,无法执行代码(因为它们不存储任何代码)。代码与数据的解耦意味着程序是无状态的——程序会与存储在其他账户中的数据交互,而这些账户在交易期间通过引用传入。
Solana 受攻击者控制
一笔交易会指定要调用的程序、账户列表以及指令数据的字节数组。该模型依赖程序解析并解释给定交易提供的账户和指令。由于任何账户都可以传入程序函数,攻击者便能在很大程度上控制程序所操作的数据。要开发安全的程序,理解 Solana 在编程模型层面为何天然受攻击者控制至关重要。
由于攻击者能够将任何账户传入程序函数,数据验证就成了 Solana 程序安全的基础支柱。开发者必须确保程序能够区分合法输入和恶意输入。这包括验证账户所有权、确保账户属于预期类型,以及确认账户是否为签名者。
潜在攻击向量
Solana 独特的编程模型和执行环境带来了特定的攻击向量。开发者必须理解这些攻击向量,才能保护程序免受潜在攻击。这些攻击向量包括:
- 逻辑错误:程序逻辑中的缺陷可能被利用并引发非预期行为,例如资产损失或未经授权的访问。这也包括未能正确实现项目规范——如果程序声称会执行 x,那么它就应执行 x 及其所有特殊行为
- 数据验证缺陷:输入数据验证不充分,可能使攻击者传入恶意数据并操纵程序状态或执行过程
- Rust 特有问题:尽管 Rust 提供了安全特性,不安全代码块、并发问题和 panic 仍可能引入漏洞
- 访问控制漏洞:未能正确实现访问控制检查(例如验证账户所有者),可能导致恶意行为者执行未经授权的操作
- 算术和精度错误:上溢/下溢和精度错误可能被用于牟取经济利益,或导致程序出现故障
- 跨程序调用(CPI)问题:如果被调用的程序存在恶意行为或发生非预期行为,CPI 处理缺陷可能导致非预期的状态变化或错误
- 程序派生地址(PDA)误用:错误生成或处理 PDA 可能产生漏洞,使攻击者能够劫持或伪造 PDA,从而获得未经授权的访问权限或操纵程序控制的账户
请注意,由于 Solana 的执行模型,重入本身受到限制。Solana 运行时将 CPI 的最大深度限制为四层,并实施严格的账户规则,例如只有账户所有者才能修改其数据。这些约束限制了直接自递归,并确保程序不会在中间状态下被非自愿调用,从而防止重入攻击。
缓解策略
为缓解这些潜在攻击,开发者应结合严格测试、代码审计和最佳实践:
- 实施全面的输入验证和访问控制检查
- 充分利用 Rust 的类型系统和安全特性,除非必要,否则避免使用不安全代码
- 遵循 Solana 和 Rust 的安全最佳实践,并及时了解最新进展
- 在程序开发期间开展内部代码审查,并使用自动化工具识别常见漏洞和逻辑错误
- 让信誉良好的第三方审计代码库,包括安全公司和独立安全研究员
- 为程序创建漏洞赏金平台,鼓励主动报告漏洞,而不是依赖灰帽黑客
以下章节将按字母顺序探讨不同漏洞。每一节都会描述一种潜在漏洞,说明如何缓解该漏洞,并尽可能提供示例场景。
账户数据匹配
漏洞
当开发者未检查账户中存储的数据是否与预期值集合匹配时,就会产生账户数据匹配漏洞。如果没有适当的数据验证检查,程序可能会在无意中使用错误或被恶意替换的账户执行操作。在涉及权限检查的场景中,这类漏洞尤其严重。
示例场景
假设某个程序提供管理其管理员设置的功能。该程序包含一条用于更新当前管理配置的指令,例如功能标志或运行参数。该指令必须验证请求是否来自获授权的管理员。然而,程序没有验证请求变更的账户是否与配置数据中存储的管理员账户匹配:
pub fn update_admin_settings(ctx: Context<UpdateAdminSettings>, new_settings: AdminSettings) -> Result<()> {
ctx.accounts.config_data.settings = new_settings;
Ok(())
}
#[derive(Accounts)]
pub struct UpdateAdminSettings<'info> {
#[account(mut)]
pub config_data: Account<'info, ConfigData>,
pub admin: Signer<'info>,
}
#[account]
pub struct ConfigData {
admin: Pubkey,
settings: AdminSettings
}建议的缓解措施
为缓解此漏洞,开发者可以实施显式检查,将账户密钥和存储的数据与预期值进行比较。例如,验证存款人的公钥是否与用于存款的代币账户的所有者字段匹配:
pub fn update_admin_settings(ctx: Context<UpdateAdminSettings>, new_settings: AdminSettings) -> Result<()> {
if ctx.accounts.admin.key() != ctx.accounts.config_data.admin {
return Err(ProgramError::Unauthorized);
}
ctx.accounts.config_data.settings = new_settings;
Ok(())
}开发者还可以使用 Anchor 的 has_one 和 constraint 属性,以声明方式实施数据验证检查。沿用上面的示例,我们可以使用 constraint 属性检查存款人的公钥是否与存款代币账户的所有者相同:
pub struct UpdateAdminSettings<'info> {
#[account(
mut,
constraint = config_data.admin == admin.key()
)]
pub config_data: Account<'info, ConfigData>,
pub admin: Signer<'info>,
}账户数据重新分配
漏洞
在 Anchor 中,AccountInfo 结构体提供的 realloc 函数会引入一种与内存管理有关的微妙漏洞。该函数允许重新分配账户的数据大小,这对程序中的动态数据处理很有用。然而,错误使用 realloc 可能产生非预期后果,包括浪费计算单元或暴露陈旧数据。
realloc 方法有两个参数:
- new_len:一个 usize,用于指定账户数据的新长度
- zero_init:一个 bool,用于确定是否应将新内存空间初始化为零
realloc 的定义如下:
pub fn realloc(
&self,
new_len: usize,
zero_init: bool
) -> Result<(), ProgramError>在程序入口点,为账户数据分配的内存已经初始化为零。这意味着,在单笔交易中将数据重新分配为更大尺寸时,新的内存空间已经归零。再次将这块内存归零没有必要,还会额外消耗计算单元。相反,如果在同一笔交易中先重新分配为更小尺寸,再恢复到更大尺寸,而 zero_init 为 false,就可能暴露陈旧数据。
示例场景
假设有一个动态待办事项列表程序,用户可以在单笔交易中添加、删除或修改条目。该程序需要根据用户操作动态重新分配其数据大小:
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 可能会暴露陈旧数据。
建议的缓解措施
要缓解此问题,谨慎使用 zero_init 参数至关重要:
- 如果在同一次交易调用中先缩减后增大数据大小,请将
zero_init设置为true。这能确保所有新内存空间都初始化为零,防止陈旧数据暴露 - 如果在同一次交易调用中没有先缩减数据大小便直接增大,请将
zero_init设置为false,因为内存已经初始化为零
开发者不应通过重新分配数据来满足特定大小要求,而应使用地址查找表(ALT)。ALT 允许开发者在单个链上账户中存储最多 256 个地址,从而压缩交易数据。随后,表中的每个地址都可以通过 1 字节索引引用,大幅减少给定交易中地址引用所需的数据。对于需要动态账户交互、但不需要频繁调整内存大小的场景,ALT 更加实用。
账户重新加载
漏洞
当开发者执行 CPI 后未更新已反序列化的账户时,就会产生账户重新加载漏洞。Anchor 不会在 CPI 后自动刷新已反序列化账户的状态。这可能导致程序逻辑使用陈旧数据,引发逻辑错误或计算错误。
示例场景
假设有一个协议,用户可以质押代币并随时间推移获得奖励。负责此功能的程序可以根据特定条件或外部触发器更新用户的质押奖励。用户奖励通过对奖励分发程序发起 CPI 来计算和更新。然而,该程序未在 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.staking_account.rewards(即奖励余额),然后继续执行使用陈旧 ctx.accounts.staking_account.rewards 数据的逻辑。问题在于,质押账户的状态不会在 CPI 后自动更新,因此这些数据已经过时。
建议的缓解措施
要缓解此问题,请显式调用 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(())
}任意 CPI
漏洞
当程序调用另一个程序却未验证目标程序的身份时,就会发生任意 CPI。该漏洞之所以存在,是因为只要调用方拥有被调用程序的程序 ID 并遵循其接口,Solana 运行时就允许任何程序调用另一个程序。如果程序根据用户输入执行 CPI,却未验证被调用方的程序 ID,就可能执行攻击者所控制程序中的代码。
示例场景
假设有一个程序根据参与者对项目的贡献向其发放奖励。发放奖励后,程序会将详细信息记录到一个独立的账本程序中,用于审计和跟踪。该账本程序被视为可信程序,并提供公共接口,用于跟踪来自获授权程序的特定条目。程序包含一个用于分发并记录奖励的函数,该函数将账本程序作为账户传入。然而,该函数在对提供的 ledger_program 发起 CPI 前,未验证它的身份:
pub fn distribute_and_record_rewards(ctx: Context<DistributeAndRecord>, reward_amount: u64) -> ProgramResult {
// Reward distribution logic
let instruction = custom_ledger_program::instruction::record_transaction(
&ctx.accounts.ledger_program.key(),
&ctx.accounts.reward_account.key(),
reward_amount,
)?;
invoke(
&instruction,
&[
ctx.accounts.reward_account.clone(),
ctx.accounts.ledger_program.clone(),
],
)
}
#[derive(Accounts)]
pub struct DistributeAndRecord<'info> {
reward_account: AccountInfo<'info>,
ledger_program: AccountInfo<'info>,
}攻击者可以利用这一点,将恶意程序的 ID 作为 ledger_program 传入,从而造成非预期后果。
建议的缓解措施
为防范此问题,开发者可以添加检查,在执行 CPI 前验证账本程序的身份。这项检查能够确保 CPI 调用发往预期程序,从而防止任意 CPI:
pub fn distribute_and_record_rewards(ctx: Context<DistributeAndRecord>, reward_amount: u64) -> ProgramResult {
// Reward distribution logic
// Verify the ledger_program is the expected custom ledger program
if ctx.accounts.ledger_program.key() != &custom_ledger_program::ID {
return Err(ProgramError::IncorrectProgramId.into())
}
let instruction = custom_ledger_program::instruction::record_transaction(
&ctx.accounts.ledger_program.key(),
&ctx.accounts.reward_account.key(),
reward_amount,
)?;
invoke(
&instruction,
&[
ctx.accounts.reward_account.clone(),
ctx.accounts.ledger_program.clone(),
],
)
}
#[derive(Accounts)]
pub struct DistributeAndRecord<'info> {
reward_account: AccountInfo<'info>,
ledger_program: AccountInfo<'info>,
}如果程序使用 Anchor 编写,它可能会提供公开可用的 CPI 模块。这样,从另一个 Anchor 程序调用该程序会更轻松、更安全。Anchor CPI 模块会自动检查传入的程序地址是否与模块中存储的程序地址匹配。另一种可行方案是将地址硬编码,而不是让用户传入地址。
权限转移功能
漏洞
Solana 程序通常会将特定公钥指定为关键功能的权限主体,例如更新程序参数或提取资金。然而,如果无法将此权限转移到其他地址,可能会带来重大风险。在团队变动、协议出售或权限主体遭到入侵等场景中,这种限制尤其棘手。
示例场景
假设某个程序由全局管理员权限主体负责通过 set_params 函数设置特定的协议参数。该程序没有提供更改全局管理员的机制:
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
}这里的权限主体是静态定义的,无法更新为新地址。
建议的缓解措施
缓解此问题的安全方法是创建一个两步权限转移流程。该流程允许当前权限主体提名新的 pending_authority,且后者必须明确接受该角色。这不仅能提供权限转移功能,还能防止意外转移或恶意接管。流程如下:
- 当前权限主体提名:当前权限主体调用 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 账户结构保存当前的 authority 和一个可选的 pending_authority。NominateAuthority 上下文确保当前权限主体对交易签名,使其能够提名新的权限主体。AcceptAuthority 上下文检查 pending_authority 是否与交易签名者匹配,使其能够接受并成为新的权限主体。这种设置确保程序中的权限能够安全、可控地完成转移。
Bump 种子规范化
漏洞
Bump 种子规范化是指派生 PDA 时使用最高的有效 bump 种子(即规范 bump)。使用规范 bump,可以通过一组给定种子以确定且安全的方式找到地址。未使用规范 bump 可能引发漏洞,例如恶意行为者创建或操纵 PDA,进而破坏程序逻辑或数据完整性。
示例场景
假设某个程序用于创建唯一的用户资料,每份资料都有一个使用 create_program_address 显式派生的关联 PDA。该程序允许使用用户提供的 bump 创建资料。但这种做法会带来使用非规范 bump 的风险,因此存在问题:
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 派生 UserProfile PDA,其种子包含用户提供的 bump。使用用户提供的 bump 存在问题,因为它无法确保使用规范 bump。这样一来,恶意行为者便能针对同一个用户 ID,使用不同 bump 创建多个 PDA。
建议的缓解措施
要缓解此问题,可以重构示例,使用 find_program_address 派生 PDA,并显式验证 bump 种子:
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,因为后者会在不搜索 bump 种子的情况下创建有效的 PDA。由于它不搜索 bump 种子,因此对于任何给定的种子集合,都可能不可预测地返回错误,通常不适合用于创建 PDA。find_program_address 创建 PDA 时将始终使用规范 bump。这是因为它会反复调用 create_program_address,从 bump 255 开始,每次迭代递减。一旦找到有效地址,该函数就会返回派生的 PDA,以及派生它时使用的规范 bump。
Anchor 通过其 seeds 和 bump 约束强制 PDA 派生使用规范 bump,简化整个流程,确保 PDA 的创建和验证安全且具有确定性。
关闭账户
漏洞
如果程序未正确关闭账户,可能会引发多种漏洞,包括所谓的“已关闭”账户可能被重新初始化或遭到滥用。问题源于未正确地将账户标记为已关闭,或未能防止账户在后续交易中被重复使用。这种疏忽可能让恶意行为者利用相关账户,在程序中执行未经授权的操作或获取未经授权的访问权限。
示例场景
假设某个程序允许用户创建和关闭数据存储账户。该程序通过转出账户中的 lamports 来关闭账户:
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,
}这种做法存在问题,因为程序既没有将账户数据清零,也没有将其标记为已关闭。仅仅转出剩余的 lamports 并不能关闭账户。
推荐的缓解措施
为缓解此问题,程序不仅应转出所有 lamports,还应将账户数据清零,并使用判别器(即 "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,
}但是,仅将数据清零并添加已关闭判别器还不够。用户可以在指令结束前向账户退还 lamports,从而阻止该账户被垃圾回收。这会使账户陷入一种异常的中间状态:既无法使用,也无法被垃圾回收。因此,我们添加了 force_defund 函数来处理这一边缘情况;现在任何人都可以清空已关闭账户中的资金。
Anchor 通过 #[account(close = destination)] 约束简化了此流程,只需一次操作即可转移 lamports、清零数据并设置已关闭账户判别器,从而自动安全地关闭账户。
重复的可变账户
漏洞
重复的可变账户是指同一个账户作为可变参数被多次传递给一条指令的情况。当一条指令需要两个相同类型的可变账户时,就可能发生这种情况。恶意行为者可能两次传入同一个账户,导致账户以非预期方式被修改(例如覆盖数据)。此漏洞的严重程度取决于具体场景。
示例场景
假设某个程序根据用户参与特定链上活动的情况发放奖励。该程序包含一条用于更新两个账户余额的指令:一个奖励账户和一个奖金账户。用户应在一个账户中获得标准奖励,并根据预先确定的特定条件,在另一个账户中获得可能的奖金:
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_account 和 bonus_account 传入同一个账户,该账户的余额将被错误地更新两次。
推荐的缓解措施
为缓解此问题,请在指令逻辑中添加检查,验证两个账户的公钥并不相同:
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,
}抢跑
漏洞
随着交易捆绑器日益普及,构建于 Solana 上的协议应认真对待抢跑问题。随着 Jito 移除其内存池,本文所说的抢跑,是指恶意行为者通过精心构造的交易来操纵预期值与实际值的能力。
示例场景
假设某个协议负责处理产品的购买和竞价,并将卖家的定价信息存储在名为 SellInfo 的账户中:
#[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,
}要购买已上架的 Product,买家必须传入与目标产品相关的 ProductListing 账户。但如果卖家可以更改其商品的 sale_price,会发生什么?
pub fn change_sale_price(ctx: Context<ChangeSalePrice>, new_price: u64) -> Result<()> {...}这会给卖家带来抢跑机会,尤其是在买家的购买交易未包含 expected_price 检查,无法确保其支付的金额不超过目标产品的预期价格时。如果买家提交一笔交易来购买指定的 Product,卖家就可能调用 change_sale_price,并利用 Jito 确保这笔交易先于买家的交易被纳入。恶意卖家可以在买家不知情的情况下,将 ProductListing 账户中的价格改为极高金额,迫使买家为 Product! 支付远超预期的费用
推荐的缓解措施
一种简单的解决方案是在交易的购买方加入 expected_price 检查,防止买家为想要购买的 Product 支付超过预期的金额:
pub fn purchase_product(ctx: Context<PurchaseProduct>, expected_price: u64) -> Result<()> {
assert!(ctx.accounts.product_listing.sale_price <= expected_price);
...
}不安全的初始化
与部署到 EVM 的合约不同,Solana 程序在部署时不会通过构造函数设置状态变量。相反,它们需要手动初始化(通常通过名为 initialize 或类似名称的函数)。初始化函数通常会设置程序权限等数据,或创建构成所部署程序基础的账户(例如中央状态账户或类似账户)。
由于初始化函数需要手动调用,而不是在程序部署时自动执行,因此该指令必须由程序开发团队控制的已知地址调用。否则,攻击者可能抢跑初始化过程,并使用由其控制的账户设置程序。
如果程序具有升级权限,一种常见做法是使用程序的 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,
...
}上面的示例是一个精简版 initialize 函数,它将 CentralState 账户的权限设置为指令调用者。但任何账户都可以调用 initialize!如前所述,保护初始化函数的一种常见方式是使用部署时已知的程序 upgrade_authority。
以下是 Anchor 文档中的示例,它使用 constraint 确保只有程序的升级权限才能调用 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>,
}精度损失
漏洞
精度损失看似微不足道,却可能对程序构成重大威胁。它可能导致错误计算、套利机会以及非预期的程序行为。
算术运算中的精度损失是常见的错误来源。对于 Solana 程序,建议尽可能使用定点运算。这是因为程序仅支持Rust 浮点运算的有限子集。如果程序尝试使用不受支持的浮点运算,运行时将返回未解析符号错误。此外,与对应的整数运算相比,浮点运算需要更多指令。使用定点运算,以及准确处理大量代币和小数金额的需求,都可能加剧精度损失。
除法后执行乘法
尽管大多数数学运算都满足结合律,但将其应用于计算机算术时可能导致非预期的精度损失。一个典型示例是在除法之后执行乘法,这可能与先乘后除得到不同的结果。例如,请考虑以下表达式:(a / c) * b 和 (a * b) / c。从数学角度看,这些表达式满足结合律——它们应该得到相同的结果。然而,在 Solana 和定点运算环境中,运算顺序非常重要。如果先执行除法 (a / c),商在乘以 b 之前向下取整,可能造成精度损失,进而使结果小于预期。相反,先执行乘法 (a * b) 再除以 c,可能保留更多原始精度。这种差异可能导致错误计算,造成非预期的程序行为和/或套利机会。
saturating_* 算术函数
虽然 saturating_* 算术函数通过将值限制在可能的最大值或最小值来防止溢出和下溢,但如果意外达到此上限或下限,也可能造成隐蔽的 bug 和精度损失。当程序逻辑假定仅靠饱和处理就能保证结果准确,而忽略了处理潜在的精度或准确性损失时,就会出现这种问题。
例如,假设某个程序根据用户在特定时间段内交易的代币数量计算并发放奖励:
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 向下舍入到最接近的整数。此方法可最大限度降低人为增大数值的风险,并确保舍入不会让用户以系统利益为代价获得优势。或者,也可以实施专门逻辑,处理舍入可能明确影响结果的场景。这可以包括为舍入决策设置特定阈值,或根据相关数值的大小应用不同逻辑。
缺少所有权检查
漏洞
所有权检查对于验证交易或操作所涉及的账户是否由预期程序拥有至关重要。账户包含一个 owner 字段,用于指示有权写入账户数据的程序。该字段确保只有获授权的程序才能修改账户状态。此外,该字段还可用于确保传入指令的账户由预期程序拥有。缺少所有权检查可能导致严重漏洞,包括未经授权的资金转移和特权操作执行。
示例场景
假设定义了一个程序函数,仅允许管理员从金库中提款。该函数接收一个配置账户(即 config),并使用其 admin 字段检查所提供管理员账户的公钥是否与 config 账户中存储的公钥相同。但是,该函数没有验证 config 账户的所有权,而是假定它值得信任:
pub fn admin_token_withdraw(program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult {
// Account setup
if config.admin != admin.pubkey() {
return Err(ProgramError::InvalidAdminAccount)
}
// Transfer funds logic
}恶意行为者可以提供一个由其控制且包含匹配 admin 字段的 config 账户来利用此漏洞,从而诱骗程序执行提款。
推荐的缓解措施
为缓解此问题,请执行所有权检查,验证账户的 owner 字段:
pub fn admin_token_withdraw(program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult {
// Account setup
if config.admin != admin.pubkey() {
return Err(ProgramError::InvalidAdminAccount)
}
if config.owner != program_id {
return Err(ProgramError::InvalidConfigAccount)
}
// Transfer funds logic
}Anchor 通过 Account 类型简化了此检查。Account<'info, T> 是 AccountInfo 的封装器,可验证程序所有权,并将底层数据反序列化为 T(即指定的账户类型)。因此,开发者可以使用 Account<'info, T> 轻松验证账户所有权。开发者还可以使用 #[account] 属性,将 Owner trait 添加到指定账户。该 trait 定义了预期拥有此账户的地址。此外,如果应拥有指定账户的程序与当前执行的程序不同,开发者可以使用 owner 约束定义该程序。例如,在编写一条指令,并预期某个账户是从其他程序派生的 PDA 时,这会很有用。owner 约束定义为 #[account(owner = <expr>)],其中 <expr> 是任意表达式。
只读账户
验证程序执行上下文中指定为只读的账户是否有效同样重要。这一点至关重要,因为恶意行为者可能传入包含任意或精心构造数据的账户,而非合法账户。这可能导致非预期或有害的程序行为。开发者仍应执行检查,确保程序需要读取的账户真实有效且未被篡改。具体方法可以是将账户地址与已知值进行比对,或确认账户所有者符合预期,这对于 sysvar(即只读系统账户,例如 Clock 或 EpochSchedule)尤其重要。请使用 get() 方法访问 sysvar,该方法无需手动检查地址或所有权。这是访问这些账户的更安全方式;但并非所有 sysvar 都支持 get() 方法。在这种情况下,请使用其公共地址进行访问。
缺少签名者检查
漏洞
交易使用钱包私钥签名,以确保身份验证、完整性、不可否认性,并确认特定钱包对特定交易的授权。通过要求使用发送方私钥为交易签名,Solana 运行时可以验证交易是否由正确的账户发起且未被篡改。此机制是去中心化网络无需信任特性的基础。如果缺少此验证,任何能够将正确账户作为参数提供的账户都可以执行交易。这可能导致对特权信息、资金或功能的未授权访问。此漏洞源于在执行某些特权功能之前,未验证操作是否由相应账户的私钥签名。
示例场景
请看以下函数:
pub fn update_admin(program_id: &Pubkey, accounts &[AccountInfo]) -> ProgramResult {
let account_iter = &mut accounts.iter();
let config = ConfigAccount::unpack(next_account_info(account_iter)?)?;
let admin = next_account_info(account_iter)?;
let new_admin = next_account_info (account_iter)?;
if admin.pubkey() != config.admin {
return Err(ProgramError::InvalidAdminAccount);
}
config.admin = new_admin.pubkey();
Ok(())
}此函数旨在更新程序管理员。它包含一项检查,用于确保操作由当前管理员发起,这是良好的访问控制。但该函数没有验证交易是否由当前管理员的私钥签名。因此,无论调用此函数的账户是否确实是当前管理员,任何人都可以在调用时传入正确的 admin 账户,使 admin.pubkey() = config.admin。恶意行为者因此可以执行该指令,并将自己的账户作为新管理员传入,直接绕过当前管理员的授权要求。
推荐的缓解措施
程序必须包含检查,以验证账户是否由相应钱包签名。这可以通过检查交易所涉及账户的 AccountInfo::is_signer 字段来完成。程序可以检查执行特权操作的账户是否将 is_signer 标志设置为 true,从而确保只有获授权的账户才能执行特定操作。
更新后的代码示例如下:
pub fn update_admin(program_id: &Pubkey, accounts &[AccountInfo]) -> ProgramResult {
let account_iter = &mut accounts.iter();
let config = ConfigAccount::unpack(next_account_info(account_iter)?)?;
let admin = next_account_info(account_iter)?;
let new_admin = next_account_info (account_iter)?;
if admin.pubkey() != config.admin {
return Err(ProgramError::InvalidAdminAccount);
}
// Add in a check for the admin's signature
if !admin.is_signer {
return Err(ProgramError::NotSigner);
}
config.admin = new_admin.pubkey();
Ok(())
}Anchor 通过 Signer<’info> 账户类型简化了整个流程。
溢出与下溢
漏洞
整数是不含小数部分的数。Rust 将整数存储为固定大小的变量。这些变量由其符号属性(即有符号或无符号)及其占用的内存空间决定。例如,u8 类型表示占用 8 位空间的无符号整数,可存储 0 到 255 之间的值。存储超出该范围的值会导致整数溢出或下溢。当变量超过其最大容量并回绕至最小值时,就会发生整数溢出。当变量低于其最小容量并回绕至最大值时,就会发生整数下溢。
在调试模式下编译时,Rust 会检查整数溢出和下溢。如果检测到此类情况,这些检查会导致程序在运行时发生 panic。然而,使用 --release 标志以发布模式编译时,Rust 不会加入会因整数溢出和下溢而触发 panic 的检查。由于溢出或下溢会静默发生,这种行为可能引入难以察觉的漏洞。伯克利数据包过滤器(BPF)工具链是 Solana 开发环境的重要组成部分,用于编译 Solana 程序。cargo build-bpf 命令会将 Rust 项目编译为可供部署的 BPF 字节码。问题在于,它默认以发布模式编译程序。因此,Solana 程序容易受到整数溢出和下溢漏洞的影响。
示例场景
攻击者可以利用发布模式下静默溢出或下溢的行为来攻击此漏洞,尤其是处理代币余额的函数。请看以下示例:
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 个代币
建议的缓解措施
overflow-checks
缓解此漏洞最简单的方法是在项目的 Cargo.toml 文件中将 overflow-checks 键设置为 true。这样,Rust 会在编译器中加入溢出和下溢检查。不过,加入溢出和下溢检查会增加交易的计算成本。如果需要优化计算资源,将 overflow-checks 设置为 false 可能更有利。
checked_* 算术运算
对每种整数类型使用 Rust 的 checked_* 算术函数,有针对性地检查整个程序中的溢出和下溢。如果发生溢出或下溢,这些函数会返回 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。因此,如果 balance 足以完成减法,checked_sub 会返回 Some(new_balance)。程序会继续安全地更新账户余额并记录日志。如果减法会导致下溢,checked_sub 则返回 None,我们可以通过返回错误来处理。
Checked Math 宏
Checked Math 是一种过程宏,它可以在基本不修改数学表达式的情况下,改变检查这些表达式的方式。checked_* 算术函数的问题在于会丢失数学表示法。你必须使用 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(_),因此需要在表达式末尾进行解包。
类型转换
同样,使用 as 关键字在整数类型之间进行转换而不做适当检查,也可能引入整数溢出或下溢漏洞。这是因为类型转换可能以非预期方式截断或扩展值。从较大的整数类型转换为较小的整数类型时(例如从 u64 转换为 u32),Rust 会截断原始值中无法容纳于目标类型的高位。如果原始值超过目标类型可存储的最大值,就会出现问题。从较小的整数类型转换为较大的整数类型时(例如从 i16 转换为 i32),Rust 会扩展该值。对于无符号类型,这一过程很直接。然而,对有符号整数执行此操作可能引发符号扩展,从而产生非预期的负值。
建议的缓解措施
使用 Rust 的安全类型转换方法来缓解此漏洞,包括 try_from 和 from 等方法。使用 try_from 会返回 Result 类型,以便显式且妥善地处理值无法容纳于目标类型的情况。对于确定不会丢失数据的转换(例如从 u8 转换为 u32),可以使用 Rust 的 from 方法进行安全的隐式转换。例如,假设程序需要将 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 共享
漏洞
当同一个 PDA 被用于多个权限域或角色时,就会出现常见的 PDA 共享漏洞。如果没有适当的访问控制检查,恶意行为者可能通过滥用作为签名者的 PDA,访问不属于自己的数据或资金。
示例场景
假设一个程序用于支持代币质押和奖励分发。该程序使用单个 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
}这会带来问题,因为代币质押和奖励提取功能依赖于从 staking_pool_pda 派生的同一个 PDA。用户可能借此操纵合约,未经授权地提取奖励或操纵质押。
建议的缓解措施
为缓解此漏洞,应为不同功能使用不同的 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 分别使用不同的种子(staking_pool 和 rewards_pool),并结合具体账户的密钥进行派生。这能确保各 PDA 与其预期功能唯一绑定,降低未经授权执行操作的风险。
剩余账户
漏洞
ctx.remaining_accounts 提供了一种向函数传入额外账户的方法,这些账户最初并未在 Accounts 结构体中指定。这为开发者提供了更大的灵活性,使其能够处理需要动态账户数量的场景(例如处理数量不定的用户或与不同程序交互)。不过,这种灵活性也有一个隐患:通过 ctx.remaining_accounts 传入的账户不会接受与 Accounts 结构体中定义的账户相同的验证。由于 ctx.remaining_accounts 不会验证传入的账户,恶意行为者可以传入程序原本无意交互的账户,从而执行未经授权的操作或获取未经授权的访问权限。
示例场景
假设一个奖励程序使用 ctx.remaining_accounts 接收用户 PDA,并动态计算奖励:
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 传入的账户,因此无法确保奖励计算和分发只处理有效且符合条件的用户账户。恶意行为者可以传入不属于自己的账户,或自行创建的账户,以获得超过其实际应得数量的奖励。
建议的缓解措施
为缓解此漏洞,开发者应在函数中手动验证每个账户的有效性。这包括检查账户所有者,确保其与预期用户匹配,并验证账户中的所有相关数据。加入这些手动检查后,开发者便可利用 ctx.remaining_acocunts 的灵活性,同时降低未经授权访问或操纵的风险。
Rust 特有错误
Rust 是 Solana 程序开发的通用语言。使用 Rust 开发会带来一系列独特的挑战和注意事项,尤其是在不安全代码和 Rust 特有错误方面。了解 Rust 的注意事项有助于开发安全、高效且可靠的程序。
不安全 Rust
Rust 以其内存安全保证而闻名,这些保证通过严格的所有权和借用系统实现。然而,这些保证有时也会造成阻碍,因此 Rust 提供了 unsafe 关键字来绕过安全检查。unsafe Rust 主要用于以下四种场景:
- 不安全函数:执行可能违反 Rust 安全保证的操作时,函数必须使用 unsafe 关键字标记。例如,unsafe fn dangerous_function() {}
- 不安全代码块:允许执行不安全操作的代码块。例如,unsafe { // Unsafe operations }
- 不安全 trait:表示某些编译器无法验证的不变量的 trait。例如,unsafe trait BadTrait {}
- 实现不安全 trait:unsafe trait 的实现也必须标记为 unsafe。例如,unsafe impl UnsafeTrait for UnsafeType {}
不安全 Rust 之所以存在,是因为静态分析较为保守。当编译器尝试判断代码是否满足一组特定保证时,宁可拒绝少量有效代码,也不能接受少量无效代码。即使代码可以完全正常地运行,如果 Rust 编译器没有足够的信息来确信代码满足 Rust 的安全保证,也会拒绝该代码。不安全代码允许开发者自行承担风险并绕过这些检查。此外,计算机硬件本质上并不安全。为了使用 Rust 进行底层编程,必须允许开发者执行不安全操作。
借助 unsafe 关键字,开发者可以:
- 解引用裸指针:允许直接访问裸指针指向的内存。裸指针可以指向任意内存位置,其中可能不包含有效数据
- 调用不安全函数:这些函数可能不遵守 Rust 的安全保证,并可能导致未定义行为
- 访问可变静态变量:全局可变状态可能导致数据竞争
缓解不安全 Rust 风险的最佳方法是尽量减少使用 unsafe 代码块。如果出于任何原因确实必须使用 unsafe 代码,请确保它有完善的文档、接受定期审计,并尽可能封装在可供程序其他部分使用的安全抽象中。
Panic 与错误管理
当 Rust 程序遇到不可恢复的错误并终止执行时,就会发生 panic。Panic 用于不应被捕获的意外错误。在 Solana 程序中,panic 可能导致非预期行为,因为运行时要求程序妥善处理错误,而不是崩溃。
发生 panic 时,Rust 会开始展开堆栈,并在此过程中进行清理。这会返回包含详细错误信息的堆栈跟踪,可能向攻击者泄露底层文件结构的信息。虽然这不直接适用于 Solana 程序,但程序使用的依赖项可能容易受到此类攻击。请确保依赖项保持最新,并使用不包含已知漏洞的版本。
常见的 panic 场景包括:
- 除以零:尝试除以零时,Rust 会发生 panic。因此,在执行除法前始终要检查除数是否为零
- 数组索引越界:使用超出数组边界的索引访问数组会导致 panic。为缓解此问题,请使用返回 Option 类型的方法(如 get)安全地访问数组元素
- 解包 None 值:对包含 None 值的 Option 调用 .unwrap() 会导致 panic。在返回 Result 的函数中,始终使用模式匹配、unwrap_or 或 unwrap_or_else 等方法,或者使用 ? 运算符
为缓解与 panic 相关的问题,必须避免会触发 panic 的操作,验证所有输入以及可能导致问题操作的条件,并使用 Result 和 Option 类型处理错误。此外,编写全面的程序测试有助于在部署前发现并解决潜在的 panic 场景。
种子冲突
漏洞
当用于生成 PDA 的不同输入(即种子和程序 ID)产生相同的 PDA 地址时,就会发生种子冲突。如果程序将这些 PDA 用于不同目的,就会产生问题,并可能导致非预期行为,包括拒绝服务攻击或程序遭到完全入侵。
示例场景
假设一个程序用于为各种提案和倡议提供去中心化投票平台。给定提案或倡议的每个投票会话都会使用唯一标识符创建,用户可以提交投票。该程序同时为投票会话和单独投票使用 PDA:
// 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>,
}在此场景中,攻击者会尝试精心构造一个投票会话,使其与静态种子 "session" 组合后生成的 PDA 恰好与另一个投票会话生成的 PDA 相同。蓄意创建一个与其他投票会话 PDA 冲突的 PDA 可能破坏平台运行。例如,由于 Solana 运行时无法区分发生冲突的 PDA,攻击者可能阻止对提案的合法投票,或阻止平台添加新的倡议。
建议的缓解措施
为降低种子冲突风险,开发者可以:
- 在同一程序的不同 PDA 中使用唯一的种子前缀。这有助于确保各 PDA 彼此不同
- 使用唯一标识符(例如时间戳、用户 ID、nonce 值),确保每次都生成唯一的 PDA
- 通过程序验证生成的 PDA 不会与现有 PDA 冲突
类型伪装
漏洞
类型伪装是一种漏洞:由于反序列化过程中缺少类型检查,一种账户类型被错误表示为另一种类型。这可能导致执行未经授权的操作或数据损坏,因为程序会基于对账户角色或权限的错误假设来运行。在反序列化期间,务必显式检查账户的预期类型。
示例场景
假设一个程序根据用户角色管理对管理员操作的访问权限。每个用户账户都包含角色判别器,用于区分普通用户和管理员。程序包含一个更新管理员设置的函数,该函数只能由管理员使用。然而,程序没有检查账户判别器,而是在未确认账户是否属于管理员的情况下反序列化用户账户数据:
pub fn update_admin_settings(ctx: Context<UpdateSettings>) -> ProgramResult {
// Deserialize without checking the discriminator
let user = User::try_from_slice(&ctx.accounts.user.data.borrow()).unwrap();
// Sensitive update logic
msg!("Admin settings updated by: {}", user.authority)
Ok(())
}
#[derive(Accounts)]
pub struct UpdateSettings<'info> {
user: AccountInfo<'info>
}
#[derive(BorshSerialize, BorshDeserialize)]
pub struct User {
authority: Pubkey,
}问题在于,update_admin_settings 在未检查账户角色判别器的情况下,就对传入的用户账户进行了反序列化,部分原因是 User 结构体中根本没有判别器字段!
建议的缓解措施
为缓解此问题,开发者可以在 User 结构体中添加判别器字段,并在反序列化过程中验证该字段:
pub fn update_admin_settings(ctx: Context<UpdateSettings>) -> ProgramResult {
let user = User::try_from_slice(&ctx.accounts.user.data.borrow()).unwrap();
// Verify the user's discriminator
if user.discriminant != AccountDiscriminant::Admin {
return Err(ProgramError::InvalidAccountData.into())
}
// Sensitive update logic
msg!("Admin settings updated by: {}", user.authority)
Ok(())
}
#[derive(Accounts)]
pub struct UpdateSettings<'info> {
user: AccountInfo<'info>
}
#[derive(BorshSerialize, BorshDeserialize)]
pub struct User {
discriminant: AccountDiscriminant,
authority: Pubkey,
}
#[derive(BorshSerialize, BorshDeserialize, PartialEq)]
pub enum AccountDiscriminant {
Admin,
// Other account types
}Anchor 会自动管理账户类型的判别器,从而简化类型伪装漏洞的缓解工作。这通过 Account<'info, T> 包装器实现,Anchor 会在反序列化期间自动检查判别器,以确保类型安全。这样,开发者就能将更多精力投入程序的业务逻辑,而不必手动实现各种类型检查。
结语
程序安全的重要性怎么强调都不为过。本文全面介绍了常见漏洞,涵盖 Rust 特有的错误以及 Anchor realloc 方法的复杂问题。要掌握每一种漏洞乃至整个程序安全领域,需要持续学习、适应和协作。作为开发者,我们对安全的承诺不只是保护资产,更要建立信任、确保应用的完整性,并为 Solana 的发展与稳定贡献力量。
如果你读到了这里,感谢你,匿名朋友!请务必在下方填写电子邮件地址,这样就不会错过 Solana 的任何最新动态。准备好深入探索了吗?立即阅读 Helius 博客上的最新文章,继续你的 Solana 之旅。
其他资源
相关文章
订阅 Helius
及时了解 Solana 开发的最新动态,并在我们发布新内容时收到更新


