Solana is not Ethereum wearing a faster costume. Its execution model is architecturally distinct, and that distinction directly shapes the vulnerability surface every auditor must internalize before reading a single line of program code. This article is a technical deep-dive into Solana-specific security: the account model, Cross-Program Invocation (CPI), signer and owner verification, account confusion attacks, and how the Anchor framework mitigates — but does not eliminate — these issues.


1. The Solana Account Model vs. the EVM

Before cataloguing attack surfaces, you need to understand why Solana’s vulnerabilities are structurally different.

Unlike Ethereum — where each smart contract is an account with execution logic and storage tied together — Solana’s smart contracts are entirely stateless. State must be passed to accounts for them to execute on. This is the single most important sentence an EVM developer can read before writing their first Solana program.

Unlike EVM contracts that bundle code and storage together, Solana separates them completely — all state lives in accounts. Programs are stateless: they contain only executable code, compiled to a bytecode format called sBPF. Most Solana programs are written in Rust. When a program runs, it reads and writes to accounts passed into it, but stores nothing internally.

Account-defined fields include Lamports (account balance), Owner (account owner), Executable (whether it is an executable account), and Data (data stored in the account). Each account designates a program as its owner to distinguish which program the account is used as a state store for.

Unlike Solidity contracts, Solana programs operate with an explicit account passing model, meaning every contract call lists exactly which state/data accounts are read or modified.

The direct consequence for security: accounts are passed in by callers. Programs are stateless. There’s no msg.sender equivalent baked into the runtime. Every validation must be explicit, and every missing check is a potential exploit vector.

In Solidity, msg.sender is a runtime primitive that the EVM guarantees. In Solana, you are the guarantee. If you don’t verify it, no one does.

Account Structure at a Glance

Every account on-chain carries:

FieldTypeNotes
keyPubkey32-byte address
lamportsu64SOL balance in lamports
data[u8]Arbitrary byte slice; program-defined schema
ownerPubkeyProgram that owns and may modify this account
executableboolIf true, account contains a deployed program
is_signerboolTrue if account signed the transaction
is_writableboolTrue if account may be mutated

The owner and is_signer fields are where most Solana exploits begin.


2. Missing Signer Checks

The Vulnerability

A signer check confirms that an account’s private key holder authorized the transaction. In native Rust, the AccountInfo struct exposes is_signer: bool. Forgetting to assert it is one of the most frequent and most damaging bugs in Solana development.

An attacker may impersonate another user and authorize a transaction due to a missing signer check. For example, the attacker supplies an unsigned user account and authorizes access to the data account of the impersonated user, as the program lacks an explicit signer check. The contract verifies the authority key but omits an is_signer check that confirms private key control for the authority. Without the signer check, anyone can supply the correct key.

The Wormhole exploit ($320M) occurred because the program checked a pubkey without verifying the is_signer flag. The attacker passed the correct guardian set public key as an account — but since the is_signer flag was never asserted, the runtime accepted the unsigned instruction.

Vulnerable Native Rust

// ❌ VULNERABLE: checks the key, but not is_signer
pub fn update_admin(
    accounts: &[AccountInfo],
    new_admin: Pubkey,
) -> ProgramResult {
    let iter = &mut accounts.iter();
    let admin_info  = next_account_info(iter)?;
    let state_info  = next_account_info(iter)?;

    // Key comparison happens here...
    let mut state = AdminState::try_from_slice(&state_info.data.borrow())?;

    if state.admin == admin_info.key.to_bytes() {
        // ...but nothing proves admin_info signed the tx.
        // An attacker can pass *any* account with the correct pubkey.
        state.admin = new_admin.to_bytes();
        state.serialize(&mut &mut state_info.data.borrow_mut()[..])?;
    } else {
        return Err(ProgramError::InvalidAccountData);
    }
    Ok(())
}

Correct Native Rust

