Every month, another Solana program gets drained through a Cross-Program Invocation (CPI) vulnerability. Not because the concept is hard — because the defaults are dangerous and the failure modes are subtle.
This article breaks down the five most common CPI vulnerability patterns I see in Solana program audits, with real Anchor code showing both the broken and fixed versions. If you write or audit Solana programs, bookmark this.
Why CPIs Are Solana's Biggest Attack Surface
On Solana, programs are stateless. They don't store data internally — they operate on accounts passed in by the caller. When Program A calls Program B via CPI, it forwards accounts along with the call. Here's the catch: signer privileges travel with the accounts.
This creates a class of vulnerabilities called confused deputy attacks — where your program acts as an unwitting intermediary, using its authority on behalf of an attacker.
Pattern 1: Unverified Program ID in CPI Target
The most basic and most devastating pattern.
❌ Vulnerable Code
pub fn swap_tokens(ctx: Context<SwapTokens>) -> Result<()> {
let cpi_program = ctx.accounts.dex_program.to_account_info();
let cpi_accounts = DexSwap {
pool: ctx.accounts.pool.to_account_info(),
user_token_a: ctx.accounts.user_token_a.to_account_info(),
user_token_b: ctx.accounts.user_token_b.to_account_info(),
authority: ctx.accounts.user.to_account_info(),
};
let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);
dex::swap(cpi_ctx, amount)?;
Ok(())
}
#[derive(Accounts)]
pub struct SwapTokens<'info> {
/// CHECK: DEX program
pub dex_program: AccountInfo<'info>,
// ... other accounts
}
The dex_program account is completely unchecked. An attacker passes their own malicious program, which receives the user's token accounts with signer authority forwarded through the CPI.
✅ Fixed Code
#[derive(Accounts)]
pub struct SwapTokens<'info> {
#[account(address = dex::ID)]
pub dex_program: Program<'info, Dex>,
// ... other accounts
}
Rule: Always use Program<'info, T> or an explicit address constraint for CPI targets. Never accept raw AccountInfo for program IDs.
Pattern 2: Stale Account Data After CPI
This one is insidious because it involves Anchor's deserialization model.
❌ Vulnerable Code
pub fn deposit_and_check(ctx: Context<DepositAndCheck>, amount: u64) -> Result<()> {
// CPI: transfer tokens to vault
token::transfer(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
Transfer {
from: ctx.accounts.user_token.to_account_info(),
to: ctx.accounts.vault.to_account_info(),
authority: ctx.accounts.user.to_account_info(),
},
),
amount,
)?;
// BUG: vault.amount is stale — still has the pre-transfer value
let vault_balance = ctx.accounts.vault.amount;
msg!("Vault balance: {}", vault_balance); // Wrong!
// Logic based on stale data...
if vault_balance >= THRESHOLD {
// This check uses pre-CPI data
unlock_rewards(ctx)?;
}
Ok(())
}
Anchor deserializes accounts at instruction entry. After a CPI modifies an account, the deserialized struct still holds old data. You must explicitly reload.
✅ Fixed Code
pub fn deposit_and_check(ctx: Context<DepositAndCheck>, amount: u64) -> Result<()> {
token::transfer(/* ... same as above ... */)?;
// Reload the account data after CPI
ctx.accounts.vault.reload()?;
let vault_balance = ctx.accounts.vault.amount;
if vault_balance >= THRESHOLD {
unlock_rewards(ctx)?;
}
Ok(())
}
Rule: Call .reload() on any Anchor account you read after a CPI that could modify it.
Pattern 3: Forwarding User Signers to Untrusted Programs
When your program does a CPI, any account marked as is_signer in the original transaction retains that status through the CPI chain. This is by design — but it means your program can inadvertently give another program authority over a user's wallet.
❌ Vulnerable Code
pub fn execute_strategy(ctx: Context<ExecuteStrategy>) -> Result<()> {
// User signed the transaction, trusting OUR program
// But we're forwarding their signer to an arbitrary strategy
let cpi_accounts = vec![
AccountMeta::new(ctx.accounts.user.key(), true), // is_signer = true
AccountMeta::new(ctx.accounts.user_token.key(), false),
];
invoke(
&Instruction {
program_id: ctx.accounts.strategy_program.key(),
accounts: cpi_accounts,
data: strategy_data,
},
&[
ctx.accounts.user.to_account_info(),
ctx.accounts.user_token.to_account_info(),
ctx.accounts.strategy_program.to_account_info(),
],
)?;
Ok(())
}
If strategy_program is attacker-controlled, it now has the user's signer authority and can drain their token accounts.
✅ Fixed Code
#[derive(Accounts)]
pub struct ExecuteStrategy<'info> {
// Whitelist strategy programs via on-chain registry
#[account(
seeds = [b"strategy", strategy_program.key().as_ref()],
bump,
constraint = strategy_registry.is_active @ ErrorCode::StrategyNotApproved,
)]
pub strategy_registry: Account<'info, StrategyRegistry>,
/// CHECK: Validated via strategy_registry PDA
pub strategy_program: AccountInfo<'info>,
// ...
}
Rule: Never forward user signers to unvalidated programs. Use on-chain registries or hardcoded program IDs to whitelist CPI targets.
Pattern 4: Missing Owner Checks on CPI Accounts
Solana accounts carry an owner field indicating which program controls them. Failing to verify ownership lets attackers substitute look-alike accounts.
❌ Vulnerable Code
#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(mut)]
pub vault: Account<'info, TokenAccount>,
/// CHECK: we just check the key matches
#[account(address = vault.owner)]
pub vault_authority: AccountInfo<'info>,
}
This checks vault.owner (the SPL token owner field) but doesn't verify the account is actually owned by the Token Program. An attacker could create a fake account with arbitrary data at the same address layout.
✅ Fixed Code
#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(
mut,
token::mint = expected_mint,
token::authority = vault_authority,
)]
pub vault: Account<'info, TokenAccount>,
#[account(
seeds = [b"vault_authority", vault.key().as_ref()],
bump,
)]
/// CHECK: PDA derived from vault
pub vault_authority: AccountInfo<'info>,
}
Rule: Use Anchor's token::mint, token::authority constraints and PDA derivation. Never trust account fields without verifying the account's program ownership.
Pattern 5: CPI Reentrancy via remaining_accounts
Anchor's remaining_accounts is an escape hatch that bypasses all automatic validation. Combined with CPIs, it creates reentrancy-like scenarios.
❌ Vulnerable Code
pub fn process_batch(ctx: Context<ProcessBatch>) -> Result<()> {
for account in ctx.remaining_accounts.iter() {
// No validation on these accounts whatsoever
let data = account.try_borrow_data()?;
let parsed: UserPosition = UserPosition::try_deserialize(&mut &data[..])?;
// CPI that modifies state
update_position_cpi(&ctx, &parsed)?;
// More operations assuming the state is still valid...
}
Ok(())
}
An attacker can inject malicious accounts into remaining_accounts, and because there's no validation, each iteration could operate on attacker-controlled data.
✅ Fixed Code
pub fn process_batch(ctx: Context<ProcessBatch>) -> Result<()> {
// Validate ALL remaining accounts up front
let validated: Vec<Account<UserPosition>> = ctx.remaining_accounts
.iter()
.map(|acc| {
// Verify owner is our program
require!(acc.owner == &crate::ID, ErrorCode::InvalidOwner);
// Verify discriminator
let account: Account<UserPosition> = Account::try_from(acc)?;
// Verify relationship to known state
require!(
account.pool == ctx.accounts.pool.key(),
ErrorCode::PoolMismatch
);
Ok(account)
})
.collect::<Result<Vec<_>>>()?;
for position in validated.iter() {
update_position_cpi(&ctx, position)?;
}
Ok(())
}
Rule: Treat remaining_accounts as fully untrusted input. Validate owner, discriminator, and relational integrity before any operation.
The Audit Checklist
When reviewing Solana programs for CPI safety, verify each of these:
- CPI target program ID is hardcoded or PDA-verified → Prevents arbitrary program invocation
- No user signer forwarded to unvalidated programs → Prevents confused deputy wallet drain
-
.reload()called after CPIs that modify accounts → Prevents stale data logic errors -
remaining_accountsfully validated before use → Prevents injection of malicious accounts -
Token accounts verified with
token::mint+token::authority→ Prevents mint/authority spoofing - PDA seeds include sufficient entropy → Prevents seed collision attacks
- CPI depth considered (max 4 on Solana) → Prevents unexpected CPI chain failures
Final Thoughts
Solana's CPI model is powerful — it's what makes composability possible. But composability without validation is just a fancy word for "anyone can call anything." The Anchor framework handles a lot of the boilerplate, but it can't save you from architectural mistakes.
Every CPI is a trust boundary. Treat it like one.
If you found this useful, I write regularly about DeFi security, smart contract auditing, and blockchain vulnerability research. Follow for more.
Top comments (0)