Solana Programs Common Vulnerabilities


1. Integer overflow or underflow

In Solana smart contracts (programs) written in Rust, integer overflow and underflow can occur when arithmetic operations exceed the maximum or minimum values of the integer type. By default, Rust performs checked arithmetic in debug mode but allows wrapping in release mode unless explicitly handled.

Let’s consider the example extracted from Neodyme’s Workshop, which by the way I highly recommend visiting.

fn withdraw(program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult {

    ...

    assert_eq!(vault_account.owner, program_id);
    assert_eq!(vault_account.authority, *authority_info.key);
    assert!(authority_info.is_signer, "authority must sign!");

    let min_balance = rent.minimum_balance(WALLET_LEN as usize);
    if min_balance + amount > **vault_account.lamports.borrow_mut() {
        return Err(ProgramError::InsufficientFunds);
    }

    msg!("Min balance: {}", min_balance);
    msg!("Amount: {}", amount);
    **vault_account.lamports.borrow_mut() -= amount;
    **destination_account.lamports.borrow_mut() += amount;

    ...
}

Happy path scenario:

  1. User creates a vault (user_vault) using our program, then deposits some SOL there.
  2. User withdraws deposited SOL using withdraw() function, using previously created user_vault as vault_account and his wallet as destination_account

Hack scenario:

  1. Hacker creates his own vault (hacker_vault) using our program
  2. Hacker calls withdraw function, but instead withdrawing from hacker_vault, he sets destination_account as user_vault and vault_account as hacker_vault. He puts huge number as amount
  3. All asserts pass:
  • hacker_vault was created by our program, so the owner check passes
  • hacker has the authority to withdraw from hacker_vault
  • hacker (authority) signs the transaction
  1. Overflow occurs here
if min_balance + amount > **vault_account.lamports.borrow_mut()

the result of min_balance + amount is very small number, less than minimum balance required for account to be rent exempt, so the above check will pass, even if hacker did not deposit anything in hacker_vault. 5. Overflow occurs again in

**vault_account.lamports.borrow_mut() -= amount; 
**destination_account.lamports.borrow_mut() += amount;

where there resulting amount of lamports in vault_account (our hacker_vault) is actually higher than before transaction and amount of lamports in destination_account (user_vault) is lower!

Mitigation

Use safe integer arithmetic operations:

  • checked_add() instead of +
  • checked_sub() instead of -
  • checked_mul() instead of *
  • checked_div() instead of /

2. Missing account signer check

Some instructions should only be accessible to a restricted set of accounts. For example, certain program configurations should be modified only by the admin. If you don’t verify that the admin has signed the transaction, anyone could invoke that instruction!

Let’s consider example from the previous vulnerability about integer overflows/underflows, but with signer check removed:

fn withdraw(program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult {

    ...

    assert_eq!(vault_account.owner, program_id);
    assert_eq!(vault_account.authority, *authority_info.key);
    // assert!(authority_info.is_signer, "authority must sign!");

    let min_balance = rent.minimum_balance(WALLET_LEN as usize);
    if min_balance + amount > **vault_account.lamports.borrow_mut() {
        return Err(ProgramError::InsufficientFunds);
    }

    msg!("Min balance: {}", min_balance);
    msg!("Amount: {}", amount);
    **vault_account.lamports.borrow_mut() -= amount;
    **destination_account.lamports.borrow_mut() += amount;

    ...
}

Hack scenario:

  1. Hacker calls withdraw function, he correctly passes all accounts for withdrawal, including vault_account and authority of that account.
  2. None of above accounts are owned or controlled by the hacker, but it doesn’t matter, because the program does not verify signature of authority account, we can pass there someone else’s account without having their private key.
  3. Funds are easily withdrawn from vault_account to hacker’s account

Mitigation

Just don’t forget to check the signer:

assert!(authority_info.is_signer, "authority must sign!");

3. Missing account owner check

Every account in Solana is owned by a program. By default all new accounts are owned by the System Program. The System Program can then assign ownership to a different program. Only the owner can:

  • Modify the account’s data field
  • Deduct lamports from the account’s balance

But anyone can read from an account. If you expect the account to be created and owned by your program, you have to verify owner of the account before reading from it, otherwise someone can inject data into your program.

Let’s consider very simple program where we can withdraw rewards if we are eligible.

use anchor_lang::prelude::*;

declare_id!("Example111111111111111111111111111111111111111");

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

    pub fn withdraw_rewards(ctx: Context<WithdrawRewards>, amount: u64) -> Result<()> {
        let eligibility_data = &ctx.accounts.rewards_eligibility_account;

        // 1) Ensure the user transaction signer is the same as the stored authority of rewards_eligibility_account
        require!(ctx.accounts.authority.is_signer, CustomError::MissingSignature);
        require!(eligibility_data.authority == ctx.accounts.authority.key(), CustomError::NotAuthorized);

        // 2) Ensure the user is actually marked as eligible
        require!(eligibility_data.is_eligible, CustomError::NotEligible);

        // 3) Transfer lamports from the vault to the user's destination
        **ctx.accounts.rewards_vault.try_borrow_mut_lamports()? -= amount;
        **ctx.accounts.destination.try_borrow_mut_lamports()? += amount;

        // 4) Mark the user as no longer eligible after withdrawal
        eligibility_data.is_eligible = false;

        Ok(())
    }
}

#[derive(Accounts)]
pub struct WithdrawRewards<'info> {
    // ❌ No ownership/PDA constraints here, so any account can be passed in
    pub rewards_eligibility_account: Account<'info, EligibilityData>,

    #[account(mut)]
    pub rewards_vault: AccountInfo<'info>,

    #[account(mut)]
    pub destination: AccountInfo<'info>,

    pub authority: Signer<'info>,
}

