Reinitialization Attacks

Summary #

  • Prevent Account Reinitialization: Use an account discriminator or initialization flag to prevent an account from being reinitialized and overwriting existing data.

  • Anchor Approach: Simplify this by using Anchor's init constraint to create an account via a CPI to the system program, automatically setting its discriminator.

  • Native Rust Approach: In native Rust, set an is_initialized flag during account initialization and check it before reinitializing:

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

Lesson #

Initialization sets the data of a new account for the first time. It's essential to check if an account has already been initialized to prevent overwriting existing data. Note that creating and initializing an account are separate actions. Creating an account involves invoking the create_account instruction handler on the System Program, which allocates space, rent in lamports, and assigns the program owner. Initialization sets the account data. These steps can be combined into a single transaction.

Missing Initialization Check #

In the example below, there's no check on the user account. The initialize instruction handler sets the authority field on the User account type and serializes the data. Without checks, an attacker could reinitialize the account, overwriting the existing authority.

use anchor_lang::prelude::*;
 
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
 
#[program]
pub mod initialization_insecure {
    use super::*;
 
    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        ctx.accounts.user.authority = ctx.accounts.authority.key();
        Ok(())
    }
}
 
#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(mut)]
    pub user: Account<'info, User>,
    #[account(mut)]
    pub authority: Signer<'info>,
    pub system_program: Program<'info, System>,
}
 
#[account]
#[derive(InitSpace)]
pub struct User {
    pub authority: Pubkey,
}

Add is_initialized Check #

To fix this, add an is_initialized field to the User account type and check it before reinitializing:

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

This ensures the user account is only initialized once. If is_initialized is true, the transaction fails, preventing an attacker from changing the account authority.

use anchor_lang::prelude::*;
use anchor_lang::solana_program::program_error::ProgramError;
 
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
 
#[program]
pub mod initialization_secure {
    use super::*;
 
    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        let user = &mut ctx.accounts.user;
 
        if user.is_initialized {
            return Err(ProgramError::AccountAlreadyInitialized.into());
        }
 
        user.is_initialized = true;
        user.authority = ctx.accounts.authority.key();
 
        Ok(())
    }
}
 
#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(mut)]
    pub user: Account<'info, User>,
    #[account(mut)]
    pub authority: Signer<'info>,
    pub system_program: Program<'info, System>,
}
 
#[account]
#[derive(InitSpace)]
pub struct User {
    pub is_initialized: bool,
    pub authority: Pubkey,
}

Use Anchor's init Constraint #

Anchor's init constraint, used with the #[account(...)] attribute, initializes an account, sets the account discriminator, and ensures that the instruction handler can only be called once per account. The init constraint must be used with payer and space constraints to specify the account paying for initialization and the amount of space required.

use anchor_lang::prelude::*;
 
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
 
const DISCRIMINATOR_SIZE: usize = 8;
 
#[program]
pub mod initialization_recommended {
    use super::*;
 
    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        msg!("GM");
        ctx.accounts.user.authority = ctx.accounts.authority.key();
        Ok(())
    }
}
 
#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(
        init,
        payer = authority,
        space = DISCRIMINATOR_SIZE + User::INIT_SPACE
    )]
    pub user: Account<'info, User>,
    #[account(mut)]
    pub authority: Signer<'info>,
    pub system_program: Program<'info, System>,
}
 
#[account]
#[derive(InitSpace)]
pub struct User {
    pub authority: Pubkey,
}

Anchor's init_if_needed Constraint #

Caution

Anchor's init_if_needed constraint, guarded by a feature flag, should be used with caution.It initializes an account only if it hasn't been initialized yet. If the account is already initialized, the instruction handler will still execute, so it's *****extremely***** important to include checks in your instruction handler to prevent resetting the account to its initial state.

For example, if the authority field is set in the instruction handler, ensure that your instruction handler includes checks to prevent an attacker from reinitializing it after it's already been set. Typically, it's safer to have a separate instruction handler for initializing account data.

Lab #

In this lab, we'll create a simple Solana program with two instruction handlers:

  • insecure_initialization - Initializes an account without checks, allowing reinitialization.
  • recommended_initialization - Initializes an account using Anchor's init constraint, preventing reinitialization.

