DEV Community

Cover image for Solana Vulnerabilities Every Developer Should Know
Mira
Mira

Posted on

Solana Vulnerabilities Every Developer Should Know

Solana Security: The $500M+ Lesson

Solana, rust, anchor, pinocchio

$325 million stolen from Wormhole. $115 million drained from Mango Markets. $52 million vanished from Cashio. These aren't hypothetical scenarios or theoretical attacks. These are real hacks that happened on Solana, affecting real projects, and costing real money to real people.

Between 2021 and 2025, Solana-based protocols have lost over $450 million to exploits, with individual incidents ranging from hundreds of thousands to hundreds of millions of dollars. The DEXX hack in November 2024 alone affected over 9,000 wallets and drained $30 million. In January 2025, the NoOnes bridge exploit siphoned $8 million across multiple chains.

Here's what makes this terrifying: most of these hacks weren't sophisticated zero-days or nation-state attacks. They were simple mistakes. Missing signer checks. Unverified account ownership. Fake accounts accepted as real. The kind of bugs that could have been caught with proper validation.

If you're building on Solana, you're not just writing code. You're handling other people's money. Every missing check is a potential exploit. Every unvalidated account is a backdoor. This guide breaks down 15 critical vulnerabilities that have cost projects millions, shows you exactly how each exploit worked, and teaches you how to fix them before they cost you everything.

The Reality of Solana Security

Solana's architecture is fundamentally different from EVM chains. Programs are stateless. Accounts are passed in by users. Nothing can be trusted by default. This creates unique security challenges that developers coming from Ethereum often miss.

The golden rule: Trust nothing. Not the account passed in. Not its owner. Not the signer. Not the data inside it. Every single thing must be explicitly validated.

The examples below cover 15 vulnerabilities from intermediate to advanced. Each has both a vulnerable and a secure version, with tests that demonstrate the exploit and confirm the fix. They are implemented in Anchor and Pinocchio so you can see how different frameworks handle the same issues.


1. Missing Signer Check

Type: Access Control | Severity: Critical

What Breaks

Your program needs someone to authorize an action (like withdrawing from a vault). You check that the right public key is present in the instruction, but you never verify that the key's owner actually signed the transaction. An attacker can pass anyone's public key without owning it, and your program executes the privileged action anyway.

Real Exploit

In August 2021, a hacker attempted to steal $2 million from Solend by manipulating protocol parameters. They bypassed admin checks by passing the admin's public key without signing with it. The protocol checked "is this the admin's key?" but not "did the admin actually sign this?" The attack was caught before funds were lost, but it exposed a critical flaw that exists in countless programs.

The Attack Pattern

1. Vault stores authority = "AuthPubkey123"
2. Attacker sends instruction with authority account = "AuthPubkey123"  
3. Program checks: vault.authority == passed_authority.key ✓
4. Program never checks: passed_authority.is_signer
5. Withdrawal executes, funds gone
Enter fullscreen mode Exit fullscreen mode

The attacker never needed the private key. They just needed to know the public key and pass it through.

How to Fix

Anchor: Use Signer<'info> instead of AccountInfo

pub struct Withdraw<'info> {
    #[account(mut)]
    pub vault: Account<'info, Vault>,
    pub authority: Signer<'info>,  // Enforces signature check
}
Enter fullscreen mode Exit fullscreen mode

Pinocchio/Native: Manually verify the signer flag

if !authority.is_signer() {
    return Err(ProgramError::MissingRequiredSignature);
}
Enter fullscreen mode Exit fullscreen mode

The fix is simple. The cost of missing it is catastrophic.


2. Missing Owner Check

Type: Access Control | Severity: Critical

What Breaks

Every Solana account has an "owner" field indicating which program controls it. Your program acts on an account (like reading vault data) without checking if your program actually owns it. An attacker creates a fake account with identical data layout but owned by a different program. Your program reads it, trusts it, and gets exploited.

Real Exploit

The Solend exploit in August 2021 involved creating fake lending markets with manipulated parameters. Because the program didn't verify account ownership, the attacker's fake accounts were treated as legitimate protocol accounts. They set liquidation thresholds to 1%, liquidation bonuses to 90%, and attempted to liquidate healthy positions for massive profit.

In the Crema Finance hack (July 2022, $8.8M stolen), the attacker created a fake "Tick" account with false price data. This fake account wasn't owned by Crema's program, but because ownership wasn't validated, it was accepted as real. The attacker used this fake data to claim inflated LP fees through flash loans.

The Attack Pattern

1. Real vault owned by YourProgram, has authority + balance
2. Attacker creates fake vault owned by SystemProgram  
3. Fake vault has same data layout, attacker's key as authority
4. Program accepts fake vault, reads attacker's authority
5. Program allows withdrawal, funds stolen
Enter fullscreen mode Exit fullscreen mode

How to Fix

Anchor: Use Account<'info, T> which automatically verifies ownership

#[account(mut)]
pub vault: Account<'info, Vault>,  // Checks owner == program_id
Enter fullscreen mode Exit fullscreen mode

