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:
- 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
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
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!
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]]
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,
);
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);
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);
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)