Account Close Reopen Race
Detects race conditions from account close/reopen between transactions.
Account Close Reopen Race
Overview
Remediation Guide: How to Fix Account Close Reopen Race
The account close reopen race detector identifies time-of-check-to-time-of-use (TOCTOU) vulnerabilities where accounts are read before a CPI call and used again afterward without revalidating ownership or discriminator. Between the check and the subsequent use, an attacker can close and reopen the account with different data or a different owner, causing the program to operate on stale references.
Sigvex detects this by tracking account data reads before CPI calls, then checking whether ownership or discriminator revalidation occurs before the next use of the same account. It also flags close operations that lack generation/version tracking.
Why This Is an Issue
Close-reopen races exploit the gap between account validation and subsequent use:
- Stale references: data cached before the CPI becomes invalid if the account is closed and recreated with different contents.
- Owner substitution: the reopened account may be owned by a different program, bypassing cross-program trust assumptions.
- State confusion: the program processes the new account’s data as if it were the original, leading to unauthorized transfers or privilege escalation.
CWE mapping: CWE-367 (Time-of-Check Time-of-Use Race Condition).
How to Resolve
Native Solana
// After CPI, revalidate account ownership and discriminator
invoke(&external_instruction, &account_infos)?;
// Revalidate: owner must still be our program
if *account.owner != program_id {
return Err(ProgramError::IncorrectProgramId);
}
// Revalidate: discriminator must match expected type
let data = account.data.borrow();
if data[0..8] != EXPECTED_DISCRIMINATOR {
return Err(ProgramError::InvalidAccountData);
}
Anchor
// Anchor revalidates account types on deserialization, but
// when using remaining_accounts or UncheckedAccount, add manual checks:
let account = &ctx.remaining_accounts[0];
require!(account.owner == &crate::ID, InvalidOwner);
Examples
Vulnerable
let cached_balance = read_balance(account)?;
invoke(&external_cpi, &[account.clone()])?;
// Uses cached_balance without revalidation
transfer(account, destination, cached_balance)?;
Fixed
let _initial_balance = read_balance(account)?;
invoke(&external_cpi, &[account.clone()])?;
// Revalidate after CPI
require!(*account.owner == program_id, InvalidOwner);
let fresh_balance = read_balance(account)?;
transfer(account, destination, fresh_balance)?;
JSON Finding
{
"detector": "account-close-reopen-race",
"severity": "High",
"confidence": 0.80,
"title": "Account Used After CPI Without Revalidation",
"description": "Account is read before a CPI call and used again after without revalidating ownership or discriminator.",
"cwe": [367]
}
Detection Methodology
The detector performs a linear scan tracking four categories: early account reads, CPI call locations, post-CPI account uses, and revalidation events (owner checks, discriminator reads). When an account is used after a CPI without intervening revalidation, a finding is generated. The detector also identifies close operations without generation/version tracking by checking for version field reads at offset 8 (immediately after the discriminator).
Limitations
- The detector uses intraprocedural analysis and cannot detect close-reopen races across separate transactions or instructions.
- Version field detection relies on the convention of placing version data at offset 8, which may not apply to all programs.
- PDA-derived accounts receive reduced confidence since reopening requires the same program seeds.
Related Detectors
- Account Resurrection - detects resurrection via PDA re-derivation.
- Account Use After Close - detects use of accounts after closure within a single function.
- Account Close Discriminator - detects close without discriminator zeroing.