Pinocchio/Native: Manually check the owner field

if account.owner() != program_id {
    return Err(ProgramError::IllegalOwner);
}
Enter fullscreen mode Exit fullscreen mode

Ownership checks are not optional. They're the foundation of Solana security.


3. Account Data Matching

Type: Account Validation | Severity: High

What Breaks

An account might be the correct type and owned by your program, but it's not the specific account you need. For example, accepting any token account when you specifically need the pool's token account. The attacker substitutes their own account that passes type checks but doesn't match the required relationships.

Real Exploit

This vulnerability enabled the Solend oracle manipulation attack (November 2022, $1.26M stolen). The protocol accepted USDH as collateral but only checked price data from a single Saber pool. The attacker traded between Saber and Orca, manipulating the Saber pool price while keeping costs low. They deposited USDH valued at the inflated price ($8.80 instead of $1.00), borrowed against it, and defaulted on the loan.

The root cause: the oracle only validated that it was reading a price feed, not that it was reading the correct price feed that represented true market prices across multiple sources.

The Attack Pattern

1. Pool expects token account for MintA
2. Attacker passes token account for MintB (which they control)
3. Both are valid token accounts, both owned by TokenProgram
4. Program accepts it because type matches
5. Attacker manipulates their own mint, drains pool
Enter fullscreen mode Exit fullscreen mode

How to Fix

Anchor: Use constraints to validate relationships

#[account(
    constraint = user_token.mint == pool.mint,
    constraint = user_token.owner == user.key()
)]
pub user_token: Account<'info, TokenAccount>,
Enter fullscreen mode Exit fullscreen mode

Pinocchio/Native: Manually compare public keys

if token_account.mint != expected_mint {
    return Err(ProgramError::InvalidAccountData);
}
if token_account.owner != expected_owner {
    return Err(ProgramError::InvalidAccountData);
}
Enter fullscreen mode Exit fullscreen mode

Validate not just type, but context. Every relationship matters.


4. Type Cosplay

Type: Account Validation | Severity: Critical

What Breaks

You have multiple account types (Vault, User) with similar data layouts. If certain fields align at the same byte offsets, an attacker can pass one type where another is expected. Your program reads a User's data as if it were a Vault, misinterpreting field meanings entirely.

Real Exploit

While there's no single famous "type cosplay" hack, this vulnerability class has been identified in multiple security audits. Programs that store different account types without discriminators are vulnerable to having accounts misinterpreted. If a User struct and a Vault struct both have a u64 at offset 8, an attacker can create a User with a high value there and pass it as a Vault, making the program think it has more funds than it does.

The Attack Pattern

Vault: [discriminator: 8 bytes][authority: 32 bytes][balance: 8 bytes]
User:  [discriminator: 8 bytes][user_key: 32 bytes][points: 8 bytes]

1. Attacker creates User with 1,000,000 points
2. Passes User account to instruction expecting Vault
3. Program reads offset 48 (balance field)
4. Gets 1,000,000 (actually the points field)
5. Allows withdrawal based on fake balance
Enter fullscreen mode Exit fullscreen mode

How to Fix

Anchor: Use discriminators automatically via Account<'info, T>

// Anchor adds 8-byte discriminator to each type automatically
#[account]
pub struct Vault {
    pub authority: Pubkey,
    pub balance: u64,
}
Enter fullscreen mode Exit fullscreen mode

Pinocchio/Native: Implement and verify discriminators manually

const VAULT_DISCRIMINATOR: [u8; 8] = /* hash("account:Vault") */;

let disc = &account.data[0..8];
if disc != VAULT_DISCRIMINATOR {
    return Err(ProgramError::InvalidAccountData);
}
Enter fullscreen mode Exit fullscreen mode

Every account type needs a unique identifier. No exceptions.


5. PDA Bump Seed Canonicalization

Type: Account Validation | Severity: High

What Breaks

Program Derived Addresses (PDAs) are found using seeds and a "bump" value. find_program_address returns the canonical bump (usually 255, the first valid one). But other bumps can also create valid PDAs at different addresses. If your program accepts any valid bump without checking it's canonical, attackers can create shadow PDAs at different addresses for the same logical seeds.

Real Exploit

This vulnerability has been found in numerous audits. If a program stores "vault" PDAs using [b"vault", user_pubkey] as seeds but doesn't enforce the canonical bump, an attacker can:

  1. Find the canonical PDA at bump 255
  2. Find another valid PDA at bump 254 (different address)
  3. Initialize the non-canonical PDA
  4. Now two "vaults" exist for the same user
  5. Attacker controls one, can drain funds or create accounting chaos

The impact depends on program logic, but it fundamentally breaks the assumption that "one set of seeds = one address."

The Attack Pattern

Seeds: [b"vault", user_pubkey]
Canonical bump: 255 -> Address A
Non-canonical bump: 254 -> Address B

