Skip to main content
Sigvex

Remediating CPI Program ID Validation

How to ensure Cross-Program Invocations target the intended program by validating program account keys before invocation.

Remediating CPI Program ID Validation

Overview

Related Detector: CPI Program ID Validation

Missing program ID validation allows an attacker to substitute a malicious program in any position that your instruction uses for a CPI target. The fix is straightforward: compare the program account’s key against the expected program ID before invoking, or use the account-validation framework’s Program<'info, T> type which performs this check automatically at account deserialization.

Option 1: Use the account-validation framework’s Program Type (Preferred)

use anchor_lang::prelude::*;

#[derive(Accounts)]
pub struct TransferTokens<'info> {
    #[account(mut)]
    pub source: Account<'info, TokenAccount>,
    #[account(mut)]
    pub destination: Account<'info, TokenAccount>,
    pub authority: Signer<'info>,
    /// Program<'info, Token> automatically validates:
    /// - account.key() == Token::id() (spl_token::id())
    /// - account is executable
    pub token_program: Program<'info, Token>,
}

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

Option 2: Explicit Key Check (Native Programs)

use solana_program::{
    account_info::AccountInfo,
    program_error::ProgramError,
    program::invoke,
};

pub fn transfer_tokens(
    accounts: &[AccountInfo],
    amount: u64,
) -> Result<(), ProgramError> {
    let source = &accounts[0];
    let dest = &accounts[1];
    let authority = &accounts[2];
    let token_program = &accounts[3];

    // Explicit check before any CPI
    if *token_program.key != spl_token::id() {
        return Err(ProgramError::IncorrectProgramId);
    }

    // Also verify executability as a secondary check
    if !token_program.executable {
        return Err(ProgramError::InvalidAccountData);
    }

    let transfer_ix = spl_token::instruction::transfer(
        token_program.key,
        source.key,
        dest.key,
        authority.key,
        &[],
        amount,
    )?;

    invoke(
        &transfer_ix,
        &[source.clone(), dest.clone(), authority.clone(), token_program.clone()],
    )?;

    Ok(())
}

Alternative Mitigations

Hardcode the Program ID in the Instruction

For well-known programs (SPL Token, System Program, Associated Token Account), remove the program account from the instruction accounts entirely and hardcode the program ID:

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

pub fn transfer_tokens(ctx: Context<TransferTokens>, amount: u64) -> Result<()> {
    // No need to accept token_program as an account — it's hardcoded in the CPI
    let cpi_accounts = Transfer {
        from: ctx.accounts.source.to_account_info(),
        to: ctx.accounts.destination.to_account_info(),
        authority: ctx.accounts.authority.to_account_info(),
    };
    // CpiContext automatically uses the token program ID from the Token type
    let cpi_ctx = CpiContext::new(ctx.accounts.token_program.to_account_info(), cpi_accounts);
    token::transfer(cpi_ctx, amount)?;
    Ok(())
}

#[derive(Accounts)]
pub struct TransferTokens<'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>, // Typed and validated
}

Common Mistakes

Mistake: Checking Executable Flag Only

// INSUFFICIENT: any deployed program is executable
if !token_program.executable {
    return Err(ProgramError::InvalidAccountData);
}
// Missing: if *token_program.key != spl_token::id() { ... }

The executable flag only confirms the account contains deployed program code. It does not confirm which program it is.

Mistake: Validating Owner Instead of Key

// WRONG: program account owner is the BPF loader, not the program itself
if token_program.owner != &spl_token::id() {
    return Err(ProgramError::IncorrectProgramId);
}
// Correct check: if *token_program.key != spl_token::id() { ... }

Program accounts are owned by the BPF Loader programs, not by themselves. The program’s identity is its key (address), not its owner.

References