Stale Account Data After CPI
Detects use of cached account data after CPI without reload, leading to stale reads.
Stale Account Data After CPI
Overview
Remediation Guide: How to Fix Stale Account Data
The stale account data detector identifies cases where account data is read before a CPI call and then used after the CPI returns without reloading. The called program may modify the account data during the CPI, making previously cached values stale. Using stale data leads to incorrect state transitions, authorization bypasses, or fund loss.
Why This Is an Issue
In Solana, account data is shared across programs during CPI. When Program A reads a balance, calls Program B via CPI, and Program B modifies the balance, Program A’s cached value is now stale. If Program A uses the stale balance for subsequent calculations (e.g., “balance is sufficient for withdrawal”), the result is incorrect. Attackers exploit this pattern to drain funds or bypass invariant checks.
CWE mapping: CWE-362 (Concurrent Execution Using Shared Resource with Improper Synchronization).
How to Resolve
Native Solana
pub fn process(accounts: &[AccountInfo]) -> ProgramResult {
let vault = &accounts[0];
// Read data before CPI
let balance_before = read_balance(vault)?;
// CPI that may modify the vault
invoke(&transfer_ix, accounts)?;
// FIXED: Re-read data after CPI
let balance_after = read_balance(vault)?;
// Use fresh data
if balance_after < MIN_BALANCE {
return Err(ProgramError::InsufficientFunds);
}
Ok(())
}
Anchor
pub fn process(ctx: Context<Process>) -> Result<()> {
// CPI that may modify the account
let cpi_ctx = CpiContext::new(/* ... */);
token::transfer(cpi_ctx, amount)?;
// FIXED: Reload account after CPI
ctx.accounts.vault.reload()?;
// Use fresh data
require!(ctx.accounts.vault.amount >= min_balance, ErrorCode::InsufficientFunds);
Ok(())
}
Examples
Vulnerable Code
pub fn compound(accounts: &[AccountInfo]) -> ProgramResult {
let vault = &accounts[0];
let cached_balance = {
let data = vault.data.borrow();
u64::from_le_bytes(data[0..8].try_into().unwrap())
};
// CPI modifies vault balance
invoke(&yield_ix, accounts)?;
// VULNERABLE: using cached_balance which is now stale
let new_shares = cached_balance / SHARE_PRICE;
update_shares(accounts, new_shares)?;
Ok(())
}
Fixed Code
pub fn compound(accounts: &[AccountInfo]) -> ProgramResult {
let vault = &accounts[0];
invoke(&yield_ix, accounts)?;
// FIXED: re-read fresh data after CPI
let fresh_balance = {
let data = vault.data.borrow();
u64::from_le_bytes(data[0..8].try_into().unwrap())
};
let new_shares = fresh_balance / SHARE_PRICE;
update_shares(accounts, new_shares)?;
Ok(())
}
Sample Sigvex Output
{
"detector_id": "stale-account-data",
"severity": "high",
"confidence": 0.80,
"description": "Account data for 'var_0' was cached before CPI at stmt 1 and is being used at stmt 3 without reloading.",
"location": { "function": "compound", "block": 0, "stmt": 3 }
}
Detection Methodology
- Account read tracking: Records which accounts have their data read (AccountData, AccountLamports) and at which statement.
- CPI boundary detection: Identifies
InvokeCpistatements that divide the function into pre-CPI and post-CPI regions. - Intra-block analysis: Within a single block, detects when an account is read before a CPI and the same account is read again after the CPI.
- Cross-block analysis: Tracks account reads across blocks to detect stale data patterns spanning multiple basic blocks.
Limitations
- The detector conservatively assumes that any CPI can modify any account. In practice, only writable accounts passed to the CPI can be modified.
- Explicit reload operations (like Anchor’s
.reload()) at the HIR level may not be distinguishable from regular data reads. - Indirect account data access through helper functions is not tracked.
Related Detectors
- CPI Reentrancy — detects re-entrant CPI patterns
- CPI Signer Propagation — detects stale signer state after CPI