1. Real vault at Address A, bump 255
2. Attacker creates account at Address B, bump 254
3. Program accepts both as valid (both pass PDA checks)
4. Attacker has shadow vault, can bypass restrictions
Enter fullscreen mode Exit fullscreen mode

How to Fix

Anchor: Store and verify the canonical bump

#[account(
    init,
    payer = user,
    space = 8 + 32 + 1,
    seeds = [b"vault", user.key().as_ref()],
    bump  // Anchor finds and stores canonical bump
)]
pub vault: Account<'info, Vault>,

// Later validations automatically check:
// seeds = [b"vault", user.key().as_ref()],
// bump = vault.bump  // Must match stored canonical bump
Enter fullscreen mode Exit fullscreen mode

Pinocchio/Native: Find and store canonical bump explicitly

let (pda, bump) = Pubkey::find_program_address(
    &[b"vault", user_pubkey.as_ref()],
    program_id
);

// Store bump in account data
vault.bump = bump;

// Later, verify it matches
if account_key != Pubkey::create_program_address(
    &[b"vault", user_pubkey.as_ref(), &[vault.bump]],
    program_id
)? {
    return Err(ProgramError::InvalidSeeds);
}
Enter fullscreen mode Exit fullscreen mode

One PDA per seed set. Enforce it.


6. Account Reinitialization

Type: Account Validation | Severity: Critical

What Breaks

An initialize instruction can be called on an already-initialized account, overwriting its data. An attacker calls initialize on someone else's vault, overwrites the authority field with their key, and takes control of existing funds.

Real Exploit

This was a core vulnerability in early Solana programs before best practices solidified. The pattern showed up in the Slope wallet compromise (though that was ultimately a key exposure issue), where re-initialization concerns were part of the broader security review that followed.

The Attack Pattern

1. User creates vault, authority = User's key
2. Vault accumulates funds
3. Attacker calls initialize on same vault account
4. Program overwrites authority = Attacker's key
5. Attacker now controls user's funds
Enter fullscreen mode Exit fullscreen mode

How to Fix

Anchor: Use init which fails if account exists

#[account(
    init,  // Fails if account already has data
    payer = user,
    space = 8 + 32 + 8
)]
pub vault: Account<'info, Vault>,
Enter fullscreen mode Exit fullscreen mode

Pinocchio/Native: Check if already initialized

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

// Or check discriminator
if account.data[0..8] != [0u8; 8] {
    return Err(ProgramError::AccountAlreadyInitialized);
}
Enter fullscreen mode Exit fullscreen mode

Initialize once. Verify always.


7. Arbitrary CPI

Type: Cross-Program Security | Severity: Critical

What Breaks

Cross-Program Invocations (CPI) let your program call other programs. If you accept the target program ID from user input without validation, an attacker can point it to their malicious program. Your program invokes the fake program with your authority, and the attacker's code runs with your program's privileges.

Real Exploit

While no single massive CPI exploit made headlines, this vulnerability class is found regularly in audits. The danger is particularly acute when programs invoke token transfers or other privileged operations. If the program ID isn't hardcoded or validated, an attacker's program can receive your PDA signatures and drain funds.

The Attack Pattern

1. Program intends to CPI to Token Program for transfer
2. Program accepts token_program account from user
3. Attacker passes their malicious program instead
4. CPI executes to malicious program with PDA signature
5. Malicious program has your program's authority, drains funds
Enter fullscreen mode Exit fullscreen mode

How to Fix

Anchor: Use Program<'info, T> to fix program at compile time

pub struct Transfer<'info> {
    pub token_program: Program<'info, Token>,  // Must be Token Program
}
Enter fullscreen mode Exit fullscreen mode

Pinocchio/Native: Hardcode and verify program IDs

const TOKEN_PROGRAM_ID: Pubkey = /* known token program */;

if program.key != TOKEN_PROGRAM_ID {
    return Err(ProgramError::IncorrectProgramId);
}
Enter fullscreen mode Exit fullscreen mode

Never CPI to user-supplied program IDs. Ever.


8. Integer Overflow

Type: Arithmetic Safety | Severity: High

What Breaks

Arithmetic operations overflow or underflow without panicking. A user withdraws 11 tokens from a balance of 10. With unchecked math, 10 - 11 wraps to maximum value (18 quintillion for u64). User now has infinite balance.

Real Exploit

The Nirvana Finance hack (July 2022, $3.5M stolen) involved arithmetic manipulation. While not a simple overflow, the exploit relied on the protocol's pricing curve calculation being vulnerable to extreme inputs that created artificial price inflation. The attacker used flash loans to manipulate internal accounting, ultimately draining the treasury.

Overflow vulnerabilities have been found in numerous audits across DeFi protocols. They're particularly dangerous in reward calculations, fee distributions, and price computations.

The Attack Pattern

Balance: 10 tokens
Withdrawal: 11 tokens

Unchecked: 10 - 11 = 18446744073709551615 (wraparound)
Checked: 10 - 11 = Error

1. User deposits 10 tokens
2. User withdraws 11 tokens
3. With unchecked math, balance wraps to maximum
4. User can now withdraw infinite tokens
Enter fullscreen mode Exit fullscreen mode

