DEV Community

ohmygod
ohmygod

Posted on

Auditing Solana CPI Chains: How Static Analysis Tools Catch the Vulnerabilities That Manual Review Misses

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>,
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. The struct uses AccountInfo (common in CPI patterns)
  2. The logic looks standard (transfer + swap)
  3. The /// CHECK comment 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
Enter fullscreen mode Exit fullscreen mode

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>.
Enter fullscreen mode Exit fullscreen mode

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>,
}
Enter fullscreen mode Exit fullscreen mode

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 .
Enter fullscreen mode Exit fullscreen mode

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(())
}
Enter fullscreen mode Exit fullscreen mode

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(())
    }
}
Enter fullscreen mode Exit fullscreen mode

What Trident catches that static analysis misses:

  1. Integer overflow in CPI parameters
  2. Account substitution attacks
  3. Instruction ordering dependencies
  4. 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
Enter fullscreen mode Exit fullscreen mode

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 raw AccountInfo
  • [ ] PDA seeds include domain separators

Signer Safety:

  • [ ] User signers never forwarded to unvalidated programs
  • [ ] invoke_signed seeds 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

  1. CPI = trust boundary. Treat every cross-program invocation like an external API call.
  2. Stale-account-after-CPI is 2026's most underrated bug class. Anchor does NOT auto-reload after CPI.
  3. Layer your tools. Soteria catches 60%, Xray adds 25%, Trident covers the rest.
  4. The /// CHECK comment is not security. AccountInfo with 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)