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

Published: (March 16, 2026 at 12:50 AM EDT)
5 min read
Source: Dev.to

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)

[![ohmygod](https://media2.dev.to/dynamic/image/width=50,height=50,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3750445%2F38ad506e-4cd9-4f3c-b848-9391167fb580.png)](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 /// CHECK comment 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 json

Soteria CPI checks

Check IDDescriptionSeverity
missing-program-id-checkUnvalidated CPI target programsCritical
signer-forwardingUser signers passed to unvalidated CPIsCritical
missing-owner-checkAccounts not verified against expected ownerHigh
pda-seed-collisionPredictable or shared PDA derivationsMedium
unchecked-cpi-returnCPI results not validatedMedium

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_signed with program‑derived seeds only.

Signer Safety

  • User‑provided signers are never forwarded to unvalidated programs.
  • invoke_signed seeds 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 /// CHECK comment is not security. Using 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.

0 views
Back to Blog

Related posts

Read more »