How to Fix

Rust/Anchor: Use checked arithmetic

// Bad
vault.balance = vault.balance - amount;

// Good
vault.balance = vault.balance
    .checked_sub(amount)
    .ok_or(ProgramError::InsufficientFunds)?;

// Also good
vault.balance = vault.balance
    .saturating_sub(amount);
Enter fullscreen mode Exit fullscreen mode

Enable overflow checks in Cargo.toml

[profile.release]
overflow-checks = true
Enter fullscreen mode Exit fullscreen mode

Check every operation. Assume users will try to break math.


9. Closing Accounts Without Clearing Data And Rent Siphoning

Type: State Management | Severity: High

What Breaks

Closing an account transfers its lamports (rent) to another account but doesn't zero its data. The account sits dormant with old data intact. An attacker re-funds it with minimal lamports before garbage collection. The account "revives" with stale authority, state, or permissions, and the attacker can abuse the old data.

Real Exploit

This has been a persistent issue in Solana programs. The Raydium exploit (December 2022, $4.4M stolen) involved compromised admin keys, but during the security review afterward, account closure procedures were scrutinized. Proper account closure prevents many attack vectors that rely on resurrecting old state.

The Attack Pattern

1. User closes vault: transfers lamports, leaves data
2. Account sits with 0 lamports, old authority in data
3. Attacker funds account with 1 lamport in same tx
4. Account "alive" again with stale authority
5. Program treats revived account as valid
Enter fullscreen mode Exit fullscreen mode

How to Fix

Anchor: Use close constraint which zeros data

#[account(
    mut,
    close = destination  // Transfers lamports AND zeros data
)]
pub vault: Account<'info, Vault>,
Enter fullscreen mode Exit fullscreen mode

Pinocchio/Native: Explicitly zero data and transfer lamports

// Zero all data
account.data.fill(0);

// Transfer lamports
**destination.lamports.borrow_mut() += **account.lamports.borrow();
**account.lamports.borrow_mut() = 0;
Enter fullscreen mode Exit fullscreen mode

Dead accounts must stay dead. Zero everything.


10. Duplicate Mutable Accounts

Type: Account Validation | Severity: High

What Breaks

An instruction accepts two mutable accounts (source and destination for a transfer). It doesn't verify they're different. Attacker passes the same account for both. Depending on read/write order, this can double balances, skip fees, or create accounting chaos.

Real Exploit

This vulnerability appeared in the Jet Protocol discovery (December 2021, potential $25M if exploited). The issue involved how the protocol handled position arrays when accounts were closed. While the specific bug was different, it highlighted how account relationship assumptions can be violated when the same account appears multiple times.

The Attack Pattern

Transfer 50 from source to destination
Source = Destination = Same account (balance 100)

Vulnerable code:
source.balance -= 50     // balance = 50
dest.balance += 50       // balance = 100 (if reading stale value)

Result: Created 50 tokens from nothing
Enter fullscreen mode Exit fullscreen mode

How to Fix

Anchor: Add constraint to enforce different accounts

#[account(
    mut,
    constraint = source.key() != destination.key()
)]
pub source: Account<'info, TokenAccount>,

#[account(mut)]
pub destination: Account<'info, TokenAccount>,
Enter fullscreen mode Exit fullscreen mode

Pinocchio/Native: Explicitly check equality

if source.key() == destination.key() {
    return Err(ProgramError::InvalidArgument);
}
Enter fullscreen mode Exit fullscreen mode

Assume users will try every input combination. Defend against all of them.


11. Insecure Randomness

Type: Randomness | Severity: Medium-High

What Breaks

Program needs randomness (lottery, game, NFT reveal) and uses on-chain data like slot number, timestamp, or recent blockhash. These values are public and predictable. Attackers (or validators) simulate outcomes and only submit transactions when they win.

Real Exploit

Numerous NFT mints and gaming applications on Solana have been vulnerable to randomness manipulation. Users can simulate mints locally, check which slots produce rare traits, and only submit during those slots. This turned "random" drops into deterministic farming.

In DeFi, oracle-based games and prediction markets have been manipulated when they relied on predictable on-chain values instead of verifiable random functions.

The Attack Pattern

Game uses: winner = slot % 100 < 10 (10% win rate)

1. Attacker simulates game at current slot: loses
2. Simulates at slot + 1: loses  
3. Simulates at slot + 2: wins!
4. Waits for slot + 2, submits transaction
5. Guaranteed win, "random" game broken
Enter fullscreen mode Exit fullscreen mode

How to Fix

DON'T USE:

let random = Clock::get()?.slot % 100;  // Predictable
let random = Clock::get()?.unix_timestamp;  // Predictable  
let random = recent_blockhashes[0];  // Predictable
Enter fullscreen mode Exit fullscreen mode

DO USE:

  • Switchboard VRF
  • Chainlink VRF
  • Commit-reveal schemes
  • Off-chain randomness with on-chain verification
