Skip to content

Latest commit

 

History

History

README.md

Missing Discriminator Checks - Security Example

Vulnerability Overview

Severity: CRITICAL

This example demonstrates the Missing Discriminator Check vulnerability, where a program manually deserializes account data without verifying the 8-byte discriminator that identifies the account type.

The Problem

Anchor uses an 8-byte discriminator (derived from SHA256("account:TypeName")[0..8]) as the first 8 bytes of every account to identify its type. When you use AccountInfo and manually deserialize data, you bypass this check.

If two account types have the same data layout (after the discriminator), an attacker can pass the wrong account type and your program will misinterpret the data!

Real-World Impact

This vulnerability can lead to:

  • Type confusion - Reading Config.setting as Vault.balance
  • Privilege escalation - Using admin accounts as user accounts
  • Fund theft - Interpreting arbitrary data as withdrawal amounts
  • State corruption - Writing to wrong account types

What You'll Learn

  1. What discriminators are and why they matter
  2. How Anchor uses discriminators for type safety
  3. The danger of manual deserialization with AccountInfo
  4. How Account<T> prevents type confusion attacks

Program Structure

This program has two account types with identical data layouts:

  1. Vault - Stores user funds (owner, balance, bump)
  2. Config - Stores settings (admin, setting_value, bump)

And four instructions:

  1. initialize_vault - Creates a Vault account
  2. initialize_config - Creates a Config account
  3. withdraw_vulnerable - Deserializes without discriminator check (VULNERABLE)
  4. withdraw_secure - Uses Account<Vault> with automatic check (SECURE)

The Vulnerability: Missing Discriminator Check

Vulnerable Code

Located in: src/instructions/withdraw_vulnerable.rs

#[derive(Accounts)]
pub struct WithdrawVulnerable<'info> {
    /// ⚠️ Using AccountInfo - no type safety!
    #[account(mut)]
    pub vault: AccountInfo<'info>,

    pub owner: Signer<'info>,
}

pub fn handler(ctx: Context<WithdrawVulnerable>, amount: u64) -> Result<()> {
    let data = vault_info.try_borrow_data()?;

    // ⚠️ VULNERABLE: Skip discriminator without checking it!
    let owner = Pubkey::try_from(&data[8..40]).unwrap();
    let balance = u64::from_le_bytes(data[40..48].try_into().unwrap());

    // If attacker passes a Config account:
    // - owner reads Config.admin
    // - balance reads Config.setting_value (could be huge!)

    require!(balance >= amount, InsufficientFunds);
    // Attacker withdraws based on setting_value, not actual balance!
}

Why This Is Dangerous

Both account types have the same layout after discriminator:

Vault Account:
┌──────────────┬─────────┬─────────┬──────┐
│ discriminator│  owner  │ balance │ bump │
│   8 bytes    │32 bytes │ 8 bytes │1 byte│
└──────────────┴─────────┴─────────┴──────┘

Config Account:
┌──────────────┬─────────┬──────────────┬──────┐
│ discriminator│  admin  │setting_value │ bump │
│   8 bytes    │32 bytes │   8 bytes    │1 byte│
└──────────────┴─────────┴──────────────┴──────┘

Without discriminator check, Config looks like Vault!

The Attack

Step-by-step exploit:

ATTACKER CREATES CONFIG ACCOUNT
┌─────────────────────────────────────────┐
│ Config {                                │
│   admin: attacker_pubkey,               │
│   setting_value: 1_000_000_000, // HUGE!│
│   bump: 255,                            │
│ }                                       │
└─────────────────────────────────────────┘
              │
              │ Pass as "vault" parameter
              ▼