#[account]
pub struct EligibilityData {
    pub authority: Pubkey,
    pub is_eligible: bool,
}

Happy path scenario:

  1. User registers in our rewards system and rewards_eligibility_account is created for him (the code is ommitted for brevity)
  2. Once user earns the reward (for example through some interaction with our program), rewards_eligibility_account.is_eligibleturns to true
  3. User gets reward using withdraw_rewards() function

Hack scenario:

  1. Hacker calls withdraw_rewards() function passing his own rewards_eligibility_account with is_eligible flag set to true and rewards are sent to hacker’s account
  2. Repeats doing that, just editing is_eligible flag to true before each call
  3. rewards_vault is drained from rewards after a while

Mitigation

  1. Check the owner of every account that you expect your account created
  2. Consider using Program Derived Addresses (PDAs) which add another layer of security. When using Anchor framework you can add constraints to accounts:
#[derive(Accounts)]
pub struct SomeInstruction<'info> {
    #[account(
        mut,
        seeds = [b"vault", user.key().as_ref()],
        bump
    )]
    pub vault_pda: Account<'info, VaultData>,
    ...
}

Anchor will automatically check that:

  • The account’s public key is the correct Program-Derived Address (PDA) derived from (program_id, seeds, bump)
  • The account’s owner is your program.

4. Type Cosplay (Type Confusion)

This vulnerability is often named Type Cosplay in Solana, but it is better known as Type Confusion in a more wider security context. Whenever we save data to an account, it is serialized to a byte array, but not everything is serialized, we don’t have:

  • type of the data structure
  • field names

Hack scenario:

Let’s consider these two accounts:

pub struct Config {
    pub admin: Pubkey,
    pub fee: u32,
    pub user_count: u32,
}

pub struct User {
    pub user_authority: Pubkey,
    pub balance: u64,
}

For example, such Config account:

admin: 7DB7UBNvhN2uCbtSD5Vvzhmbu3vWA6riqiJn9BYpN8tY
user_count: 311
fee: 1265170944

after serialization would be:

5c44863f9618a18da6ba92f5382a32516662ae2038e7055aaa961217e40a1ec1 (32 bytes) + 00000137 (4 bytes) + 4B68FA00 (4 bytes)

= 5c44863f9618a18da6ba92f5382a32516662ae2038e7055aaa961217e40a1ec1000001374B68FA00

but when deserializing above byte array to User acccount, we will use first 32 bytes for user_authority public key and then next 8 bytes for u64 balance

5c44863f9618a18da6ba92f5382a32516662ae2038e7055aaa961217e40a1ec1 (32 bytes) + 000001374B68FA00 (8 bytes)

user_authority: 7DB7UBNvhN2uCbtSD5Vvzhmbu3vWA6riqiJn9BYpN8tY;
balance: 1337000000000

The hacker could use Config account wherever our program expects User. Please note that checking account owner is not enough, because both accounts are owned by our program.

Mitigation

Native programs

Add type field to your struct:

pub struct Config {
    pub account_type: AccountType,
    pub admin: Pubkey,
    pub fee: u32,
    pub user_count: u32,
}

pub struct User {
    pub account_type: AccountType,
    pub user_authority: Pubkey,
    pub balance: u64,
}

pub enum AccountType {
    Admin,
    User,
}

and check account_type every time you want to use an account.

Anchor programs

Anchor prevents this type of vulnerabilities, because when using #[account] macro, under the hood Anchor generates 8-byte discriminator from the struct’s name and adds it in the beginning of byte array

#[derive(Accounts)]
pub struct Instruction<'info> {
    config: Account<'info, Config>,
}

#[account]
pub struct Config {
    pub admin: Pubkey,
    pub fee: u32,
    pub user_count: u32,
}

#[account]
pub struct User {
    pub user_authority: Pubkey,
    pub balance: u64,
}

So instead of generic: 5c44863f9618a18da6ba92f5382a32516662ae2038e7055aaa961217e40a1ec1000001374B68FA00 we would have:

  • 9b0caae01efacc825c44863f9618a18da6ba92f5382a32516662ae2038e7055aaa961217e40a1ec100fa684b37010000 for Config (discriminator: 9b0caae01efacc82)
  • 9f755fe3ef973aec5c44863f9618a18da6ba92f5382a32516662ae2038e7055aaa961217e40a1ec100fa684b37010000 for User (discriminator: 9f755fe3ef973aec).

Please note that the way Anchor determines account (type) discriminator is by taking first 8 bytes of SHA256(“account:*type*”). In our example it will be SHA256("account:User")[0..8] and SHA256("account:Config")[0..8].

Sometimes it is not possible to use Anchor checks. For example, if we use accounts created by native programs. In that case we need to use AccountInfo or UncheckedAccount to handle account without Anchor discriminator and manually check validity of it.