Skip to main content
Sigvex

Account Type Confusion Remediation

How to validate account discriminators and use the account-validation framework's typed account system to prevent attackers from substituting accounts of the wrong type.

Account Type Confusion Remediation

Overview

Related Detector: Account Type Confusion

Account type confusion — also called type cosplay — occurs when a Solana program deserializes account data without verifying that the account actually contains the expected struct type. In Anchor, every account type is prefixed with a unique 8-byte discriminator derived from the type name. If a program uses AccountInfo<'info> instead of Account<'info, T>, or calls try_from_slice directly on raw bytes, the discriminator check is bypassed. An attacker can then craft an account of a different type whose field layout overlaps with the expected type, causing the program to act on attacker-controlled field values.

The fix is to use the account-validation framework’s typed account system wherever possible, and to manually validate the discriminator, program owner, and data length whenever AccountInfo is unavoidable.

Before (Vulnerable)

use anchor_lang::prelude::*;

#[program]
mod vulnerable_protocol {
    pub fn process_vault(ctx: Context<ProcessVault>) -> Result<()> {
        // VULNERABLE: AccountInfo bypasses the account-validation framework's discriminator check entirely
        let data = ctx.accounts.vault.try_borrow_data()?;

        // Raw deserialization — no discriminator verification
        let vault = Vault::try_from_slice(&data)?;

        // vault.balance comes from attacker-controlled bytes
        msg!("Vault balance: {}", vault.balance);
        transfer_funds(&ctx, vault.balance)?;
        Ok(())
    }
}

#[derive(Accounts)]
pub struct ProcessVault<'info> {
    /// CHECK: No type validation — vulnerable to type confusion!
    pub vault: AccountInfo<'info>,
    pub authority: Signer<'info>,
}

After (Fixed)

use anchor_lang::prelude::*;

#[program]
mod secure_protocol {
    pub fn process_vault(ctx: Context<ProcessVault>) -> Result<()> {
        // FIXED: vault is typed — Anchor already verified discriminator,
        // program ownership, and data length during account deserialization
        let vault = &ctx.accounts.vault;

        msg!("Vault balance: {}", vault.balance);
        transfer_funds(&ctx, vault.balance)?;
        Ok(())
    }
}

#[derive(Accounts)]
pub struct ProcessVault<'info> {
    // Account<'info, Vault> automatically verifies:
    // 1. First 8 bytes == sha256("account:Vault")[..8]
    // 2. Account is owned by this program
    // 3. Account data length >= size_of::<Vault>() + 8
    #[account(has_one = authority)]
    pub vault: Account<'info, Vault>,
    pub authority: Signer<'info>,
}

Switching from AccountInfo to Account<'info, Vault> eliminates the type confusion surface. Anchor validates all three required properties — discriminator, owner, and size — before the handler body executes.

Alternative Mitigations

1. SPL token accounts via anchor_spl typed wrappers

For SPL token accounts, use the provided typed wrappers with additional mint and authority constraints:

use anchor_lang::prelude::*;
use anchor_spl::token::{Token, TokenAccount};

#[derive(Accounts)]
pub struct Withdraw<'info> {
    // TokenAccount checks: owner == spl_token::id(), discriminator, data length
    #[account(
        mut,
        token::mint = vault_mint,           // Ensures correct mint
        token::authority = user,            // Ensures correct authority
    )]
    pub user_token_account: Account<'info, TokenAccount>,

    #[account(
        mut,
        token::mint = vault_mint,
    )]
    pub vault_token_account: Account<'info, TokenAccount>,

    pub vault_mint: Account<'info, Mint>,
    pub user: Signer<'info>,
    pub token_program: Program<'info, Token>,
}

2. Manual discriminator check when AccountInfo is required

In some patterns — such as dynamic dispatch or generic account handling — AccountInfo cannot be avoided. Always perform these four checks manually:

use anchor_lang::prelude::*;

fn validate_and_deserialize_vault(
    account: &AccountInfo,
    program_id: &Pubkey,
) -> Result<Vault> {
    // 1. Verify the account is owned by this program
    require!(
        account.owner == program_id,
        ErrorCode::InvalidAccountOwner
    );

    let data = account.try_borrow_data()?;

    // 2. Verify minimum data length (discriminator + struct)
    const DISCRIMINATOR_LEN: usize = 8;
    require!(
        data.len() >= DISCRIMINATOR_LEN + std::mem::size_of::<Vault>(),
        ErrorCode::AccountDataTooSmall
    );

    // 3. Verify the discriminator matches Vault
    let expected = Vault::discriminator();
    require!(
        data[..DISCRIMINATOR_LEN] == expected,
        ErrorCode::AccountDiscriminatorMismatch
    );

    // 4. Deserialize (skip discriminator bytes)
    let vault = Vault::try_from_slice(&data[DISCRIMINATOR_LEN..])?;

    Ok(vault)
}

3. Owner verification for accounts owned by external programs

When validating accounts owned by other programs (e.g., the System Program or the SPL Token program), check the owner field explicitly:

pub fn verify_system_account(account: &AccountInfo) -> Result<()> {
    // System-owned accounts have no discriminator — check the owner
    require!(
        account.owner == &solana_program::system_program::id(),
        ErrorCode::InvalidAccountOwner
    );
    Ok(())
}

Common Mistakes

Mistake 1: Using AccountInfo with a /// CHECK: comment but no actual validation

// WRONG: The comment acknowledges the risk but provides no protection
#[derive(Accounts)]
pub struct BadExample<'info> {
    /// CHECK: safe because we check the owner
    pub vault: AccountInfo<'info>,  // But the owner check never happens in the handler!
}

Every /// CHECK: annotation must be paired with actual runtime validation in the handler or as an account constraint.

Mistake 2: Relying on key equality to distinguish account types

// WRONG: two different account types can exist at the same address over time
require!(
    ctx.accounts.account.key() == expected_vault_key,
    ErrorCode::InvalidAccount
);
// This does not verify the account's current type — only its address

Address equality is not type equality. Always check the discriminator.

Mistake 3: Calling try_from_slice on the full account data including the discriminator

// WRONG: Anchor accounts include an 8-byte discriminator prefix
let vault = Vault::try_from_slice(&data)?;  // Panics or produces garbage data

// CORRECT: skip the first 8 bytes
let vault = Vault::try_from_slice(&data[8..])?;

If using raw Borsh deserialization on Anchor accounts, always skip the leading 8-byte discriminator.

References