Anchor 中的 Init if needed 与重初始化攻击

本篇文章详细介绍了Anchor框架的init_if_needed宏,提供了一种在一次事务中初始化账户并写入数据的方法。文中阐述了该宏的便利性与可能引发的重初始化攻击风险,特别是在账户状态和lamport余额的处理上。同时,通过示例代码和测试用例,深入分析了如何安全地使用这些功能,以避免潜在的错误和安全隐患。

英雄图片显示 Anchor init_if_needed

在之前的教程中,我们必须在单独的交易中初始化一个账户,然后才能对其写入数据。我们可能希望能够在一个交易中初始化一个账户并对其写入数据,以简化用户的操作。

Anchor 提供了一个方便的宏 init_if_needed,顾名思义,如果账户不存在,则会初始化该账户。

下面的示例计数器不需要单独的初始化交易,它会立即开始将“1”添加到 counter 存储中。

Rust:

use anchor_lang::prelude::*;
use std::mem::size_of;

declare_id!("9DbiqCqtqgP3NYufxBakbeRd7JyNpNYbsm6Jqrn8Z2Hn");

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

    pub fn increment(ctx: Context<Initialize>) -> Result<()> {
        let current_counter = ctx.accounts.my_pda.counter;
        ctx.accounts.my_pda.counter = current_counter + 1;
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(
        init_if_needed,
        payer = signer,
        space = size_of::<MyPDA>() + 8,
        seeds = [],
        bump
    )]
    pub my_pda: Account<'info, MyPDA>,

    #[account(mut)]
    pub signer: Signer<'info>,
    pub system_program: Program<'info, System>,
}

#[account]
pub struct MyPDA {
    pub counter: u64,
}

Typescript:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { InitIfNeeded } from "../target/types/init_if_needed";

describe("init_if_needed", () => {
  // 配置客户端以使用本地集群。
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.InitIfNeeded as Program<InitIfNeeded>;

  it("已初始化!", async () => {
    const [myPda, _bump] = anchor.web3.PublicKey.findProgramAddressSync([], program.programId);
    await program.methods.increment().accounts({myPda: myPda}).rpc();
    await program.methods.increment().accounts({myPda: myPda}).rpc();
    await program.methods.increment().accounts({myPda: myPda}).rpc();

    let result = await program.account.myPda.fetch(myPda);
    console.log(`counter是 ${result.counter}`);
  });
});

当我们尝试使用 anchor build 构建这个程序时,将会遇到以下错误:

错误: init_if_needed

为了消除错误 init_if_needed requires that anchor-lang be imported with the init-if-needed cargo feature enabled,我们可以打开 programs/<anchor_project_name> 中的 Cargo.toml 文件并添加以下行:

[dependencies]
anchor-lang = { version = "0.29.0", features = ["init-if-needed"] }

但在我们只是消声错误之前,我们应该了解什么是重新初始化攻击及其如何发生。

在 Anchor 程序中,账户不能被重初始化(默认)

如果我们尝试初始化一个已经初始化的账户,交易将会失败。

Anchor 如何知道一个账户已经初始化?

从 Anchor 的角度来看,如果该账户的 lamport 余额非零 或者该账户由系统程序拥有,那么它就是未初始化的。

由系统程序拥有或具有零 lamport 余额的账户可以再次初始化。

为了说明这一点,我们有一个具有典型 initialize 函数的 Solana 程序(它使用的是 init,而不是 init_if_needed)。它还具有 drain_lamports 函数和 give_to_system_program 函数,这两个函数的作用如其名所示:

use anchor_lang::prelude::*;
use std::mem::size_of;
use anchor_lang::system_program;