// Example: Switchboard VRF
#[account(mut)]
pub vrf: AccountLoader<'info, VrfAccountData>,

// Verify VRF was properly requested and callback received
// Use vrf.result.value as randomness
Enter fullscreen mode Exit fullscreen mode

Additional Solana Security Vulnerabilities to Consider

Based on extensive research into recent Solana exploits and security audits, here are critical additional vulnerabilities that should be included in your security guide:

12. Sysvar Account Validation (CRITICAL)

Priority: HIGH - This caused the $325M Wormhole hack

What It Is

Solana has system accounts called "sysvars" that contain cluster information (Clock, Rent, Instructions, etc.). Programs trust these to get authentic system data. If you don't verify that a sysvar account is actually THE system sysvar, attackers can pass fake sysvar accounts with manipulated data.

Real Exploit

Wormhole Bridge Exploit (February 2022, $325M)

  • The bridge used load_instruction_at() to check if signature verification happened
  • This function reads instruction data but DOESN'T verify the account is the real Instructions sysvar
  • Attacker created fake Instructions sysvar with fake signature verification data
  • Bridge thought signatures were verified when they weren't
  • Attacker minted 120,000 ETH on Solana and bridged it to Ethereum

The Attack Pattern

1. Program expects Sysvar::Instructions account
2. Attacker creates fake account with same data layout
3. Program uses deprecated load_instruction_at() without checking account ID
4. Fake data is read as authentic system data
5. Critical validation bypassed
Enter fullscreen mode Exit fullscreen mode

How to Fix

Anchor: Use constraints to verify sysvar address

use anchor_lang::prelude::*;

#[derive(Accounts)]
pub struct VerifyInstruction<'info> {
    /// CHECK: Verified via constraint
    #[account(address = sysvar::instructions::ID)]
    pub instruction_sysvar: AccountInfo<'info>,
}

pub fn process(ctx: Context<VerifyInstruction>) -> Result<()> {
    // Safe to use - address constraint verified it's the real sysvar
    let instruction_data = load_current_index_checked(
        &ctx.accounts.instruction_sysvar
    )?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Pinocchio/Native: Manually verify sysvar public key

use solana_program::{
    sysvar::{instructions, clock, rent},
    account_info::AccountInfo,
    pubkey::Pubkey,
    program_error::ProgramError,
};

pub fn verify_instructions_sysvar(
    instructions_account: &AccountInfo,
) -> Result<(), ProgramError> {
    // Verify this is the real Instructions sysvar
    if instructions_account.key != &instructions::ID {
        return Err(ProgramError::InvalidArgument);
    }

    // Now safe to use
    let instruction_data = instructions_account.data.borrow();
    // Process instruction data...
    Ok(())
}

// For Clock sysvar
pub fn verify_clock_sysvar(
    clock_account: &AccountInfo,
) -> Result<(), ProgramError> {
    if clock_account.key != &clock::ID {
        return Err(ProgramError::InvalidArgument);
    }

    let clock = Clock::from_account_info(clock_account)?;
    // Use clock data safely...
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Impact: $325M stolen. This is one of the largest DeFi hacks ever.


13. Account Reload After CPI (HIGH SEVERITY)

What It Is

When you make a CPI (cross-program invocation), the called program can modify accounts. BUT Anchor/Rust doesn't automatically update your in-memory copy of the account data. You're operating on stale data.

Real Risk

Found in numerous audits. Programs that:

  • Transfer tokens via CPI
  • Then check balances
  • Make decisions based on old balance data Can be exploited to bypass spending limits, drain funds, or manipulate state.

The Attack Pattern

1. Program loads account balance: 100 tokens
2. Program makes CPI that transfers 50 tokens
3. Program still thinks balance is 100 (stale data)
4. Program allows another withdrawal based on wrong balance
5. Account actually has 50 but program thinks 100
Enter fullscreen mode Exit fullscreen mode

How to Fix

Anchor: Use .reload() method after CPI

use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount, Transfer};

pub fn transfer_and_check(ctx: Context<TransferAndCheck>, amount: u64) -> Result<()> {
    let balance_before = ctx.accounts.token_account.amount;

    // Make CPI transfer
    let cpi_accounts = Transfer {
        from: ctx.accounts.token_account.to_account_info(),
        to: ctx.accounts.destination.to_account_info(),
        authority: ctx.accounts.authority.to_account_info(),
    };
    let cpi_ctx = CpiContext::new(ctx.accounts.token_program.to_account_info(), cpi_accounts);
    token::transfer(cpi_ctx, amount)?;

    // CRITICAL: Reload to get fresh data after CPI
    ctx.accounts.token_account.reload()?;

    // Now we have the correct balance
    require!(
        ctx.accounts.token_account.amount >= MIN_BALANCE,
        ErrorCode::BalanceTooLow
    );

    Ok(())
}

#[derive(Accounts)]
pub struct TransferAndCheck<'info> {
    #[account(mut)]
    pub token_account: Account<'info, TokenAccount>,
    #[account(mut)]
    pub destination: Account<'info, TokenAccount>,
    pub authority: Signer<'info>,
    pub token_program: Program<'info, Token>,
}
Enter fullscreen mode Exit fullscreen mode

