DEV Community

ohmygod
ohmygod

Posted on

The CPI Trust Boundary: 7 Ways Solana Cross-Program Invocations Betray You (And How to Lock Them Down)

Cross-Program Invocations (CPIs) are the backbone of composable Solana programs. They're also where $400M+ in exploits originated.

Every time your program invokes another program, it crosses a trust boundary. The problem? Most developers treat CPIs like safe function calls. They're not. They're inter-process communication where the callee can do anything your signer privileges allow.

This guide covers seven CPI anti-patterns that have led to real exploits, with concrete Rust code showing both the vulnerable and secure implementations.


1. The Phantom Program: Not Validating the Target Program ID

Severity: Critical | Real-world impact: Wormhole ($326M)

The most fundamental CPI mistake is invoking a program without verifying its identity.

The Vulnerable Pattern

// ❌ DANGEROUS: program_id comes from user input
pub fn transfer_tokens(ctx: Context<Transfer>, amount: u64) -> 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(),
    };
    // The token_program account is never validated!
    let cpi_ctx = CpiContext::new(
        ctx.accounts.token_program.to_account_info(),
        cpi_accounts,
    );
    token::transfer(cpi_ctx, amount)?;
    Ok(())
}

#[derive(Accounts)]
pub struct Transfer<'info> {
    #[account(mut)]
    pub source: AccountInfo<'info>,
    #[account(mut)]
    pub destination: AccountInfo<'info>,
    pub authority: Signer<'info>,
    /// CHECK: No validation — attacker can substitute a malicious program
    pub token_program: AccountInfo<'info>,
}
Enter fullscreen mode Exit fullscreen mode

An attacker deploys a fake token program that simply marks the transfer as successful without moving any tokens, then drains the real tokens through a separate transaction.

The Fix

// ✅ SECURE: Anchor validates the 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>, // Validated!
}
Enter fullscreen mode Exit fullscreen mode

Anchor's Program<'info, T> type checks the account's key matches the expected program ID at deserialization time. If you're writing native Solana programs, compare manually:

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

2. Signer Forwarding to the Abyss

Severity: Critical | Real-world impact: Multiple DeFi protocols

When you pass a user's signer privileges into a CPI, the callee inherits those privileges. If the callee is attacker-controlled (see #1) or has a vulnerability, the attacker effectively becomes the user.

The Vulnerable Pattern

