All articles
Smart Contracts·November 28, 2025·5 min read

Anchor Account Discriminators and Type-Confusion Attack Vectors

Anchor prefixes every account with an 8-byte discriminator to prevent type confusion, but misuse of unchecked account deserialization can still bypass it. See exactly how this exploit works and how to enforce discriminator validation in every instruction handler.

Anchor's account discriminator is one of the framework's most important safety guarantees — and one of the most frequently misunderstood. It stops an attacker from passing a TokenAccount where your program expects a VaultState, but only if you let the framework actually check it. Skip that check, even once, and you've opened a door that is much harder to close than it was to leave open.

What the discriminator actually is

When you #[account] a struct in Anchor, it computes an 8-byte prefix: sha256("account:<TypeName>")[..8]. This prefix is written to the first 8 bytes of the account's data on initialization. On every subsequent deserialization, Anchor re-derives the discriminator for the expected type and compares it to those first 8 bytes before reading any of the account's fields.

The discriminator is deterministic per program. If your program defines VaultState, its discriminator is always the same binary value everywhere that program is deployed. Two different programs defining a type with the same name will produce different discriminators, because Anchor's derivation does not include the program ID in the hash — but the runtime account ownership check provides that boundary separately.

Where type confusion becomes exploitable

Consider a program with two account types: a privileged AdminConfig holding an authority pubkey and fee parameters, and a user-owned UserAccount holding a balance. Both are #[account] structs. An attacker who controls a UserAccount crafted with specific field values can attempt to substitute it anywhere the program expects an AdminConfig — because at the byte level, both are just data sitting in an account.

Without the discriminator check, field values from UserAccount land in the wrong struct's fields. If byte offsets happen to align — and in many real protocols they do, because types share common prefixes like authority: Pubkey — the program reads the attacker's user_balance as the admin_authority pubkey, or their deposit_amount as fee_basis_points. The consequences range from fee manipulation to full privilege escalation.

The key facts that make this practical:

  • Solana accounts are arbitrary byte arrays; the runtime enforces ownership (which program wrote this data) but not type within that program
  • A single program can own many different account types, so ownership alone does not distinguish them
  • An attacker only needs to control one writable account to construct the confusion payload

The three ways programs get this wrong

1. AccountInfo without deserialization. Passing raw AccountInfo skips all framework checks — ownership, discriminator, and field validation. This is sometimes necessary for CPI targets or PDAs you'll only pass through, but it should never be used for accounts whose fields your instruction reads.

2. UncheckedAccount with a manual try_borrow_data call. The #[account(unchecked)] / UncheckedAccount type (annotated with /// CHECK: since Anchor 0.25) signals that you've opted out of automatic deserialization. If you then call try_borrow_data() and cast the bytes yourself, you've done all the dangerous footwork Anchor was designed to hide — including skipping the discriminator.

3. Deserializing with AccountDeserialize::try_deserialize_unchecked. Anchor exposes both try_deserialize and try_deserialize_unchecked on every account type. The former checks the discriminator; the latter skips it. The unchecked variant exists for accounts that may have been written by a different version of the program or a migration path. Calling it on user-supplied accounts in a live instruction handler is almost always a mistake.

Enforcing discriminator validation: the mechanics

The safe path is to let Anchor's Account<'info, T> do the work:

#[derive(Accounts)]
pub struct ExecuteVault<'info> {
    #[account(
        mut,
        seeds = [b"vault", authority.key().as_ref()],
        bump,
        has_one = authority,
    )]
    pub vault: Account<'info, VaultState>,
    pub authority: Signer<'info>,
}

Account<'info, VaultState> calls try_deserialize internally, which compares the first 8 bytes against the expected discriminator before touching any fields. The has_one and seeds constraints narrow the attack surface further — even if someone created a valid VaultState account, it has to be the one derived from the correct seeds and owned by the correct authority.

If you genuinely need to handle an account whose type you determine at runtime, check the discriminator explicitly before branching:

let data = ctx.accounts.dynamic_account.try_borrow_data()?;
let expected = VaultState::discriminator();  // [u8; 8]
require!(data[..8] == expected, ErrorCode::InvalidAccountDiscriminator);

VaultState::discriminator() is generated by Anchor's #[account] macro and is always available as a const-time call — there is no reason to compute it at runtime or inline the magic bytes yourself.

Numbers that calibrate the risk

The 8-byte discriminator space gives 2^64 possible values, making a brute-force collision search impractical. The actual risk is not cryptographic — it's architectural. An attacker does not need to forge a discriminator; they need to find a code path that skips the check entirely. In the SolanaFM incident dataset, every known type-confusion exploit on Anchor programs traced back to one of the three bypass patterns above, not a hash collision.

An internal audit pass focused on type confusion takes roughly 30–60 minutes per instruction handler if you know what to look for:

  • Search for UncheckedAccount and AccountInfo in your context structs; every instance needs a /// CHECK: comment that explains the manual validation replacing the automatic one
  • Search for try_deserialize_unchecked; confirm the call site is a migration path or tooling, never a live instruction
  • Confirm every account whose fields you read is typed as Account<'info, T> with at least one PDA seed or ownership constraint

What this looks like in a live audit finding

A common finding in contracts we review: a protocol has an UpdateFees instruction that accepts a Config account typed as AccountInfo because an early developer assumed the PDA derivation check in the seeds constraint was sufficient. It is not — seeds only constrains the address, not the type of data stored at that address. If the protocol has a second account type whose discriminator the attacker can craft at that PDA (rare but possible after certain migrations or redeploys), the type confusion follows directly. The fix is a single line: change AccountInfo to Account<'info, Config>.


If your Solana program handles real funds, a discriminator audit is table stakes — not optional polish. If you're building on Anchor and want a second set of eyes on the instruction handlers before mainnet, contact us or take a look at how we build and secure Solana programs.

Need a bot like this built?

We design, build and run trading bots on Solana, Hyperliquid and Polymarket.

Start a project
#Solana#Anchor#Smart Contracts#Security#Rust