declare_id!("FC467mPCCKXG97ut1WdLLi55vuAcyCW8AD1vid27bZfn");

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

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }

    pub fn drain_lamports(ctx: Context<DrainLamports>) -> Result<()> {
        let lamports = ctx.accounts.my_pda.to_account_info().lamports();
        ctx.accounts.my_pda.sub_lamports(lamports)?;
        ctx.accounts.signer.add_lamports(lamports)?;
        Ok(())
    }

    pub fn give_to_system_program(ctx: Context<GiveToSystemProgram>) -> Result<()> {
        let account_info = &mut ctx.accounts.my_pda.to_account_info();
        // assign 方法更改拥有者
        account_info.assign(&system_program::ID);
        account_info.realloc(0, false)?;
        Ok(())
    }
}

#[derive(Accounts)]
pub struct DrainLamports<'info> {
    #[account(mut)]
    pub my_pda: Account<'info, MyPDA>,
    #[account(mut)]
    pub signer: Signer<'info>,
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(init, payer = signer, space = 8, seeds = [], bump)]
    pub my_pda: Account<'info, MyPDA>,

    #[account(mut)]
    pub signer: Signer<'info>,

    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct GiveToSystemProgram<'info> {
    #[account(mut)]
    pub my_pda: Account<'info, MyPDA>,
}

#[account]
pub struct MyPDA {}

现在考虑以下单元测试:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { ReinitAttack } from "../target/types/reinit_attack";

describe("程序", () => {
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.ReinitAttack as Program<ReinitAttack>;

  it("在转交给系统程序或排空 lamports 后初始化", async () => {
    const [myPda, _bump] = anchor.web3.PublicKey.findProgramAddressSync([], program.programId);

    await program.methods.initialize().accounts({myPda: myPda}).rpc();

    await program.methods.giveToSystemProgram().accounts({myPda: myPda}).rpc();

    await program.methods.initialize().accounts({myPda: myPda}).rpc();
    console.log("账户在转交给系统程序后已初始化!")

    await program.methods.drainLamports().accounts({myPda: myPda}).rpc();

    await program.methods.initialize().accounts({myPda: myPda}).rpc();
    console.log("账户在排空 lamports 后已初始化!")
  });
});

顺序如下:

  1. 我们初始化了 PDA
  2. 我们将 PDA 的所有权转移给系统程序
  3. 我们再次调用初始化,它成功了
  4. 我们从 my_pda 账户中排空 lamports
  5. 由于零 lamport 余额,Solana 运行时将该账户视为不存在,因为它将被安排删除,因为它不再符合租金豁免条件。
  6. 我们再次调用初始化,它成功了。我们在遵循此顺序后成功重新初始化了账户。

再说一遍,Solana 没有“初始化”标记或其他东西。如果所有者是系统程序或者 lamport 余额为零,Anchor 将允许初始化交易成功。

为什么在我们的例子中重新初始化可能是一个问题

将所有权转移给系统程序需要擦除账户中的数据。移除所有 lamports “表明” 你不希望账户继续存在。

你的意图是通过其中任何一种方式重启计数器或结束计数器的生命周期吗?如果你的应用程序从不期望计数器被重置,这可能会导致错误。

Anchor 希望你认真思考你的意图,这就是为什么它使你在 Cargo.toml 中启用功能标志额外增加了步骤。

如果你接受计数器在某个时刻被重置并向上计数,重新初始化就不是问题。但如果在任何情况下计数器都不应该重置为零,那么你最好单独实现 initialization 函数,并添加一个保护措施,以确保它一生中只能调用一次(例如,在一个单独的账户中存储一个布尔标记)。

当然,你的程序不一定具有将账户转移给系统程序或从账户中提取 lamports 的机制。但 Anchor 无法知道这一点,因此它总是会发出关于 init_if_needed 的警告,因为它无法确定该账户是否能够返回到可初始化状态。

拥有两条初始化路径可能导致越界错误或其他意外行为

在我们的计数器示例中,使用 init_if_needed,计数器从未等于零,因为第一次初始化交易也将值从零递增到一。

如果我们_也_有一个普通的初始化函数,它不会递增计数器,那么计数器将被初始化,并且值为零。如果一些业务逻辑永远不希望看到计数器的值为零,那么可能会发生意外行为。

