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
transferinstruction) - 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
transferinstructions 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_accountorassignthat 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:
- CPI call identification: Scans all basic blocks for
HirStmt::InvokeCpistatements,HirExpr::Cpiexpressions, and syscall patterns matchinginvokeorinvoke_signed. - 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).
- Pre-call validation check: For each CPI target variable, searches all preceding statements in all predecessor blocks for a key comparison (
CheckOwneror 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. - 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_signedwith 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.
Related Detectors
- Arbitrary CPI — the broader class where any program can be invoked by design
- Missing Owner Check — program-derived accounts also need owner validation
- Account Alias Attack — substituting accounts in multiple positions