每天都有数百万美元以闪电般的速度通过 Solana 程序流转。在这种高风险、高性能的环境中,一行存在漏洞的代码可能在几秒钟内耗尽协议,而一个安全性良好的程序则可以毫不费力地每秒处理数千笔交易。
Solana 革命性的并行处理架构不仅比其他区块链更快,而且在本质上完全不同。
以太坊开发者需要担心 gas 优化和顺序执行,而 Solana 开发者则必须掌握一个全新的威胁环境,在这个环境中,账户取代了存储,程序是无状态的,交易在并行执行通道中相互竞争。
Solana 区块链开发中存在许多独特的安全漏洞。在本课程中,您将学习所有基于 Coral 的 Sealevel 攻击库 的 Sealevel 攻击,包括真实案例和实用的缓解策略。
在将您的程序部署到主网之前,您至少应该对安全基础知识有基本的了解。
⚠️ 相同的漏洞对 Native 和 Anchor 开发都是“有效”的
Solana 开发中最关键的安全考量源于平台的账户所有权模型以及程序与账户的交互方式。
程序必须仔细验证账户所有权,实施适当的访问控制,并处理跨程序调用 (CPI) 的复杂性。
程序的无状态特性意味着所有验证逻辑都必须明确实现,因为没有内置的保护措施来防止恶意账户操作。
此外,Solana 程序中常见的漏洞模式包括缺少所有权检查、签名者验证不足、算术溢出/下溢以及账户初始化和关闭处理不当。
⚠️ Anchor 的开发初衷是通过强制开发者使用特定类型或做出明确选择,使 Solana 程序更加安全。
尽管每个安全漏洞乍看之下可能显得“简单”,但每种情况都蕴含着深刻的讨论内容。这些课程减少了文字叙述,更多地提供了实用的代码示例,确保您能够深入、实践性地理解所讨论的安全风险。
准备好提升您的 Solana 安全知识了吗?让我们一起开始构建更安全的程序吧。
签名者检查是手写签名的数字等价物,它们证明账户持有人确实授权了交易,而不是由其他人代为操作。在 Solana 的无信任环境中,这种加密证明是验证真实授权的唯一方式。
当处理程序派生账户 (PDA) 和权限控制操作时,这一点尤为重要。大多数程序账户存储一个 authority 字段,用于确定谁可以修改它们,而许多 PDA 是从特定用户账户派生的。如果没有签名者验证,您的程序将无法区分合法所有者和恶意冒充者。
缺少签名者检查的后果是灾难性的:任何账户都可以执行本应限制在特定权限内的操作,导致未经授权的访问、账户资金被盗以及对程序状态的完全失控。
请考虑以下这个易受攻击的指令,它会转移程序账户的所有权:
#[program]
pub mod insecure_update{
use super::*;
//..
pub fn update_ownership(ctx: Context<UpdateOwnership>) -> Result<()> {
ctx.accounts.program_account.owner = ctx.accounts.new_owner.key();
Ok(())
}
//..
}
#[derive(Accounts)]
pub struct UpdateOwnership<'info> {
/// 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>,
}
#[account]
pub struct ProgramAccount {
owner: Pubkey,
}
乍一看,这似乎是安全的。has_one = owner 约束确保传递给指令的所有者账户与存储在 program_account 中的 owner 字段匹配。数据验证是完美的,但存在一个致命的缺陷。
请注意,owner 是一个 UncheckedAccount,而不是一个 Signer。这意味着虽然 Anchor 验证了提供的账户与存储的所有者匹配,但它从未检查该账户是否实际签署了交易。
攻击者可以通过以下方式利用这一点:
new_ownerhas_one 约束条件通过是因为公钥匹配,但由于没有签名者验证,攻击者成功地在未经合法所有者同意的情况下将所有权转移给自己。一旦他们控制了账户,就可以作为新的权限执行任何操作。
幸运的是,Anchor 使得直接在账户结构中执行此检查变得非常简单,只需将 UncheckedAccount 更改为 Signer,如下所示:
#[derive(Accounts)]
pub struct UpdateOwnership<'info> {
pub owner: Signer<'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>,
}
或者,您可以像这样添加 signer 账户约束:
#[derive(Accounts)]
pub struct UpdateOwnership<'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>,
}
或者,您也可以在指令中使用 ctx.accounts.owner.is_signer 检查添加签名者验证,如下所示:
pub fn update_ownership(ctx: Context<UpdateOwnership>) -> Result<()> {
if !ctx.accounts.owner.is_signer {
return Err(ProgramError::MissingRequiredSignature.into());
}
ctx.accounts.program_account.owner = ctx.accounts.new_owner.key();
Ok(())
}
通过添加此检查,指令处理程序仅在权限账户签署交易时才会继续。如果账户未签署,交易将失败。
在 Pinocchio 中,由于我们无法直接在账户结构中添加安全检查,因此我们被迫在指令逻辑中进行。
我们可以通过使用 is_signer() 函数以类似于 Anchor 的方式实现,如下所示:
if !self.accounts.owner.is_signer() {
return Err(ProgramError::MissingRequiredSignature.into());
}
所有者检查是 Solana 程序安全的第一道防线。它们验证传递到指令处理器的账户是否确实由预期的程序拥有,从而防止攻击者替换恶意的相似账户。
在 Solana 的 AccountInfo 结构中,每个账户都包含一个 owner 字段,该字段标识哪个程序控制该账户。所有者检查确保在程序信任账户数据之前,此 owner 字段与预期的 program_id 匹配。
AccountInfo 结构包含多个字段,其中包括代表拥有该账户的程序的 owner。所有者检查确保 AccountInfo 中的 owner 字段与预期的 program_id 匹配。
如果没有所有者检查,攻击者可以创建一个完美的账户数据结构“副本”,包括正确的鉴别器和所有正确的字段,并利用它操纵依赖数据验证的指令。这就像有人制作了一个看起来与真实身份证完全相同的假身份证,但由错误的权限控制。
⚠️ 一个重要的例外是当您修改账户的内部数据时。在这些情况下,Solana 的运行时会自动阻止其他程序写入它们不拥有的账户。但是对于读取操作和验证逻辑,您需要自行处理。
请考虑以下易受攻击的指令,它基于 owner 执行 program_account 的逻辑:
#[program]
pub mod insecure_check{
use super::*;
//..
pub fn instruction(ctx: Context<Instruction>) -> Result<()> {
let account_data = ctx.accounts.program_account.try_borrow_data()?;
let mut account_data_slice: &[u8] = &account_data;
let account_state = ProgramAccount::try_deserialize(&mut account_data_slice)?;
if account_state.owner != ctx.accounts.owner.key() {
return Err(ProgramError::InvalidArgument.into());
}
//..do something
Ok(())
}
//..
}
#[derive(Accounts)]
pub struct Instruction<'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,
}
UncheckedAccount 类型是 Anchor 表示“我不检查任何内容,请极其小心”的方式。虽然账户数据可能会完美反序列化并看起来合法,但缺少所有者检查会造成严重的漏洞。
攻击者可以创建一个具有相同数据结构的账户,并将其传递给您的指令。您的程序会愉快地检查所有权字段,但由于攻击者控制了该账户,他们可以在指令中随意操作。
解决方法既简单又关键:在信任账户内容之前,始终验证该账户是否由您的程序拥有。
使用 Anchor 这非常简单,因为可以直接在账户结构中通过将 UncheckedAccount 更改为 ProgramAccount 来执行此检查,如下所示:
#[derive(Accounts)]
pub struct Instruction<'info> {
pub owner: Signer<'info>,
#[account(mut)]
/// CHECK: This account will not be checked by Anchor
pub program_account: Account<'info, ProgramAccount>,
}
或者,您可以像这样添加 owner 账户约束:
#[derive(Accounts)]
pub struct Instruction<'info> {
pub owner: Signer<'info>,
#[account(mut, owner = ID)]
/// CHECK: This account will not be checked by Anchor
pub program_account: UncheckedAccount<'info>,
}
或者,您也可以在指令中使用 ctx.accounts.program_account.owner 检查添加一个所有者检查,如下所示:
pub fn update_ownership(ctx: Context<UpdateOwnership>) -> Result<()> {
if ctx.accounts.program_account.owner != ID {
return Err(ProgramError::IncorrectProgramId.into());
}
//..do something
Ok(())
}
通过添加此检查,指令处理程序仅在账户具有正确的 program_id 时才会继续。如果账户不属于我们的程序,交易将失败。
在 Pinocchio 中,由于我们无法直接在账户结构中添加安全检查,因此我们被迫在指令逻辑中进行。
我们可以通过使用 is_owned_by() 函数以类似于 Anchor 的方式实现,如下所示:
if !self.accounts.owner.is_owned_by(ID) {
return Err(ProgramError::IncorrectProgramId.into());
}
数据匹配是一种安全实践,用于验证账户数据是否包含预期值,然后再在程序逻辑中信任它。虽然 owner 检查可以验证谁控制了一个账户,signer 检查可以验证授权,但数据匹配确保账户的内部状态与程序的假设一致。
当指令处理程序依赖账户之间的关系或特定数据值决定程序行为时,这一点变得尤为重要。如果没有适当的数据验证,攻击者可以通过构造包含意外数据组合的账户来操纵程序流程,即使这些账户通过了基本的所有权和授权检查。
危险在于结构验证和逻辑验证之间的差距。您的程序可能正确验证了一个账户的类型正确且由正确的程序拥有,但仍可能对不同数据片段之间的关系做出错误的假设。
请考虑以下易受攻击的指令,它会更新程序账户的所有权:
#[program]
pub mod insecure_update{
use super::*;
//..
pub fn update_ownership(ctx: Context<UpdateOwnership>) -> Result<()> {
ctx.accounts.program_account.owner = ctx.accounts.new_owner.key();
Ok(())
}
//..
}
#[derive(Accounts)]
pub struct UpdateOwnership<'info> {
pub owner: Signer<'info>,
/// CHECK: This account will not be checked by Anchor
pub new_owner: UncheckedAccount<'info>,
#[account(mut)]
pub program_account: Account<'info, ProgramAccount>,
}
#[account]
pub struct ProgramAccount {
owner: Pubkey,
}
这段代码乍一看似乎是安全的。owner 被正确标记为 Signer,确保他们授权了交易。program_account 的类型正确且由程序拥有。所有基本的安全检查都通过了。
但存在一个关键漏洞:程序从未验证签署交易的 owner 是否与存储在 program_account 数据中的 owner 是同一个。
攻击者可以通过以下方式利用这一点:
attacker_keypair)owner 是 attacker_keypair(他们控制并可以签署的);new_owner 是他们的主公钥,program_account 是受害者的账户。交易成功是因为 attacker_keypair 正确地对其进行了签名,但程序从未检查 attacker_keypair 是否与存储在 program_account.owner 中的实际 owner 匹配。攻击者成功地将他人的账户所有权转移到了自己名下。
幸运的是,Anchor 使得直接在账户结构中通过添加 has_one 约束来执行此检查变得非常简单,如下所示:
#[derive(Accounts)]
pub struct UpdateOwnership<'info> {
pub owner: Signer<'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>,
}
或者我们可以决定更改程序的设计,使 program_account 成为从 owner 派生的 PDA,如下所示:
#[derive(Accounts)]
pub struct UpdateOwnership<'info> {
pub owner: Signer<'info>,
/// CHECK: This account will not be checked by Anchor
pub new_owner: UncheckedAccount<'info>,
#[account(
mut,
seeds = [owner.key().as_ref()],
bump
)]
pub program_account: Account<'info, ProgramAccount>,
}
或者你可以直接在指令中使用 ctx.accounts.program_account.owner 检查来验证数据,如下所示:
pub fn update_ownership(ctx: Context<UpdateOwnership>) -> Result<()> {
if ctx.accounts.program_account.owner != ctx.accounts.owner.key() {
return Err(ProgramError::InvalidAccountData.into());
}
ctx.accounts.program_account.owner = ctx.accounts.new_owner.key();
Ok(())
}
通过添加此检查,指令处理程序仅在账户具有正确的 owner 时才会继续。如果 owner 不正确,交易将失败。
在 Pinocchio 中,由于我们无法直接在账户结构中添加安全检查,因此我们被迫在指令逻辑中进行。
我们可以通过反序列化账户的数据并检查 owner 的值来实现:
let account_data = ctx.accounts.program_account.try_borrow_data()?;
let mut account_data_slice: &[u8] = &account_data;
let account_state = ProgramAccount::try_deserialize(&mut account_data_slice)?;
if account_state.owner != self.accounts.owner.key() {
return Err(ProgramError::InvalidAccountData.into());
}
⚠️ 你需要创建自己的
ProgramAccount::try_deserialize()函数,因为 Pinocchio 允许我们根据需要处理反序列化和序列化。