CPI Reentrancy
Detects Solana programs that modify state after making a cross-program invocation, allowing the called program to invoke back before the state update completes.
CPI Reentrancy
Overview
Remediation Guide: How to Fix CPI Reentrancy
The CPI reentrancy detector identifies Solana programs that perform state modifications — account data writes or lamport transfers — after making a cross-program invocation (CPI). In Solana, when a program calls another program via invoke() or invoke_signed(), the called program executes synchronously. If the called program invokes back into the original program (or into another program that modifies the same shared state) before the original call returns, and if state modifications are pending after the CPI, a reentrancy condition exists.
Sigvex performs both intra-block analysis (CPI and state modification in the same basic block) and cross-block analysis (CPI in one control-flow block, state modification in a successor block reachable via the CFG). The cross-block variant uses breadth-first search over the function’s control-flow graph to detect state modifications in any block reachable after the CPI call site.
While Solana’s runtime enforces some reentrancy protections for the same program, reentrancy through shared mutable accounts — where program A calls program B which calls program C that shares mutable account state with A — is a real attack vector. CWE mapping: CWE-841 (Improper Enforcement of Behavioral Workflow).
Why This Is an Issue
Solana programs that modify shared mutable accounts after a CPI can be vulnerable to reentrancy if the called program re-enters the original program’s other instructions or modifies the same shared accounts. The pattern is most dangerous when:
- Lamport transfers occur after a CPI to an arbitrary program
- Account data is written after a CPI to a user-supplied program
- A DeFi protocol makes CPIs to DEX programs before finalizing vault state
While Solana does not permit calling the same program during a CPI execution (direct reentrancy is blocked at the runtime level), reentrancy via intermediate programs or through shared account state is possible and has been exploited in production protocols.
How to Resolve
// Before: Vulnerable — lamport transfer after CPI
pub fn withdraw_and_notify(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let vault = &accounts[0];
let user = &accounts[1];
let notification_program = &accounts[2];
// CPI to notification program BEFORE updating vault state
invoke(
&build_notification_instruction(),
&[notification_program.clone()],
)?;
// VULNERABLE: state modified after CPI — notification program could re-enter
**vault.lamports.borrow_mut() -= amount;
**user.lamports.borrow_mut() += amount;
Ok(())
}
// After: Complete all state changes before CPI
pub fn withdraw_and_notify(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let vault = &accounts[0];
let user = &accounts[1];
let notification_program = &accounts[2];
// FIXED: all state changes BEFORE CPI
**vault.lamports.borrow_mut() -= amount;
**user.lamports.borrow_mut() += amount;
// CPI after state is fully updated
invoke(
&build_notification_instruction(),
&[notification_program.clone()],
)?;
Ok(())
}
For Anchor programs:
// Anchor: Use CpiContext and ensure state writes precede CPI calls
pub fn process_swap(ctx: Context<Swap>, amount: u64) -> Result<()> {
// Update internal accounting FIRST
ctx.accounts.pool.total_volume += amount;
ctx.accounts.pool.last_trade = Clock::get()?.unix_timestamp;
// CPI after all state is consistent
let cpi_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
Transfer {
from: ctx.accounts.source.to_account_info(),
to: ctx.accounts.destination.to_account_info(),
authority: ctx.accounts.user.to_account_info(),
},
);
token::transfer(cpi_ctx, amount)?;
Ok(())
}
Examples
Vulnerable Code
use solana_program::{
account_info::AccountInfo,
entrypoint::ProgramResult,
program::invoke,
};
pub fn exchange_tokens(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let user_token_account = &accounts[0];
let vault = &accounts[1];
let dex_program = &accounts[2];
// CPI to DEX — dex_program could call back into this program
invoke(
&dex_instruction(amount),
&[vault.clone(), user_token_account.clone()],
)?;
// VULNERABLE: vault balance updated AFTER CPI
// If dex_program re-enters, vault shows incorrect balance
let mut vault_data = vault.try_borrow_mut_data()?;
let vault_balance = u64::from_le_bytes(vault_data[0..8].try_into().unwrap());
vault_data[0..8].copy_from_slice(&(vault_balance - amount).to_le_bytes());
Ok(())
}
Fixed Code
pub fn exchange_tokens(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let user_token_account = &accounts[0];
let vault = &accounts[1];
let dex_program = &accounts[2];
// FIXED: update vault balance BEFORE CPI
{
let mut vault_data = vault.try_borrow_mut_data()?;
let vault_balance = u64::from_le_bytes(vault_data[0..8].try_into().unwrap());
require!(vault_balance >= amount, ProgramError::InsufficientFunds);
vault_data[0..8].copy_from_slice(&(vault_balance - amount).to_le_bytes());
} // Release borrow before CPI
// CPI after state is consistent
invoke(
&dex_instruction(amount),
&[vault.clone(), user_token_account.clone()],
)?;
Ok(())
}
Sample Sigvex Output
{
"detector_id": "cpi-reentrancy",
"severity": "high",
"confidence": 0.75,
"description": "State modification detected after CPI call at block 0 stmt 2. The called program could invoke back into this program before returning, observing an inconsistent state.",
"location": { "function": "exchange_tokens", "offset": 3 }
}
Detection Methodology
Sigvex performs two-phase CPI reentrancy detection:
Phase 1 — Intra-block analysis:
Scans each basic block sequentially. When an InvokeCpi statement is encountered, all subsequent StoreAccountData and TransferLamports statements in the same block are flagged with high confidence (0.75). Non-stack Store statements after CPI are flagged with reduced confidence (0.45) since local memory is not shared across invocations.
Phase 2 — Cross-block analysis:
For each block containing a CPI statement, performs a BFS over the control-flow graph to enumerate all reachable successor blocks. Any successor block containing state modifications (StoreAccountData, TransferLamports, or non-stack Store) is flagged. Cross-block findings for lamport transfers receive high severity with confidence 0.80; account data modifications receive medium severity with confidence 0.70.
Context modifiers:
- Admin/initialization functions: confidence reduced
- Read-only functions: confidence reduced by 60%
- Anchor programs with discriminator validation: confidence reduced by 75%
- Anchor programs without discriminator validation: confidence reduced by 60%
Limitations
False positives:
- CPIs to known-safe programs (SPL Token, System Program) followed by state updates are flagged even though those programs cannot re-enter.
- Programs that use explicit reentrancy guards via a boolean storage flag may still be flagged if the guard is not recognized at the HIR level.
- Anchor
CpiContextpatterns provide some protection but are still flagged since re-entry via intermediate programs remains possible.
False negatives:
- Reentrancy via shared mutable account state across multiple programs requires cross-program analysis, which is a separate analysis pass.
- Programs that use Solana’s
AccountInfo::borrow_mut()lifetime system to prevent simultaneous mutable borrows may avoid reentrancy at the language level but not be recognized.
Related Detectors
- Arbitrary CPI — detects unvalidated CPI target programs
- Missing Signer Check — CPI reentrancy combined with missing signer checks amplifies the attack surface
- Lamport Drain — lamport drain is a common consequence of CPI reentrancy