Skip to main content

Command Palette

Search for a command to run...

Solana CPI Security: 7 Deadly Patterns That Get Anchor Programs Drained

Published
5 min read

Cross-Program Invocations (CPIs) are Solana's superpower — and its most exploited attack surface.

Every composable DeFi protocol on Solana uses CPIs. Lending protocols invoke token programs. DEXes invoke vault programs. Bridges invoke everything. And when CPI handling goes wrong, attackers don't need flash loans or oracle manipulation — they just call your program with the wrong accounts.

This guide covers the 7 most dangerous CPI patterns in Anchor programs, with real code examples showing both the vulnerability and the fix. If you're building on Solana or auditing Anchor programs, this is your checklist.


Why CPIs Are Uniquely Dangerous on Solana

On EVM chains, contracts call other contracts by address — the callee's storage is isolated. On Solana, programs receive accounts as inputs, and a CPI forwards those accounts to another program. This creates attack surfaces that don't exist on Ethereum:

  1. Account substitution: An attacker passes a fake account where your program expects a legitimate one
  2. Signer forwarding: Your program accidentally grants its PDA authority to an untrusted program
  3. Stale state: Account data changes during a CPI but your program reads the old cached version
  4. Authority hijacking: A CPI transfers ownership of your protocol's accounts to an attacker

The Anchor framework mitigates many of these through its account validation macros — but only if you use them correctly. Here are the patterns that still slip through.


Pattern 1: Missing Program ID Verification in CPI Targets

Severity: Critical

The most basic CPI vulnerability: your program invokes "the token program" without verifying it's actually the token program.

Vulnerable Code

// ❌ VULNERABLE: No verification of token_program identity
#[derive(Accounts)]
pub struct TransferTokens<'info> {
    #[account(mut)]
    pub from: AccountInfo<'info>,
    #[account(mut)]
    pub to: AccountInfo<'info>,
    pub authority: Signer<'info>,
    /// CHECK: We trust this is the token program
    pub token_program: AccountInfo<'info>,
}

pub fn transfer_tokens(ctx: Context<TransferTokens>, amount: u64) -> Result<()> {
    let ix = spl_token::instruction::transfer(
        ctx.accounts.token_program.key,
        ctx.accounts.from.key,
        ctx.accounts.to.key,
        ctx.accounts.authority.key,
        &[],
        amount,
    )?;
    invoke(&ix, &[
        ctx.accounts.from.clone(),
        ctx.accounts.to.clone(),
        ctx.accounts.authority.clone(),
    ])?;
    Ok(())
}

Fixed Code

// ✅ SAFE: Anchor's Program type enforces the correct program ID
use anchor_spl::token::{self, Token, Transfer};

#[derive(Accounts)]
pub struct TransferTokens<'info> {
    #[account(mut)]
    pub from: Account<'info, TokenAccount>,
    #[account(mut)]
    pub to: Account<'info, TokenAccount>,
    pub authority: Signer<'info>,
    pub token_program: Program<'info, Token>,
}

pub fn transfer_tokens(ctx: Context<TransferTokens>, amount: u64) -> Result<()> {
    let cpi_accounts = Transfer {
        from: ctx.accounts.from.to_account_info(),
        to: ctx.accounts.to.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)?;
    Ok(())
}

Key takeaway: Always use Anchor's Program<'info, T> type instead of raw AccountInfo for CPI targets.


Pattern 2: Forwarding User Signers to Untrusted Programs

Severity: Critical

When your program performs a CPI, it can forward the original transaction's signers to the invoked program. If that program is attacker-controlled, the attacker gains the user's signing authority.

Vulnerable Code

// ❌ VULNERABLE: Forwards user's signer to an unverified program
pub fn interact_with_partner(ctx: Context<PartnerInteraction>) -> Result<()> {
    let ix = Instruction {
        program_id: ctx.accounts.partner_program.key(),
        accounts: vec![
            AccountMeta::new(ctx.accounts.user_token_account.key(), false),
            AccountMeta::new_readonly(ctx.accounts.user.key(), true),
        ],
        data: vec![],
    };
    invoke(&ix, &[
        ctx.accounts.user_token_account.to_account_info(),
        ctx.accounts.user.to_account_info(),
    ])?;
    Ok(())
}

Key takeaway: Never forward a user's signer authority to a program you don't control. Use a protocol PDA as an intermediary.


Pattern 3: Stale Account Data After CPI (The Reload Bug)

