在之前的教程中,我们必须在一个单独的交易中初始化账户,然后才能向其写入数据。为了简化用户的操作,我们可能希望能够在一个交易中同时完成账户的初始化和数据写入。
Anchor 提供了一个非常方便的宏,名为 init_if_needed,顾名思义,如果账户不存在,它将初始化该账户。
下面的计数器示例不需要单独的初始化交易,它会立即开始向 counter 存储中加“1”。
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", () => {
// Configure the client to use the local cluster.
anchor.setProvider(anchor.AnchorProvider.env());
const program = anchor.workspace.InitIfNeeded as Program<InitIfNeeded>;
it("Is initialized!", 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 is ${result.counter}`);
});
});
当我们尝试使用 anchor build 构建此程序时,会收到以下错误:

要消除 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"] }
但在直接屏蔽此错误之前,我们应该了解什么是重初始化攻击(re-initialization attack)以及它是如何发生的。
在 Anchor 程序中,账户不能被初始化两次(默认情况下)
如果我们尝试初始化一个已经初始化过的账户,交易将会失败。
Anchor 如何知道账户已经被初始化?
从 Anchor 的角度来看,如果账户的 lamport 余额为零,或者该账户由系统程序(system program)拥有,那么它就没有被初始化。
由系统程序拥有或 lamport 余额为零的账户可以再次被初始化。
为了说明这一点,我们来看一个包含常规 initialize 函数(使用 init 而不是 init_if_needed)的 Solana 程序。它还具有一个 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();
// the assign method changes the owner
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("Program", () => {
anchor.setProvider(anchor.AnchorProvider.env());
const program = anchor.workspace.ReinitAttack as Program<ReinitAttack>;
it("initialize after giving to system program or draining 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("account initialized after giving to system program!")
await program.methods.drainLamports().accounts({myPda: myPda}).rpc();
await program.methods.initialize().accounts({myPda: myPda}).rpc();
console.log("account initialized after draining lamports!")
});
});
执行顺序如下:
- 我们初始化 PDA
- 我们将 PDA 的所有权转移给系统程序
- 我们再次调用 initialize,操作成功
- 我们清空
my_pda账户中的 lamports - 由于 lamport 余额为零,Solana 运行时会认为该账户不存在,因为它不再满足租金豁免(rent exempt)条件,将被安排删除。
- 我们再次调用 initialize,操作成功。通过遵循这一顺序,我们成功地对该账户进行了重初始化。
再次强调,Solana 并没有“已初始化”标志之类的东西。如果所有者是系统程序或 lamport 余额为零,Anchor 将允许初始化交易成功。
为什么重初始化在我们的示例中可能是一个问题
将所有权转移给系统程序需要擦除账户中的数据。移除所有的 lamports 传达了一个“信号”,即你不希望该账户继续存在。
你执行这两个操作的目的是为了重启计数器,还是结束计数器的生命周期?如果你的应用程序根本不期望计数器被重置,这可能会导致错误(bugs)。
Anchor 希望你仔细考虑此操作的意图,这也是为什么它要求你在 Cargo.toml 中通过启用功能标志(feature flag)来额外确认的原因。
如果你能够接受计数器在某个时刻被重置并重新计数,那么重初始化就不是问题。但如果计数器在任何情况下都不应该重置为零,那么对你来说,更好的做法可能是单独实现 initialization 函数,并添加一个安全机制以确保在其生命周期内只能被调用一次(例如,在一个单独的账户中存储一个布尔标志)。
当然,你的程序未必具备将账户转移到系统程序或从账户中提取 lamports 的机制。但 Anchor 无法预知这一点,因此它总是抛出关于 init_if_needed 的警告,因为它无法确定账户是否可能退回到可初始化的状态。
拥有两条初始化路径可能会导致相差一错误(off-by-one error)或其他意外行为
在我们使用 init_if_needed 的计数器示例中,计数器永远不会等于零,因为第一次初始化交易还会将值从零递增到一。
如果我们同时拥有一个不递增计数器的常规初始化函数,那么计数器将被初始化,并且其值为零。如果某些业务逻辑完全没有预料到计数器的值为零,就可能会发生意外行为。
在以太坊中,从未被“触碰”(touched)过的变量的存储值具有默认值零。在 Solana 中,未被初始化的账户并不持有零值变量——它们根本不存在且无法被读取。
在 Anchor 中“初始化(Initialization)”并不总是意味着 “init”
有些令人困惑的是,有的人在比 Anchor 的 init 宏更广泛的意义上,使用“初始化(initialize)”一词来表示“首次将数据写入账户”。
如果我们看一下来自 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 行直接读取账户,然后设置字段。无论这是第一次还是第二次(或第三次)写入,该程序都会盲目地覆盖数据。
相反,这里“初始化”的术语含义是“首次向账户写入”。
这里的“重初始化攻击”与 Anchor 框架警告的类型不同。具体来说,这里的 initialize 可以被调用多次。Anchor 的 init 宏会检查 lamport 余额是否非零以及程序是否已经拥有该账户,这可以防止多次调用 initialize。init 宏能够察觉到账户中是否已有 lamports 或是否已归程序所有。然而,上面的代码却没有这类检查。
值得去通读一下他们的教程,看看这种不同变体的重初始化攻击。
请注意,这里使用的是旧版本的 Anchor。AccountInfo 相当于 UncheckedAccount,因此你需要在它的上方添加一个 /// Check: 注释。
擦除账户鉴别器(discriminator)并不会使账户变得可重初始化
一个账户是否被初始化,与其内部的数据(或没有数据)毫无关系。
要在不转移账户的情况下擦除其中的数据:
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: We are going to erase the account
#[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 可用的方法。
这个操作会擦除账户鉴别器(discriminator),因此它将不再能通过 Account 被读取。
练习:初始化账户,调用 erase,然后尝试再次初始化该账户。它将会失败,因为即使账户没有数据,它仍然归程序所有,并且具有非零的 lamport 余额。
总结
使用 init_if_needed 宏可以很方便地避免需要两次交易来与新的存储账户交互。Anchor 框架默认将其屏蔽,是强制我们考虑以下可能出现的不良情况:
- 如果存在一种方法可以将 lamport 余额减少为零或将所有权转移给系统程序,那么该账户就可以被重新初始化。这是否会成为一个问题取决于业务需求。
- 如果程序同时拥有
init宏和init_if_needed宏,开发者必须确保存在这两条代码路径不会导致意外状态。 - 即使账户中的数据被完全擦除,该账户仍然是处于初始化状态的。
- 如果程序有一个“盲目”写入账户的函数,那么该账户中的数据可能会被覆盖。这通常需要通过
AccountInfo或其别名UncheckedAccount加载账户。
通过 RareSkills 了解更多
请查看我们的 Solana 开发课程 以获取我们的其余 Solana 教程。感谢您的阅读!
原文首发于 2024 年 3 月 8 日