Skip to main content
Sigvex

CPI Program ID Validation

Detects Cross-Program Invocations where the target program account key is not verified against the expected program ID, allowing an attacker to substitute a malicious program that accepts the same instruction format.

CPI Program ID Validation

Overview

Remediation Guide: How to Fix CPI Program ID Validation

The CPI program validation detector identifies Solana programs that invoke other programs via Cross-Program Invocation (CPI) without first verifying that the program account’s key matches the expected program ID. In Solana’s runtime, programs are passed as AccountInfo entries in the accounts array. Nothing prevents a caller from supplying any account in the position designated for a known program — including a malicious program that implements the same instruction interface but performs unauthorized actions.

Sigvex detects this pattern by scanning all functions for invoke(), invoke_signed(), and equivalent CPI syscall patterns (HirStmt::InvokeCpi), then checking whether any program account referenced in those calls was validated via a key comparison (*program.key == expected_program::id() or Anchor’s typed Program<'info, T>) before the invocation occurred. Functions that contain CPI calls but have no preceding program ID validation for the invoked program account are flagged.

Anchor’s Program<'info, T> type performs this check automatically at account deserialization, reducing the attack surface substantially. Native programs that pass raw AccountInfo references are the primary risk.

Why This Is an Issue

When a program accepts a user-supplied account as the program to invoke, the attacker controls which code executes. The attacker can deploy a program that:

  • Accepts the same instruction data format as the target (e.g., SPL Token’s transfer instruction)
  • Returns success without performing any real operation (no-op attack)
  • Performs an entirely different operation, such as modifying accounts the caller did not expect to be modified

This is distinct from arbitrary CPI (where any program is invoked by design). In program ID validation attacks, the vulnerability is that the code intends to invoke a specific, trusted program — but fails to enforce that intent.

Affected patterns include:

  • Token program substitution: A protocol passes a user-supplied “token program” account. An attacker passes a program that returns success on all transfer instructions without moving any tokens, allowing them to claim they deposited assets they never actually transferred.
  • Oracle program substitution: A protocol accepts a user-supplied oracle program. The attacker provides a program that returns an arbitrary price answer, bypassing the real oracle.
  • System program substitution: Critical instructions like create_account or assign that use the system program can be redirected to a no-op if the system program key is not checked.

How to Resolve

use solana_program::{account_info::AccountInfo, program_error::ProgramError, pubkey::Pubkey};

// Before: Vulnerable — no check that token_program is actually SPL Token
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]; // Could be any program!

    let transfer_ix = spl_token::instruction::transfer(
        token_program.key, // Key used to build the instruction
        source.key,
        dest.key,
        authority.key,
        &[],
        amount,
    )?;

    // VULNERABLE: invokes whatever program was passed in accounts[3]
    solana_program::program::invoke(
        &transfer_ix,
        &[source.clone(), dest.clone(), authority.clone(), token_program.clone()],
    )?;

    Ok(())
}

// After: Validate the program key before invoking
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];

    // Validate: the program account must be the real SPL Token program
    if *token_program.key != spl_token::id() {
        return Err(ProgramError::IncorrectProgramId);
    }

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

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

    Ok(())
}

Examples

Vulnerable Code

use anchor_lang::prelude::*;
use anchor_lang::solana_program::program::invoke;

// Vulnerable: oracle_program passed as AccountInfo without type validation
pub fn fetch_price(ctx: Context<FetchPrice>) -> Result<()> {
    let oracle_program = &ctx.accounts.oracle_program;
    let price_account = &ctx.accounts.price_account;

    // Build an instruction for the oracle program
    let get_price_ix = oracle_protocol::instruction::get_price(
        oracle_program.key,
        price_account.key,
    );

    // VULNERABLE: oracle_program.key is never compared to the known oracle program ID
    invoke(
        &get_price_ix,
        &[oracle_program.clone(), price_account.clone()],
    )?;

    Ok(())
}

#[derive(Accounts)]
pub struct FetchPrice<'info> {
    /// CHECK: No type safety — any program can be passed here
    pub oracle_program: AccountInfo<'info>,
    /// CHECK: No owner validation
    pub price_account: AccountInfo<'info>,
}

