DeFi Reentrancy
Detects DeFi-specific reentrancy patterns where external calls occur before state updates in vault, lending, and staking operations.
DeFi Reentrancy
Overview
Remediation Guide: How to Fix DeFi Reentrancy
The DeFi reentrancy detector identifies patterns in vault withdrawals, LP token minting, staking reward calculations, and lending operations where external calls (CPI or lamport transfers) occur before internal state is updated. When a program transfers tokens before updating the user’s balance, a malicious contract can re-enter the program during the transfer callback and withdraw again using the stale (unchanged) balance.
While Solana’s single-threaded execution prevents traditional Ethereum-style reentrancy across transactions, reentrancy within a single transaction via CPI is possible and has been exploited in production DeFi protocols.
The detector checks four DeFi-specific patterns:
- Vault withdrawal reentrancy: Transfer before balance update
- LP token minting reentrancy: Mint before pool state update
- Staking reward reentrancy: Reward distribution before checkpoint update
- Borrow/lend reentrancy: Fund disbursement before debt recording
Why This Is an Issue
DeFi protocols manage pooled funds and track individual balances. The core invariant is: state must be updated before any external interaction. When this order is reversed:
- Double withdrawal: User withdraws 100 tokens, callback re-enters and withdraws 100 again (balance hasn’t been decremented). Vault loses 200 while the user’s balance only decreases by 100.
- Inflated LP minting: Attacker deposits, mints LP tokens, callback re-enters and mints again before pool reserves are updated. Attacker gets 2x the correct LP tokens.
- Reward theft: Attacker claims rewards, callback re-enters and claims again using stale reward accumulator. Drains the reward pool.
- Unbounded borrowing: Attacker borrows funds, callback re-enters and borrows again before debt is recorded. Creates phantom debt-free positions.
CWE mapping: CWE-841 (Improper Enforcement of Behavioral Workflow).
How to Resolve
// Before: Vulnerable -- transfer before state update
pub fn withdraw(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let vault = &accounts[0];
let user = &accounts[1];
let user_record = &accounts[2];
let record_data = user_record.try_borrow_data()?;
let record = UserRecord::try_from_slice(&record_data[8..])?;
if record.balance < amount {
return Err(ProgramError::InsufficientFunds);
}
drop(record_data);
// VULNERABLE: transfer occurs before state update
**vault.try_borrow_mut_lamports()? -= amount;
**user.try_borrow_mut_lamports()? += amount;
// State update happens after transfer -- re-entry reads stale balance
let mut record_data = user_record.try_borrow_mut_data()?;
let mut record = UserRecord::try_from_slice(&record_data[8..])?;
record.balance -= amount;
record.serialize(&mut &mut record_data[8..])?;
Ok(())
}
// After: Checks-Effects-Interactions pattern
pub fn withdraw(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let vault = &accounts[0];
let user = &accounts[1];
let user_record = &accounts[2];
// CHECK: validate
let record_data = user_record.try_borrow_data()?;
let record = UserRecord::try_from_slice(&record_data[8..])?;
if record.balance < amount {
return Err(ProgramError::InsufficientFunds);
}
drop(record_data);
// EFFECT: update state FIRST
let mut record_data = user_record.try_borrow_mut_data()?;
let mut record = UserRecord::try_from_slice(&record_data[8..])?;
record.balance = record.balance
.checked_sub(amount)
.ok_or(ProgramError::ArithmeticOverflow)?;
record.serialize(&mut &mut record_data[8..])?;
drop(record_data);
// INTERACTION: transfer LAST
**vault.try_borrow_mut_lamports()? -= amount;
**user.try_borrow_mut_lamports()? += amount;
Ok(())
}
Examples
Vulnerable Code
pub fn claim_staking_reward(accounts: &[AccountInfo]) -> ProgramResult {
let reward_vault = &accounts[0];
let staker = &accounts[1];
let stake_record = &accounts[2];
let data = stake_record.try_borrow_data()?;
let record = StakeRecord::try_from_slice(&data[8..])?;
let pending_reward = calculate_pending_reward(&record);
drop(data);
// VULNERABLE: transfer before updating last_claim_timestamp
let ix = spl_token::instruction::transfer(
&spl_token::id(), reward_vault.key, staker.key,
reward_vault.key, &[], pending_reward,
)?;
invoke(&ix, accounts)?;
// Stale record -- re-entrant call calculates same reward again
let mut data = stake_record.try_borrow_mut_data()?;
let mut record = StakeRecord::try_from_slice(&data[8..])?;
record.last_claim_timestamp = Clock::get()?.unix_timestamp;
record.serialize(&mut &mut data[8..])?;
Ok(())
}
Fixed Code
pub fn claim_staking_reward(accounts: &[AccountInfo]) -> ProgramResult {
let reward_vault = &accounts[0];
let staker = &accounts[1];
let stake_record = &accounts[2];
let data = stake_record.try_borrow_data()?;
let record = StakeRecord::try_from_slice(&data[8..])?;
let pending_reward = calculate_pending_reward(&record);
drop(data);
// FIXED: update state BEFORE transfer
let mut data = stake_record.try_borrow_mut_data()?;
let mut record = StakeRecord::try_from_slice(&data[8..])?;
record.last_claim_timestamp = Clock::get()?.unix_timestamp;
record.serialize(&mut &mut data[8..])?;
drop(data);
// Transfer AFTER state update -- re-entry sees updated timestamp
let ix = spl_token::instruction::transfer(
&spl_token::id(), reward_vault.key, staker.key,
reward_vault.key, &[], pending_reward,
)?;
invoke(&ix, accounts)?;
Ok(())
}
Sample Sigvex Output
{
"detector_id": "defi-reentrancy",
"severity": "critical",
"confidence": 0.82,
"description": "Vault withdrawal at block 3 statement 5 transfers tokens before updating user balance. A malicious contract can re-enter during the transfer callback and withdraw again with the stale (non-zero) balance, draining the vault.",
"location": { "function": "withdraw", "offset": 5 }
}
Detection Methodology
The detector identifies reentrancy-prone statement orderings:
- Operation collection: Scans all blocks for external interactions (CPI calls, lamport transfers) and state modifications (
StoreAccountData). - Vault withdrawal pattern: Detects
TransferLamportsor token transfer CPI followed by aStoreAccountDatathat modifies a balance-related field. If the transfer precedes the state update, a vault withdrawal reentrancy finding is emitted. - LP minting pattern: Detects token mint CPI followed by pool reserve state updates. If minting occurs before reserve accounting, an LP minting reentrancy finding is emitted.
- Staking reward pattern: Detects reward transfer followed by checkpoint/timestamp update. If the reward distribution precedes the accumulator update, a staking reward reentrancy finding is emitted.
- Borrow/lend pattern: Detects fund transfer CPI followed by debt state recording. If disbursement precedes debt recording, a lending reentrancy finding is emitted.
- Category filtering: Only runs on programs identified as DeFi-related by the detection context.
Limitations
False positives:
- Programs that use a reentrancy guard (mutex flag) to prevent re-entry may still be flagged, since the detector does not currently recognize guard patterns.
- Programs where the state update and transfer are in separate instructions by design (with the state update always executing first).
False negatives:
- Reentrancy through indirect CPI chains (Program A calls Program B, which calls back Program A) may not be detected.
- State updates performed through helper functions that are not inlined.
Related Detectors
- CPI Reentrancy — general CPI reentrancy detection
- Readonly Reentrancy — reentrancy through readonly account state
- Vault Manipulation — unauthorized vault state changes