DEV Community

ohmygod
ohmygod

Posted on

Zombie Accounts: How Solana's Garbage Collection Gap Enables Revival Attacks That Drain Programs

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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(())
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
);
Enter fullscreen mode Exit fullscreen mode

After this transaction:

  • The vault account is alive (has rent-exempt lamports)
  • Its data may still contain the old authority and balance fields
  • 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
Enter fullscreen mode Exit fullscreen mode

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>,
}
Enter fullscreen mode Exit fullscreen mode

Anchor's close constraint does four things:

  1. Transfers all lamports to the specified account
  2. Zeros all data in the account (prevents stale data exploitation)
  3. Sets the discriminator to CLOSED_ACCOUNT_DISCRIMINATOR (8 bytes of 0xff)
  4. 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(())
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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>,
}
Enter fullscreen mode Exit fullscreen mode

Attack flow:

  1. User creates vault, deposits funds
  2. User closes vault (gets lamports back)
  3. In the same transaction, attacker revives the PDA by funding it
  4. init_if_needed sees the account exists (has lamports) → skips initialization
  5. 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>,
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

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!");
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Solana's garbage collection gap is a feature, not a bug — but it creates security implications that every developer must account for.

  2. 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.

  3. Anchor's close constraint 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.

  4. init_if_needed is dangerous near closure logic. If your program both closes and conditionally initializes accounts, test the interaction exhaustively.

  5. 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)