Back to all posts
Solana Sealevel Attacks In A Nutshell

Anchor is a framework for building Solana programs. It provides a lot of features and abstractions that make it easier to build programs. This post is a summary of Anchor's sealevel-attacks, list of footguns/attacks and protections in the Solana programming model.

1. Signer Authorization

One of the most critical security checks is verifying that an "authority" account has actually signed the transaction.

Without proper checks, anyone could pass an authority account without proving ownership.

⚠️ Vulnerable: The authority account is not verified as a transaction signer

pub struct User<'info> {
    authority: AccountInfo<'info>,
}

✓ Secure: The authority must be a transaction signer

pub struct User<'info> {
    authority: Signer<'info>,
}

2. Account Data Validation

Ensure that the accounts contain the valid data.

For instance, when working with token accounts, verify the token account contains owner, mint, and amount fields.

⚠️ Vulnerable: No validation of token account data structure

pub struct User<'info> {
    token: AccountInfo<'info>,
    authority: Signer<'info>,
}

✓ Secure: Add a constraint to verify that the token account is owned by the authority

pub struct User<'info> {
    #[account(constraint = authority.key == &token.owner)]
    token: Account<'info, TokenAccount>,
    authority: Signer<'info>,
}

3. Checking Account Ownership

Ensure that passed-in accounts are owned by the expected program.

For example, token accounts must be owned by the SPL Token program to prevent manipulation with fake accounts.

⚠️ Vulnerable: No verification of token account program ownership

let token = SplTokenAccount::unpack(&ctx.accounts.token.data.borrow())?;
if ctx.accounts.authority.key != &token.owner {
    return Err(ProgramError::InvalidAccountData);
}
msg!("Your account balance is: {}", token.amount)?;

✓ Secure: Add an Anchor constraint to verify program ownership

pub struct User<'info> {
    #[account(constraint = authority.key == &token.owner)]
    token: Account<'info, TokenAccount>,
    authority: Signer<'info>,
}

4. Account Type Confusion

Prevent account type confusion by ensuring different account types can be distinguished from each other.

Without proper type discrimination, one account type could be mistaken for another.

⚠️ Vulnerable: No way to distinguish between User and Metadata accounts

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

#[derive(BorshSerialize, BorshDeserialize)]
pub struct Metadata {
    account: Pubkey,
}

Manual fix using discriminants:

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

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

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

✓ Secure: Using Anchor's #[account] macro for automatic discriminators (where Anchor adds an 8-byte discriminator)

#[account]
pub struct User {
    authority: Pubkey,
}

#[account]
pub struct Metadata {
    account: Pubkey,
}

5. Account Initialization

When creating new accounts, proper initialization with discriminators is important.

Otherwise, this could lead to both incorrect account initialization and unnecessary re-initialization.

⚠️ Vulnerable: No discriminator and allows re-initialization

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

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

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

✓ Secure: Anchor's #[account(init)] constraint handles initialization safely

#[derive(Accounts)]
pub struct Init<'info> {
    #[account(init, payer = authority, space = 8+32)]
    user: Account<'info, User>,
    authority: Signer<'info>,
    system_program: Program<'info, System>,
}

6. Arbitrary CPI

When performing CPIs, make sure you're invoking the correct program.

⚠️ Vulnerable: No verification of token program address

pub fn cpi(ctx: Context<Cpi>, amount: u64) -> ProgramResult {
    solana_program::program::invoke(
        &spl_token::instruction::transfer(
            ctx.accounts.token_program.key,
            ctx.accounts.source.key,
            ctx.accounts.destination.key,
            ctx.accounts.authority.key,
            &[],
            amount,
        )?,
        &[
            ctx.accounts.source.clone(),
            ctx.accounts.destination.clone(),
            ctx.accounts.authority.clone(),
        ],
    )
}

Manual program ID verification:

pub fn cpi_secure(ctx: Context<Cpi>, amount: u64) -> ProgramResult {
    if &spl_token::ID != ctx.accounts.token_program.key {
        return Err(ProgramError::IncorrectProgramId);
    }
    solana_program::program::invoke(
        &spl_token::instruction::transfer(
            ctx.accounts.token_program.key,
            ctx.accounts.source.key,
            ctx.accounts.destination.key,
            ctx.accounts.authority.key,
            &[],
            amount,
        )?,
        &[
            ctx.accounts.source.clone(),
            ctx.accounts.destination.clone(),
            ctx.accounts.authority.clone(),
        ],
    )
}

