CPI Reentrancy Exploit Generator
Sigvex exploit generator that validates Cross-Program Invocation (CPI) reentrancy vulnerabilities in Solana programs by simulating a malicious program that re-enters the victim program during a CPI callback before state is updated.
CPI Reentrancy Exploit Generator
Overview
The CPI reentrancy exploit generator validates findings from the cpi-reentrancy detector by simulating an attack where a malicious program re-enters the victim program through a CPI callback before the victim program has updated its state. If the re-entrant call succeeds in exploiting the stale state, the vulnerability is confirmed as likely exploitable with confidence 0.75.
Solana’s runtime permits CPI (Cross-Program Invocation) — one program calling another within the same transaction. This creates a reentrancy surface analogous to Ethereum’s but with Solana-specific constraints. Solana’s runtime does prohibit direct recursive CPI (a program calling itself), but allows A → B → A reentrancy when an intermediate program triggers the callback. If program A makes a CPI to program B and program B calls back into program A before A finishes its state update (e.g., zeroing a balance), B can exploit the inconsistent intermediate state.
Severity score: 95/100 (Critical).
Note: Exploit generation in Sigvex is for vulnerability validation purposes only.
Attack Scenario
CPI callback reentrancy:
- A token vault program (V) processes a withdrawal:
fn withdraw(ctx) { transfer_to_user(); vault.balance = 0; }. - V invokes the SPL Token program via CPI to transfer tokens to the user.
- The SPL Token program invokes a hook on the receiving program (a malicious token account program M).
- M calls back into V’s
withdrawinstruction while V’s balance is still non-zero (step 2 hasn’t executed yet). - V processes a second withdrawal because the balance is still shown as non-zero.
- The attacker receives double the tokens.
Shared account state manipulation:
- Program A reads an account balance:
let balance = account.lamports. - A makes a CPI to program B.
- Program B (controlled by the attacker) modifies the same account’s lamports via a second CPI.
- Program A continues with its cached
balancevalue, unaware the account changed. - The attacker has manipulated A’s logic without A’s knowledge.
Exploit Mechanics
The engine maps cpi-reentrancy detector findings to the CPI reentrancy exploit pattern (severity score 95/100).
Strategy: Deploy a malicious intermediate program that, when invoked via CPI from the victim program, immediately calls back into the victim program’s withdraw instruction before the victim has updated its balance state.
Simulation steps:
- The engine identifies the victim program and the instruction that performs a CPI before updating state (CEI pattern violation) from the finding location.
- A malicious callback program is modeled as accepting a CPI from the victim and immediately issuing a second CPI back to the victim’s withdraw instruction.
- The exploit transaction calls the victim’s
withdrawwithamount = vault.balance. - The victim performs its CPI to the SPL Token program (or callback receiver).
- Before returning, the callback triggers a second
withdrawcall. Becausevault.balancewas not decremented before the first CPI, the second call also succeeds with the full balance. - If the simulation detects two successful withdraw calls with the same vault balance, the vulnerability is confirmed as likely exploitable with confidence 0.75.
- The exploit result records:
status = Likely,description = "State manipulation through reentrant calls",impact = "Critical".
Attack flow diagram:
Attacker Tx
└─> VaultProgram::withdraw(100)
│ Check: vault.balance(100) >= 100 ✓
│ CPI to MaliciousCallbackProgram
│ └─> VaultProgram::withdraw(100) [RE-ENTRY]
│ Check: vault.balance(100) >= 100 ✓ (still non-zero!)
│ vault.balance -= 100 (now 0)
│ Transfer 100 tokens to attacker ✓
│ Return from callback
vault.balance -= 100 (underflow! wraps or errors)
Transfer 100 tokens to attacker ✓
Total extracted: 200 tokens (from a vault that held 100)
// VULNERABLE: State update after CPI
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;
// Check balance
require!(vault.balance >= amount, VaultError::InsufficientFunds);
// CPI to token program — re-entrancy window!
token::transfer(
ctx.accounts.transfer_ctx(),
amount,
)?;
// State updated AFTER CPI — too late!
// If re-entrant call occurs, vault.balance is still non-zero
ctx.accounts.vault.balance -= amount;
Ok(())
}
}
// SECURE: Check-Effects-Interactions pattern (Solana version)
#[program]
mod secure_vault {
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
let vault = &mut ctx.accounts.vault;
// 1. Check
require!(vault.balance >= amount, VaultError::InsufficientFunds);
// 2. Effects — update state BEFORE external call
vault.balance -= amount;
// 3. Interactions — CPI after state is updated
// Even if re-entered now, vault.balance is already reduced
token::transfer(
ctx.accounts.transfer_ctx(),
amount,
)?;
Ok(())
}
}
// Alternative: Reentrancy guard
#[account]
pub struct Vault {
pub balance: u64,
pub locked: bool, // Reentrancy guard
}
pub fn withdraw_guarded(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
require!(!ctx.accounts.vault.locked, VaultError::Locked);
ctx.accounts.vault.locked = true;
let result = do_withdraw(&ctx, amount);
ctx.accounts.vault.locked = false; // Reset in all paths
result
}
Remediation
- Detector: CPI Reentrancy Detector
- Remediation Guide: CPI Reentrancy Remediation
Apply the check-effects-interactions pattern consistently:
- Check: Validate all preconditions (balances, permissions, state).
- Effects: Update all account state (reduce balances, mark processed).
- Interactions: Only after state is committed, make external CPI calls.
This order ensures that if a CPI triggers a re-entrant call, the state has already been updated to reflect the first call — so the re-entrant call will fail the precondition checks.
For additional protection:
// Reentrancy guard via account flag
pub fn withdraw(ctx: Context<Withdraw>) -> Result<()> {
require!(!ctx.accounts.vault.locked, VaultError::ReentrancyDetected);
ctx.accounts.vault.locked = true;
// All logic including CPIs...
ctx.accounts.vault.locked = false;
Ok(())
}
Note: Solana’s account data is copied at the start of each instruction. Anchor refreshes accounts after CPIs, but only when explicitly requested. Be aware of stale account data after any CPI.