Pinocchio/Native: Re-deserialize account after CPI

use solana_program::{
    account_info::AccountInfo,
    program::{invoke, invoke_signed},
    program_error::ProgramError,
};
use spl_token::state::Account as TokenAccount;

pub fn transfer_and_check<'a>(
    token_account: &AccountInfo<'a>,
    destination: &AccountInfo<'a>,
    authority: &AccountInfo<'a>,
    token_program: &AccountInfo<'a>,
    amount: u64,
) -> Result<(), ProgramError> {
    // Make CPI transfer
    let transfer_instruction = spl_token::instruction::transfer(
        token_program.key,
        token_account.key,
        destination.key,
        authority.key,
        &[],
        amount,
    )?;

    invoke(
        &transfer_instruction,
        &[
            token_account.clone(),
            destination.clone(),
            authority.clone(),
            token_program.clone(),
        ],
    )?;

    // CRITICAL: Re-deserialize to get fresh data
    let token_data = TokenAccount::unpack(&token_account.data.borrow())?;

    // Now check with correct balance
    if token_data.amount < MIN_BALANCE {
        return Err(ProgramError::InsufficientFunds);
    }

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Impact: Found in multiple audits, potential for fund loss when combined with spending limits or balance checks.


14. Rent Siphoning / Account Revival (MEDIUM-HIGH)

What It Is

Beyond just not zeroing data on close, there's a more subtle attack:

  • Attacker can keep "closed" accounts alive by adding lamports
  • Or prevent account creation by pre-funding the address
  • Or drain rent from accounts that drop below rent-exempt threshold

Real Issues

Not a single famous exploit, but found repeatedly in audits and can be combined with other attacks.

Attack Patterns

Pattern 1: Account Revival (covered in your guide)
Already in your guide as "Closing Accounts"

Pattern 2: Account Squatting

1. Program tries to create account at deterministic address
2. Attacker pre-funds that address with 1 lamport
3. Create instruction fails (account already exists)
4. Denial of service, program can't initialize
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Rent Drain

1. Account drops below rent-exempt minimum
2. Solana starts deducting rent per epoch
3. Account slowly drains to zero
4. Account becomes unusable
Enter fullscreen mode Exit fullscreen mode

How to Fix

Anchor: Use init constraints and rent checks

use anchor_lang::prelude::*;

// Prevent account squatting during initialization
#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(
        init,
        payer = user,
        space = 8 + 32 + 8,
        seeds = [b"vault", user.key().as_ref()],
        bump
    )]
    pub vault: Account<'info, Vault>,

    #[account(mut)]
    pub user: Signer<'info>,
    pub system_program: Program<'info, System>,
    pub rent: Sysvar<'info, Rent>,
}

// Verify rent exemption for critical accounts
pub fn verify_rent_exempt(ctx: Context<CheckRent>) -> Result<()> {
    let account = &ctx.accounts.critical_account;
    let rent = Rent::get()?;

    require!(
        rent.is_exempt(account.to_account_info().lamports(), account.to_account_info().data_len()),
        ErrorCode::NotRentExempt
    );

    Ok(())
}

// Proper account closing
#[derive(Accounts)]
pub struct Close<'info> {
    #[account(
        mut,
        close = destination,  // Transfers lamports AND zeros data
        has_one = authority
    )]
    pub vault: Account<'info, Vault>,
    pub authority: Signer<'info>,
    #[account(mut)]
    pub destination: SystemAccount<'info>,
}
Enter fullscreen mode Exit fullscreen mode

Pinocchio/Native: Manual checks and proper closing

use solana_program::{
    account_info::AccountInfo,
    program_error::ProgramError,
    rent::Rent,
    sysvar::Sysvar,
    system_instruction,
    program::invoke,
};

// Check if account is uninitialized before creating
pub fn safe_initialize<'a>(
    account: &AccountInfo<'a>,
    payer: &AccountInfo<'a>,
    space: usize,
    owner: &Pubkey,
    system_program: &AccountInfo<'a>,
) -> Result<(), ProgramError> {
    // Prevent account squatting
    if account.lamports() > 0 {
        return Err(ProgramError::AccountAlreadyInitialized);
    }

    // Create account
    let rent = Rent::get()?;
    let lamports = rent.minimum_balance(space);

    invoke(
        &system_instruction::create_account(
            payer.key,
            account.key,
            lamports,
            space as u64,
            owner,
        ),
        &[payer.clone(), account.clone(), system_program.clone()],
    )?;

    Ok(())
}

// Verify rent exemption
pub fn verify_rent_exempt(
    account: &AccountInfo,
) -> Result<(), ProgramError> {
    let rent = Rent::get()?;

    if !rent.is_exempt(account.lamports(), account.data_len()) {
        return Err(ProgramError::InsufficientFunds);
    }

    Ok(())
}

