DEV Community

ohmygod
ohmygod

Posted on

The Solana CPI Security Playbook: 7 Cross-Program Invocation Patterns That Prevent Nine-Figure Exploits

Cross-Program Invocations (CPIs) are the composability backbone of Solana DeFi. They're also where the money disappears.

From the Wormhole bridge exploit ($320M) to countless smaller drains, the pattern is almost always the same: a CPI that trusted something it shouldn't have. The program ID wasn't verified. The signer authority was forwarded blindly. The account passed in looked right but wasn't.

This playbook distills the seven CPI security patterns every Solana developer needs to internalize before deploying anything that touches real funds.


Pattern 1: Always Verify the Target Program ID

The vulnerability: Your program invokes what it thinks is the SPL Token program. An attacker substitutes a malicious program that mimics the interface but steals funds.

The fix:

// ❌ DANGEROUS: No program ID verification
pub fn transfer_tokens(ctx: Context<Transfer>) -> Result<()> {
    let cpi_accounts = token::Transfer {
        from: ctx.accounts.source.to_account_info(),
        to: ctx.accounts.destination.to_account_info(),
        authority: ctx.accounts.authority.to_account_info(),
    };
    // Attacker can pass ANY program here
    let cpi_ctx = CpiContext::new(
        ctx.accounts.token_program.to_account_info(),
        cpi_accounts,
    );
    token::transfer(cpi_ctx, amount)
}

// ✅ SECURE: Anchor validates program ID automatically
#[derive(Accounts)]
pub struct Transfer<'info> {
    #[account(mut)]
    pub source: Account<'info, TokenAccount>,
    #[account(mut)]
    pub destination: Account<'info, TokenAccount>,
    pub authority: Signer<'info>,
    pub token_program: Program<'info, Token>,
}
Enter fullscreen mode Exit fullscreen mode

Anchor's Program<'info, T> type automatically verifies the program ID. Without Anchor, you must manually check:

if *token_program.key != spl_token::id() {
    return Err(ProgramError::IncorrectProgramId);
}
Enter fullscreen mode Exit fullscreen mode

Real-world impact: The Wormhole exploit leveraged insufficient program verification in the guardian signature validation flow. $320M gone.


Pattern 2: Never Forward User Signers to Untrusted Programs

The vulnerability: Your program receives a user's signer privilege and blindly passes it along in a CPI. If the target program is compromised, it inherits the user's full signing authority.

The fix: Use PDA-based authority patterns:

// ❌ DANGEROUS: Forwarding user signer to external program
pub fn risky_swap(ctx: Context<RiskySwap>) -> Result<()> {
    let cpi_ctx = CpiContext::new(
        ctx.accounts.external_dex.to_account_info(),
        ExternalSwap {
            user: ctx.accounts.user.to_account_info(), // Signer forwarded!
        },
    );
    external_program::swap(cpi_ctx, amount)
}

// ✅ SECURE: Use PDA as intermediary authority
pub fn safe_swap(ctx: Context<SafeSwap>) -> Result<()> {
    // Transfer FROM user TO program's PDA vault first
    let transfer_ctx = CpiContext::new(
        ctx.accounts.token_program.to_account_info(),
        token::Transfer {
            from: ctx.accounts.user_token.to_account_info(),
            to: ctx.accounts.vault.to_account_info(),
            authority: ctx.accounts.user.to_account_info(),
        },
    );
    token::transfer(transfer_ctx, amount)?;

    // Then CPI with PDA signer — user keys never touch external program
    let seeds = &[b"vault", &[ctx.bumps.vault_authority]];
    let signer_seeds = &[&seeds[..]];
    let cpi_ctx = CpiContext::new_with_signer(
        ctx.accounts.external_dex.to_account_info(),
        ExternalSwap {
            authority: ctx.accounts.vault_authority.to_account_info(),
        },
        signer_seeds,
    );
    external_program::swap(cpi_ctx, amount)
}
Enter fullscreen mode Exit fullscreen mode

The principle: Your program should be a trust boundary. User signing authority stays on your side.


Pattern 3: Validate All CPI Account Inputs

The vulnerability: An attacker passes accounts that satisfy the CPI interface but contain malicious data — wrong mint, wrong owner, wrong PDA seeds.

The fix: Constrain every account relationship:

#[derive(Accounts)]
pub struct SecureLend<'info> {
    #[account(
        mut,
        token::mint = collateral_mint,
        token::authority = user,
    )]
    pub user_collateral: Account<'info, TokenAccount>,

    #[account(
        mut,
        seeds = [b"vault", collateral_mint.key().as_ref()],
        bump,
        token::mint = collateral_mint,
        token::authority = vault_authority,
    )]
    pub vault: Account<'info, TokenAccount>,

    /// CHECK: PDA authority — seeds verified
    #[account(seeds = [b"authority"], bump)]
    pub vault_authority: UncheckedAccount<'info>,

    pub collateral_mint: Account<'info, Mint>,
    pub user: Signer<'info>,
    pub token_program: Program<'info, Token>,
}
Enter fullscreen mode Exit fullscreen mode

Every constraint (has_one, seeds, token::mint, token::authority) is an assertion the attacker must satisfy. Stack them deep.


Pattern 4: Respect the CPI Depth Limit

The vulnerability: Solana enforces a CPI depth limit of 4. Token-2022 transfer hooks consume one level. Chain CPIs near the limit and a hook pushes you over — a griefing vector.