// ✅ CORRECT: assert is_signer before trusting the account
pub fn update_admin(
    accounts: &[AccountInfo],
    new_admin: Pubkey,
) -> ProgramResult {
    let iter = &mut accounts.iter();
    let admin_info  = next_account_info(iter)?;
    let state_info  = next_account_info(iter)?;

    // Prove the caller controls the private key.
    if !admin_info.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
    }

    let mut state = AdminState::try_from_slice(&state_info.data.borrow())?;
    if state.admin == admin_info.key.to_bytes() {
        state.admin = new_admin.to_bytes();
        state.serialize(&mut &mut state_info.data.borrow_mut()[..])?;
    } else {
        return Err(ProgramError::InvalidAccountData);
    }
    Ok(())
}

In Anchor

Anchor makes this constraint declarative. Using Signer<'info> in the accounts struct means the framework enforces is_signer automatically before your instruction logic runs.

// ✅ ANCHOR: Signer<'info> enforces is_signer at the framework level
#[derive(Accounts)]
pub struct UpdateAdmin<'info> {
    #[account(mut)]
    pub authority: Signer<'info>,   // ← runtime rejects if not signed

    #[account(
        mut,
        has_one = authority,        // ← vault.authority must == authority.key()
    )]
    pub vault: Account<'info, VaultState>,
}

pub fn update_admin(ctx: Context<UpdateAdmin>, new_admin: Pubkey) -> Result<()> {
    ctx.accounts.vault.authority = new_admin;
    Ok(())
}

Functions that only check admin.key() == expected_key without Signer<'info>, AccountInfo<'info>, or UncheckedAccount without ownership constraints are vulnerable patterns. Use Signer<'info> as the type, not a raw key comparison.


3. Missing Owner Checks

The Vulnerability

Every Solana account has an owner field — the program that is allowed to modify its data. When a program deserializes account data without verifying who owns the account, an attacker can substitute a look-alike account populated with attacker-controlled data.

Attackers might craft accounts containing counterfeit state. In this case, a missing owner check can trick the program into performing critical actions.

Consider a vault program that reads a token balance from a VaultInfo account. If the program trusts any account shaped like a VaultInfo without checking that the owning program is its own program ID, an attacker constructs a fake VaultInfo with an inflated balance and passes it in.

Vulnerable Native Rust

// ❌ VULNERABLE: deserializes account data without checking owner
pub fn withdraw(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
    let iter = &mut accounts.iter();
    let vault_info  = next_account_info(iter)?;
    let user_info   = next_account_info(iter)?;

    // No check: is vault_info.owner == &crate::id() ?
    let vault = VaultState::try_from_slice(&vault_info.data.borrow())?;

    if vault.balance >= amount {
        // Attacker's fake VaultState passes this check trivially.
        transfer_lamports(vault_info, user_info, amount)?;
    }
    Ok(())
}

Correct Native Rust

// ✅ CORRECT: verify owner before trusting the data
pub fn withdraw(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
    let iter = &mut accounts.iter();
    let vault_info  = next_account_info(iter)?;
    let user_info   = next_account_info(iter)?;

    // Reject accounts not owned by this program.
    if vault_info.owner != &crate::id() {
        return Err(ProgramError::IncorrectProgramId);
    }

    let vault = VaultState::try_from_slice(&vault_info.data.borrow())?;
    if vault.balance >= amount {
        transfer_lamports(vault_info, user_info, amount)?;
    }
    Ok(())
}

In Anchor

Account<'info, T> automatically verifies that the account is owned by the currently executing program and that the discriminator matches type T. This is an implicit owner check.

// ✅ ANCHOR: Account<'info, VaultState> enforces owner == program_id
#[derive(Accounts)]
pub struct Withdraw<'info> {
    #[account(mut, has_one = user)]
    pub vault: Account<'info, VaultState>,  // ← owner check is implicit

    pub user: Signer<'info>,
}

The owner constraint ensures that an account is owned by a specific program. This is a critical security check when working with program-owned accounts, as it prevents unauthorized access to accounts that should be managed by a particular program.


4. Account Confusion Attacks (Type Cosplay)

