DEV Community

ohmygod
ohmygod

Posted on • Originally published at dreamworksecurity.hashnode.dev

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.


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

Pattern 1: Missing Program ID Verification in CPI Targets

Severity: Critical

// ❌ VULNERABLE: No verification of token_program identity
pub token_program: AccountInfo<'info>,  // Attacker can pass ANY program

// ✅ SAFE: Anchor's Program type enforces the correct program ID
pub token_program: Program<'info, Token>,  // Anchor verifies program ID
Enter fullscreen mode Exit fullscreen mode

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. If the target is attacker-controlled, they gain the user's signing authority.

Fix: Never forward a user's signer authority to an unverified program. Use a protocol PDA as an intermediary — the user authorizes transfer to your PDA, and your PDA (with invoke_signed) authorizes downstream operations.


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

Severity: High

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

// ❌ VULNERABLE: Reads stale balance after CPI
let balance_before = ctx.accounts.vault.amount;
token::transfer(cpi_ctx, amount)?;
let balance_after = ctx.accounts.vault.amount; // STILL the old value!
let actual_deposited = balance_after - balance_before; // Always 0!

// ✅ SAFE: Reload account data after CPI
let balance_before = ctx.accounts.vault.amount;
token::transfer(cpi_ctx, amount)?;
ctx.accounts.vault.reload()?; // RELOAD!
let balance_after = ctx.accounts.vault.amount; // Correct!
Enter fullscreen mode Exit fullscreen mode

This is the #1 missed pattern in Anchor audits.


Pattern 4: PDA Seed Collisions

Severity: High

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]]
Enter fullscreen mode Exit fullscreen mode

Think of seeds as a composite database primary key — they must be globally unique for each distinct record.


Pattern 5: Missing Owner Check After CPI

Severity: High

A CPI to an untrusted program can reassign account ownership. Your program continues operating on an account it no longer controls.

// ✅ 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,
);
Enter fullscreen mode Exit fullscreen mode

Pattern 6: Unchecked Return Data from CPI

Severity: Medium

Solana programs can return data via set_return_data. If your program uses CPI return data to make decisions, an attacker-controlled program can return arbitrary data.

// ✅ SAFE: Verify return data source
let (program_id, data) = get_return_data().ok_or(ErrorCode::NoReturnData)?;
require!(program_id == TRUSTED_ORACLE_PROGRAM_ID, ErrorCode::InvalidOracleSource);
Enter fullscreen mode Exit fullscreen mode

Pattern 7: Lamport Balance Drain via CPI

Severity: Medium

Any program can debit lamports from accounts it owns. If your program passes a PDA-owned account to an untrusted CPI, the untrusted program could drain them.

// ✅ 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);
Enter fullscreen mode Exit fullscreen mode

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 (ownership, type, relations)
  • [ ] User signers are NOT forwarded to untrusted programs
  • [ ] PDA seeds are unique across all logical entity types
  • [ ] PDA bump seeds are stored and reused (not re-derived)

Post-CPI Checks

  • [ ] Modified accounts are reloaded with .reload()
  • [ ] Account ownership is re-verified after CPI to untrusted programs
  • [ ] Lamport balances haven't decreased unexpectedly
  • [ ] Return data source (program_id) is verified before use
  • [ ] 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 (if applicable)
  • [ ] 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

Every CPI is a trust boundary. The 7 patterns above cover the most common ways that trust gets violated. Build with these in mind, and your Anchor programs will survive the attacks that drain the protocols that didn't.


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

Top comments (0)