YOUR PROGRAM (withdraw_vulnerable)
┌─────────────────────────────────────────┐
│ 1. Read data[8..40] as owner            │
│    → Gets Config.admin (attacker) ✓     │
│ 2. Read data[40..48] as balance         │
│    → Gets Config.setting_value (1B!) 🚨│
│ 3. Check owner == signer ✓              │
│ 4. Check balance >= amount ✓            │
│ 5. Withdraw 1B based on fake balance!   │
└─────────────────────────────────────────┘

Attack Code Example

// Attacker creates a Config with huge setting_value
await program.methods
  .initializeConfig(new BN(1_000_000_000)) // 1 billion!
  .accounts({
    config: configPDA,
    admin: attacker.publicKey,
  })
  .rpc();

// Pass Config account as "vault" to vulnerable instruction
await program.methods
  .withdrawVulnerable(new BN(1_000_000_000))
  .accounts({
    vault: configPDA,        // Config, not Vault!
    owner: attacker.publicKey,
    recipient: attacker.publicKey,
  })
  .rpc();

// Program reads Config.setting_value as Vault.balance!

The Fix: Use Account for Type Safety

Located in: src/instructions/withdraw_secure.rs

#[derive(Accounts)]
pub struct WithdrawSecure<'info> {
    /// ✅ SECURE: Account<Vault> checks discriminator automatically
    #[account(
        mut,
        seeds = [b"vault", vault.owner.as_ref()],
        bump = vault.bump,
        has_one = owner
    )]
    pub vault: Account<'info, Vault>,

    pub owner: Signer<'info>,
}

pub fn handler(ctx: Context<WithdrawSecure>, amount: u64) -> Result<()> {
    let vault = &mut ctx.accounts.vault;

    // ✅ vault.balance is guaranteed to be from a real Vault
    require!(vault.balance >= amount, InsufficientFunds);

    vault.balance -= amount;
    Ok(())
}

What Account Checks

When you use Account<'info, Vault>, Anchor automatically verifies:

Check Description
Owner Account is owned by this program
Discriminator First 8 bytes match SHA256("account:Vault")[0..8]
Deserialization Data correctly deserializes to Vault struct

Why This Prevents The Attack

ATTACKER'S CONFIG ACCOUNT
┌─────────────────────────────────────────┐
│ discriminator: [Config's discriminator] │
│ admin: attacker_pubkey                  │
│ setting_value: 1_000_000_000           │
└─────────────────────────────────────────┘
              │
              │ Pass as "vault" parameter
              ▼
YOUR PROGRAM (withdraw_secure)
┌─────────────────────────────────────────┐
│ Anchor checks:                          │
│ discriminator == Vault discriminator?   │
│                                         │
│ Config disc != Vault disc               │
│                                         │
│ ❌ REJECTED! AccountDidNotDeserialize   │
└─────────────────────────────────────────┘

Understanding Discriminators

Anchor generates discriminators using:

// For an account named "Vault"
let discriminator = &Sha256::hash(b"account:Vault")[0..8];

Each account type has a unique discriminator:

  • Vault[211, 8, 232, 43, 2, 152, 117, 119]
  • Config[different 8 bytes]

Key Takeaways

DON'T

  • Use AccountInfo for accounts that need type validation
  • Manually deserialize without checking discriminator
  • Skip the first 8 bytes assuming they're "just metadata"
  • Trust that callers pass the correct account type

DO

  • Use Account<'info, T> for typed accounts
  • Let Anchor handle discriminator validation
  • Use #[account] attribute for all account structs
  • Manually check discriminator if you must use AccountInfo

Manual Discriminator Check (If Needed)

If you must use AccountInfo, check the discriminator manually:

use anchor_lang::Discriminator;

let data = account_info.try_borrow_data()?;
let discriminator = &data[0..8];

require!(
    discriminator == Vault::DISCRIMINATOR,
    ErrorCode::InvalidDiscriminator
);

Building This Example

cd 6-missing-discriminator
anchor build

Additional Resources


Remember: The discriminator is your program's first line of defense against type confusion. Always use Account<T> to let Anchor protect you automatically!