Account confusion — sometimes called “type cosplay” — occurs when one account type is passed in place of another and the program can’t distinguish between them at the data layer.

This is structurally unique to Solana. In the EVM, storage is namespaced per contract. On Solana, multiple account types can be owned by the same program, share the same byte length, and differ only in their internal schema. If you deserialize the wrong type, you read garbage as trusted state.

Vulnerable Pattern

// ❌ VULNERABLE: Two structs, same owner, same size.
// An attacker passes a UserAccount where a StakeAccount is expected.

#[derive(BorshSerialize, BorshDeserialize)]
pub struct UserAccount {
    pub owner:   Pubkey,   // 32 bytes
    pub balance: u64,      // 8 bytes
}

#[derive(BorshSerialize, BorshDeserialize)]
pub struct StakeAccount {
    pub authority: Pubkey, // 32 bytes — same offset as UserAccount.owner
    pub amount:    u64,    // 8 bytes  — same offset as UserAccount.balance
}

pub fn process_stake(accounts: &[AccountInfo]) -> ProgramResult {
    let stake_info = next_account_info(&mut accounts.iter())?;
    // No discriminator check. An attacker supplies a UserAccount here.
    let stake = StakeAccount::try_from_slice(&stake_info.data.borrow())?;
    // stake.authority is now the UserAccount.owner — attacker controls it.
    Ok(())
}

Correct Pattern: Discriminators

Anchor automatically prepends an 8-byte discriminator to every #[account] struct — the first 8 bytes of sha256("account:<TypeName>"). Deserialization fails if the discriminator does not match, eliminating type cosplay entirely.

// ✅ ANCHOR: Discriminator is checked on deserialization automatically.
// Account<'info, StakeAccount> will reject a UserAccount at the framework level.

#[account]
pub struct StakeAccount {
    pub authority: Pubkey,
    pub amount:    u64,
    pub bump:      u8,
}

#[derive(Accounts)]
pub struct ProcessStake<'info> {
    #[account(has_one = authority)]
    pub stake: Account<'info, StakeAccount>,  // ← discriminator enforced

    pub authority: Signer<'info>,
}

For native programs without Anchor, add your own type tag as the first byte of every account’s data and verify it on every instruction.


5. Program Derived Addresses (PDAs) vs. Regular Accounts

Understanding PDAs is prerequisite to understanding CPI privilege escalation.

Program Derived Addresses (PDAs) are 32-byte account addresses that are deterministically derived from a program ID and a set of seeds. They are guaranteed to not lie on the Ed25519 curve, which means no private key exists for them.

A PDA is intentionally derived to fall off the Ed25519 curve. Because it is not a valid curve point, no secret key exists, and no external party can produce a signature. Only the deriving program can authorize operations on the PDA through invoke_signed.

Normal account public keys are all mapped to the elliptic curve and have a corresponding private key to be used to sign for transactions. The difference between a PDA and a regular address is that the PDA doesn’t have a corresponding private key and exists outside of the Ed25519 curve. This means that external users cannot generate a valid signature for the address, and therefore have no access. The access is instead completely relegated to the Program that created it.

The Canonical Bump

PDAs are derived by hashing seeds + program ID + bump via SHA-256 until the result is off the Ed25519 curve. The canonical bump is the first value that produces an off-curve address.

Always use the canonical bump when deriving PDAs. Using a non-canonical bump creates a second valid address for the same seeds, which can lead to vulnerabilities where an attacker substitutes a different account than expected.

Using find_program_address can be computationally expensive due to the process of searching for the highest valid bump. It’s considered best practice to store the derived bump in an account’s data field upon initialization. This allows the bump to be referenced in subsequent instruction handlers, avoiding the need to repeatedly call find_program_address to re-derive the PDA.

Vulnerable: Accepting an Arbitrary Bump

