TL;DR
Solana's garbage collection only runs after a transaction completes — not between instructions. This creates a window where "closed" accounts can be revived within the same transaction, bypassing program logic that assumes closure is final. Revival attacks have been found in bug bounty programs managing millions in TVL, and the fix isn't as simple as you'd think.
This article explains the attack mechanism, demonstrates it with exploitable code, and provides a hardened closure pattern that eliminates the vulnerability class entirely.
The Mental Model That Gets Developers Killed
Most Solana developers think account closure works like this:
Instruction 1: close_account() → transfer lamports out → account deleted ✓
Instruction 2: uses some other account → safe, closed account is gone
Reality:
Instruction 1: close_account() → transfer lamports out → account still exists (zero balance)
Instruction 2: transfer lamports BACK into "closed" account → account is alive again
Transaction ends: runtime checks rent exemption → account has lamports → NOT garbage collected
The account is a zombie. It was supposed to die, but it got resurrected before the reaper arrived.
Why This Happens: Solana's Transaction Execution Model
Solana processes transactions atomically, but garbage collection (account deletion due to insufficient rent) only runs at the end of the transaction slot, not between individual instructions within a transaction.
Here's the sequence:
Transaction Start
├── Instruction 1: close_account (lamports → 0, data → zeroed)
├── Instruction 2: transfer lamports BACK to account
├── Instruction 3: re-initialize account with attacker-controlled data
Transaction End
└── Runtime: check rent exemption → account has lamports → KEEP
Between instructions 1 and 2, the account exists with zero lamports but hasn't been garbage collected. Any instruction can send lamports to it and bring it back.
The Vulnerable Pattern
Here's a simplified Anchor program with a vulnerable account closure:
use anchor_lang::prelude::*;
#[program]
pub mod vulnerable_vault {
use super::*;
pub fn initialize(ctx: Context<Initialize>, authority: Pubkey) -> Result<()> {
let vault = &mut ctx.accounts.vault;
vault.authority = authority;
vault.balance = 0;
vault.is_active = true;
Ok(())
}
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
let vault = &mut ctx.accounts.vault;
require!(vault.is_active, VaultError::Inactive);
// ... transfer SOL into vault ...
vault.balance += amount;
Ok(())
}
// VULNERABLE: Naive account closure
pub fn close_vault(ctx: Context<CloseVault>) -> Result<()> {
let vault = &ctx.accounts.vault;
require!(
vault.authority == ctx.accounts.authority.key(),
VaultError::Unauthorized
);
// Transfer all lamports to authority
let vault_lamports = ctx.accounts.vault.to_account_info().lamports();
**ctx.accounts.vault.to_account_info().try_borrow_mut_lamports()? -= vault_lamports;
**ctx.accounts.authority.try_borrow_mut_lamports()? += vault_lamports;
// BUG: We zeroed lamports, but did NOT:
// 1. Zero the account data
// 2. Set a closed discriminator
// 3. Transfer ownership to system program
Ok(())
}
}
The Exploit
An attacker creates a single transaction with three instructions:
// Exploit transaction (pseudocode)
let tx = Transaction::new_signed_with_payer(
&[
// Step 1: Close the vault (transfers lamports out)
vulnerable_vault::instruction::close_vault(
vault_pubkey,
authority_pubkey,
),
// Step 2: Revive the account by sending lamports back
system_instruction::transfer(
&attacker_pubkey,
&vault_pubkey,
rent_exempt_minimum, // Just enough to keep it alive
),
// Step 3: The vault account still has its old data layout
// Re-interact with it as if it were never closed
vulnerable_vault::instruction::deposit(
vault_pubkey,
stolen_amount,
),
],
Some(&attacker_pubkey),
&[&attacker_keypair, &authority_keypair],
recent_blockhash,
);
After this transaction:
- The vault account is alive (has rent-exempt lamports)
- Its data may still contain the old
authorityandbalancefields - The attacker can interact with it using stale state
- Depending on the program logic, this enables double-spending, unauthorized withdrawals, or state confusion
Real-World Impact Scenarios
Scenario 1: Escrow Double-Spend
A marketplace escrow program closes the escrow account after releasing funds to the seller. An attacker revives the escrow account in the same transaction and claims the release a second time.
1. Buyer deposits 100 SOL into escrow
2. Seller delivers goods
3. Attacker's tx:
- Instruction 1: release_escrow() → 100 SOL to seller, escrow "closed"
- Instruction 2: transfer 0.002 SOL to escrow account (revival)
- Instruction 3: release_escrow() → attempts to release again
If the program doesn't check the closed discriminator, it pays out twice
Scenario 2: Authority Hijack
A governance program closes a proposal account after execution. An attacker revives it and re-initializes it with a different authority, gaining control over a "completed" proposal that the program treats as still valid.
Scenario 3: Reward Double-Claim
A staking program closes the user's stake account after claiming rewards. Revival allows the user to claim rewards again from the same "closed" stake position, draining the reward pool.
The Hardened Closure Pattern
Here's how to close accounts properly on Solana:
Using Anchor's close Constraint (Recommended)
#[derive(Accounts)]
pub struct CloseVault<'info> {
#[account(
mut,
close = authority, // Anchor handles everything:
// 1. Transfers all lamports to `authority`
// 2. Zeros all account data
// 3. Sets discriminator to CLOSED_ACCOUNT_DISCRIMINATOR
// 4. Assigns account to system program
has_one = authority,
)]
pub vault: Account<'info, Vault>,
#[account(mut)]
pub authority: Signer<'info>,
}
Anchor's close constraint does four things:
- Transfers all lamports to the specified account
- Zeros all data in the account (prevents stale data exploitation)
-
Sets the discriminator to
CLOSED_ACCOUNT_DISCRIMINATOR(8 bytes of0xff) - Assigns ownership to the system program
Even if an attacker revives the account by sending lamports back, the discriminator check will fail on any subsequent interaction through the program.
Manual Closure (When You Need Custom Logic)
pub fn close_vault_secure(ctx: Context<CloseVaultSecure>) -> Result<()> {
let vault_info = ctx.accounts.vault.to_account_info();
let authority_info = ctx.accounts.authority.to_account_info();
// Step 1: Transfer all lamports
let vault_lamports = vault_info.lamports();
**vault_info.try_borrow_mut_lamports()? = 0;
**authority_info.try_borrow_mut_lamports()? += vault_lamports;
// Step 2: Zero ALL account data (critical!)
let mut data = vault_info.try_borrow_mut_data()?;
for byte in data.iter_mut() {
*byte = 0;
}
// Step 3: Set closed discriminator (first 8 bytes = 0xFF)
// This prevents Anchor from deserializing this account
// even if it's revived
sol_memset(&mut data[..8], 0xFF, 8);
// Step 4: Transfer ownership to system program
// This prevents our program from being invoked with this account
vault_info.assign(&system_program::ID);
Ok(())
}
The Belt-and-Suspenders Approach: Closed Account Check
Add a validation check to every instruction that reads accounts:
fn validate_not_closed(account_info: &AccountInfo) -> Result<()> {
let data = account_info.try_borrow_data()?;
if data.len() >= 8 {
let discriminator = &data[..8];
if discriminator == &[0xFF; 8] {
return Err(ProgramError::InvalidAccountData.into());
}
}
Ok(())
}
// In every instruction handler:
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
// Anchor does this automatically for Account<'info, T> types,
// but add it explicitly for UncheckedAccount or raw AccountInfo
validate_not_closed(&ctx.accounts.vault.to_account_info())?;
// ... rest of logic
}
The init_if_needed Trap
Anchor's init_if_needed constraint is a revival attack amplifier. It's designed for convenience — initialize an account if it doesn't exist, or use it if it does. But combined with revival attacks, it becomes dangerous:
// DANGEROUS: init_if_needed + revival attack
#[derive(Accounts)]
pub struct CreateOrDeposit<'info> {
#[account(
init_if_needed, // If account exists, skip init. If not, initialize.
payer = user,
space = 8 + Vault::INIT_SPACE,
seeds = [b"vault", user.key().as_ref()],
bump,
)]
pub vault: Account<'info, Vault>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}
Attack flow:
- User creates vault, deposits funds
- User closes vault (gets lamports back)
- In the same transaction, attacker revives the PDA by funding it
-
init_if_neededsees the account exists (has lamports) → skips initialization - But the account data may be in an inconsistent state
Mitigation: Either avoid init_if_needed entirely, or add explicit state checks:
#[derive(Accounts)]
pub struct CreateOrDeposit<'info> {
#[account(
init_if_needed,
payer = user,
space = 8 + Vault::INIT_SPACE,
seeds = [b"vault", user.key().as_ref()],
bump,
// Add explicit constraint to verify state
constraint = vault.is_active || !vault.was_ever_initialized @ VaultError::ClosedAccount,
)]
pub vault: Account<'info, Vault>,
// ...
}
Testing for Revival Vulnerabilities
Anchor Test Template
import * as anchor from "@coral-xyz/anchor";
import { expect } from "chai";
describe("Revival Attack Tests", () => {
it("should reject interactions with closed accounts", async () => {
// Setup: create and fund vault
const vault = await createVault(authority, 100_000_000);
// Close the vault
await program.methods
.closeVault()
.accounts({ vault: vault.publicKey, authority: authority.publicKey })
.rpc();
// Attempt revival: send lamports back
const revivalTx = new anchor.web3.Transaction().add(
anchor.web3.SystemProgram.transfer({
fromPubkey: attacker.publicKey,
toPubkey: vault.publicKey,
lamports: 1_000_000, // Rent-exempt minimum
})
);
await provider.sendAndConfirm(revivalTx, [attacker]);
// Attempt to use the revived account — should FAIL
try {
await program.methods
.deposit(new anchor.BN(50_000_000))
.accounts({ vault: vault.publicKey, user: attacker.publicKey })
.signers([attacker])
.rpc();
expect.fail("Should have rejected closed account");
} catch (err) {
expect(err.message).to.include("AccountDiscriminatorMismatch");
}
});
it("should reject revival within same transaction", async () => {
const vault = await createVault(authority, 100_000_000);
// Build atomic transaction: close + revive + interact
const tx = new anchor.web3.Transaction();
// Instruction 1: close
tx.add(
await program.methods
.closeVault()
.accounts({ vault: vault.publicKey, authority: authority.publicKey })
.instruction()
);
// Instruction 2: revive
tx.add(
anchor.web3.SystemProgram.transfer({
fromPubkey: attacker.publicKey,
toPubkey: vault.publicKey,
lamports: 1_000_000,
})
);
// Instruction 3: exploit
tx.add(
await program.methods
.deposit(new anchor.BN(50_000_000))
.accounts({ vault: vault.publicKey, user: attacker.publicKey })
.instruction()
);
// This should fail at instruction 3
try {
await provider.sendAndConfirm(tx, [authority, attacker]);
expect.fail("Revival attack should have been blocked");
} catch (err) {
// Expected: closed account discriminator prevents deserialization
expect(err).to.exist;
}
});
});
Trident Fuzzing (Automated)
If you use Ackee Blockchain's Trident fuzzer for Solana, add revival-specific scenarios:
// In your fuzz test
fn fuzz_revival_attack(
close_ix: CloseVaultInstruction,
revival_transfer: SystemTransfer,
reuse_ix: DepositInstruction,
) {
// Execute close
let result1 = execute_instruction(close_ix);
assert!(result1.is_ok());
// Execute revival transfer
let result2 = execute_transfer(revival_transfer);
// Transfer may succeed (system program doesn't care about discriminators)
// Execute reuse — must fail
let result3 = execute_instruction(reuse_ix);
assert!(result3.is_err(), "Program accepted interaction with closed account!");
}
The Audit Checklist: Account Closure Security
For every Solana program you build or audit:
□ All account closures use Anchor's `close` constraint OR manual 4-step pattern
□ Account data is zeroed on closure (not just lamports transferred)
□ Closed discriminator (0xFF × 8) is set on closure
□ Account ownership transfers to system program on closure
□ No `init_if_needed` without explicit closed-state validation
□ Revival attack tests exist (same-tx close+revive+interact)
□ Cross-transaction revival tests exist (close in tx1, interact in tx2 after funding)
□ All instruction handlers validate account discriminator before processing
□ PDAs derived from user keys have closure logic that accounts for revival
□ Escrow/reward/staking patterns test for double-claim via revival
Key Takeaways
Solana's garbage collection gap is a feature, not a bug — but it creates security implications that every developer must account for.
Account closure ≠ account deletion. Until the transaction ends and the runtime sweeps, a "closed" account is just an account with zero lamports. Anyone can revive it.
Anchor's
closeconstraint is the correct default. It handles data zeroing, discriminator setting, and ownership transfer automatically. Don't roll your own unless you have a specific reason.init_if_neededis dangerous near closure logic. If your program both closes and conditionally initializes accounts, test the interaction exhaustively.Test the attack, not just the happy path. A test that closes an account and verifies it's gone is insufficient. You need tests that attempt revival and verify rejection.
The Solana runtime gives you the tools to close accounts securely. But it won't save you from yourself if you only transfer lamports and call it a day. Zero the data. Set the discriminator. Transfer ownership. Test the revival. Every time.
Building on Solana? The account lifecycle is where subtle bugs hide. More security research at dev.to/ohmygod and DreamWork Security.
Top comments (0)