Close Account Drain Remediation
How to safely close Solana accounts by zeroing data, verifying authority, and using Anchor's close constraint to prevent rent lamport theft.
Close Account Drain Remediation
Overview
Related Detector: Lamport Drain
Properly closing a Solana account requires three steps performed in a strict order: verify the caller is the authorized owner, zero all account data, and transfer all lamports to the legitimate recipient. Skipping or reordering any of these steps creates exploitable conditions. The most common failure is directing rent lamports to an arbitrary caller-supplied address without verifying it matches the account’s stored owner. A second failure is zeroing lamports without zeroing the data, leaving a “zombie” account that can be reused within the same transaction or when the address is later re-created. The simplest and most correct fix is to use Anchor’s close constraint, which handles all three steps atomically.
Recommended Fix
Before (Vulnerable)
use anchor_lang::prelude::*;
#[program]
mod vulnerable_positions {
pub fn close_position(ctx: Context<ClosePosition>) -> Result<()> {
let position = ctx.accounts.position.to_account_info();
let lamports = position.lamports();
// VULNERABLE: lamports go to caller-supplied recipient, not verified owner
**position.try_borrow_mut_lamports()? = 0;
**ctx.accounts.recipient.try_borrow_mut_lamports()? += lamports;
// Account data is not zeroed — zombie reuse risk within same transaction
Ok(())
}
}
#[derive(Accounts)]
pub struct ClosePosition<'info> {
#[account(mut)]
pub position: Account<'info, Position>,
pub authority: Signer<'info>,
/// CHECK: No verification this is the position owner!
#[account(mut)]
pub recipient: AccountInfo<'info>,
}
After (Fixed)
use anchor_lang::prelude::*;
#[program]
mod secure_positions {
pub fn close_position(ctx: Context<ClosePosition>) -> Result<()> {
// FIXED: Anchor's close constraint handles everything correctly:
// 1. Verifies has_one = owner (owner field matches the owner account)
// 2. Zeroes all account data
// 3. Sets discriminator to CLOSED_ACCOUNT_DISCRIMINATOR (prevents zombie reuse)
// 4. Transfers all lamports to owner
// No handler body needed — constraints handle it all
Ok(())
}
}
#[derive(Accounts)]
pub struct ClosePosition<'info> {
#[account(
mut,
has_one = owner @ PositionError::Unauthorized, // position.owner must equal owner.key()
close = owner // lamports go to owner, not an arbitrary account
)]
pub position: Account<'info, Position>,
pub owner: Signer<'info>, // Must sign AND must be position.owner
}
#[account]
pub struct Position {
pub owner: Pubkey,
pub size: u64,
pub entry_price: u64,
}
The close = owner constraint guarantees all lamports go to the account whose key is stored in position.owner, and the has_one = owner constraint ensures the signer matches that stored key. An attacker who is not the stored owner cannot sign for the owner account.
Alternative Mitigations
1. Manual close sequence when Anchor is not used
Without Anchor, implement the full close sequence with explicit ordering:
use solana_program::{account_info::AccountInfo, program_error::ProgramError, pubkey::Pubkey};
fn close_account_safely(
account: &AccountInfo,
authority: &AccountInfo,
recipient: &AccountInfo,
expected_owner: &Pubkey,
) -> Result<(), ProgramError> {
// Step 1: Verify the caller is the authorized owner — must be first
if !authority.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
if authority.key != expected_owner {
return Err(ProgramError::IllegalOwner);
}
// Verify recipient is the expected owner (not an arbitrary address)
if recipient.key != expected_owner {
return Err(ProgramError::IllegalOwner);
}
// Step 2: Zero all account data — prevents zombie reuse
let mut data = account.try_borrow_mut_data()?;
for byte in data.iter_mut() {
*byte = 0;
}
drop(data);
// Step 3: Transfer all lamports to the authorized recipient
let lamports = account.lamports();
**account.try_borrow_mut_lamports()? = 0;
**recipient.try_borrow_mut_lamports()? += lamports;
Ok(())
}
2. Protect against zombie reuse in the same transaction
Anchor writes a special CLOSED_ACCOUNT_DISCRIMINATOR value to prevent an account from being read again in the same transaction. When implementing a manual close, replicate this protection by setting a sentinel value in the discriminator bytes:
use anchor_lang::__private::CLOSED_ACCOUNT_DISCRIMINATOR;
fn mark_account_closed(account: &AccountInfo) -> Result<(), ProgramError> {
let mut data = account.try_borrow_mut_data()?;
// Overwrite the first 8 bytes with Anchor's closed discriminator sentinel
// Any subsequent Account<'info, T> deserialization will fail with AccountOwnedByWrongProgram
if data.len() >= 8 {
data[..8].copy_from_slice(&CLOSED_ACCOUNT_DISCRIMINATOR);
}
Ok(())
}
3. Destination validation using a stored field
When the lamport destination is flexible (e.g., a configured fee recipient, not just the owner), store the valid destinations in the account itself and verify against that list:
#[account]
pub struct Position {
pub owner: Pubkey,
pub fee_recipient: Pubkey, // Set at creation time, not by the close instruction
pub size: u64,
}
#[derive(Accounts)]
pub struct ClosePosition<'info> {
#[account(
mut,
has_one = owner,
close = owner // Lamports always go to owner — fee handling is separate
)]
pub position: Account<'info, Position>,
pub owner: Signer<'info>,
}
Common Mistakes
Mistake 1: Closing after making a CPI that reads the same account
// WRONG: the CPI may read the account before closure is committed
pub fn bad_close(ctx: Context<BadClose>) -> Result<()> {
// CPI reads position data (e.g., logs the final size)
invoke(&some_instruction_using_position, &accounts)?;
// THEN close — but if CPI re-enters or the closure is partially visible, risk exists
close_account(&ctx.accounts.position)?;
Ok(())
}
Complete all state reads and CPIs before zeroing the account, or restructure so the close is the final action and no CPI touches the account afterward.
Mistake 2: Zeroing lamports but leaving data intact
// WRONG: data is preserved — a subsequent create at this address inherits the stale data
**account.try_borrow_mut_lamports()? = 0;
// data NOT zeroed
**recipient.try_borrow_mut_lamports()? += lamports;
Always zero data before transferring lamports. When data is zeroed, even if the address is reused, it starts from a clean state.
Mistake 3: Trusting is_writable to imply ownership
// WRONG: writable only means the account can be modified in this transaction,
// not that the caller owns it
if ctx.accounts.position.is_writable {
// ... transfer lamports to caller — but caller may not be the position owner
}
Always verify has_one or an equivalent key equality check against a stored authority field.