第5课 - 安全最佳实践

本文详细介绍了Solana平台的安全最佳实践,包括运行时策略、所有权及账户修改、不可变性、零初始化、交易一致性等多个方面。提供了Rust和Anchor语言中实现这些最佳实践的具体代码示例,强调了开发过程中需要采取的多种安全措施,以防止攻击和确保程序的可靠性和安全性。

第5课 - 安全最佳实践

目录

Solana 运行时政策

所有权和账户修改

  • 只有所有者程序可以修改账户的内容。在分配时,数据向量保证为零。
  • CreateAccountAssign 保证当一个账户被分配给程序时,账户的数据被零初始化。
  • 运行时保证程序的代码是唯一可以修改其分配账户的数据的代码。
  • 只有账户的所有者可以分配新的所有者。

不可变性

  • 一旦分配给一个程序,账户无法重新分配。
  • 将账户分配给程序或分配空间的交易必须由账户的私钥签名,除非账户是通过 CreateAccountWithSeed 创建的,在这种情况下,账户的地址/pubkey 不存在相应的私钥。
  • 可执行性是单向的(false->true),只有账户所有者可以设置。

零初始化

  • 在分配时,数据向量保证为零。
  • CreateAccountAssign 保证当账户被分配给程序时,其数据被零初始化。

余额和交易一致性

  • 所有账户的总余额在交易执行前后是相等的。
  • 在交易执行后,只读账户的余额必须保持不变。
  • 运行时保证账户的余额在交易前后是平衡的。
  • 只有账户的所有者可以减去其 lamports。
  • 任何程序账户都可以向账户添加 lamports。

原子性

  • 交易中的所有指令都是原子执行的。如果其中一个失败,所有账户修改将被丢弃。
  • 运行时保证在交易提交时所有指令成功执行。

内存管理

  • 没有动态内存分配。客户端必须使用 CreateAccount 指令分配内存,然后再将其传递给另一个程序。
  • CreateAccount 指令可以与对程序本身的调用组合成一个单一的交易。

程序所有权和支出

  • 运行时保证程序只能在分配给它的账户中支出 lamports。

租金

  • 每个周期收取租金费用,租金由账户大小决定。
  • 余额为零的账户将在交易处理结束时被删除。
  • 在交易过程中可能创建余额为零的临时账户。

最佳安全实践

签名者授权

普通 Rust

使用 Signer Checks 验证特定账户是否已签署交易。没有适当的签名者检查,账户可能会执行它们不应被授权执行的指令。

要在 Rust 中实现签名者检查,只需检查一个账户的 is_signer 属性是否为 true

if !ctx.accounts.authority.is_signer {
    return Err(ProgramError::MissingRequiredSignature.into());
}

Anchor

在 Anchor 中,你可以在账户验证结构中使用 Signer 账户类型,使 Anchor 自动对给定账户执行签名者检查

##[derive(Accounts)]
pub struct UpdateUserData<'info> {
    // 执行签名者检查
    pub user: Signer<'info>,
}

所有者检查

普通 Rust

使用 Owner Checks 验证账户是否由预期程序拥有。没有适当的所有者检查,意外程序拥有的账户可能会在一条指令中被使用。

要在 Rust 中实现所有者检查,只需检查一个账户的所有者是否与预期的程序 ID 匹配

if ctx.accounts.account.owner != ctx.program_id {
    return Err(ProgramError::IncorrectProgramId.into());
}

Anchor

Anchor 程序账户类型实现了 Owner trait,这使得 Account<'info, T> 包装器可以自动验证程序所有权

##[derive(Accounts)]
pub struct UpdateUserData<'info> {
    pub user: Signer<'info>,
    // 执行所有权检查
    pub user_data: Account<'info, UserData>,
}

如果账户的所有者应为当前执行程序之外的其他内容,Anchor 允许你显式定义账户的所有者。

账户数据匹配

普通 Rust

使用 Data Validation checks 验证账户数据是否与预期值匹配。没有适当的数据验证检查,意外账户可能会在一条指令中被使用。

