Solana CPI Security: 7 Deadly Patterns That Get Anchor Programs Drained
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:
- Account substitution: An attacker passes a fake account where your program expects a legitimate one
- Signer forwarding: Your program accidentally grants its PDA authority to an untrusted program
- Stale state: Account data changes during a CPI but your program reads the old cached version
- 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