Cross-Program Invocations (CPIs) are Solana's superpower — and its most dangerous attack surface. In Q1 2026 alone, CPI-related vulnerabilities contributed to over $40M in losses across protocols like Step Finance, Remora Markets, and several undisclosed audit findings on Cantina and Sherlock.
The problem? CPI chains create implicit trust relationships that human auditors routinely miss. A program that's perfectly secure in isolation becomes exploitable when an untrusted program is invoked with forwarded signer privileges.
This guide compares the three leading static analysis approaches for catching CPI vulnerabilities in Anchor programs, with real detection examples from 2026 audits.
Why CPI Vulnerabilities Are Hard to Catch Manually
Consider this innocent-looking instruction:
pub fn swap_via_amm(ctx: Context<SwapViaAmm>, amount: u64) -> Result<()> {
let cpi_accounts = Transfer {
from: ctx.accounts.user_token_account.to_account_info(),
to: ctx.accounts.pool_token_account.to_account_info(),
authority: ctx.accounts.user.to_account_info(),
};
let cpi_program = ctx.accounts.amm_program.to_account_info();
let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);
amm_interface::swap(cpi_ctx, amount)?;
Ok(())
}
#[derive(Accounts)]
pub struct SwapViaAmm<'info> {
#[account(mut)]
pub user: Signer<'info>,
#[account(mut)]
pub user_token_account: Account<'info, TokenAccount>,
#[account(mut)]
pub pool_token_account: Account<'info, TokenAccount>,
/// CHECK: AMM program to invoke
pub amm_program: AccountInfo<'info>,
}
Spot the vulnerability? The amm_program account has no constraint. An attacker can substitute any program — including a malicious one that steals the forwarded user signer authority to drain the wallet.
A manual reviewer scanning 20,000+ lines of Rust might skip this because:
- The struct uses
AccountInfo(common in CPI patterns) - The logic looks standard (transfer + swap)
- The
/// CHECKcomment implies intentional design
Static analysis catches this in milliseconds.
Tool 1: Soteria — The Veteran Scanner
Soteria has been the go-to Solana static analyzer since 2022, and its 2026 updates added Anchor v0.30+ support and Token-2022 awareness.
CPI Detection Capabilities
cargo install soteria-cli
soteria analyze --project-path . --output json
Soteria CPI checks:
-
missing-program-id-check— Unvalidated CPI target programs (Critical) -
signer-forwarding— User signers passed to unvalidated CPIs (Critical) -
missing-owner-check— Accounts not verified against expected owner (High) -
pda-seed-collision— Predictable or shared PDA derivations (Medium) -
unchecked-cpi-return— CPI results not validated (Medium)
Real Detection Example
CRITICAL: missing-program-id-check at src/lib.rs:42
Account `amm_program` used as CPI target without program ID validation.
An attacker can substitute a malicious program to hijack forwarded signers.
Remediation: Add constraint #[account(address = amm::ID)] or use
Program<'info, Amm> type instead of AccountInfo<'info>.
Fix Pattern
#[derive(Accounts)]
pub struct SwapViaAmm<'info> {
#[account(mut)]
pub user: Signer<'info>,
#[account(mut)]
pub user_token_account: Account<'info, TokenAccount>,
#[account(mut)]
pub pool_token_account: Account<'info, TokenAccount>,
pub amm_program: Program<'info, Amm>,
}
Strengths: Fast (<30s), low false positives, CI/CD-friendly.
Limitations: No cross-program data flow, misses conditional CPI branches.
Tool 2: Xray — Deep Data Flow Analysis
Xray builds a full data flow graph across CPI boundaries with inter-program taint tracking.
cargo install xray-audit
xray scan --depth cross-program --project .
Detection Example: Stale Account After CPI
pub fn deposit_and_borrow(ctx: Context<DepositBorrow>, amount: u64) -> Result<()> {
let deposit_ctx = CpiContext::new(
ctx.accounts.lending_program.to_account_info(),
Deposit {
user: ctx.accounts.user.to_account_info(),
vault: ctx.accounts.vault.to_account_info(),
},
);
lending::deposit(deposit_ctx, amount)?;
// STALE: vault account data not refreshed after CPI
let vault_data = &ctx.accounts.vault;
let borrow_limit = vault_data.total_deposits * 80 / 100;
process_borrow(&ctx, borrow_limit)?;
Ok(())
}
Fix: Add ctx.accounts.vault.reload()?; after the CPI call.
Strengths: Cross-program data flow tracking, catches stale-account-after-CPI.
Limitations: Slower (2-10 min), higher false positive rate (~15%).
Tool 3: Trident — Fuzz Testing for CPI Chains
Trident is a property-based fuzzer for Anchor programs that catches emergent vulnerabilities from unexpected input combinations.
impl FuzzInstruction for SwapViaAmm {
fn check_post_conditions(
&self,
pre_state: &AccountSnapshot,
post_state: &AccountSnapshot,
) -> Result<()> {
let max_loss = self.data.amount + MAX_PROTOCOL_FEE;
let actual_loss = pre_state.user_balance - post_state.user_balance;
assert!(actual_loss <= max_loss, "Excessive loss detected");
Ok(())
}
}
What Trident catches that static analysis misses:
- Integer overflow in CPI parameters
- Account substitution attacks
- Instruction ordering dependencies
- Re-initialization attacks after CPI
The 2026 Audit Pipeline: All Three Combined
name: Solana Security Pipeline
on: [push, pull_request]
jobs:
security:
steps:
- name: Soteria (fast scan, <1 min)
run: soteria analyze --severity critical,high
- name: Xray (deep analysis, 2-10 min)
run: xray scan --depth cross-program
- name: Trident (fuzz, configurable)
run: trident fuzz run --iterations 500000 --cpi-depth 2
No single tool covers everything. Soteria is your fast first pass, Xray catches data flow issues, Trident finds emergent bugs.
CPI Security Checklist
Account Validation:
- [ ] Every CPI target uses
Program<'info, T>not rawAccountInfo - [ ] PDA seeds include domain separators
Signer Safety:
- [ ] User signers never forwarded to unvalidated programs
- [ ]
invoke_signedseeds are derived, not user-supplied
Post-CPI State:
- [ ]
account.reload()?called after CPIs modifying shared accounts - [ ] Token balances re-read after CPI transfers
Token-2022:
- [ ] Transfer hooks accounted for in fee calculations
- [ ] Close authority checked on all CPI-received token accounts
Key Takeaways
- CPI = trust boundary. Treat every cross-program invocation like an external API call.
- Stale-account-after-CPI is 2026's most underrated bug class. Anchor does NOT auto-reload after CPI.
- Layer your tools. Soteria catches 60%, Xray adds 25%, Trident covers the rest.
-
The
/// CHECKcomment is not security.AccountInfowith CHECK for a CPI target is almost always a vulnerability.
This is part of our DeFi Security Research series. Follow for weekly deep dives into audit tools and defensive patterns.
Top comments (0)