Skip to main content
Sigvex

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.

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 an account-validation framework, 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.

References