By the end of this lesson, you will be able to:
- Explain security risks associated with a reinitialization vulnerability
- Use long-form Rust check if an account has already been initialized
- Using Anchor’s
init
constraint to initialize accounts, which automatically sets an account discriminator that is checked to prevent the reinitialization of an account
- Use an account discriminator or initialization flag to check whether an account has already been initialized to prevent an account from being reinitialized and overriding existing account data.
- To prevent account reinitialization in plain Rust, initialize accounts with an
is_initialized
flag and check if it has already been set to true when initializing an accountif account.is_initialized { return Err(ProgramError::AccountAlreadyInitialized.into()); }
- To simplify this, use Anchor’s
init
constraint to create an account via a CPI to the system program and sets its discriminator
Initialization refers to setting the data of a new account for the first time. When initializing a new account, you should implement a way to check if the account has already been initialized. Without an appropriate check, an existing account could be reinitialized and have existing data overwritten.
Note that initializing an account and creating an account are two separate instructions. Creating an account requires invoking the create_account
instruction on the System Program which specifies the space required for the account, the rent in lamports allocated to the account, and the program owner of the account. Initialization is an instruction that sets the data of a newly created account. Creating and initializing an account can be combined into a single transaction.
In the example below, there are no checks on the user
account. The initialize
instruction deserializes the data of the user
account as a User
account type, sets the authority
field, and serializes the updated account data to the user
account.
Without checks on the user
account, the same account could be passed into the initialize
instruction a second time by another party to overwrite the existing authority
stored on the account data.
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,
}
One approach to fix this is to add an additional is_initialized
field to the User
account type and use it as a flag to check if an account has already been initialized.
if user.is_initialized {
return Err(ProgramError::AccountAlreadyInitialized.into());
}
By including a check within the initialize
instruction, the user
account would only be initialized if the is_initialized
field has not yet been set to true. If the is_initialized
field was already set, the transaction would fail, thereby avoiding the scenario where an attacker could replace the account authority with their own public key.
use anchor_lang::prelude::*;
use borsh::{BorshDeserialize, BorshSerialize};
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod initialization_secure {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let mut user = User::try_from_slice(&ctx.accounts.user.data.borrow()).unwrap();
if user.is_initialized {
return Err(ProgramError::AccountAlreadyInitialized.into());
}
user.authority = ctx.accounts.authority.key();
user.is_initialized = true;
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 {
is_initialized: bool,
authority: Pubkey,
}
Anchor provides an init
constraint that can be used with the #[account(...)]
attribute to initialize an account. The init
constraint creates the account via a CPI to the system program and sets the account discriminator.
The init
constraint must be used in combination with the payer
and space
constraints. The payer
specifies the account paying for the initialization of the new account. The space
specifies the amount of space the new account requires, which determines the amount of lamports that must be allocated to the account. The first 8 bytes of data is set as a discriminator that Anchor automatically adds to identify the account type.
Most importantly for this lesson, the init
constraint ensures that this instruction can only be called once per account, so you can set the initial state of the account in the instruction logic and not have to worry about an attacker trying to reinitialize the account.
use anchor_lang::prelude::*;
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod initialization_recommended {
use super::*;
pub fn initialize(_ctx: Context<Initialize>) -> Result<()> {
msg!("GM");
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(init, payer = authority, space = 8+32)]
user: Account<'info, User>,
#[account(mut)]
authority: Signer<'info>,
system_program: Program<'info, System>,
}
#[account]
pub struct User {
authority: Pubkey,
}
It’s worth noting that Anchor has an init_if_needed
constraint. This constraint should be used very cautiously. In fact, it is blocked behind a feature flag so that you are forced to be intentional about using it.
The init_if_needed
constraint does the same thing as the init
constraint, only if the account has already been initialized the instruction will still run.
Given this, it’s extremely important that when you use this constraint you include checks to avoid resetting the account to its initial state.
For example, if the account stores an authority
field that gets set in the instruction using the init_if_needed
constraint, you need checks that ensure that no attacker could call the instruction after it has already been initialized and have the authority
field set again.
In most cases, it’s safer to have a separate instruction for initializing account data.
For this demo we’ll create a simple program that does nothing but initialize accounts. We’ll include two instructions:
insecure_initialization
- initializes an account that can be reinitializedrecommended_initialization
- initialize an account using Anchor’sinit
constraint
To get started, download the starter code from the starter
branch of this repository. The starter code includes a program with one instruction and the boilerplate setup for the test file.
The insecure_initialization
instruction initializes a new user
account that stores the public key of an authority
. In this instruction, the account is expected to be allocated client-side, then passed into the program instruction. Once passed into the program, there are no checks to see if the user
account's initial state has already been set. This means the same account can be passed in a second time to override the authority
stored on an existing user
account.
use anchor_lang::prelude::*;
use borsh::{BorshDeserialize, BorshSerialize};
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod initialization {
use super::*;
pub fn insecure_initialization(ctx: Context<Unchecked>) -> 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 Unchecked<'info> {
#[account(mut)]
/// CHECK:
user: UncheckedAccount<'info>,
authority: Signer<'info>,
}
#[derive(BorshSerialize, BorshDeserialize)]
pub struct User {
authority: Pubkey,
}
The test file includes the setup to create an account by invoking the system program and then invokes the insecure_initialization
instruction twice using the same account.
Since there are no checks the verify that the account data has not already been initialized, the insecure_initialization
instruction will complete successfully both times, despite the second invocation providing a different authority account.
import * as anchor from "@project-serum/anchor"
import { Program } from "@project-serum/anchor"
import { expect } from "chai"
import { Initialization } from "../target/types/initialization"
describe("initialization", () => {
const provider = anchor.AnchorProvider.env()
anchor.setProvider(provider)
const program = anchor.workspace.Initialization as Program<Initialization>
const wallet = anchor.workspace.Initialization.provider.wallet
const walletTwo = anchor.web3.Keypair.generate()
const userInsecure = anchor.web3.Keypair.generate()
const userRecommended = anchor.web3.Keypair.generate()
before(async () => {
const tx = new anchor.web3.Transaction().add(
anchor.web3.SystemProgram.createAccount({
fromPubkey: wallet.publicKey,
newAccountPubkey: userInsecure.publicKey,
space: 32,
lamports: await provider.connection.getMinimumBalanceForRentExemption(
32
),
programId: program.programId,
})
)
await anchor.web3.sendAndConfirmTransaction(provider.connection, tx, [
wallet.payer,
userInsecure,
])
await provider.connection.confirmTransaction(
await provider.connection.requestAirdrop(
walletTwo.publicKey,
1 * anchor.web3.LAMPORTS_PER_SOL
),
"confirmed"
)
})
it("Insecure init", async () => {
await program.methods
.insecureInitialization()
.accounts({
user: userInsecure.publicKey,
})
.rpc()
})
it("Re-invoke insecure init with different auth", async () => {
const tx = await program.methods
.insecureInitialization()
.accounts({
user: userInsecure.publicKey,
authority: walletTwo.publicKey,
})
.transaction()
await anchor.web3.sendAndConfirmTransaction(provider.connection, tx, [
walletTwo,
])
})
})
Run anchor test
to see that both transactions will complete successfully.
initialization
✔ Insecure init (478ms)
✔ Re-invoke insecure init with different auth (464ms)
Let's create a new instruction called recommended_initialization
that fixes this problem. Unlike the previous insecure instruction, this instruction should handle both the creation and initialization of the user's account using Anchor's init
constraint.
This constraint instructs the program to create the account via a CPI to the system program, so the account no longer needs to be created client-side. The constraint also sets the account discriminator. Your instruction logic can then set the account's initial state.
By doing this, you ensure that any subsequent invocation of the same instruction with the same user account will fail rather than reset the account's initial state.
use anchor_lang::prelude::*;
use borsh::{BorshDeserialize, BorshSerialize};
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod initialization {
use super::*;
...
pub fn recommended_initialization(ctx: Context<Checked>) -> Result<()> {
ctx.accounts.user.authority = ctx.accounts.authority.key();
Ok(())
}
}
#[derive(Accounts)]
pub struct Checked<'info> {
#[account(init, payer = authority, space = 8+32)]
user: Account<'info, User>,
#[account(mut)]
authority: Signer<'info>,
system_program: Program<'info, System>,
}
To test the recommended_initialization
instruction, we’ll invoke the instruction twice just like before. This time, we expect the transaction to fail when we try to initialize the same account a second time.
describe("initialization", () => {
...
it("Recommended init", async () => {
await program.methods
.recommendedInitialization()
.accounts({
user: userRecommended.publicKey,
})
.signers([userRecommended])
.rpc()
})
it("Re-invoke recommended init with different auth, expect error", async () => {
try {
// Add your test here.
const tx = await program.methods
.recommendedInitialization()
.accounts({
user: userRecommended.publicKey,
authority: walletTwo.publicKey,
})
.transaction()
await anchor.web3.sendAndConfirmTransaction(provider.connection, tx, [
walletTwo,
userRecommended,
])
} catch (err) {
expect(err)
console.log(err)
}
})
})
Run anchor test
and to see that the second transaction which tries to initialize the same account twice will now return an error stating the account address is already in use.
'Program CpozUgSwe9FPLy9BLNhY2LTGqLUk1nirUkMMA5RmDw6t invoke [1]',
'Program log: Instruction: RecommendedInitialization',
'Program 11111111111111111111111111111111 invoke [2]',
'Allocate: account Address { address: EMvbwzrs4VTR7G1sNUJuQtvRX1EuvLhqs4PFqrtDcCGV, base: None } already in use',
'Program 11111111111111111111111111111111 failed: custom program error: 0x0',
'Program CpozUgSwe9FPLy9BLNhY2LTGqLUk1nirUkMMA5RmDw6t consumed 4018 of 200000 compute units',
'Program CpozUgSwe9FPLy9BLNhY2LTGqLUk1nirUkMMA5RmDw6t failed: custom program error: 0x0'
If you use Anchor's init
constraint, that's usually all you need to protect against reinitialization attacks! Remember, just because the fix for these security exploits is simple doesn't mean it isn't important. Every time your initialize an account, make sure you're either using the init
constraint or have some other check in place to avoid resetting an existing account's initial state.
If you want to take a look at the final solution code you can find it on the solution
branch of this repository.
Just as with other lessons in this module, your opportunity to practice avoiding this security exploit lies in auditing your own or other programs.
Take some time to review at least one program and ensure that instructions are properly protected against reinitialization attacks.
Remember, if you find a bug or exploit in somebody else's program, please alert them! If you find one in your own program, be sure to patch it right away.