// Properly close account
pub fn close_account<'a>(
    account: &AccountInfo<'a>,
    destination: &AccountInfo<'a>,
) -> Result<(), ProgramError> {
    // Transfer ALL lamports to destination
    let dest_starting_lamports = destination.lamports();
    **destination.lamports.borrow_mut() = dest_starting_lamports
        .checked_add(account.lamports())
        .ok_or(ProgramError::ArithmeticOverflow)?;
    **account.lamports.borrow_mut() = 0;

    // Zero out data to prevent revival
    account.data.borrow_mut().fill(0);

    // Assign to system program (optional but recommended)
    account.assign(&solana_program::system_program::ID);

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

15. Owner Change Without Verification (MEDIUM)

What It Is

Solana accounts have an "owner" field. Some legitimate use cases change ownership (like creating PDAs). But if a program doesn't track or verify ownership changes, attackers can reassign account ownership to bypass checks.

Attack Pattern

1. Program checks account.owner == expected_program at start
2. During CPI, attacker's malicious program changes owner
3. Program continues using account assuming original owner
4. Account now owned by attacker's program
5. Later operations trust wrong owner
Enter fullscreen mode Exit fullscreen mode

How to Fix

Anchor: Verify owner before and after CPI

use anchor_lang::prelude::*;

pub fn safe_cpi_with_owner_check<'info>(
    ctx: Context<'_, '_, '_, 'info, SafeCPI<'info>>,
) -> Result<()> {
    let account = &ctx.accounts.mutable_account;

    // Store owner before CPI
    let owner_before = *account.to_account_info().owner;

    // Perform CPI (example: some external program call)
    // invoke(...)?;

    // Verify owner hasn't changed
    require!(
        *account.to_account_info().owner == owner_before,
        ErrorCode::UnexpectedOwnerChange
    );

    Ok(())
}

#[derive(Accounts)]
pub struct SafeCPI<'info> {
    /// CHECK: Owner verified manually
    #[account(mut)]
    pub mutable_account: AccountInfo<'info>,
}

#[error_code]
pub enum ErrorCode {
    #[msg("Account owner changed unexpectedly during CPI")]
    UnexpectedOwnerChange,
}
Enter fullscreen mode Exit fullscreen mode

Pinocchio/Native: Manual owner verification

use solana_program::{
    account_info::AccountInfo,
    program_error::ProgramError,
    pubkey::Pubkey,
    program::invoke,
};

pub fn safe_cpi_with_owner_check<'a>(
    account: &AccountInfo<'a>,
    program_to_invoke: &AccountInfo<'a>,
    expected_owner: &Pubkey,
) -> Result<(), ProgramError> {
    // Verify owner matches expectation
    if account.owner != expected_owner {
        return Err(ProgramError::IllegalOwner);
    }

    // Store owner before CPI
    let owner_before = *account.owner;

    // Perform CPI
    invoke(
        &some_instruction,
        &[account.clone(), program_to_invoke.clone()],
    )?;

    // Verify owner hasn't changed
    if account.owner != &owner_before {
        return Err(ProgramError::IllegalOwner);
    }

    Ok(())
}

// Alternative: Verify owner after any suspicious CPI
pub fn verify_owner_unchanged<'a>(
    account: &AccountInfo<'a>,
    expected_owner: &Pubkey,
) -> Result<(), ProgramError> {
    if account.owner != expected_owner {
        return Err(ProgramError::IllegalOwner);
    }
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Note: This is rare but can be combined with reload vulnerabilities.


16. Create Account DoS (MEDIUM)

What It Is

SystemProgram::CreateAccount fails if the account already has lamports. Attacker can grief by sending 1 lamport to your deterministic addresses, blocking initialization.

Attack Pattern

1. Protocol uses deterministic PDA: seeds = [b"vault", user.key]
2. Attacker calculates same address
3. Attacker sends 1 lamport to that address
4. User tries to initialize vault
5. CreateAccount fails (account has balance)
6. User can't use protocol
Enter fullscreen mode Exit fullscreen mode

How to Fix

Anchor: Use init_if_needed with caution or check for existing lamports

use anchor_lang::prelude::*;

// Option 1: Use init_if_needed (be aware of reinitialization risks)
#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(
        init_if_needed,  // Will use existing account if it has lamports
        payer = user,
        space = 8 + 32 + 8,
        seeds = [b"vault", user.key().as_ref()],
        bump
    )]
    pub vault: Account<'info, Vault>,
    #[account(mut)]
    pub user: Signer<'info>,
    pub system_program: Program<'info, System>,
}

// Option 2: Explicitly handle existing accounts
pub fn initialize_safe(ctx: Context<InitializeSafe>) -> Result<()> {
    let vault = &mut ctx.accounts.vault;

    // If account exists and is initialized, return error
    if vault.to_account_info().lamports() > 0 {
        require!(!vault.is_initialized, ErrorCode::AlreadyInitialized);
    }

    // Initialize vault data
    vault.authority = ctx.accounts.user.key();
    vault.balance = 0;
    vault.is_initialized = true;

    Ok(())
}

