Skip to main content
Sigvex

Cross-Program Reinit Remediation

How to prevent account re-initialization through cross-program sequences by re-validating discriminators, owners, and version fields after every CPI.

Cross-Program Reinit Remediation

Overview

Related Detector: Cross-Program Reinit

A CPI can close an account and re-create it — same address, different contents — within the same transaction. A program that validates an account once at instruction entry and then trusts it after a CPI is validating the wrong object: the discriminator, owner, and every data field may have been replaced. The fix is to treat validation as expiring at each CPI boundary. After any invoke that could touch the account, reload it and re-check the properties that made it trustworthy: discriminator, owning program, and any version or initialization flag your program maintains.

Before (Vulnerable)

pub fn execute(ctx: Context<Execute>) -> Result<()> {
    // Validated once, at entry
    let config = &ctx.accounts.config;
    require!(config.initialized, NotInitialized);

    invoke(&external_ix, &account_infos)?;

    // WRONG: config may have been closed and re-initialized by the CPI;
    // admin could now be an attacker-chosen key
    require_keys_eq!(config.admin, ctx.accounts.caller.key());
    transfer_from_vault(ctx, config.withdraw_limit)?;
    Ok(())
}

After (Fixed)

pub fn execute(ctx: Context<Execute>) -> Result<()> {
    require!(ctx.accounts.config.initialized, NotInitialized);
    let admin_before = ctx.accounts.config.admin;

    invoke(&external_ix, &account_infos)?;

    // Re-validate after the CPI boundary
    ctx.accounts.config.reload()?;
    let config = &ctx.accounts.config;
    require!(config.initialized, NotInitialized);
    require_keys_eq!(config.admin, admin_before, ConfigReplaced);
    require_keys_eq!(config.admin, ctx.accounts.caller.key());

    transfer_from_vault(ctx, config.withdraw_limit)?;
    Ok(())
}

reload() forces re-deserialization, which re-runs the discriminator check — a re-initialized account of a different type fails immediately. Comparing security-critical fields (here admin) against their pre-CPI values catches same-type re-initialization with attacker-controlled contents.

Alternative Mitigations

  • Keep CPIs out of validated regions. If the external call happens before any validation, or after all state-dependent work is done, there is no window to exploit.
  • Bind identity to the PDA seed. Deriving the account address from fields that cannot change (authority pubkey, a fixed index) means a re-created account with different identity lands at a different address and never reaches your instruction.
  • Monotonic version counter. Store a version: u64 incremented on every legitimate re-initialization and reject any post-CPI reading where the version moved unexpectedly.

Common Mistakes

Mistake: Trusting a Cached Copy

let cached = ctx.accounts.state.clone();
invoke(&ix, &account_infos)?;
apply(&cached)?;  // WRONG: cached describes an account that may no longer exist

A clone taken before the CPI is a snapshot, not the account. Reload and re-read.

Mistake: Checking Only the Owner

Owner alone is not identity: a close-and-reinit through your own program keeps the owner unchanged while replacing every field. Check the discriminator and the fields your logic depends on, not just account.owner.

References