Auditing Solana CPI Chains: How Static Analysis Tools Catch the Vulnerabilities That Manual Review Misses
Here’s a cleaned‑up version of the markdown while preserving the original meaning and formatting:
> **Source:** [Dev.to](https://dev.to/ohmygod/auditing-solana-cpi-chains-how-static-analysis-tools-catch-the-vulnerabilities-that-manual-review-372d)
[](https://dev.to/ohmygod)
---Cross‑Program Invocations (CPIs) – The Superpower & Attack Surface
CPI‑related vulnerabilities accounted for over $40 M in losses in Q1 2026 (e.g., Step Finance, Remora Markets, and undisclosed findings on Cantina & Sherlock).
Why are they hard to catch?
- CPI chains create implicit trust relationships that human auditors often overlook.
- A program that is secure in isolation can become exploitable when an untrusted program is invoked with forwarded signer privileges.
- The dynamic nature of signer forwarding makes static reasoning difficult without specialized analysis tools.
Note: This guide compares the three leading static‑analysis approaches for detecting CPI vulnerabilities in Anchor programs, illustrating each method with real‑world detection examples from 2026 audits.
Why CPI Vulnerabilities Are Hard to Catch Manually
Consider this seemingly innocent instruction:
pub fn swap_via_amm(ctx: Context, 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 {
#[account(mut)]
pub user: Signer,
#[account(mut)]
pub user_token_account: Account,
#[account(mut)]
pub pool_token_account: Account,
/// CHECK: AMM program to invoke
pub amm_program: AccountInfo,
}Spot the vulnerability?
The amm_program account has no constraint. An attacker can replace it with 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 miss this because:
- The struct uses
AccountInfo(a common pattern for CPIs). - The surrounding logic looks standard (transfer + swap).
- The
/// CHECKcomment suggests the omission is intentional.
Static analysis tools, however, can flag this issue in milliseconds.
Tool 1: Soteria — The Veteran Scanner
Soteria has been the go‑to Solana static analyzer since 2022. Its 2026 updates added Anchor v0.30+ support and Token‑2022 awareness.
CPI Detection Capabilities
cargo install soteria-cli
soteria analyze --project-path . --output jsonSoteria CPI checks
| Check ID | Description | Severity |
|---|---|---|
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 type instead of AccountInfo.Fix Pattern
#[derive(Accounts)]
pub struct SwapViaAmm {
#[account(mut)]
pub user: Signer,
#[account(mut)]
pub user_token_account: Account,
#[account(mut)]
pub pool_token_account: Account,
pub amm_program: Program,
}Strengths:
- Fast analysis (typically < 2 minutes).
- Detects missing program‑ID checks, signer‑forwarding, and other critical CPI bugs.
Limitations:
- May miss subtle data‑staleness issues that arise after a CPI call.
Stale‑Account‑After‑CPI Example
pub fn process_swap(ctx: Context<SwapViaAmm>, 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: Reload the vault account after the CPI call.
pub fn process_swap(ctx: Context<SwapViaAmm>, 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)?;
// ✅ Refresh the account data before using it
ctx.accounts.vault.reload()?;
let vault_data = &ctx.accounts.vault;
let borrow_limit = vault_data.total_deposits * 80 / 100;
process_borrow(&ctx, borrow_limit)?;
Ok(())
}Strengths:
- Cross‑program data‑flow tracking; catches stale‑account‑after‑CPI bugs.
Limitations:
- Slower (2–10 minutes).
- 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 caused by unexpected input combinations across CPI (cross‑program‑invocation) boundaries.
Example Fuzz Implementation
use anchor_lang::prelude::*;
use trident::prelude::*;
/// Fuzz test for the `swap_via_amm` instruction.
impl FuzzInstruction for SwapViaAmm {
fn check_post_conditions(
&self,
pre_state: &AccountSnapshot,
post_state: &AccountSnapshot,
) -> Result<()> {
// --------------------------------------------------------------------
// 1️⃣ Define the maximum loss a user should experience.
// --------------------------------------------------------------------
// The user may lose the amount they swapped plus the protocol fee.
let max_loss = self.data.amount + MAX_PROTOCOL_FEE;
// --------------------------------------------------------------------
// 2️⃣ Compute the actual loss incurred by the user.
// --------------------------------------------------------------------
let actual_loss = pre_state.user_balance - post_state.user_balance;
// --------------------------------------------------------------------
// 3️⃣ Assert that the loss does not exceed the allowed maximum.
// --------------------------------------------------------------------
assert!(
actual_loss <= max_loss,
"User loss {} exceeds allowed maximum {}",
actual_loss,
max_loss
);
Ok(())
}
}CPI‑Safety Checklist
PDA Construction
- PDA seeds include domain separators to avoid collisions.
- All PDA derivations use
invoke_signedwith program‑derived seeds only.
Signer Safety
- User‑provided signers are never forwarded to unvalidated programs.
-
invoke_signedseeds are derived internally, not supplied by external input.
Post‑CPI State Validation
-
account.reload()?is called after any CPI that may modify shared accounts. - Token balances are re‑read after CPI transfers to ensure consistency.
Token‑2022 Considerations
- Transfer hooks are accounted for in fee calculations.
- Close authority is verified on all token accounts received via CPI.
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 a CPI.
- Layer your tools. Soteria catches ~60 %, Xray adds ~25 %, and Trident covers the rest.
- The
/// CHECKcomment is not security. UsingAccountInfowithCHECKfor 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.