Cross-Program Invocations (CPIs) are the backbone of composable Solana programs. They're also where $400M+ in exploits originated.
Every time your program invokes another program, it crosses a trust boundary. The problem? Most developers treat CPIs like safe function calls. They're not. They're inter-process communication where the callee can do anything your signer privileges allow.
This guide covers seven CPI anti-patterns that have led to real exploits, with concrete Rust code showing both the vulnerable and secure implementations.
1. The Phantom Program: Not Validating the Target Program ID
Severity: Critical | Real-world impact: Wormhole ($326M)
The most fundamental CPI mistake is invoking a program without verifying its identity.
The Vulnerable Pattern
// ❌ DANGEROUS: program_id comes from user input
pub fn transfer_tokens(ctx: Context<Transfer>, amount: u64) -> Result<()> {
let cpi_accounts = token::Transfer {
from: ctx.accounts.source.to_account_info(),
to: ctx.accounts.destination.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
};
// The token_program account is never validated!
let cpi_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
cpi_accounts,
);
token::transfer(cpi_ctx, amount)?;
Ok(())
}
#[derive(Accounts)]
pub struct Transfer<'info> {
#[account(mut)]
pub source: AccountInfo<'info>,
#[account(mut)]
pub destination: AccountInfo<'info>,
pub authority: Signer<'info>,
/// CHECK: No validation — attacker can substitute a malicious program
pub token_program: AccountInfo<'info>,
}
An attacker deploys a fake token program that simply marks the transfer as successful without moving any tokens, then drains the real tokens through a separate transaction.
The Fix
// ✅ SECURE: Anchor validates the program ID automatically
#[derive(Accounts)]
pub struct Transfer<'info> {
#[account(mut)]
pub source: Account<'info, TokenAccount>,
#[account(mut)]
pub destination: Account<'info, TokenAccount>,
pub authority: Signer<'info>,
pub token_program: Program<'info, Token>, // Validated!
}
Anchor's Program<'info, T> type checks the account's key matches the expected program ID at deserialization time. If you're writing native Solana programs, compare manually:
if *token_program.key != spl_token::ID {
return Err(ProgramError::IncorrectProgramId);
}
2. Signer Forwarding to the Abyss
Severity: Critical | Real-world impact: Multiple DeFi protocols
When you pass a user's signer privileges into a CPI, the callee inherits those privileges. If the callee is attacker-controlled (see #1) or has a vulnerability, the attacker effectively becomes the user.
The Vulnerable Pattern
// ❌ DANGEROUS: Forwarding user wallet signer to an external program
pub fn interact_with_protocol(
ctx: Context<Interact>,
data: Vec<u8>,
) -> Result<()> {
let ix = Instruction {
program_id: ctx.accounts.external_program.key(),
accounts: vec![
AccountMeta::new(ctx.accounts.user_wallet.key(), true), // signer!
AccountMeta::new(ctx.accounts.user_token_account.key(), false),
],
data,
};
invoke(
&ix,
&[
ctx.accounts.user_wallet.to_account_info(),
ctx.accounts.user_token_account.to_account_info(),
ctx.accounts.external_program.to_account_info(),
],
)?;
Ok(())
}
The external_program could call spl_token::transfer to drain the user's token account, because it received the user's signer privilege.
The Fix
Use a PDA as the authority instead of forwarding user signers:
// ✅ SECURE: PDA authority limits what the callee can do
pub fn interact_with_protocol(
ctx: Context<Interact>,
data: Vec<u8>,
) -> Result<()> {
let seeds = &[
b"vault_authority",
ctx.accounts.vault.key().as_ref(),
&[ctx.accounts.vault.bump],
];
let signer_seeds = &[&seeds[..]];
// Only the PDA is forwarded — it only has authority
// over program-controlled accounts, not the user's wallet
invoke_signed(
&ix,
&[ctx.accounts.pda_authority.to_account_info()],
signer_seeds,
)?;
Ok(())
}
Rule of thumb: If a user signs a transaction, their signer privilege should stop at your program. Never forward it to programs you don't control.
3. The Stale State Trap: Not Reloading After CPI
Severity: High | Real-world impact: Lending protocol miscalculations
Anchor deserializes accounts at the start of your instruction. If a CPI modifies one of those accounts, your local copy is stale. Any logic based on the stale data produces wrong results.
The Vulnerable Pattern
// ❌ DANGEROUS: Reading balance after CPI without reload
pub fn deposit_and_check(
ctx: Context<DepositCheck>,
amount: u64,
) -> Result<()> {
// Deposit tokens via CPI
token::transfer(
ctx.accounts.into_transfer_context(),
amount,
)?;
// BUG: vault_account still has the pre-CPI balance!
let vault_balance = ctx.accounts.vault_account.amount;
// This check uses stale data
require!(vault_balance >= MIN_BALANCE, ErrorCode::InsufficientBalance);
Ok(())
}
The Fix
// ✅ SECURE: Explicitly reload after CPI
pub fn deposit_and_check(
ctx: Context<DepositCheck>,
amount: u64,
) -> Result<()> {
token::transfer(
ctx.accounts.into_transfer_context(),
amount,
)?;
// Reload the account data from the runtime
ctx.accounts.vault_account.reload()?;
let vault_balance = ctx.accounts.vault_account.amount;
require!(vault_balance >= MIN_BALANCE, ErrorCode::InsufficientBalance);
Ok(())
}
Always call .reload() on any account that a CPI might have modified before reading its state.
4. The Missing Owner Check: Trusting Account Data Blindly
Severity: Critical | Real-world impact: Cashio ($52M)
Solana accounts carry an owner field indicating which program controls them. If you read data from an account without checking its owner, an attacker can craft an account with arbitrary data owned by their own program.
The Vulnerable Pattern
// ❌ DANGEROUS: No owner check on price oracle
pub fn liquidate(
ctx: Context<Liquidate>,
) -> Result<()> {
// Deserializing price data without verifying the account
// is actually owned by the oracle program
let price_feed = PriceFeed::try_from_slice(
&ctx.accounts.price_oracle.data.borrow()
)?;
if price_feed.price < ctx.accounts.position.liquidation_price {
// Liquidate the position...
}
Ok(())
}
An attacker creates a fake oracle account with a manipulated price, triggering illegitimate liquidations.
The Fix
// ✅ SECURE: Validate owner before trusting data
#[derive(Accounts)]
pub struct Liquidate<'info> {
#[account(
owner = PYTH_PROGRAM_ID @ ErrorCode::InvalidOracle,
// Also pin the specific price feed address
constraint = price_oracle.key() == expected_feed_key @ ErrorCode::WrongFeed
)]
/// CHECK: Owner and address validated above
pub price_oracle: AccountInfo<'info>,
// ...
}
The Cashio exploit exploited exactly this pattern: the program accepted accounts that looked correct structurally but were owned by the attacker's program, allowing unlimited minting of CASH tokens.
5. Reentrancy Through CPI Callbacks
Severity: High | Real-world impact: Emerging attack vector on Solana
While Solana's runtime prevents direct reentrancy (a program can't CPI into itself), it doesn't prevent indirect reentrancy: Program A → Program B → Program A. If your program modifies state after a CPI, the callee could call back into your program with the old state.
The Vulnerable Pattern
// ❌ DANGEROUS: State update after CPI (check-interact-update)
pub fn withdraw(
ctx: Context<Withdraw>,
amount: u64,
) -> Result<()> {
// Check
require!(
ctx.accounts.user_state.balance >= amount,
ErrorCode::InsufficientFunds
);
// Interact — CPI to transfer tokens
token::transfer(
ctx.accounts.into_transfer_context(),
amount,
)?;
// Update — but if the CPI called back into us,
// this balance was already read as the original value!
ctx.accounts.user_state.balance -= amount;
Ok(())
}
The Fix: Check-Update-Interact
// ✅ SECURE: Update state BEFORE the CPI
pub fn withdraw(
ctx: Context<Withdraw>,
amount: u64,
) -> Result<()> {
// Check
require!(
ctx.accounts.user_state.balance >= amount,
ErrorCode::InsufficientFunds
);
// Update FIRST
ctx.accounts.user_state.balance -= amount;
// Interact — even if there's a callback, state is already updated
token::transfer(
ctx.accounts.into_transfer_context(),
amount,
)?;
Ok(())
}
Additionally, consider adding a reentrancy guard:
#[account]
pub struct UserState {
pub balance: u64,
pub locked: bool, // reentrancy guard
}
// At the start of sensitive instructions:
require!(!ctx.accounts.user_state.locked, ErrorCode::Reentrancy);
ctx.accounts.user_state.locked = true;
// ... do work ...
ctx.accounts.user_state.locked = false;
6. Lamport Drain on Forwarded Signers
Severity: Medium | Real-world impact: Subtle fund loss
When you forward a signer to a CPI, the callee can transfer SOL (lamports) out of that account. Even if you validate the target program, a bug in the callee could drain the signer's SOL balance.
The Vulnerable Pattern
// ❌ RISKY: No lamport balance check after CPI
pub fn interact(ctx: Context<Interact>) -> Result<()> {
// Forward signer to trusted (but complex) protocol
invoke(
&protocol_instruction,
&[ctx.accounts.user.to_account_info()],
)?;
// User's SOL might have been drained
Ok(())
}
The Fix
// ✅ SECURE: Guard lamport balance across CPI
pub fn interact(ctx: Context<Interact>) -> Result<()> {
let lamports_before = ctx.accounts.user.lamports();
invoke(
&protocol_instruction,
&[ctx.accounts.user.to_account_info()],
)?;
let lamports_after = ctx.accounts.user.to_account_info().lamports();
require!(
lamports_after >= lamports_before,
ErrorCode::UnexpectedLamportDrain
);
Ok(())
}
7. The Sysvar Spoofing Trap
Severity: Critical | Real-world impact: Wormhole ($326M)
Before Solana v1.8, programs could accept sysvar accounts (like rent, clock, or instructions) as regular account inputs without verifying their addresses. The Wormhole exploit leveraged exactly this: the attacker passed a fake Instructions sysvar, bypassing signature verification entirely.
The Vulnerable Pattern
// ❌ DANGEROUS: Accepting sysvar without address check
#[derive(Accounts)]
pub struct VerifySig<'info> {
/// CHECK: supposed to be Instructions sysvar
pub instructions_sysvar: AccountInfo<'info>,
}
pub fn verify(ctx: Context<VerifySig>) -> Result<()> {
// Uses the account as if it's the real sysvar
let current_ix = load_instruction_at_checked(
0,
&ctx.accounts.instructions_sysvar,
)?;
// Attacker controls what this returns!
Ok(())
}
The Fix
// ✅ SECURE: Use Anchor's Sysvar type or verify the address
#[derive(Accounts)]
pub struct VerifySig<'info> {
/// Anchor automatically validates this is the real sysvar
pub instructions_sysvar: Sysvar<'info, Instructions>,
}
// Or manually:
require!(
*instructions_sysvar.key == solana_program::sysvar::instructions::ID,
ErrorCode::InvalidSysvar
);
The CPI Security Checklist
Before deploying any Solana program that makes CPIs, verify:
| Check | How |
|---|---|
| Program ID validated | Use Program<'info, T> or manual key comparison |
| No user signer forwarding | Use PDA authorities instead |
| Account reload after CPI | Call .reload() before reading CPI-modified state |
| Account ownership verified | Use owner = PROGRAM_ID constraint |
| Check-Update-Interact order | Mutate state before CPI, not after |
| Lamport guards | Compare balances before/after when forwarding signers |
| Sysvar addresses pinned | Use typed sysvars or verify addresses |
| Reentrancy guards | Add lock flags for state-modifying instructions |
Beyond Code: Process-Level Defenses
Secure CPIs are necessary but not sufficient. Layer these on:
Audit scope must include CPI paths. Auditors should trace every CPI call and verify what privileges are forwarded. A "clean" program can still be exploited through an insecure CPI to a dependency.
Use
invoke_signedoverinvokewhenever possible. PDA-signed CPIs are scoped to your program's derived addresses, limiting blast radius.Monitor with Forta or custom validators. Set up real-time alerts for unexpected CPI patterns in your deployed programs.
Version-pin your dependencies. A CPI to a program you control today might be upgraded tomorrow. Use immutable program IDs or verify the program's upgrade authority.
Test CPI boundaries with Trident or custom BPF test harnesses. Property-based testing should specifically target CPI validation logic.
Conclusion
Every CPI is a trust boundary crossing. The Solana runtime gives you the raw mechanism — it's on you to validate who you're calling, what privileges you're sharing, and what state you trust after the call returns.
The $326M Wormhole exploit, the $52M Cashio drain, and dozens of smaller incidents all trace back to the same root cause: treating CPIs as safe function calls instead of hostile interfaces.
Build your programs like every callee is adversarial. Because on a permissionless blockchain, they might be.
This article is part of an ongoing series on DeFi security research. Follow for deep dives into smart contract vulnerabilities, audit methodologies, and security engineering for Web3.
Top comments (0)