// ❌ DANGEROUS: Forwarding user wallet signer to an external program
pub fn interact_with_protocol(
    ctx: Context<Interact>,
    data: Vec<u8>,
) -> Result<()> {
    let ix = Instruction {
        program_id: ctx.accounts.external_program.key(),
        accounts: vec![
            AccountMeta::new(ctx.accounts.user_wallet.key(), true), // signer!
            AccountMeta::new(ctx.accounts.user_token_account.key(), false),
        ],
        data,
    };
    invoke(
        &ix,
        &[
            ctx.accounts.user_wallet.to_account_info(),
            ctx.accounts.user_token_account.to_account_info(),
            ctx.accounts.external_program.to_account_info(),
        ],
    )?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The external_program could call spl_token::transfer to drain the user's token account, because it received the user's signer privilege.

The Fix

Use a PDA as the authority instead of forwarding user signers:

// ✅ SECURE: PDA authority limits what the callee can do
pub fn interact_with_protocol(
    ctx: Context<Interact>,
    data: Vec<u8>,
) -> Result<()> {
    let seeds = &[
        b"vault_authority",
        ctx.accounts.vault.key().as_ref(),
        &[ctx.accounts.vault.bump],
    ];
    let signer_seeds = &[&seeds[..]];

    // Only the PDA is forwarded — it only has authority
    // over program-controlled accounts, not the user's wallet
    invoke_signed(
        &ix,
        &[ctx.accounts.pda_authority.to_account_info()],
        signer_seeds,
    )?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Rule of thumb: If a user signs a transaction, their signer privilege should stop at your program. Never forward it to programs you don't control.


3. The Stale State Trap: Not Reloading After CPI

Severity: High | Real-world impact: Lending protocol miscalculations

Anchor deserializes accounts at the start of your instruction. If a CPI modifies one of those accounts, your local copy is stale. Any logic based on the stale data produces wrong results.

The Vulnerable Pattern

// ❌ DANGEROUS: Reading balance after CPI without reload
pub fn deposit_and_check(
    ctx: Context<DepositCheck>,
    amount: u64,
) -> Result<()> {
    // Deposit tokens via CPI
    token::transfer(
        ctx.accounts.into_transfer_context(),
        amount,
    )?;

    // BUG: vault_account still has the pre-CPI balance!
    let vault_balance = ctx.accounts.vault_account.amount;

    // This check uses stale data
    require!(vault_balance >= MIN_BALANCE, ErrorCode::InsufficientBalance);

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The Fix

// ✅ SECURE: Explicitly reload after CPI
pub fn deposit_and_check(
    ctx: Context<DepositCheck>,
    amount: u64,
) -> Result<()> {
    token::transfer(
        ctx.accounts.into_transfer_context(),
        amount,
    )?;

    // Reload the account data from the runtime
    ctx.accounts.vault_account.reload()?;

    let vault_balance = ctx.accounts.vault_account.amount;
    require!(vault_balance >= MIN_BALANCE, ErrorCode::InsufficientBalance);

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Always call .reload() on any account that a CPI might have modified before reading its state.


4. The Missing Owner Check: Trusting Account Data Blindly

Severity: Critical | Real-world impact: Cashio ($52M)

Solana accounts carry an owner field indicating which program controls them. If you read data from an account without checking its owner, an attacker can craft an account with arbitrary data owned by their own program.

The Vulnerable Pattern

// ❌ DANGEROUS: No owner check on price oracle
pub fn liquidate(
    ctx: Context<Liquidate>,
) -> Result<()> {
    // Deserializing price data without verifying the account
    // is actually owned by the oracle program
    let price_feed = PriceFeed::try_from_slice(
        &ctx.accounts.price_oracle.data.borrow()
    )?;

    if price_feed.price < ctx.accounts.position.liquidation_price {
        // Liquidate the position...
    }
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

An attacker creates a fake oracle account with a manipulated price, triggering illegitimate liquidations.

The Fix

// ✅ SECURE: Validate owner before trusting data
#[derive(Accounts)]
pub struct Liquidate<'info> {
    #[account(
        owner = PYTH_PROGRAM_ID @ ErrorCode::InvalidOracle,
        // Also pin the specific price feed address
        constraint = price_oracle.key() == expected_feed_key @ ErrorCode::WrongFeed
    )]
    /// CHECK: Owner and address validated above
    pub price_oracle: AccountInfo<'info>,
    // ...
}
Enter fullscreen mode Exit fullscreen mode

The Cashio exploit exploited exactly this pattern: the program accepted accounts that looked correct structurally but were owned by the attacker's program, allowing unlimited minting of CASH tokens.


5. Reentrancy Through CPI Callbacks

Severity: High | Real-world impact: Emerging attack vector on Solana

While Solana's runtime prevents direct reentrancy (a program can't CPI into itself), it doesn't prevent indirect reentrancy: Program A → Program B → Program A. If your program modifies state after a CPI, the callee could call back into your program with the old state.

The Vulnerable Pattern

// ❌ DANGEROUS: State update after CPI (check-interact-update)
pub fn withdraw(
    ctx: Context<Withdraw>,
    amount: u64,
) -> Result<()> {
    // Check
    require!(
        ctx.accounts.user_state.balance >= amount,
        ErrorCode::InsufficientFunds
    );

    // Interact — CPI to transfer tokens
    token::transfer(
        ctx.accounts.into_transfer_context(),
        amount,
    )?;

    // Update — but if the CPI called back into us,
    // this balance was already read as the original value!
    ctx.accounts.user_state.balance -= amount;

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The Fix: Check-Update-Interact

// ✅ SECURE: Update state BEFORE the CPI
pub fn withdraw(
    ctx: Context<Withdraw>,
    amount: u64,
) -> Result<()> {
    // Check
    require!(
        ctx.accounts.user_state.balance >= amount,
        ErrorCode::InsufficientFunds
    );

    // Update FIRST
    ctx.accounts.user_state.balance -= amount;

    // Interact — even if there's a callback, state is already updated
    token::transfer(
        ctx.accounts.into_transfer_context(),
        amount,
    )?;

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Additionally, consider adding a reentrancy guard:

#[account]
pub struct UserState {
    pub balance: u64,
    pub locked: bool, // reentrancy guard
}

// At the start of sensitive instructions:
require!(!ctx.accounts.user_state.locked, ErrorCode::Reentrancy);
ctx.accounts.user_state.locked = true;
// ... do work ...
ctx.accounts.user_state.locked = false;
Enter fullscreen mode Exit fullscreen mode

6. Lamport Drain on Forwarded Signers

Severity: Medium | Real-world impact: Subtle fund loss

When you forward a signer to a CPI, the callee can transfer SOL (lamports) out of that account. Even if you validate the target program, a bug in the callee could drain the signer's SOL balance.

The Vulnerable Pattern

// ❌ RISKY: No lamport balance check after CPI
pub fn interact(ctx: Context<Interact>) -> Result<()> {
    // Forward signer to trusted (but complex) protocol
    invoke(
        &protocol_instruction,
        &[ctx.accounts.user.to_account_info()],
    )?;
    // User's SOL might have been drained
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The Fix

// ✅ SECURE: Guard lamport balance across CPI
pub fn interact(ctx: Context<Interact>) -> Result<()> {
    let lamports_before = ctx.accounts.user.lamports();

    invoke(
        &protocol_instruction,
        &[ctx.accounts.user.to_account_info()],
    )?;

    let lamports_after = ctx.accounts.user.to_account_info().lamports();
    require!(
        lamports_after >= lamports_before,
        ErrorCode::UnexpectedLamportDrain
    );
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

7. The Sysvar Spoofing Trap

Severity: Critical | Real-world impact: Wormhole ($326M)

Before Solana v1.8, programs could accept sysvar accounts (like rent, clock, or instructions) as regular account inputs without verifying their addresses. The Wormhole exploit leveraged exactly this: the attacker passed a fake Instructions sysvar, bypassing signature verification entirely.

The Vulnerable Pattern

// ❌ DANGEROUS: Accepting sysvar without address check
#[derive(Accounts)]
pub struct VerifySig<'info> {
    /// CHECK: supposed to be Instructions sysvar
    pub instructions_sysvar: AccountInfo<'info>,
}

pub fn verify(ctx: Context<VerifySig>) -> Result<()> {
    // Uses the account as if it's the real sysvar
    let current_ix = load_instruction_at_checked(
        0,
        &ctx.accounts.instructions_sysvar,
    )?;
    // Attacker controls what this returns!
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The Fix

// ✅ SECURE: Use Anchor's Sysvar type or verify the address
#[derive(Accounts)]
pub struct VerifySig<'info> {
    /// Anchor automatically validates this is the real sysvar
    pub instructions_sysvar: Sysvar<'info, Instructions>,
}

// Or manually:
require!(
    *instructions_sysvar.key == solana_program::sysvar::instructions::ID,
    ErrorCode::InvalidSysvar
);
Enter fullscreen mode Exit fullscreen mode

The CPI Security Checklist

Before deploying any Solana program that makes CPIs, verify:

Check How
Program ID validated Use Program<'info, T> or manual key comparison
No user signer forwarding Use PDA authorities instead
Account reload after CPI Call .reload() before reading CPI-modified state
Account ownership verified Use owner = PROGRAM_ID constraint
Check-Update-Interact order Mutate state before CPI, not after
Lamport guards Compare balances before/after when forwarding signers
Sysvar addresses pinned Use typed sysvars or verify addresses
Reentrancy guards Add lock flags for state-modifying instructions

Beyond Code: Process-Level Defenses

Secure CPIs are necessary but not sufficient. Layer these on:

  1. Audit scope must include CPI paths. Auditors should trace every CPI call and verify what privileges are forwarded. A "clean" program can still be exploited through an insecure CPI to a dependency.

  2. Use invoke_signed over invoke whenever possible. PDA-signed CPIs are scoped to your program's derived addresses, limiting blast radius.

  3. Monitor with Forta or custom validators. Set up real-time alerts for unexpected CPI patterns in your deployed programs.

  4. Version-pin your dependencies. A CPI to a program you control today might be upgraded tomorrow. Use immutable program IDs or verify the program's upgrade authority.

  5. Test CPI boundaries with Trident or custom BPF test harnesses. Property-based testing should specifically target CPI validation logic.


Conclusion

Every CPI is a trust boundary crossing. The Solana runtime gives you the raw mechanism — it's on you to validate who you're calling, what privileges you're sharing, and what state you trust after the call returns.

The $326M Wormhole exploit, the $52M Cashio drain, and dozens of smaller incidents all trace back to the same root cause: treating CPIs as safe function calls instead of hostile interfaces.

Build your programs like every callee is adversarial. Because on a permissionless blockchain, they might be.


This article is part of an ongoing series on DeFi security research. Follow for deep dives into smart contract vulnerabilities, audit methodologies, and security engineering for Web3.

Top comments (0)