// ❌ VULNERABLE: user-supplied bump allows substituting a non-canonical PDA
pub fn process(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    user_bump: u8,  // ← caller controls this
) -> ProgramResult {
    let vault_info = next_account_info(&mut accounts.iter())?;

    // Attacker provides a different bump, deriving a different-but-valid PDA
    // that they may have initialized with attacker-controlled data.
    let (expected_pda, _) = Pubkey::create_program_address(
        &[b"vault", &[user_bump]],
        program_id,
    )?;

    if vault_info.key != &expected_pda {
        return Err(ProgramError::InvalidArgument);
    }
    Ok(())
}

Correct: Store and Reuse the Canonical Bump

// ✅ CORRECT: store canonical bump at init, use it on every subsequent call
pub fn initialize(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
) -> ProgramResult {
    let vault_info = next_account_info(&mut accounts.iter())?;
    let (expected_pda, canonical_bump) = Pubkey::find_program_address(
        &[b"vault"],
        program_id,
    );
    // Store canonical_bump in the account data on first initialization.
    let mut vault = VaultState::try_from_slice(&vault_info.data.borrow())?;
    vault.bump = canonical_bump;
    vault.serialize(&mut &mut vault_info.data.borrow_mut()[..])?;
    Ok(())
}

In Anchor, #[account(seeds = [...], bump)] derives and verifies the canonical bump automatically. #[account(seeds = [...], bump = vault.bump)] on subsequent instructions re-verifies against the stored value.

PDA Sharing: A Subtle but Critical Risk

PDA sharing refers to using the same PDA as a signer across multiple users or domains. Especially when using PDAs for signing, it may seem appropriate to use a global PDA to represent the program. However, this opens up the possibility of account validation passing but a user being able to access funds, transfers, or data not belonging to them.

Always scope PDA seeds with user-specific data (user.key().as_ref()) to ensure each user’s PDA is unique to them:

// ❌ WRONG: global PDA shared across all users
seeds = [b"vault"]

// ✅ CORRECT: PDA scoped to the user
seeds = [b"vault", user.key().as_ref()]

6. Cross-Program Invocation (CPI) and Privilege Escalation

How CPI Works

A Cross-Program Invocation (CPI) is when one program calls an instruction on another program during execution. Use invoke when no PDA signing is needed, invoke_signed when the calling program must sign as a PDA.

Both functions can forward signer accounts that are already marked as signers. Hence, when a user or program provides a signer account, they are essentially entrusting downstream programs with a piece of verified authority.

The Solana runtime restricts CPIs to a maximum depth of four and enforces strict account rules, such as only allowing an account’s owner to modify its data.

CPI Privilege Escalation

The ability to forward signer privileges and execute instructions across programs introduces complexity, especially when combined with Solana’s unique execution model. These features make CPIs a common site for subtle bugs and exploitation, particularly in high-stakes programs.

The vulnerability emerges when this trust is misplaced. If an untrusted program is invoked with a signer account that possesses sensitive privileges, it can forward this signer with arbitrary arguments to exploit these privileges. For instance, an attacker might leverage this oversight to perform operations on behalf of an unsuspecting user.

In essence, mishandling signer accounts can transform a useful delegation mechanism into an exploitable backdoor, where an attacker could chain CPIs to bypass critical authorisation checks.

Vulnerable: Forwarding Signers to an Untrusted Program

// ❌ VULNERABLE: accepts program_id as user input,
// then invokes it with the user's signer privilege forwarded
pub fn proxy_call(
    accounts: &[AccountInfo],
    target_program_id: Pubkey,  // ← attacker controls this
    instruction_data: Vec<u8>,
) -> ProgramResult {
    let user_info    = next_account_info(&mut accounts.iter())?;
    let target_prog  = next_account_info(&mut accounts.iter())?;

    // user_info.is_signer is true — the user signed this transaction.
    // We are about to forward that authority to an arbitrary program.
    let ix = Instruction {
        program_id: target_program_id,
        accounts: vec![
            AccountMeta::new(*user_info.key, true),  // signer forwarded!
        ],
        data: instruction_data,
    };

    invoke(&ix, &[user_info.clone(), target_prog.clone()])?;
    Ok(())
}

