DEV Community

ohmygod
ohmygod

Posted on

Solana Account Revival Attacks: How Closed Accounts Come Back to Haunt You

You closed the account. You zeroed the lamports. You thought the data was gone forever. But on Solana, "closed" doesn't always mean "dead" — and attackers know it.

Account revival attacks are one of Solana's most insidious vulnerability classes. They exploit a fundamental gap between what developers think happens when an account is closed and what the runtime actually guarantees. In this deep dive, we'll dissect the mechanics, walk through real exploitation scenarios, and build a detection pipeline using open-source audit tools.

The Anatomy of a Closed Account

When a Solana program "closes" an account, the standard pattern looks like this:

// Typical account closure
**dest_account.lamports.borrow_mut() += **target_account.lamports.borrow();
**target_account.lamports.borrow_mut() = 0;
*target_account.try_borrow_mut_data()? = &mut [];
Enter fullscreen mode Exit fullscreen mode

The developer's mental model: "Account is gone. Lamports transferred. Data wiped."

The runtime's reality: The account data remains accessible for the rest of the transaction. The runtime only garbage-collects zero-lamport accounts after the transaction completes. This creates a window — and that window is all an attacker needs.

The Revival Playbook

Here's how a revival attack typically unfolds:

Step 1: Trigger Account Closure

The attacker calls the program's close_account instruction, which zeroes the lamports and clears the data.

Step 2: Re-fund Within the Same Transaction

In a subsequent instruction within the same transaction, the attacker sends lamports back to the "closed" account address. Since the runtime hasn't garbage-collected it yet, the account springs back to life.

Step 3: Exploit the Zombie State

The revived account may retain its original owner (the program), but its data has been zeroed. Depending on the program's logic, this can lead to:

  • Re-initialization with attacker-controlled data: If the program doesn't check whether an account was previously initialized
  • Bypassed access controls: If authorization was stored in the now-zeroed data
  • Double-spend scenarios: If the account tracked a one-time claim or withdrawal
// Attack transaction (pseudocode)
Transaction {
    instructions: [
        // Instruction 1: Close the victim account
        program.close_account(victim_account),

        // Instruction 2: Re-fund the closed account
        system_program.transfer(attacker, victim_account, 1_lamport),

        // Instruction 3: Re-initialize with attacker data
        program.initialize(victim_account, attacker_controlled_data),
    ]
}
Enter fullscreen mode Exit fullscreen mode

Why Anchor's close Constraint Isn't Enough

Anchor framework's #[account(close = destination)] constraint handles lamport transfer and data zeroing. But it has a subtle behavior that many developers miss:

#[derive(Accounts)]
pub struct CloseAccount<'info> {
    #[account(mut, close = destination)]
    pub data_account: Account<'info, GameState>,
    #[account(mut)]
    pub destination: SystemAccount<'info>,
}
Enter fullscreen mode Exit fullscreen mode

Anchor sets the account discriminator to a special CLOSED_ACCOUNT_DISCRIMINATOR (an 8-byte sequence). This means Anchor-based programs will reject attempts to deserialize a closed account in subsequent instructions — but only if the receiving instruction also uses Anchor's account deserialization.

