CPI Reentrancy Remediation
How to prevent Cross-Program Invocation reentrancy by applying the check-effects-interactions pattern and using CPI guards in Solana programs.
CPI Reentrancy Remediation
Overview
Related Detector: Arbitrary CPI
Solana’s runtime prevents a program from directly calling itself via CPI, but it does not prevent indirect reentrancy through an intermediate program. If program A calls program B, and program B calls back into program A before A has finished updating its state, program A may execute a second time with stale state — processing the same withdrawal twice from an unchanged balance, for example. This is Solana’s equivalent of the classic Ethereum reentrancy vulnerability.
The primary remediation is to apply the Check-Effects-Interactions (CEI) pattern: validate all preconditions, update all state, and only then make external CPI calls. An attacker who re-enters after state has been committed will fail the precondition checks on the second invocation. For cases where CEI is difficult to apply, a reentrancy guard — a lock flag stored in the program account — provides an additional defense.
Recommended Fix
Before (Vulnerable)
use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount, Transfer};
#[program]
mod vulnerable_vault {
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
let vault = &ctx.accounts.vault_state;
// 1. Check — balance is sufficient
require!(vault.balance >= amount, VaultError::InsufficientFunds);
// 2. Interact — CPI to token program BEFORE updating state
// If the recipient triggers a callback into withdraw(), vault.balance
// is still at the original value, so the check passes again
token::transfer(ctx.accounts.transfer_ctx(), amount)?;
// 3. Effects — state updated AFTER the CPI (too late)
ctx.accounts.vault_state.balance -= amount;
Ok(())
}
}
After (Fixed — CEI Pattern)
use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount, Transfer};
#[program]
mod secure_vault {
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
let vault = &mut ctx.accounts.vault_state;
// 1. Check — validate preconditions
require!(vault.balance >= amount, VaultError::InsufficientFunds);
// 2. Effects — update state BEFORE any external call
// A reentrant withdraw() call will now see balance = 0 and fail
vault.balance = vault.balance
.checked_sub(amount)
.ok_or(error!(VaultError::InsufficientFunds))?;
// 3. Interactions — external CPI after state is committed
token::transfer(ctx.accounts.transfer_ctx(), amount)?;
Ok(())
}
}
With CEI applied, the balance is decremented before the token transfer CPI. Any reentrant call to withdraw will observe the already-decremented balance and fail the require! check.
Alternative Mitigations
1. Reentrancy guard via a lock flag
For complex instructions where strict CEI ordering is impractical — for example, when state depends on the CPI result — use a boolean lock in the program account:
use anchor_lang::prelude::*;
#[account]
pub struct Vault {
pub balance: u64,
pub authority: Pubkey,
pub locked: bool, // Reentrancy guard
}
#[program]
mod guarded_vault {
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
let vault = &mut ctx.accounts.vault;
// Guard: reject reentrant calls
require!(!vault.locked, VaultError::ReentrancyDetected);
vault.locked = true;
// Business logic — state updates and CPIs
require!(vault.balance >= amount, VaultError::InsufficientFunds);
vault.balance = vault.balance
.checked_sub(amount)
.ok_or(error!(VaultError::InsufficientFunds))?;
token::transfer(ctx.accounts.transfer_ctx(), amount)?;
// Release the lock — always reset, even if the CPI above fails
// (Anchor will roll back the transaction on error, so this is belt-and-suspenders)
vault.locked = false;
Ok(())
}
}
Important: Anchor rolls back all state changes on error, so a failed CPI will also roll back the locked = true assignment. The guard is effective when the reentrancy attempt occurs while the lock is held, not after an error.
2. Validate CPI targets to prevent arbitrary callbacks
Reentrancy often requires that the CPI target is attacker-controlled. Restricting CPI targets to known, trusted programs eliminates the callback vector:
use anchor_lang::prelude::*;
use anchor_spl::token::Token;
#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(mut)]
pub vault_state: Account<'info, Vault>,
#[account(mut)]
pub vault_token_account: Account<'info, TokenAccount>,
#[account(mut)]
pub recipient_token_account: Account<'info, TokenAccount>,
pub authority: Signer<'info>,
// Program<'info, Token> validates the program key == spl_token::id()
// An attacker cannot substitute a malicious callback program here
pub token_program: Program<'info, Token>,
}
Using Program<'info, Token> instead of an unchecked program account prevents the attacker from injecting a malicious program that performs the reentrant callback.
3. Complete all state writes before the first CPI
Structure instructions so every account mutation precedes every CPI, even when there are multiple CPIs:
pub fn complex_operation(ctx: Context<ComplexOp>, amount: u64) -> Result<()> {
// All state updates first
let vault = &mut ctx.accounts.vault;
vault.balance = vault.balance.checked_sub(amount).ok_or(error!(ErrorCode::Underflow))?;
vault.last_withdrawal_slot = Clock::get()?.slot;
vault.withdrawal_count = vault.withdrawal_count.saturating_add(1);
// All CPIs last, after state is fully committed
token::transfer(ctx.accounts.token_transfer_ctx(), amount)?;
emit!(WithdrawalEvent { amount, slot: vault.last_withdrawal_slot });
Ok(())
}
Common Mistakes
Mistake 1: Reading account state after a CPI without refreshing
// WRONG: cached pre-CPI value used after the CPI may have modified the account
let cached_balance = ctx.accounts.vault.balance;
token::transfer(/* ... */)?;
// If the CPI modified vault.balance, cached_balance is now stale
require!(cached_balance >= MIN_BALANCE, ErrorCode::BelowMinimum);
After any CPI, reload account state from the on-chain data rather than using cached values.
Mistake 2: Releasing the reentrancy lock in a finally-equivalent block that can be skipped
// WRONG: the lock is only released if the early-return path is not taken
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
ctx.accounts.vault.locked = true;
if some_condition {
return Ok(()); // Lock never released!
}
ctx.accounts.vault.locked = false;
Ok(())
}
Rust does not have finally blocks. Use the guard pattern with a RAII wrapper, or ensure every code path releases the lock before returning.
Mistake 3: Applying CEI to checks only, not to all state mutations
// INCOMPLETE: balance is decremented before CPI, but fee ledger is not
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
ctx.accounts.vault.balance -= amount; // Effects: balance decremented
token::transfer(/* ... */)?; // Interactions: CPI
// WRONG: fee update AFTER the CPI — a reentrant call sees stale fee state
ctx.accounts.fee_ledger.total_fees += calculate_fee(amount);
Ok(())
}
All state mutations — not just the primary balance — must be completed before any CPI.