The attacker deploys a malicious program at target_program_id that uses the forwarded user signer to drain token accounts or sign unauthorized transactions.

Correct: Hardcode or Validate the Target Program

// ✅ CORRECT: only invoke known, trusted programs
use spl_token::ID as TOKEN_PROGRAM_ID;

pub fn safe_transfer(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
    let user_info    = next_account_info(&mut accounts.iter())?;
    let src_token    = next_account_info(&mut accounts.iter())?;
    let dst_token    = next_account_info(&mut accounts.iter())?;
    let token_prog   = next_account_info(&mut accounts.iter())?;

    // Assert the token program is the canonical SPL Token program.
    if token_prog.key != &TOKEN_PROGRAM_ID {
        return Err(ProgramError::IncorrectProgramId);
    }

    let ix = spl_token::instruction::transfer(
        token_prog.key,
        src_token.key,
        dst_token.key,
        user_info.key,
        &[],
        amount,
    )?;

    invoke(&ix, &[src_token.clone(), dst_token.clone(), user_info.clone()])?;
    Ok(())
}

7. Arbitrary CPI Vulnerabilities

The arbitrary CPI vulnerability is a specific and severe variant: a program accepts an entire AccountInfo for the target program from user input, with no validation at all.

When making an arbitrary CPI, the program, accounts, and instruction data must be provided for the call to succeed. Without verification, these accounts can contain anything.

Arbitrary Cross-Program Invocation (ACPI): vulnerabilities arising when CPI instructions (sol_invoke_signed) lack correct target or signer validation.

Real-World Attack Scenario

Consider a simple “bank” program where users deposit tokens and can call a claim_yield function that makes a CPI to a “yield strategy” program. The yield program address is passed by the user:

// ❌ VULNERABLE: arbitrary CPI — attacker supplies the yield_program
pub fn claim_yield(accounts: &[AccountInfo]) -> ProgramResult {
    let user_info          = next_account_info(&mut accounts.iter())?;
    let vault_token_acct   = next_account_info(&mut accounts.iter())?;
    let yield_program      = next_account_info(&mut accounts.iter())?; // ← untrusted

    // No check on yield_program.key
    let ix = Instruction {
        program_id: *yield_program.key,  // attacker's malicious program
        accounts: vec![
            AccountMeta::new(*vault_token_acct.key, false),
            AccountMeta::new(*user_info.key, true),
        ],
        data: vec![],
    };

    // The vault's entire token account is now accessible to an arbitrary program.
    invoke(&ix, &[vault_token_acct.clone(), user_info.clone()])?;
    Ok(())
}

In the bank example, all tokens were sent to the same TokenAccount owned by the program, ultimately leading to the arbitrary CPI stealing all of the tokens from it. A better strategy would have been to have a token account specifically for the bank user. Then, even if there is an accounting vulnerability, the impact is limited because the account will only have the tokens that the user deposited.

In Anchor: Program<'info, T> Validates Program IDs

// ✅ ANCHOR: Program<'info, Token> enforces the program is SPL Token
#[derive(Accounts)]
pub struct ClaimYield<'info> {
    #[account(mut)]
    pub authority: Signer<'info>,

    #[account(
        mut,
        associated_token::mint = mint,
        associated_token::authority = authority,
    )]
    pub user_token_account: Account<'info, TokenAccount>,

    #[account(
        mut,
        seeds = [b"vault"],
        bump = vault_state.bump,
    )]
    pub vault_token_account: Account<'info, TokenAccount>,

    pub mint: Account<'info, Mint>,

    // ← Only accepts the canonical SPL Token program.
    pub token_program: Program<'info, Token>,
}

Program<'info, Token> checks that the account is executable and that its key matches the expected program ID. An attacker cannot substitute their own program here.


8. Anchor Account Constraints: A Security Layer Summary

Anchor provides a powerful set of constraints that can be applied directly in the #[account] attribute. These constraints help ensure account validity and enforce program rules at the account level, before your instruction logic runs.

Here is a systematic breakdown of how Anchor constraints map to vulnerability mitigations:

ConstraintWhat It ChecksVulnerability Mitigated
Signer<'info>is_signer == trueMissing signer check
Account<'info, T>owner == program_id, discriminator matches TMissing owner check, type cosplay
#[account(has_one = field)]account.field == other_account.key()Account substitution / confusion
#[account(seeds, bump)]PDA derivation with canonical bumpNon-canonical bump, PDA substitution
#[account(owner = expr)]account.owner == exprCross-program account confusion
Program<'info, T>executable == true, key matches expected program IDArbitrary CPI
#[account(address = expr)]account.key == exprAccount substitution
#[account(constraint = expr)]Custom boolean expressionArbitrary business logic
#[account(close = target)]Sets discriminator to CLOSED_ACCOUNT_DISCRIMINATORAccount revival attacks

The close constraint marks the account as closed at the end of the instruction’s execution by setting its discriminator to the CLOSED_ACCOUNT_DISCRIMINATOR and sends its lamports to a specified account. Setting the discriminator to a special variant makes account revival attacks (where a subsequent instruction adds the rent exemption lamports again) impossible.

The has_one Constraint in Depth

The has_one constraint is used to further ensure that only the correct accounts stored on the pool account are passed into the instruction handler.

// ✅ has_one = authority ensures vault.authority == authority.key()
#[derive(Accounts)]
pub struct CloseVault<'info> {
    #[account(
        mut,
        close = recipient,
        has_one = authority,        // vault.authority must equal authority.key()
        has_one = recipient,        // vault.recipient must equal recipient.key()
        seeds = [b"vault", authority.key().as_ref()],
        bump = vault.bump,
    )]
    pub vault: Account<'info, VaultState>,

    pub authority: Signer<'info>,

    #[account(mut)]
    pub recipient: SystemAccount<'info>,
}

Without has_one, an attacker could pass their own recipient account — the program would happily drain the vault to an attacker-controlled address.

What Anchor Does NOT Do Automatically

The key insight: you must explicitly declare what to validate. Anchor doesn’t magically know your security requirements.

Anchor does not automatically:

  • Verify relationships between two accounts (use has_one or constraint).
  • Prevent duplicate mutable accounts from being passed (use dup consciously or check manually).
  • Validate token account ownership in UncheckedAccount (use Account<'info, TokenAccount> with SPL constraints).
  • Reload account state after a CPI that may have modified it (call .reload() manually).

Always verify that the account’s owner is still the system_program after a CPI call completes if your logic depends on that invariant — CPIs can transfer account ownership.


9. Solana-Specific Audit Checklist

Use this checklist against every instruction handler during an audit. Each item maps to a concrete vulnerability class described above.

🔐 Signer Verification

  • Every account that must authorize an action uses Signer<'info> (Anchor) or explicitly asserts is_signer (native).
  • No instruction relies solely on pubkey comparison without is_signer — the pattern that caused the Wormhole exploit.
  • Admin or privileged authority accounts are Signer<'info>, not AccountInfo<'info> or UncheckedAccount<'info>.
  • Multi-signature or DAO governance authority accounts are properly validated.

🏷️ Owner Checks

  • All data accounts use Account<'info, T> (Anchor) or manually check account.owner == &program_id (native).
  • UncheckedAccount<'info> or raw AccountInfo usage is documented and justified; every such account has a /// CHECK: comment.
  • Token accounts verify owner == token_program_id (automatic in Account<'info, TokenAccount>).
  • System accounts verify owner == system_program::ID.

🪪 Account Type / Discriminator

  • No two account types in the same program share byte length without type-tagging / discriminators.
  • Native programs prefix account data with an explicit type discriminator.
  • Anchor #[account] macro is used for all data accounts to ensure discriminator enforcement.

📍 PDA Safety

  • All PDAs are derived with find_program_address (canonical bump) at initialization.
  • Canonical bump is stored in account data and used in all subsequent seed derivations.
  • No user-supplied bump values are accepted without verifying they produce the canonical PDA.
  • PDA