Skip to main content
Sigvex

Arbitrary CPI Remediation

How to validate cross-program invocation targets to prevent attackers from redirecting CPI calls to malicious programs.

Arbitrary CPI Remediation

Overview

Related Detector: Arbitrary CPI

Arbitrary CPI vulnerabilities arise when a Solana program invokes another program without validating the target program’s ID. The recommended fix is to verify the program ID before every CPI, using either hardcoded constants, the account-validation framework’s Program<'info, T> type, or explicit key comparisons.

Before (Vulnerable)

pub fn execute_token_transfer(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
    let token_program = &accounts[3]; // Caller-supplied — could be malicious

    let transfer_ix = spl_token::instruction::transfer(
        token_program.key,  // Using unvalidated program key
        accounts[1].key,
        accounts[2].key,
        accounts[0].key,
        &[],
        amount,
    )?;

    // VULNERABLE: token_program.key could be an attacker's program
    invoke(&transfer_ix, accounts)?;
    Ok(())
}

After (Fixed)

use spl_token::ID as TOKEN_PROGRAM_ID;

pub fn execute_token_transfer(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
    let token_program = &accounts[3];

    // FIXED: validate program ID before CPI
    if token_program.key != &TOKEN_PROGRAM_ID {
        msg!("Invalid token program: expected {}, got {}",
             TOKEN_PROGRAM_ID, token_program.key);
        return Err(ProgramError::IncorrectProgramId);
    }

    let transfer_ix = spl_token::instruction::transfer(
        token_program.key,
        accounts[1].key,
        accounts[2].key,
        accounts[0].key,
        &[],
        amount,
    )?;

    invoke(&transfer_ix, accounts)?;
    Ok(())
}

The fix adds a single program ID comparison before the CPI. The comparison is a constant-time key equality check that cannot be manipulated.

Alternative Mitigations

an account-validation framework automatically validates the program ID at account deserialization — no manual check needed:

use anchor_lang::prelude::*;
use anchor_spl::token::{Token, TokenAccount, Transfer};

#[derive(Accounts)]
pub struct ExecuteTransfer<'info> {
    #[account(mut)]
    pub source: Account<'info, TokenAccount>,
    #[account(mut)]
    pub destination: Account<'info, TokenAccount>,
    pub authority: Signer<'info>,
    // Program<'info, Token> validates program.key == spl_token::id() at deserialization
    pub token_program: Program<'info, Token>,
}

pub fn execute_transfer(ctx: Context<ExecuteTransfer>, amount: u64) -> Result<()> {
    let cpi_ctx = CpiContext::new(
        ctx.accounts.token_program.to_account_info(),
        Transfer {
            from: ctx.accounts.source.to_account_info(),
            to: ctx.accounts.destination.to_account_info(),
            authority: ctx.accounts.authority.to_account_info(),
        },
    );
    anchor_spl::token::transfer(cpi_ctx, amount)?;
    Ok(())
}

2. Config PDA for upgradeable program IDs

When the target program ID may change (e.g., protocol upgrades), store validated IDs in a config PDA rather than hardcoding:

#[account]
pub struct ProtocolConfig {
    pub authority: Pubkey,
    pub dex_program: Pubkey,       // Governance-updated, not user-controlled
    pub oracle_program: Pubkey,
}

pub fn execute_swap(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
    let config = &accounts[4];  // config is a PDA — owner-validated
    let config_data = config.data.borrow();
    let protocol_config: ProtocolConfig = ProtocolConfig::try_from_slice(&config_data[8..])?;

    let dex_program = &accounts[5];
    if dex_program.key != &protocol_config.dex_program {
        return Err(ProgramError::IncorrectProgramId);
    }
    // Safe CPI to validated dex program
    invoke(/* ... */, accounts)?;
    Ok(())
}

3. Whitelist for multiple valid programs

When multiple program versions are acceptable:

const ALLOWED_PROGRAMS: &[Pubkey] = &[
    // SPL Token
    pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"),
    // SPL Token-2022
    pubkey!("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"),
];

pub fn flexible_transfer(accounts: &[AccountInfo]) -> ProgramResult {
    let token_program = &accounts[3];

    if !ALLOWED_PROGRAMS.contains(token_program.key) {
        return Err(ProgramError::IncorrectProgramId);
    }

    invoke(/* ... */, accounts)?;
    Ok(())
}

Common Mistakes

Mistake 1: Validating after the CPI

// WRONG: the malicious program has already executed
invoke(&instruction, accounts)?;
if token_program.key != &TOKEN_PROGRAM_ID {  // Too late!
    return Err(ProgramError::IncorrectProgramId);
}

Mistake 2: Trusting the instruction’s program_id field instead of the account

// WRONG: building instruction from unvalidated user input
let user_program_id = Pubkey::from(user_supplied_bytes); // User-controlled
let instruction = Instruction {
    program_id: user_program_id, // This is not the same as the validated account
    // ...
};

Always validate the AccountInfo for the program, not a reconstructed program ID from instruction data.

Mistake 3: Only checking executable flag, not the actual program ID

// INSUFFICIENT: checks executable but not which program
if !program_account.executable {
    return Err(ProgramError::InvalidAccountData);
}
// Still vulnerable — any executable program passes this check
invoke(&instruction, accounts)?;

References