// Audit your CPI depth:
// Level 0: User calls your program
// Level 1: Your program CPIs to DEX
// Level 2: DEX CPIs to Token-2022 transfer
// Level 3: Token-2022 invokes transfer hook
// Level 4: Transfer hook CPIs to... ❌ LIMIT REACHED

// Design rule: Keep your protocol's CPI depth ≤ 2
pub fn process_trade(ctx: Context<Trade>) -> Result<()> {
    // FLATTEN: Token transfers at the top level
    token::transfer(/* ... */)?;  // Depth 1
    // NOT: helper_fn() → inner_fn() → token::transfer() // Depth 3!
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Design rule: Maximum CPI depth of 2 in your own code. Leave headroom for Token-2022 hooks.


Pattern 5: PDA Signer Seeds — Store the Bump

The vulnerability: Recalculating PDA bumps at runtime is expensive and, if done incorrectly, allows non-canonical bumps to derive different PDAs.

#[account]
pub struct VaultState {
    pub authority: Pubkey,
    pub total_deposited: u64,
    pub bump: u8, // Store the canonical bump!
}

#[derive(Accounts)]
pub struct WithdrawFromVault<'info> {
    #[account(
        seeds = [b"vault", vault_state.authority.as_ref()],
        bump = vault_state.bump, // Use stored bump
    )]
    pub vault_state: Account<'info, VaultState>,
}

pub fn withdraw(ctx: Context<WithdrawFromVault>, amount: u64) -> Result<()> {
    let authority_key = ctx.accounts.vault_state.authority;
    let bump = ctx.accounts.vault_state.bump;
    let seeds = &[b"vault", authority_key.as_ref(), &[bump]];
    let signer_seeds = &[&seeds[..]];

    let cpi_ctx = CpiContext::new_with_signer(
        ctx.accounts.token_program.to_account_info(),
        token::Transfer {
            from: ctx.accounts.vault_token.to_account_info(),
            to: ctx.accounts.user_token.to_account_info(),
            authority: ctx.accounts.vault_state.to_account_info(),
        },
        signer_seeds,
    );
    token::transfer(cpi_ctx, amount)
}
Enter fullscreen mode Exit fullscreen mode

Using bump in the Anchor constraint ensures only the canonical (highest) bump is accepted.


Pattern 6: Guard Against CPI Reentrancy

The vulnerability: A malicious program invoked via CPI can call back into your program with different accounts, manipulating state mid-execution.

#[account]
pub struct ProtocolState {
    pub locked: bool,
    pub total_value: u64,
}

pub fn sensitive_operation(ctx: Context<SensitiveOp>) -> Result<()> {
    let state = &mut ctx.accounts.protocol_state;

    // Reentrancy guard
    require!(!state.locked, ErrorCode::ReentrancyDetected);
    state.locked = true;

    // State mutations BEFORE CPIs (CEI pattern)
    state.total_value = state.total_value.checked_sub(amount)
        .ok_or(ErrorCode::InsufficientFunds)?;

    // CPI that could potentially call back
    external_program::execute(/* ... */)?;

    state.locked = false;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Apply CEI (Checks-Effects-Interactions): Update all state before external CPIs.


Pattern 7: Token-2022 Transfer Hook Validation

The vulnerability: Token-2022 transfer hooks execute automatically on every transfer. A malicious hook can block transfers (DoS), exploit your state, consume CPI depth, or inject accounts via ExtraAccountMetaList.

pub fn validate_mint(ctx: Context<ValidateMint>) -> Result<()> {
    let mint_data = ctx.accounts.mint.to_account_info().data.borrow();

    if let Some(hook_program_id) = get_transfer_hook_program_id(&mint_data) {
        // Whitelist known-safe hook programs
        require!(
            APPROVED_HOOKS.contains(&hook_program_id),
            ErrorCode::UnapprovedTransferHook
        );
    }
    Ok(())
}

// In your test suite, deploy adversarial hooks that:
// - Try to reenter your program
// - Consume max compute units
// - Pass garbage in ExtraAccountMeta
Enter fullscreen mode Exit fullscreen mode

The CPI Security Checklist

Before every deployment:

# Check
1 All CPI target program IDs verified via Program<'info, T> or manual check
2 User signer authority never forwarded to external programs
3 All CPI account inputs constrained (mint, authority, seeds, owner)
4 Maximum CPI depth ≤ 2 in your code (headroom for hooks)
5 PDA bumps stored in account data, canonical bump enforced
6 Reentrancy guard on state-mutating functions with external CPIs
7 Token-2022 transfer hooks whitelisted or depth-budgeted
8 checked_* arithmetic in all CPI amount calculations
9 Integration tests include adversarial account substitution
10 Formal audit completed for CPI flows touching >$1M TVL

The Uncomfortable Truth

Most Solana exploits aren't sophisticated. They're a missing Program<'info, T> constraint. A forwarded signer that shouldn't have been. An account that looked right but had the wrong owner.

CPIs are where trust boundaries blur. Every CPI is a question: Do I trust this program with this authority over these accounts? If you can't answer that precisely for every CPI in your codebase, you have a vulnerability. You just haven't found it yet.

These seven patterns aren't optional hardening. They're the minimum. The floor. Everything above is your protocol's specific logic — and that needs its own audit.

But get the floor wrong, and the building collapses. Every time.


Part of the DeFi Security Deep Dives series. Follow for weekly security research.

Top comments (0)