CPI Return Value Forgery
Detects CPI return data used for critical operations without independent on-chain state verification.
CPI Return Value Forgery
Overview
Remediation Guide: How to Fix CPI Return Value Forgery
The CPI return forgery detector identifies Solana programs that trust data returned from cross-program invocations without independently verifying the underlying account state. A malicious program can return arbitrary data via sol_set_return_data, so any program that uses CPI return values to make transfer, access control, or state change decisions without reading the actual account data is vulnerable to forgery.
The attack follows a substitution pattern: the attacker replaces the expected target program with a malicious one that returns fabricated data (fake balances, forged approval flags, spoofed price feeds). The calling program acts on this forged data as if it were verified truth.
Why This Is an Issue
Solana’s CPI mechanism allows any invoked program to set return data. The runtime does not validate the correctness of return data — it is simply a byte buffer that the caller can read. This means:
- A malicious program can return any value the attacker wants
- If the CPI target is not validated (see Arbitrary CPI), the attacker controls both the program and its output
- Even with a validated program, return data represents the program’s claim about state, not the actual on-chain state
- Using forged return data for transfer amounts leads to direct fund theft
- Using forged return data for access control leads to privilege escalation
CWE mapping: CWE-345 (Insufficient Verification of Data Authenticity).
How to Resolve
// Before: Vulnerable -- trusts CPI return data for transfer amount
pub fn process_withdrawal(accounts: &[AccountInfo]) -> ProgramResult {
let balance_program = &accounts[3];
let ix = build_check_balance_ix(accounts[1].key);
invoke(&ix, &[accounts[1].clone(), balance_program.clone()])?;
// VULNERABLE: attacker controls this value
let (_, return_data) = get_return_data().unwrap();
let balance: u64 = u64::from_le_bytes(return_data[..8].try_into().unwrap());
// Transfer based on forged balance
transfer_lamports(accounts[1], accounts[2], balance)?;
Ok(())
}
// After: Independently verify account state
pub fn process_withdrawal(accounts: &[AccountInfo]) -> ProgramResult {
let token_account = &accounts[1];
// FIXED: read actual on-chain account data directly
let account_data = token_account.try_borrow_data()?;
let token_state = spl_token::state::Account::unpack(&account_data)?;
let actual_balance = token_state.amount;
transfer_lamports(accounts[1], accounts[2], actual_balance)?;
Ok(())
}
Examples
Vulnerable Code
pub fn liquidate(accounts: &[AccountInfo]) -> ProgramResult {
let oracle_program = &accounts[4];
// CPI to get price -- attacker substitutes oracle_program
let price_ix = build_price_query(accounts[1].key);
invoke(&price_ix, &[accounts[1].clone(), oracle_program.clone()])?;
let (_, return_data) = get_return_data().unwrap();
let price: u64 = u64::from_le_bytes(return_data[..8].try_into().unwrap());
// CRITICAL: liquidation decision based on forged price
if price < LIQUIDATION_THRESHOLD {
// Liquidate position using attacker-controlled price
execute_liquidation(accounts, price)?;
}
Ok(())
}
Fixed Code
use pyth_sdk_solana::load_price_feed_from_account_info;
pub fn liquidate(accounts: &[AccountInfo]) -> ProgramResult {
let price_feed_account = &accounts[4];
// FIXED: validate the oracle account owner
if price_feed_account.owner != &PYTH_PROGRAM_ID {
return Err(ProgramError::IncorrectProgramId);
}
// FIXED: read price directly from on-chain account data
let price_feed = load_price_feed_from_account_info(price_feed_account)?;
let current_price = price_feed.get_current_price().unwrap();
if current_price.price < LIQUIDATION_THRESHOLD as i64 {
execute_liquidation(accounts, current_price.price as u64)?;
}
Ok(())
}
Sample Sigvex Output
{
"detector_id": "cpi-return-forgery",
"severity": "critical",
"confidence": 0.82,
"description": "Variable v5 is assigned from CPI return data at block 2 and used in a transfer operation at block 4 without independent account state verification. A malicious CPI target can forge the return data to control the transfer amount.",
"location": { "function": "liquidate", "offset": 8 }
}
Detection Methodology
The detector traces CPI return data through the function’s dataflow:
- CPI return tracking: Identifies variables assigned from CPI return data (
get_return_data,sol_get_return_data, or direct CPI result assignments). - Taint propagation: Tracks how CPI-derived variables flow through assignments, arithmetic, and comparisons.
- Critical usage detection: Flags when tainted variables reach transfer operations, state writes, or access control decisions.
- Verification recognition: Checks whether independent account data reads (via
try_borrow_dataorAccountDataaccess) occur between the CPI and the critical usage. If verified state is used instead of return data, no finding is emitted. - Program validation check: If the CPI target program is validated via
CheckKey, the confidence is reduced but the finding is still reported, since return data forgery is a distinct risk from arbitrary CPI.
Limitations
False positives:
- Programs that validate CPI return data against independently read account state in a helper function may be flagged if the helper is not inlined.
- Programs using verified oracle accounts (owner-checked) where the CPI is to the same owner-verified program may receive reduced-confidence findings.
False negatives:
- Return data stored in an intermediate account and read later is not tracked across instructions.
- CPI return data validated through complex conditional logic may not be fully recognized.
Related Detectors
- Arbitrary CPI — unvalidated CPI target program enables return forgery
- Oracle Manipulation — price oracle attacks that share the forged-data pattern
- CPI Program Validation — missing program ID checks on CPI targets