要在 Rust 中实现数据验证检查,只需将账户上存储的数据与预期值进行比较。

if ctx.accounts.user.key() != ctx.accounts.user_data.user {
    return Err(ProgramError::InvalidAccountData.into());
}

Anchor

在 Anchor 中,你可以使用 constraint 检查给定表达式是否计算为真。或者,你可以使用 has_one 检查存储在账户中的目标账户字段是否与 Accounts 结构中的一个账户的密钥匹配。

##[derive(Accounts)]
pub struct UpdateUserData<'info> {
    pub user: Signer<'info>,
    #[account(constraint = user_data.authority = user.key())]
    pub user_data: Account<'info, UserData>,
}

重新初始化攻击

普通 Rust

使用 账户鉴别器或初始化标志 检查一个账户是否已被初始化,以防止账户被重新初始化并覆盖现有账户数据。

要在普通 Rust 中防止账户重新初始化,请使用 is_initialized 标志初始化账户,并在初始化账户时检查其是否已被设置为 true。

if account.is_initialized {
    return Err(ProgramError::AccountAlreadyInitialized.into());
}

Anchor

为了简化此过程,请使用 Anchor 的 init 约束通过 CPI 创建账户并设置其鉴别器。

##[derive(Accounts)]
pub struct UpdateUserData<'info> {
    pub user: Signer<'info>,
    // 如果账户已经初始化,init 将返回错误
    #[account(init)]
    pub user_data: Account<'info, UserData>,
}

重复可变账户

普通 Rust

当一条指令需要两个相同类型的可变账户时,攻击者可以将同一个账户传入两次,从而导致账户以意外的方式被修改。

要在 Rust 中检查重复可变账户,只需比较两个账户的公钥,并在它们相同的情况下抛出错误。

if ctx.accounts.account_one.key() == ctx.accounts.account_two.key() {
    return Err(ProgramError::InvalidArgument)
}

Anchor

在 Anchor 中,你可以使用约束为账户添加一个明确的约束,检查它与另一个账户是否不同。

##[derive(Accounts)]
pub struct UpdateUserData<'info> {
    pub user: Signer<'info>,
    #[account(
        mut,
        constraint = user_data.key() != different_user_data.key()
    )]
    pub user_data: Account<'info, UserData>,
    #[account(mut)]
    pub different_user_data: Account<'info, UserData>,
}

类型伪装

普通 Rust

使用鉴别器来区分不同的账户类型

要在 Rust 中实现鉴别器,请在账户结构中包含一个字段以表示账户类型

##[derive(BorshSerialize, BorshDeserialize)]
pub struct User {
    discriminant: AccountDiscriminant,
    user: Pubkey,
}

##[derive(BorshSerialize, BorshDeserialize, PartialEq)]
pub enum AccountDiscriminant {
    User,
    Admin,
}

要在 Rust 中实现鉴别器检查,请验证反序列化的账户数据的鉴别器是否与预期值匹配

if user.discriminant != AccountDiscriminant::User {
    return Err(ProgramError::InvalidAccountData.into());
}

Anchor

在 Anchor 中,程序账户类型自动实现了 Discriminator trait,这为一个类型创建了一个 8 字节唯一标识符。

使用 Anchor 的 Account<'info, T> 类型在反序列化账户数据时自动检查账户的鉴别器。

##[derive(Accounts)]
pub struct UpdateUserData<'info> {
    pub user: Signer<'info>,
    // 账户类型自动检查 UserData 账户的鉴别器
    pub user_data: Account<'info, UserData>,
}

任意 CPI

普通 Rust

要生成 CPI,目标程序必须作为账户传入调用指令。这意味着任何目标程序都可以传入指令。你的程序应检查不正确或意外的程序。

通过比较传入程序的公钥与你预期的程序进行程序检查。

pub fn cpi_secure(ctx: Context<Cpi>, amount: u64) -> Result<()> {
    if &spl_token::ID != ctx.accounts.token_program.key {
        return Err(ProgramError::IncorrectProgramId);
    }
    ...
    // CPI 逻辑在这里
}

