Close Account Drain
Detects account closure patterns that leave residual lamports accessible to the program or allow the account to be drained without proper authorization.
Close Account Drain
Overview
Remediation Guide: How to Fix Close Account Drain
The close-account-drain detector identifies Solana programs that close accounts — zeroing their data and transferring the rent-exempt lamport balance — without properly validating the authority and destination of the transferred lamports. When an account is closed, all of its lamports must be transferred to a recipient. If the program does not verify that the caller is authorized to close the account, or if the destination address is user-controlled without validation, an attacker can redirect the lamports to an address they control.
Sigvex analyzes the control-flow graph around StoreAccountData operations that zero account data alongside TransferLamports operations, looking for patterns where either: (a) no signer or authority check precedes the transfer, or (b) the recipient address originates from unvalidated calldata. The detector also flags the “close and reopen” race condition where an account is closed but can be recreated with different data within the same slot.
Account closure vulnerabilities are particularly insidious because they appear in “cleanup” code paths that are often less rigorously reviewed during audits.
Why This Is an Issue
In Solana, account closure typically involves two steps: zeroing the account’s data and transferring its lamport balance to a recipient. Both steps must be atomic and authorized. If the authorization check is missing or insufficient:
- An attacker can close any account the program manages, stealing the rent-exempt lamports
- Critically, closing an account does not remove it from the validator’s account database until the end of the transaction — a subsequent instruction in the same transaction can read stale data from a “closed” account
- Programs that close accounts without zeroing the discriminator byte first allow an attacker to reopen the account and impersonate the original state
The Anchor framework’s close constraint handles this correctly, but native programs and improperly implemented Anchor programs remain vulnerable. The #[account(close = destination)] constraint in Anchor transfers lamports and zeroes the account data, but only if the program correctly validates who can trigger the close.
How to Resolve
// Before: Vulnerable — no authorization check on closure
pub fn close_user_account(accounts: &[AccountInfo]) -> ProgramResult {
let user_account = &accounts[0];
let destination = &accounts[1]; // Attacker-controlled destination!
// Zero the account data
let mut data = user_account.try_borrow_mut_data()?;
data.fill(0);
// Transfer lamports — no check that caller owns the account
**destination.lamports.borrow_mut() += **user_account.lamports.borrow();
**user_account.lamports.borrow_mut() = 0;
Ok(())
}
// After: Fixed — verify authority before closing
pub fn close_user_account(accounts: &[AccountInfo]) -> ProgramResult {
let authority = &accounts[0]; // Must be signer
let user_account = &accounts[1];
let destination = &accounts[2];
// Verify the authority has signed
if !authority.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// Verify the authority owns this account
let account_data = UserAccount::try_from_slice(&user_account.data.borrow())?;
if account_data.authority != *authority.key {
return Err(ProgramError::InvalidAccountData);
}
// Zero data first (prevents reopen attacks)
let mut data = user_account.try_borrow_mut_data()?;
data.fill(0);
// Transfer lamports to the verified destination
**destination.lamports.borrow_mut() += **user_account.lamports.borrow();
**user_account.lamports.borrow_mut() = 0;
Ok(())
}
For Anchor programs, use the close constraint with proper authority validation:
#[derive(Accounts)]
pub struct CloseUserAccount<'info> {
#[account(mut)]
pub authority: Signer<'info>, // Must be signer
#[account(
mut,
has_one = authority, // Verifies account.authority == authority.key
close = authority, // Transfers lamports to authority and zeroes data
)]
pub user_account: Account<'info, UserAccount>,
}
pub fn close_user_account(ctx: Context<CloseUserAccount>) -> Result<()> {
// Anchor handles zeroing and lamport transfer automatically
Ok(())
}
Examples
Vulnerable Code
// Missing authority check — any caller can drain any user account
pub fn process_close(accounts: &[AccountInfo]) -> ProgramResult {
let victim_account = &accounts[0];
let attacker_wallet = &accounts[1]; // Attacker passes their own wallet
// No is_signer check, no ownership verification
let lamports = **victim_account.lamports.borrow();
**victim_account.lamports.borrow_mut() = 0;
**attacker_wallet.lamports.borrow_mut() += lamports;
// Data is not zeroed — account can be partially read after "close"
Ok(())
}
Fixed Code
use solana_program::{
account_info::{next_account_info, AccountInfo},
entrypoint::ProgramResult,
program_error::ProgramError,
};
pub fn process_close(accounts: &[AccountInfo]) -> ProgramResult {
let accounts_iter = &mut accounts.iter();
let authority = next_account_info(accounts_iter)?;
let user_account = next_account_info(accounts_iter)?;
let destination = next_account_info(accounts_iter)?;
// Authority must have signed
if !authority.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// Verify account ownership
let stored_authority = {
let data = user_account.data.borrow();
Pubkey::from(data[0..32].try_into().unwrap())
};
if stored_authority != *authority.key {
return Err(ProgramError::InvalidAccountData);
}
// Zero ALL data before transferring (prevents discriminator attacks)
{
let mut data = user_account.data.borrow_mut();
data.fill(0);
}
// Transfer lamports to the authorized destination
let lamports = user_account.lamports();
**user_account.lamports.borrow_mut() = 0;
**destination.lamports.borrow_mut() = destination
.lamports()
.checked_add(lamports)
.ok_or(ProgramError::ArithmeticOverflow)?;
Ok(())
}
Sample Sigvex Output
{
"detector_id": "close-account-drain",
"severity": "critical",
"confidence": 0.87,
"description": "Function process_close() transfers lamports from user_account to a user-supplied destination without verifying the caller is authorized to close the account.",
"location": { "function": "process_close", "offset": 12 }
}
Detection Methodology
The detector performs the following analysis:
- Closure pattern identification: Locates functions containing both
StoreAccountData(zeroing) andTransferLamportsoperations that drain an account to zero. - Authority validation check: Examines the control-flow predecessors of the transfer to determine whether a
CheckSignerorCheckKeystatement validates the caller’s authority over the account being closed. - Destination validation: Determines whether the transfer recipient address is a runtime value from calldata (high risk) or a validated program-derived address.
- Data zeroing order: Verifies that account data is zeroed before the lamport transfer (reversed order creates a window for reopen attacks).
- Discriminator preservation: Checks whether the discriminator byte is zeroed as part of the closure, flagging cases where it is preserved (enabling reopen impersonation).
Limitations
False positives:
- Authority checks performed in a separate
modifier-equivalent function called before the close instruction may not be recognized as providing protection. - PDA-owned accounts where the program itself is the only closer may produce findings if the authority derivation is complex.
False negatives:
- Close operations spread across multiple transactions (close in one instruction, lamport drain in another) are not detected.
- Authorization delegated through complex governance mechanisms may not be recognized.
Related Detectors
- Missing Signer Check — detects privileged operations without signature verification
- Lamport Drain — detects general lamport drainage vulnerabilities
- Account Reinitialization — detects reopen attacks after account closure