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 [];
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),
]
}
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>,
}
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"
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
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]
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();
});
The Defense Checklist
For Native Rust Programs
- 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
- Check for dead discriminator on every instruction:
if account.try_borrow_data()?[..8] == [0xFF; 8] {
return Err(ProgramError::AccountAlreadyClosed);
}
- Use realloc to zero the full data, not just lamports
For Anchor Programs
-
Always use
#[account(close = dest)]— never manually close -
Add
has_oneandconstraintchecks on every instruction that reads accounts -
Check
AccountInfo::data_is_empty()in any raw account access
For Auditors
- Map all account lifecycle paths — identify every instruction that creates, modifies, or closes accounts
- Test multi-instruction transactions — your exploit PoC should include close + revive in one tx
- 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
- Closing a Solana account doesn't remove it until the transaction ends
- Attackers can revive closed accounts by re-funding within the same transaction
- Anchor's
closeconstraint helps but isn't bulletproof against raw access patterns - Combine static analysis (Semgrep, X-ray) with runtime testing (Bankrun, Trident) for full coverage
- 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)