Anchor

如果程序是用 Anchor 编写的,则可能有一个公开可用的 CPI 模块。这使得从另一个 Anchor 程序调用程序既简单又安全。Anchor CPI 模块自动检查传入程序的地址是否与模块中存储的程序地址匹配。

使用 Anchor 的最佳实践是始终使用 Program<'info, T>,这将检查账户是否可执行且为给定程序。例如:

use anchor_spl::token::Token;

##[derive(Accounts)]
pub struct InitializeExchange<'info> {
    // ...
    pub token_program: Program<'info, Token>,
    // ...
}

碰撞种子规范化

普通 Rust

create_program_address 函数在不搜索 canonical bump 的情况下推导 PDA。这意味着可能存在多个有效的 bump,所有这些 bump 都将生成不同的地址。

使用 find_program_address 确保使用最高有效 bump 或 canonical bump 进行推导,从而以确定性的方法找到给定特定种子的地址。

推荐工作流:

  1. 在账户初始化期间,使用 find_program_address 推导 PDA。这会生成包含标识 bump 的 PDA。下一步是在账户中存储生成的 bump 和程序数据。
  2. 使用 PDA 配合不同指令时,使用 create_program_address 及步骤 1 中存储的 bump。

Anchor

在初始化时,你可以使用 Anchor 的 seedsbump 约束确保账户验证结构中的 PDA 推导始终使用 canonical bump

当验证 PDA 的地址时,Anchor 允许你用 bump = <some_bump> 约束指定一个 bump。

pub fn _initialize_exchange(
    ctx: Context<InitializeExchange>,
) -> Result<()> {
    // ...
    // 存储生成的 canonical bump
    let escrow = &mut ctx.accounts.escrow;
    escrow.bump = ctx.bumps.escrow;
    // ...
    Ok(())
}
pub fn _update_exchange(
    ctx: Context<UpdateExchange>,
) -> Result<()> {
    // 在上下文中确保地址的正确性
}

pub struct InitializeExchange<'info> {
    #[account(mut)]
    pub side_a: Signer<'info>,
    #[account(
        init,
        payer = side_a,
        space = 8 + Escrow::LEN,
        seeds = [b"escrow"], // 在这里指定所需种子
        bump // 生成 canonical bump
    )]
    pub escrow: Account<'info, Escrow>,
    // ...
}
pub struct UpdateExchange<'info> {
    #[account(
        mut,
        seeds = [b"escrow"], // 在这里指定所需种子
        escrow.bump // 检查与已存储的 bump 是否相符
    )]
    pub escrow: Account<'info, Escrow>,
}

关闭账户与复生攻击

不当关闭账户 为重新初始化/复生攻击创造了机会。

当账户不再免租时,Solana 运行时会 垃圾收集账户。关闭账户涉及将存储在该账户中的 lamports 转移到你所选择的另一个账户以进行租金豁免。

普通 Rust

手动关闭账户的推荐工作流是使用 Anchor 的逻辑(即检查关闭 参考

Anchor

你可以使用 Anchor 的 #[account(close = <address_to_send_lamports>)] 约束安全地关闭账户。关闭参数将 (关闭参考):

  1. 将数据账户中的食用 lamports 转移到 <address_to_send_lamports>
  2. 将数据账户的所有权返回给 系统程序
  3. 将账户的数据大小重新分配为 0。

例如:

##[derive(Accounts)]
pub struct CancelExchange<'info> {
    #[account(mut)]
    pub side_a: Signer<'info>,
    #[account(
        mut,
        close = side_a, // 这是 <address_to_send_lamports> (即租金接收者)
        seeds = [b"escrow"],
        escrow.bump
    )]
    pub escrow: Account<'info, Escrow>,
}
  • 原文链接: github.com/Ackee-Blockch...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Ackee-Blockchain
Ackee-Blockchain
江湖只有他的大名,没有他的介绍。