1. Starter #

To get started, download the starter code from the starter branch of this repository. The starter code includes a program with one instruction handler and the boilerplate setup for the test file.

The insecure_initialization instruction handler initializes a new user account that stores the public key of an authority. The account is expected to be allocated client-side and then passed into the program instruction. However, there are no checks to verify if the user account's initial state has already been set. This means the same account can be passed in a second time, allowing the authority to be overwritten.

use anchor_lang::prelude::*;
 
declare_id!("HLhxJzFYjtXCET4HxnSzv27SpXg16FWNDi2LvrNmSvzH");
 
#[program]
pub mod initialization {
    use super::*;
 
    pub fn insecure_initialization(ctx: Context<Unchecked>) -> Result<()> {
        let user = &mut ctx.accounts.user;
        let mut user_data = User::try_from_slice(&user.data.borrow())?;
        user_data.authority = ctx.accounts.authority.key();
        user_data.serialize(&mut *user.data.borrow_mut())?;
        Ok(())
    }
}
 
#[derive(Accounts)]
pub struct Unchecked<'info> {
    #[account(mut)]
    /// CHECK: This account will be initialized in the instruction
    pub user: UncheckedAccount<'info>,
    pub authority: Signer<'info>,
}
 
#[account]
#[derive(InitSpace)]
pub struct User {
    pub authority: Pubkey,
}

2. Test insecure_initialization Instruction Handler #

The test file includes the setup to create an account by invoking the system program and then invokes the insecure_initialization instruction handler twice using the same account.

Since there are no checks in the insecure_initialization instruction handler to verify that the account data has not already been initialized, this instruction handler will execute successfully both times, even with a different authority account.

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Initialization } from "../target/types/initialization";
import {
  Keypair,
  LAMPORTS_PER_SOL,
  SystemProgram,
  Transaction,
  SendTransactionError,
} from "@solana/web3.js";
import { expect } from "chai";
import { airdropIfRequired } from "@solana-developers/helpers";
 
describe("Initialization", () => {
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);
 
  const program = anchor.workspace.Initialization as Program<Initialization>;
 
  const walletAuthority = provider.wallet as anchor.Wallet;
  const secondWallet = Keypair.generate();
 
  const insecureUserAccount = Keypair.generate();
  const recommendedUserAccount = Keypair.generate();
 
  const ACCOUNT_SPACE = 32;
  const AIRDROP_AMOUNT = 1 * LAMPORTS_PER_SOL;
  const MINIMUM_BALANCE_FOR_RENT_EXEMPTION = 1 * LAMPORTS_PER_SOL;
 
  before(async () => {
    try {
      const rentExemptionAmount =
        await provider.connection.getMinimumBalanceForRentExemption(
          ACCOUNT_SPACE,
        );
 
      const createAccountInstruction = SystemProgram.createAccount({
        fromPubkey: walletAuthority.publicKey,
        newAccountPubkey: insecureUserAccount.publicKey,
        space: ACCOUNT_SPACE,
        lamports: rentExemptionAmount,
        programId: program.programId,
      });
 
      const transaction = new Transaction().add(createAccountInstruction);
 
      await anchor.web3.sendAndConfirmTransaction(
        provider.connection,
        transaction,
        [walletAuthority.payer, insecureUserAccount],
      );
 
      await airdropIfRequired(
        provider.connection,
        secondWallet.publicKey,
        AIRDROP_AMOUNT,
        MINIMUM_BALANCE_FOR_RENT_EXEMPTION,
      );
    } catch (error) {
      console.error("Setup failed:", error);
      throw error;
    }
  });
 
  it("performs insecure initialization", async () => {
    try {
      await program.methods
        .insecureInitialization()
        .accounts({
          user: insecureUserAccount.publicKey,
          authority: walletAuthority.publicKey,
        })
        .signers([walletAuthority.payer])
        .rpc();
    } catch (error) {
      console.error("Insecure initialization failed:", error);
      throw error;
    }
  });
 
  it("re-invokes insecure initialization with different authority", async () => {
    try {
      const transaction = await program.methods
        .insecureInitialization()
        .accounts({
          user: insecureUserAccount.publicKey,
          authority: secondWallet.publicKey,
        })
        .signers([secondWallet])
        .transaction();
 
      await anchor.web3.sendAndConfirmTransaction(
        provider.connection,
        transaction,
        [secondWallet],
      );
    } catch (error) {
      console.error("Re-invocation of insecure initialization failed:", error);
      throw error;
    }
  });
});

