DEV Community

ohmygod
ohmygod

Posted on

Stale Accounts After CPI: The Solana Bug Class Your Anchor Program Isn't Catching

TL;DR

When your Solana program makes a Cross-Program Invocation (CPI) that mutates an account, Anchor does not automatically reload the deserialized struct. Your program continues operating on stale data — and attackers know it. This article explains the bug class, walks through a concrete exploit scenario, and provides five defense patterns every Solana developer should adopt in 2026.


The Setup: Why CPI Makes Solana Different

Solana's programming model is fundamentally different from the EVM. On Ethereum, a contract call returns a value and the caller's storage is never silently modified by the callee. On Solana, accounts are shared mutable state — a CPI can modify any account the caller passes to it, and the caller's deserialized view of that account won't reflect the change.

Consider this flow:

1. Program A deserializes Account X (balance: 1000)
2. Program A invokes Program B via CPI, passing Account X
3. Program B modifies Account X (balance: 0)
4. Program A continues — still sees balance: 1000
Enter fullscreen mode Exit fullscreen mode

This isn't a runtime bug. It's by design. Anchor deserializes accounts at instruction entry, and CPI happens at the runtime level, modifying the raw account data underneath the deserialized Rust struct. The struct is never refreshed.

The Exploit: Double-Withdraw via Stale Balance

Here's a simplified but realistic scenario. Imagine a lending protocol on Solana:

#[derive(Accounts)]
pub struct Withdraw<'info> {
    #[account(mut)]
    pub vault: Account<'info, Vault>,
    #[account(mut)]
    pub user_position: Account<'info, UserPosition>,
    pub collateral_program: Program<'info, CollateralManager>,
    // ... other accounts
}

pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
    let position = &ctx.accounts.user_position;

    // Step 1: Check collateral ratio via CPI
    // This CPI *modifies* user_position internally 
    // (e.g., locks collateral, updates a timestamp)
    collateral_manager::cpi::lock_collateral(
        ctx.accounts.into_lock_context(),
        amount,
    )?;

    // Step 2: Check balance — BUT position is STALE
    // The CPI above may have reduced available_balance,
    // but our deserialized struct still shows the old value
    require!(
        position.available_balance >= amount,
        ErrorCode::InsufficientBalance
    );

    // Step 3: Transfer funds based on stale data
    transfer_from_vault(&ctx.accounts.vault, amount)?;

    // Step 4: Update state — writing back stale + delta
    let position = &mut ctx.accounts.user_position;
    position.available_balance -= amount;

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The attacker calls withdraw with the maximum amount. The CPI to lock_collateral modifies the position account at the raw data level. But the deserialized position struct still shows the pre-CPI balance. The balance check passes. The transfer executes. Funds drained.

Why This Is Worse Than It Looks

When Anchor serializes the account back at instruction exit, it overwrites the raw data with the stale struct — effectively reverting the CPI's changes. This means the attacker doesn't just read stale data; they also undo the CPI's state mutation, potentially leaving the protocol in an inconsistent state that enables repeated exploitation.

Five Defense Patterns

1. Reload After CPI (The Direct Fix)

Anchor provides reload() on account wrappers. Call it immediately after any CPI that might mutate an account your instruction uses:

collateral_manager::cpi::lock_collateral(
    ctx.accounts.into_lock_context(),
    amount,
)?;

// Force reload from raw account data
ctx.accounts.user_position.reload()?;

// Now this check uses fresh data
require!(
    ctx.accounts.user_position.available_balance >= amount,
    ErrorCode::InsufficientBalance
);
Enter fullscreen mode Exit fullscreen mode

When to use: Always, after any CPI that passes a mutable account your instruction also reads or writes.

2. CPI-Last Pattern (Structural Prevention)

Restructure your instruction so all CPIs happen after all state reads and checks. This is analogous to the Checks-Effects-Interactions pattern from Solidity:

pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
    // CHECKS: All reads and validations first
    let position = &ctx.accounts.user_position;
    require!(
        position.available_balance >= amount,
        ErrorCode::InsufficientBalance
    );

    // EFFECTS: All local state mutations
    let position = &mut ctx.accounts.user_position;
    position.available_balance -= amount;

    // INTERACTIONS: CPIs last
    collateral_manager::cpi::lock_collateral(
        ctx.accounts.into_lock_context(),
        amount,
    )?;
    transfer_from_vault(&ctx.accounts.vault, amount)?;

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

When to use: When you can logically reorder operations without breaking invariants.

3. Split Into Multiple Instructions

If a single instruction must CPI and then act on mutated state, split it into two instructions within the same transaction:

Transaction:
  Instruction 1: lock_collateral (CPI happens here)
  Instruction 2: withdraw (reads fresh state, transfers funds)
Enter fullscreen mode Exit fullscreen mode

Anchor deserializes fresh at each instruction boundary. This eliminates stale data by design.

When to use: When reload is insufficient (e.g., multiple accounts mutated by CPI) or when the instruction is complex enough to benefit from separation.

4. Assertion-Based Invariant Checks

Add post-CPI assertions that verify critical invariants directly from raw account data, independent of your deserialized structs:

// After CPI, verify invariant directly from account info
let raw_data = ctx.accounts.user_position.to_account_info().data.borrow();
let raw_balance = u64::from_le_bytes(
    raw_data[BALANCE_OFFSET..BALANCE_OFFSET + 8].try_into().unwrap()
);

require!(
    raw_balance >= amount,
    ErrorCode::StaleAccountInvariantViolation
);
Enter fullscreen mode Exit fullscreen mode

When to use: As a defense-in-depth layer, especially for high-value operations.

5. Static Analysis Integration

Incorporate tooling that flags CPI-then-read patterns automatically:

  • Soteria and Sec3's X-Ray can detect missing reload patterns
  • Custom Anchor lint rules can flag any account read after a CPI invocation
  • Trident fuzzer can generate test cases that exercise CPI mutation paths

Add to your CI pipeline:

- name: Check stale-account patterns
  run: |
    soteria -analyzeAll ./programs/
    # Flag any instruction with CPI followed by account field access
    # without intervening reload()
Enter fullscreen mode Exit fullscreen mode

When to use: Always. This should be part of every Solana program's CI.

Audit Checklist: Stale-Account-After-CPI

For auditors reviewing Solana programs, here's a focused checklist:

# Check Severity
1 Does any instruction perform CPI with mutable accounts it also reads? Critical
2 Is reload() called on every mutated account after CPI? Critical
3 Does the instruction write back to any CPI-mutated account (potentially overwriting CPI changes)? Critical
4 Can instruction ordering be restructured to CPI-last? Medium
5 Are there post-CPI invariant assertions? Medium
6 Does CI include static analysis for CPI patterns? Low

The Bigger Picture

This bug class is a consequence of Solana's shared-account architecture — the same design that enables composability and performance also creates subtle data-consistency pitfalls. As protocols grow more complex with nested CPIs (Solana allows up to 4 levels deep), the surface area for stale-account bugs expands combinatorially.

The introduction of Firedancer adds another dimension: its independent validator client may handle account caching differently, and programs that accidentally depend on runtime-specific behavior around account data visibility could face new edge cases.

The bottom line: Treat every CPI as a potential state mutation boundary. Reload aggressively. Test with fuzzers that exercise CPI paths. And build your static analysis pipeline to catch what humans miss.


This article is part of DreamWork Security's ongoing research into smart contract vulnerability patterns across Solana, EVM, and cross-chain protocols.


Tags: solana, security, smart-contracts, anchor, defi, web3, audit, cpi

Top comments (0)