✓ Secure: Using Anchor's wrapper of the SPL token program

use anchor_spl::token::{self, Token, TokenAccount};

#[program]
pub mod arbitrary_cpi_recommended {
    use super::*;
    
    pub fn cpi(ctx: Context<Cpi>, amount: u64) -> ProgramResult {
        token::transfer(ctx.accounts.transfer_ctx(), amount)
    }
}

#[derive(Accounts)]
pub struct Cpi<'info> {
    source: Account<'info, TokenAccount>,
    destination: Account<'info, TokenAccount>,
    authority: Signer<'info>,
    token_program: Program<'info, Token>,
}

impl<'info> Cpi<'info> {
    pub fn transfer_ctx(&self) -> CpiContext<'_, '_, '_, 'info, token::Transfer<'info>> {
        let program = self.token_program.to_account_info();
        let accounts = token::Transfer {
            from: self.source.to_account_info(),
            to: self.destination.to_account_info(),
            authority: self.authority.to_account_info(),
        };
        CpiContext::new(program, accounts)
    }
}

7. Duplicate Mutable Accounts

When working with multiple mutable accounts, ensure they're distinct.

⚠️ Vulnerable: Same account could be used for both user_a and user_b

pub fn update(ctx: Context<Update>, a: u64, b: u64) -> ProgramResult {
    let user_a = &mut ctx.accounts.user_a;
    let user_b = &mut ctx.accounts.user_b;
    user_a.data = a;
    user_b.data = b;
    Ok(())
}

#[derive(Accounts)]
pub struct Update<'info> {
    user_a: Account<'info, User>,
    user_b: Account<'info, User>,
}

✓ Secure: Enforce unique accounts using constraints

#[derive(Accounts)]
pub struct Update<'info> {
    #[account(constraint = user_a.key() != user_b.key())]
    user_a: Account<'info, User>,
    user_b: Account<'info, User>,
}

8. PDA Bump Seed Canonicalization

When working with Program Derived Addresses (PDAs), always use the canonical bump seed to ensure uniqueness.

Using create_program_address with user-provided bumps is not recommended because, multiple valid PDAs could exist for the same seeds.

Always verify the canonical bump either by storing it and re-using it with create_program_address or by using Pubkey::find_program_address and comparing the expected bump.

⚠️ Vulnerable: User-provided bump allows multiple valid PDAs

pub fn set_value(ctx: Context<BumpSeed>, key: u64, new_value: u64, bump: u8) -> ProgramResult {
    let address = Pubkey::create_program_address(
        &[key.to_le_bytes().as_ref(), &[bump]], 
        ctx.program_id
    )?;
    if address != ctx.accounts.data.key() {
        return Err(ProgramError::InvalidArgument);
    }

    ctx.accounts.data.value = new_value;
    Ok(())
}

✓ Secure: Verify canonical bump seed

pub fn set_value_secure(
    ctx: Context<BumpSeed>,
    key: u64,
    new_value: u64,
    bump: u8,
) -> ProgramResult {
    let (address, expected_bump) = Pubkey::find_program_address(
        &[key.to_le_bytes().as_ref()],
        ctx.program_id
    );
    if address != ctx.accounts.data.key() {
        return Err(ProgramError::InvalidArgument);
    }
    if expected_bump != bump {
        return Err(ProgramError::InvalidArgument);
    }

    ctx.accounts.data.value = new_value;
    Ok(())
}

9. PDA Sharing

Use unique PDAs for different authority domains to maintain proper security boundaries.

For example, in DeFi applications, each liquidity pool should have its own unique PDA authority derived from its specific parameters (like token pair addresses).

10. Closing Accounts

When an account is no longer needed, close it properly to reclaim rent and prevent security issues.

✓ Recommended: Use Anchor's close constraint

#[derive(Accounts)]
pub struct Close<'info> {
    #[account(mut, close = destination)]
    account: Account<'info, Data>,
    destination: AccountInfo<'info>,
}