在以太坊中,从未“触摸”的变量的存储值默认值为零。在 Solana 中,未初始化的账户不存在,无法读取。

“初始化”在 Anchor 中并不总是意味着“init”

有点令人困惑,有些人使用“initialize”一词在一般层面上表示“第一次将数据写入账户”,而不仅仅是 Anchor 的 init 宏。

如果我们查看 Soldev 的示例程序,我们看到没有使用 init 宏:

use anchor_lang::prelude::*;
use borsh::{BorshDeserialize, BorshSerialize};

declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");

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

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        let mut user = User::try_from_slice(&ctx.accounts.user.data.borrow()).unwrap();
        user.authority = ctx.accounts.authority.key();
        user.serialize(&mut *ctx.accounts.user.data.borrow_mut())?;
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(mut)]
    user: AccountInfo<'info>,
    #[account(mut)]
    authority: Signer<'info>,
}

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

代码在第11行直接读取账户,然后设置字段。程序随意覆盖数据,无论是第一次写入还是第二次(或第三次)写入。

相反,“initialize”在这里的命名法是“第一次写入账户”。

这里的“重新初始化攻击”与 Anchor 框架所警告的攻击不同。具体而言,“initialize”可以多次调用。Anchor 的 init 宏检查 lamport 余额是否非零以及程序是否已经拥有该账户,这将防止多次调用 initialize。init 宏可以看到账户已经有 lamports 或已由程序拥有。然而,上述代码没有此类检查。

值得阅读他们的教程,以了解这种重新初始化攻击的变种。

请注意,这里使用的是较旧版本的 Anchor。AccountInfoUncheckedAccount 的另一种说法,因此你需要在其上方添加 /// Check: 注释。

擦除账户鉴别符不会让账户重新初始化

一个账户是否已初始化与其内部数据(或缺失数据)无关。

要在不转移的情况下擦除账户中的数据:

use anchor_lang::prelude::*;
use std::mem::size_of;
use anchor_lang::system_program;

declare_id!("FC467mPCCKXG97ut1WdLLi55vuAcyCW8AD1vid27bZfn");

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

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }

    pub fn erase(ctx: Context<Erase>) -> Result<()> {
        ctx.accounts.my_pda.realloc(0, false)?;
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Erase<'info> {
    /// CHECK: 我们将要擦除账户
    #[account(mut)]
    pub my_pda: UncheckedAccount<'info>,
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(init, payer = signer, space = 8, seeds = [], bump)]
    pub my_pda: Account<'info, MyPDA>,

    #[account(mut)]
    pub signer: Signer<'info>,

    pub system_program: Program<'info, System>,
}

#[account]
pub struct MyPDA {}

重要的是,我们使用 UncheckedAccount 擦除数据,而 .realloc(0, false) 不是常规 Account 中可用的方法。

该操作将擦除账户鉴别符,因此不再通过 Account 可读取。

练习:初始化账户,调用 erase 然后尝试再次初始化账户。它将失败,因为即使账户没有数据,仍然由程序拥有且 lamport 余额非零。

摘要

init_if_needed 宏可以方便地避免与新存储账户交互时需要两个交易。Anchor 框架默认阻止它,以迫使我们思考以下可能的不良情况:

  • 如果有方法将 lamport 余额减少至零或将所有权转移给系统程序,则可以重新初始化账户。根据业务需求,这可能是一个问题,也可能不是。
  • 如果程序同时具有 init 宏和 init_if_needed 宏,则开发人员必须确保拥有两个代码路径不会导致状态意外。
  • 即使账户中的数据完全被擦除,账户仍然处于初始化状态。
  • 如果程序具有“盲目”写入账户的函数,则该账户中的数据可能被覆盖。这通常需要通过 AccountInfo 或其别名 UncheckedAccount 加载账户。

了解更多

请参阅我们的 Solana 开发课程以获取其余的 Solana 教程。感谢你的阅读!

最初发布于 2024 年 3 月 8 日

  • 原文链接: rareskills.io/post/init-...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/