The gap: If any instruction in the program uses raw account access (bypassing Anchor's deserialization), or if a CPI target doesn't check the discriminator, the revival attack still works.

Detection: Building a Static Analysis Pipeline

Here's a practical approach to finding revival vulnerabilities using available tools:

1. Grep for Closure Patterns Without Guards

# Find account closures that don't set a discriminator
grep -rn "lamports.borrow_mut.*= 0" programs/ |   grep -v "CLOSED_ACCOUNT_DISCRIMINATOR"
Enter fullscreen mode Exit fullscreen mode

2. Use Sec3's X-ray Scanner

Sec3's automated scanner checks for missing close guards. If you have access:

# Run X-ray on your program
sec3 x-ray scan --program-path ./programs/my_program
Enter fullscreen mode Exit fullscreen mode

Look for findings tagged ACCOUNT_REVIVAL or MISSING_CLOSE_GUARD.

3. Custom Semgrep Rules

Write targeted Semgrep rules to catch the pattern:

rules:
  - id: solana-account-revival-risk
    patterns:
      - pattern: |
          **$ACCOUNT.lamports.borrow_mut() = 0;
      - pattern-not-inside: |
          $ACCOUNT.try_borrow_mut_data()?[..8]
            .copy_from_slice(&$DISCRIMINATOR);
    message: >
      Account closure without discriminator guard.
      Vulnerable to revival attacks within the same transaction.
    severity: ERROR
    languages: [rust]
Enter fullscreen mode Exit fullscreen mode

4. Runtime Testing with Bankrun

The solana-bankrun framework lets you simulate multi-instruction transactions:

import { start } from 'solana-bankrun';

it('should reject revived accounts', async () => {
    const context = await start([/* program deploy */]);
    const client = context.banksClient;

    // Build a transaction that closes then re-funds
    const tx = new Transaction();
    tx.add(closeInstruction);
    tx.add(refundInstruction);
    tx.add(reinitInstruction);

    // This SHOULD fail if properly guarded
    await expect(
        client.processTransaction(tx)
    ).rejects.toThrow();
});
Enter fullscreen mode Exit fullscreen mode

The Defense Checklist

For Native Rust Programs

  1. Set a dead discriminator on close:
let data = &mut target_account.try_borrow_mut_data()?;
data[..8].copy_from_slice(&[0xFF; 8]); // Dead marker
// Then zero lamports
Enter fullscreen mode Exit fullscreen mode
  1. Check for dead discriminator on every instruction:
if account.try_borrow_data()?[..8] == [0xFF; 8] {
    return Err(ProgramError::AccountAlreadyClosed);
}
Enter fullscreen mode Exit fullscreen mode
  1. Use realloc to zero the full data, not just lamports

For Anchor Programs

  1. Always use #[account(close = dest)] — never manually close
  2. Add has_one and constraint checks on every instruction that reads accounts
  3. Check AccountInfo::data_is_empty() in any raw account access

For Auditors

  1. Map all account lifecycle paths — identify every instruction that creates, modifies, or closes accounts
  2. Test multi-instruction transactions — your exploit PoC should include close + revive in one tx
  3. Check CPI boundaries — even if Program A guards against revival, does Program B (called via CPI) also check?

Tools Comparison for Revival Detection

Tool Detection Method Revival Coverage Integration
Sec3 X-ray Automated static analysis Direct detection GitHub CI
Soteria Pattern matching Partial (closure patterns) CLI
Cargo-clippy Lint rules Indirect (unsafe checks) Cargo
Custom Semgrep Rule-based static analysis Configurable CI/CD
Bankrun Runtime simulation Full (with test cases) Test suite
Trident (Ackee) Fuzz testing Probabilistic Anchor

The Bigger Picture: Why This Matters More in 2026

With Firedancer now processing transactions alongside the original validator client, the account lifecycle has additional complexity:

  • Different garbage collection timing between clients could create edge cases
  • Parallel transaction execution in Firedancer means the revival window's behavior may differ from Agave
  • Token-2022's transfer hooks add CPI chains where revival checks can be missed

As the Solana ecosystem matures, the "close an account = it's gone" mental model needs to die. Programs must defensively assume that any account could be in a zombie state, and audit tooling must evolve to catch these patterns automatically.

Key Takeaways

  1. Closing a Solana account doesn't remove it until the transaction ends
  2. Attackers can revive closed accounts by re-funding within the same transaction
  3. Anchor's close constraint helps but isn't bulletproof against raw access patterns
  4. Combine static analysis (Semgrep, X-ray) with runtime testing (Bankrun, Trident) for full coverage
  5. The Firedancer transition makes revival attack surfaces more complex

This article is part of our ongoing DeFi Security Research series at DreamWork Security. Follow for weekly deep dives into smart contract vulnerabilities, audit methodologies, and security tooling.

Top comments (0)