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, Anchor’s Program<'info, T> type, or explicit key comparisons.
Recommended Fix
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
1. Anchor Program<'info, T> type (recommended for Anchor users)
Anchor 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)?;