Run anchor test to verify that the insecure_initialization instruction handler executes successfully in both invocations.

Initialization
 performs insecure initialization (420ms)
 re-invokes insecure initialization with different authority (419ms)

Now, let's create a new instruction handler called recommended_initialization that addresses the issue. Unlike the insecure instruction handler, this one will handle both the creation and initialization of the user's account using Anchor's init constraint.

This constraint ensures the account is created via a CPI to the system program, and the discriminator is set. This way, any subsequent invocation with the same user account will fail, preventing reinitialization.

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 = DISCRIMINATOR_SIZE + User::INIT_SPACE
    )]
    user: Account<'info, User>,
    #[account(mut)]
    authority: Signer<'info>,
    system_program: Program<'info, System>,
}
 
#[account]
#[derive(InitSpace)]
pub struct User {
    pub authority: Pubkey,
}

To test the recommended_initialization instruction handler, invoke it twice as before. This time, the transaction should fail when attempting to initialize the same account a second time.

describe("Initialization", () => {
  ...
  it("performs recommended initialization", async () => {
    try {
      await program.methods
        .recommendedInitialization()
        .accounts({
          user: recommendedUserAccount.publicKey,
        })
        .signers([recommendedUserAccount])
        .rpc();
    } catch (error) {
      console.error("Recommended initialization failed:", error);
      throw error;
    }
  });
 
  it("fails to re-invoke recommended initialization with different authority", async () => {
    try {
      const transaction = await program.methods
        .recommendedInitialization()
        .accounts({
          user: recommendedUserAccount.publicKey,
          authority: secondWallet.publicKey,
        })
        .transaction();
 
      await anchor.web3.sendAndConfirmTransaction(
        provider.connection,
        transaction,
        [secondWallet, recommendedUserAccount],
        { commitment: "confirmed" }
      );
 
      throw new Error("Re-invocation succeeded unexpectedly");
    } catch (error) {
      if (error.message === "Re-invocation succeeded unexpectedly") {
        throw error;
      }
 
      if (error instanceof SendTransactionError) {
        console.log("Transaction failed as expected");
      } else {
        console.error("Unexpected error:", error);
      }
      console.log(error)
      expect(error).to.exist;
    }
  });
});

Run anchor test to confirm that the second transaction fails with an error indicating the account is already in use.

'Program HLhxJzFYjtXCET4HxnSzv27SpXg16FWNDi2LvrNmSvzH invoke [1]',
'Program log: Instruction: RecommendedInitialization',
'Program 11111111111111111111111111111111 invoke [2]',
'Allocate: account Address { address: FcW7tG71GKuRgxEbgFuuNQNV3HVSMmVyKATo74iCK4yi, base: None } already in use',
'Program 11111111111111111111111111111111 failed: custom program error: 0x0',
'Program HLhxJzFYjtXCET4HxnSzv27SpXg16FWNDi2LvrNmSvzH consumed 3330 of 200000 compute units',
'Program HLhxJzFYjtXCET4HxnSzv27SpXg16FWNDi2LvrNmSvzH failed: custom program error: 0x0'

Using Anchor's init constraint is usually sufficient to protect against reinitialization attacks. While the fix for these security exploits is straightforward, it is crucial. Every time you initialize an account, ensure that you're either using the init constraint or implementing another check to prevent resetting an existing account's initial state.

For the final solution code, refer to the solution branch of this repository.

Challenge #

Your challenge is to audit your own or other programs to practice avoiding this security exploit.

Take some time to review at least one program and confirm that instruction handlers are adequately protected against reinitialization attacks.

If you find a bug or exploit in another program, alert the developer. If you find one in your own program, patch it immediately.

Completed the lab?

Push your code to GitHub and tell us what you thought of this lesson!