#[account]
pub struct Vault {
    pub authority: Pubkey,
    pub balance: u64,
    pub is_initialized: bool,
}
Enter fullscreen mode Exit fullscreen mode

Pinocchio/Native: Handle existing lamports gracefully

use solana_program::{
    account_info::AccountInfo,
    program_error::ProgramError,
    system_instruction,
    program::invoke,
    rent::Rent,
    sysvar::Sysvar,
};

pub fn initialize_account_safe<'a>(
    account: &AccountInfo<'a>,
    payer: &AccountInfo<'a>,
    space: usize,
    owner: &Pubkey,
    system_program: &AccountInfo<'a>,
) -> Result<(), ProgramError> {
    let rent = Rent::get()?;
    let required_lamports = rent.minimum_balance(space);

    if account.lamports() > 0 {
        // Account has lamports - check if properly initialized
        if account.data_len() != space || account.owner != owner {
            // Account exists but wrong size/owner
            return Err(ProgramError::InvalidAccountData);
        }

        // Check if account has enough lamports for rent
        if account.lamports() < required_lamports {
            // Transfer additional lamports needed
            let additional = required_lamports - account.lamports();
            invoke(
                &system_instruction::transfer(payer.key, account.key, additional),
                &[payer.clone(), account.clone()],
            )?;
        }

        // Use existing account
    } else {
        // Account is empty - create normally
        invoke(
            &system_instruction::create_account(
                payer.key,
                account.key,
                required_lamports,
                space as u64,
                owner,
            ),
            &[payer.clone(), account.clone(), system_program.clone()],
        )?;
    }

    Ok(())
}

// Alternative: Use allocate + assign if account has lamports
pub fn allocate_and_assign<'a>(
    account: &AccountInfo<'a>,
    space: usize,
    owner: &Pubkey,
) -> Result<(), ProgramError> {
    if account.owner != &solana_program::system_program::ID {
        return Err(ProgramError::IllegalOwner);
    }

    // Allocate space
    invoke(
        &system_instruction::allocate(account.key, space as u64),
        &[account.clone()],
    )?;

    // Assign to program
    invoke(
        &system_instruction::assign(account.key, owner),
        &[account.clone()],
    )?;

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Blockchain data is public. All of it. Use VRF or don't do randomness.


The Complete Security Checklist

Before deploying any Solana program, verify:

Access Control

  • [ ] Every privileged account requires signer check (Signer or is_signer())
  • [ ] Every program account is owned by your program (Account<T> or manual owner check)
  • [ ] Token accounts validated for correct mint and owner
  • [ ] All account relationships explicitly verified with constraints

Account Validation

  • [ ] Every account type has unique discriminator
  • [ ] PDAs use canonical bump, stored and verified
  • [ ] Initialize instructions reject already-initialized accounts
  • [ ] No duplicate mutable accounts in transfers or updates

Cross-Program Security

  • [ ] All CPI targets use fixed, validated program IDs
  • [ ] Never invoke user-supplied program IDs
  • [ ] PDA seeds properly scoped to prevent collision

Arithmetic Safety

  • [ ] All balance/amount operations use checked arithmetic
  • [ ] Overflow checks enabled in release builds
  • [ ] Explicit bounds validation before math operations

State Management

  • [ ] Closed accounts have data zeroed
  • [ ] Lamports properly transferred on close
  • [ ] No revival of closed accounts possible

Randomness

  • [ ] No use of slot, timestamp, or blockhash for randomness
  • [ ] VRF or commit-reveal for any random outcomes
  • [ ] All on-chain data treated as public and predictable

Real Talk

Security isn't about perfect code. It's about recognizing that every missing check is money waiting to be stolen. Every unvalidated account is an open vault. Every assumption is a vulnerability.

The programs in this repository show both sides: the vulnerable version that would lose everything, and the secure version that survives. The difference is often a single line of code. But that line is worth millions.

You cannot test your way to security. You cannot hope attackers won't find bugs. You must validate everything, trust nothing, and assume every user is trying to rob you. Because on a blockchain, they probably are.

This isn't paranoia. It's math. Your program is public. The money is real. The attackers are here.

Build accordingly.


Implementation Details

This guide is accompanied by a repository containing:

  • 15 Complete Vulnerability Examples

    • Each with vulnerable and secure versions
    • Implementation in both Anchor and Pinocchio
    • Tests demonstrating the exploit and the fix
    • Line-by-line annotations explaining what went wrong
  • Real Attack Simulations

    • Executable tests that actually perform the attacks
    • Proof that vulnerable code fails
    • Proof that secure code prevents the attack
  • Comparative Framework Analysis

    • Side-by-side Anchor vs Pinocchio implementations
    • Framework-specific security features
    • When to use each approach

All code is open source and designed for learning. Break it. Fix it. Learn from it. Then write code that can't be broken.

The repository contains the examples. This guide explains why they matter.
https://github.com/mira4sol/solana-security-examples


Top comments (0)