Close Account Drain Exploit Generator
Sigvex exploit generator that validates close account drain vulnerabilities in Solana programs where accounts are improperly closed, allowing an attacker to steal the rent lamports or reuse the account after closure.
Close Account Drain Exploit Generator
Overview
The close account drain exploit generator validates findings from the close-account-drain detector by simulating an attack that exploits an improperly closed account — stealing rent lamports by directing them to the attacker or reusing the account data after a partial close. The vulnerability is assessed with confidence 0.70 (likely exploitable). Impact is reported as the total SOL held in the exploit context accounts.
Closing a Solana account requires: (1) zeroing the account’s data, (2) transferring all lamports to a recipient, and (3) reassigning the account’s owner to the System Program. If any of these steps is missing or done in the wrong order, an attacker can exploit the closure. The most common failure is directing rent lamports to an attacker-controlled address, or closing an account but not zeroing its data — allowing the program to see stale data in a “zombie” account the next time the address is reused.
Severity score: 75/100 (High).
Note: Exploit generation in Sigvex is for vulnerability validation purposes only.
Attack Scenario
Rent drain to attacker:
- A program has a
close_positioninstruction that closes a user’s position account. - The instruction transfers all lamports (rent) from
position_accountto arecipientparameter. - The instruction does not verify
recipient == position.owner. - An attacker calls
close_position(position_account, attacker_account). - The position is closed and rent lamports go to the attacker instead of the legitimate owner.
Zombie account reuse:
- A program closes an account by zeroing lamports but not zeroing the account data.
- The account’s public key can be reused in a new transaction.
- The new account, when created at the same address, inherits the old data (if the runtime reuses the slot).
- Alternatively, within the same transaction, the attacker can invoke the program with the “closed” account before it is actually zeroed.
Force-close via lamport theft:
- A program does not have a close instruction, but its owner constraint allows any signer to reduce lamports.
- An attacker reduces the lamports below the rent-exempt minimum.
- The runtime garbage-collects the account (removes it from state) on the next epoch.
- The account is effectively destroyed without the owner’s consent.
Exploit Mechanics
The engine maps close-account-drain detector findings to the close-drain exploit pattern (severity score 75/100).
Strategy: Call the vulnerable close instruction and supply an attacker-controlled account as the lamport recipient. Because the program does not verify the recipient is the account’s legitimate owner, the rent lamports are redirected.
Simulation steps:
- The engine identifies the close or settle instruction from the finding location (searching for instructions that zero lamports or use the
close = recipientconstraint pattern). - The exploit transaction is constructed with the victim’s position account (writable), an unrelated victim account as the account being closed, and the attacker’s pubkey as the SOL destination.
- The transaction is submitted to the program simulator with
attacker.is_signer = true(a valid signer, but not the position owner). - If lamports are transferred to the attacker address, the vulnerability is confirmed with confidence 0.80.
- Impact is computed as: total lamports in closed accounts / 1,000,000,000 (SOL), recorded as
"Potential loss of {N} SOL". - Evidence recorded:
victim_account,attacker_recipient,lamports_stolen.
Close-account drain exploit transaction structure:
// Attacker signs, but they are not the position owner
let accounts = vec![
AccountMeta::new(victim_position_account, false), // victim's position (writable, being closed)
AccountMeta::new(attacker_pubkey, true), // attacker as signer + rent recipient
AccountMeta::new_readonly(victim_owner_key, false),// victim owner (not a signer!)
];
let ix = Instruction::new_with_bytes(
program_id,
&close_position_discriminator,
accounts,
);
// Succeeds if program routes lamports to instruction-supplied recipient
// rather than verifying recipient == position.owner
// VULNERABLE: No recipient verification
use anchor_lang::prelude::*;
#[program]
mod vulnerable_positions {
pub fn close_position(ctx: Context<ClosePosition>) -> Result<()> {
// VULNERABLE: recipient is arbitrary — not verified to be the owner!
let position = &ctx.accounts.position;
let lamports = **position.to_account_info().try_borrow_lamports()?;
// Transfer all lamports to the unchecked recipient
**position.to_account_info().try_borrow_mut_lamports()? = 0;
**ctx.accounts.recipient.try_borrow_mut_lamports()? += lamports;
// Close data — but owner of lamports was not verified!
Ok(())
}
}
#[derive(Accounts)]
pub struct ClosePosition<'info> {
#[account(mut)]
pub position: Account<'info, Position>,
pub authority: Signer<'info>,
/// CHECK: No verification that this is the position owner!
#[account(mut)]
pub recipient: AccountInfo<'info>,
}
// SECURE: Use Anchor's close constraint — correct by construction
#[derive(Accounts)]
pub struct ClosePositionSafe<'info> {
#[account(
mut,
has_one = owner @ PositionError::Unauthorized,
close = owner // Anchor: zeroes data, transfers lamports to owner, sets owner to System Program
)]
pub position: Account<'info, Position>,
pub owner: Signer<'info>, // Must be position.owner AND must sign
}
// Without Anchor: explicit close sequence
fn close_account_safely(
account: &AccountInfo,
recipient: &AccountInfo,
authority: &AccountInfo,
expected_owner: &Pubkey,
) -> Result<()> {
// 1. Verify authorized owner
require!(authority.is_signer, ErrorCode::Unauthorized);
require!(authority.key == expected_owner, ErrorCode::Unauthorized);
// 2. Zero all data
let mut data = account.try_borrow_mut_data()?;
for byte in data.iter_mut() {
*byte = 0;
}
// 3. Transfer all lamports to authorized recipient
let lamports = account.lamports();
**account.try_borrow_mut_lamports()? = 0;
**recipient.try_borrow_mut_lamports()? += lamports;
// 4. Reassign to System Program (clears program ownership)
// Note: Only the current owner program can reassign
Ok(())
}
Remediation
- Detector: Close Account Drain Detector
- Remediation Guide: Close Account Drain Remediation
Use Anchor’s close constraint which handles the complete close sequence correctly:
#[account(
mut,
close = owner, // All lamports go to owner
has_one = owner, // Verifies owner field matches
)]
pub account_to_close: Account<'info, YourType>,
pub owner: Signer<'info>, // Owner must sign
The Anchor close constraint:
- Transfers all lamports to the specified destination.
- Zeroes the account data.
- Sets the account discriminator to the special
CLOSED_ACCOUNT_DISCRIMINATOR(prevents zombie reuse).
If implementing manually, always follow this order:
- Verify the caller is authorized (signer + key match).
- Verify the lamport recipient is the authorized owner.
- Zero all account data (prevents zombie reuse).
- Transfer all lamports (account must be writable).
- Reassign to System Program.
Never close an account and then make CPIs that read the same account in the same instruction — the closed state may not be fully propagated.