Severity: High

Anchor deserializes account data at the start of instruction execution and caches it. After a CPI modifies that account's on-chain data, Anchor still holds the old cached version.

Vulnerable Code

// ❌ VULNERABLE: Reads stale balance after CPI
pub fn deposit_and_verify(ctx: Context<DepositVerify>, amount: u64) -> Result<()> {
    let balance_before = ctx.accounts.vault.amount;

    token::transfer(cpi_ctx, amount)?;

    // BUG: vault.amount is STILL the old cached value!
    let balance_after = ctx.accounts.vault.amount;
    let actual_deposited = balance_after - balance_before; // Always 0!
    ctx.accounts.user_state.deposited += actual_deposited;
    Ok(())
}

Fixed Code

// ✅ SAFE: Reload account data after CPI
pub fn deposit_and_verify(ctx: Context<DepositVerify>, amount: u64) -> Result<()> {
    let balance_before = ctx.accounts.vault.amount;
    token::transfer(cpi_ctx, amount)?;

    ctx.accounts.vault.reload()?; // RELOAD!

    let balance_after = ctx.accounts.vault.amount;
    let actual_deposited = balance_after - balance_before; // Correct!
    require!(actual_deposited == amount, ErrorCode::DepositMismatch);
    ctx.accounts.user_state.deposited += actual_deposited;
    Ok(())
}

Key takeaway: Always call .reload() on any Account<> modified by a CPI before reading its data. This is the #1 missed pattern in Anchor audits.


Pattern 4: PDA Seed Collisions

Severity: High

PDAs are derived from seeds. If two different logical entities share the same seed structure, an attacker can use one to access the other.

// ❌ VULNERABLE: Same seed for ALL vaults of this user
seeds = [user.key().as_ref()]

// ✅ SAFE: Include vault type and token mint
seeds = [b"vault", user.key().as_ref(), token_mint.key().as_ref(), &[vault_type as u8]]

Key takeaway: PDA seeds should uniquely identify every distinct logical entity.


Pattern 5: Missing Owner Check After CPI

Severity: High

A CPI to an untrusted program can reassign account ownership. Always re-verify ownership after CPIs:

// ✅ SAFE: Re-verify critical state after CPI
invoke(&partner_ix, &accounts)?;

ctx.accounts.vault.reload()?;
require!(
    ctx.accounts.vault.owner == ctx.accounts.protocol_authority.key(),
    ErrorCode::OwnershipChanged,
);

Pattern 6: Unchecked Return Data from CPI

Severity: Medium

Solana programs can return data via set_return_data. Always verify the program_id in return data:

let (program_id, data) = get_return_data().ok_or(ErrorCode::NoReturnData)?;
require!(program_id == TRUSTED_ORACLE_PROGRAM_ID, ErrorCode::InvalidOracleSource);

Pattern 7: Lamport Balance Drain via CPI

Severity: Medium

A malicious CPI target can drain lamports from accounts your program passed to it.

// ✅ SAFE: Guard lamport balance around untrusted CPI
let lamports_before = ctx.accounts.protocol_account.lamports();
invoke(&untrusted_ix, &[ctx.accounts.protocol_account.to_account_info()])?;
let lamports_after = ctx.accounts.protocol_account.lamports();
require!(lamports_after >= lamports_before, ErrorCode::LamportDrain);

The CPI Security Audit Checklist

Pre-CPI Checks

  • CPI target program ID is verified (use Program<'info, T>)
  • All accounts passed to CPI are validated
  • User signers are NOT forwarded to untrusted programs
  • PDA seeds are unique across all logical entity types
  • PDA bump seeds are stored and reused

Post-CPI Checks

  • Modified accounts are reloaded with .reload()
  • Account ownership is re-verified after CPI
  • Lamport balances haven't decreased unexpectedly
  • Return data source (program_id) is verified
  • Token balances match expected post-CPI state

Architecture-Level Checks

  • Protocol uses PDA intermediaries instead of forwarding user signers
  • Trusted program allowlist exists for CPI targets
  • CPI depth is considered (Solana has a 4-level CPI depth limit)
  • Error handling doesn't swallow CPI failures silently
  • All CPI paths are tested with adversarial account inputs

This article is part of the DeFi Security Research series. Follow for weekly breakdowns of real incidents, audit techniques, and defense patterns.

DreamWork Security — dreamworksecurity.hashnode.dev

More from this blog

D

ohmygod

65 posts