CPI Return Value Forgery Remediation
How to prevent forged CPI return data from influencing critical program operations.
CPI Return Value Forgery Remediation
Overview
Related Detector: CPI Return Value Forgery
CPI return forgery vulnerabilities arise when a program uses data returned from a cross-program invocation to drive critical logic (transfers, access control, state changes) without independently reading the actual account state. The fix is to always read on-chain account data directly rather than trusting CPI return values for critical decisions.
Recommended Fix
Before (Vulnerable)
pub fn claim_rewards(accounts: &[AccountInfo]) -> ProgramResult {
let staking_program = &accounts[3];
// CPI to calculate rewards
let ix = build_calculate_rewards_ix(accounts[0].key);
invoke(&ix, &[accounts[0].clone(), staking_program.clone()])?;
// VULNERABLE: reward amount comes from CPI return data
let (_, return_data) = get_return_data().unwrap();
let reward_amount: u64 = u64::from_le_bytes(return_data[..8].try_into().unwrap());
// Transfer based on potentially forged amount
**accounts[1].try_borrow_mut_lamports()? -= reward_amount;
**accounts[0].try_borrow_mut_lamports()? += reward_amount;
Ok(())
}
After (Fixed)
pub fn claim_rewards(accounts: &[AccountInfo]) -> ProgramResult {
let staking_account = &accounts[2];
// FIXED: validate staking account owner
if staking_account.owner != &STAKING_PROGRAM_ID {
return Err(ProgramError::IncorrectProgramId);
}
// FIXED: read staking state directly from account data
let staking_data = staking_account.try_borrow_data()?;
let stake_state = StakeState::try_from_slice(&staking_data[8..])?;
// Calculate rewards from verified on-chain state
let reward_amount = calculate_pending_rewards(&stake_state)?;
**accounts[1].try_borrow_mut_lamports()? -= reward_amount;
**accounts[0].try_borrow_mut_lamports()? += reward_amount;
Ok(())
}
The fix replaces CPI return data with a direct account data read. The staking account’s owner is verified to ensure the data was written by the expected program.
Alternative Mitigations
1. Validate return data against account state
When you must use CPI return data (e.g., for cross-program coordination), verify it against independently read state:
// CPI to external program
invoke(&ix, accounts)?;
let (_, return_data) = get_return_data().unwrap();
let claimed_balance: u64 = u64::from_le_bytes(return_data[..8].try_into().unwrap());
// Independently verify
let actual_data = token_account.try_borrow_data()?;
let actual_balance = spl_token::state::Account::unpack(&actual_data)?.amount;
// Use the independently verified value
if claimed_balance != actual_balance {
return Err(ProgramError::InvalidAccountData);
}
2. Anchor account deserialization
With Anchor, read account state directly through typed account deserialization instead of CPI return values:
#[derive(Accounts)]
pub struct ClaimRewards<'info> {
#[account(mut)]
pub user: Signer<'info>,
#[account(mut, has_one = user)]
pub stake_account: Account<'info, StakeState>,
#[account(mut)]
pub reward_vault: Account<'info, TokenAccount>,
pub token_program: Program<'info, Token>,
}
pub fn claim_rewards(ctx: Context<ClaimRewards>) -> Result<()> {
// State read directly from deserialized account -- no CPI return data
let pending = ctx.accounts.stake_account.pending_rewards;
// ... transfer pending amount
Ok(())
}
Common Mistakes
Mistake 1: Validating the CPI program but still trusting return data
// Program ID is validated, but return data is still attacker-influenced
// if the program itself has a bug or the attacker controls its state
if program.key != &EXPECTED_ID {
return Err(ProgramError::IncorrectProgramId);
}
invoke(&ix, accounts)?;
let (_, data) = get_return_data().unwrap();
// Still risky -- even trusted programs can have bugs in return data
Program validation reduces risk but does not eliminate it. Prefer direct account reads for critical values.
Mistake 2: Using return data for authorization decisions
let (_, data) = get_return_data().unwrap();
let is_authorized: bool = data[0] != 0;
if is_authorized {
// WRONG: authorization based on forgeable return data
execute_admin_action(accounts)?;
}
Authorization must be based on signer checks, owner checks, or PDA derivation — never on CPI return data.