Fixed Code

use anchor_lang::prelude::*;

// Option 1: Use Anchor's Program<'info, T> for compile-time and runtime validation
pub fn fetch_price(ctx: Context<FetchPrice>) -> Result<()> {
    // oracle_program is now typed — Anchor verifies key == OracleProtocol::id() automatically
    let cpi_ctx = CpiContext::new(
        ctx.accounts.oracle_program.to_account_info(),
        oracle_protocol::cpi::accounts::GetPrice {
            price_account: ctx.accounts.price_account.to_account_info(),
        },
    );
    oracle_protocol::cpi::get_price(cpi_ctx)?;
    Ok(())
}

#[derive(Accounts)]
pub struct FetchPrice<'info> {
    /// Anchor's Program<'info, T> validates that this account == OracleProtocol::id()
    pub oracle_program: Program<'info, oracle_protocol::OracleProtocol>,
    #[account(owner = oracle_program.key())]
    pub price_account: AccountInfo<'info>,
}

// Option 2: Explicit key check for native programs
pub fn fetch_price_native(accounts: &[AccountInfo]) -> Result<(), ProgramError> {
    let oracle_program = &accounts[0];
    let price_account = &accounts[1];

    const EXPECTED_ORACLE_ID: Pubkey = oracle_protocol::ID;

    if *oracle_program.key != EXPECTED_ORACLE_ID {
        msg!("Expected oracle program {}, got {}", EXPECTED_ORACLE_ID, oracle_program.key);
        return Err(ProgramError::IncorrectProgramId);
    }

    // Safe to invoke — program ID has been validated
    let get_price_ix = oracle_protocol::instruction::get_price(
        oracle_program.key,
        price_account.key,
    );
    invoke(&get_price_ix, &[oracle_program.clone(), price_account.clone()])?;

    Ok(())
}

Sample Sigvex Output

{
  "detector_id": "cpi-program-validation",
  "severity": "high",
  "confidence": 0.73,
  "description": "Function fetch_price() invokes a CPI targeting the account at accounts[0] without comparing its key to the expected oracle program ID. An attacker can pass a malicious program that accepts the same instruction format, allowing price manipulation or no-op execution to bypass asset transfer requirements.",
  "location": { "function": "fetch_price", "offset": 14 }
}

Detection Methodology

Sigvex identifies missing CPI program ID validation through the following steps:

  1. CPI call identification: Scans all basic blocks for HirStmt::InvokeCpi statements, HirExpr::Cpi expressions, and syscall patterns matching invoke or invoke_signed.
  2. Program account variable tracking: For each CPI call, identifies the variable or account reference used as the target program. Tracks whether this variable came directly from the instruction accounts array (user-controlled) or was derived from a hardcoded constant (safe).
  3. Pre-call validation check: For each CPI target variable, searches all preceding statements in all predecessor blocks for a key comparison (CheckOwner or equality comparison against a known program ID constant). A validated program ID set is maintained and any CPI whose target is not in the set is flagged.
  4. Anchor discriminator reduction: When the function context indicates Anchor discriminators (typed accounts), any program account typed as Program<'info, T> is automatically added to the validated set with 80% confidence reduction on findings for that call.

Base confidence: 0.73.

Context modifiers:

  • Anchor Program<’info, T> typed account detected: 80% confidence reduction (automatic validation)
  • Hardcoded program ID constant used directly in CPI (not from accounts array): finding suppressed
  • invoke_signed with PDA seeds: no additional confidence modifier (seed-based signing does not validate the program ID)

Limitations

False positives:

  • Programs that use dynamic dispatch by design (e.g., a meta-program that is intended to delegate to user-specified programs) may be flagged as missing validation even when the design is intentional.
  • Programs that validate the program ID in a separate validate() function called as a prerequisite may be flagged when the detector cannot trace the inter-function validation dependency.

False negatives:

  • Validation checks that compare the program key to a value loaded from storage (a stored configuration address) are recognized, but a compromised stored address would not be detected.
  • Cross-function CPI patterns where the program account is passed between functions as an argument may not be tracked through argument propagation.

References