Type Cosplay
Type cosplay attacks exploit programs that fail to verify account types, allowing attackers to substitute accounts with identical data structures but different intended purposes. Since Solana stores all account data as raw bytes, a program that doesn't check account types can be tricked into treating a VaultConfig
as a AdminSettings
with potentially catastrophic results.
The vulnerability stems from structural ambiguity. When multiple account types share the same data layout (like both having an owner: Pubkey
field), owner checks and data validation alone aren't enough to distinguish between them. An attacker who controls one type of account can masquerade as the owner of a completely different account type, bypassing authorization logic designed around specific account purposes.
Without discriminators (unique identifiers that distinguish account types) your program becomes vulnerable to sophisticated impersonation attacks where malicious actors can exploit the gap between structural similarity and logical intent.
Anchor
Consider this vulnerable instruction that performs admin operations based on account ownership:
#[program]
pub mod insecure_check{
use super::*;
//..
pub fn instruction(ctx: Context<Instruction>) -> Result<()> {
let program_account_one = ctx.accounts.program_account_one.to_account_info();
if program_account_one.owner != ctx.program_id {
return Err(ProgramError::IllegalOwner.into());
}
if ctx.accounts.program_account_one.owner != ctx.accounts.admin.key() {
return Err(ProgramError::InvalidAccountData.into());
}
//..do something
Ok(())
}
//..
}
#[derive(Accounts)]
pub struct Instruction<'info> {
pub admin: Signer<'info>,
#[account(mut)]
/// CHECK: This account will not be checked by Anchor
pub program_account_one: UncheckedAccount<'info>,
#[account(mut)]
/// CHECK: This account will not be checked by Anchor
pub program_account_two: UncheckedAccount<'info>,
}
#[derive(AnchorSerialize, AnchorDeserialize, InitSpace)]
pub struct ProgramAccountOne {
owner: Pubkey,
}
#[derive(AnchorSerialize, AnchorDeserialize, InitSpace)]
pub struct ProgramAccountTwo {
owner: Pubkey,
}
This code looks secure: it checks program ownership and validates the admin authority. But there's a fatal flaw: it never verifies that program_account_one
is actually a ProgramAccountOne
and not some other account type with the same data structure.
An attacker can exploit this by:
- Creating or controlling a
ProgramAccountTwo
account - Setting themselves as the owner in that account's data
- Passing their
ProgramAccountTwo
as theprogram_account_one
parameter - Since both account types have identical
owner: Pubkey
structures, the deserialization succeeds - The attacker becomes the "admin" for operations intended only for
ProgramAccountOne
owners
Solana uses discriminators to solve this problem:
- Anchor's 8-byte discriminator (default): Derived from the account name, automatically added to accounts marked with #[account]. (from anchor
0.31.0
it's possible to implement "custom" discriminators) - Length-based discrimination: Used by the Token Program to distinguish between Token and Mint accounts (though Token2022 now uses explicit discriminators)
The simplest fix is using Anchor's built-in type validation:
#[derive(Accounts)]
pub struct Instruction<'info> {
pub admin: Signer<'info>,
#[account(mut)]
/// CHECK: This account will not be checked by Anchor
pub program_account_one: Account<'info, ProgramAccountOne>,
#[account(mut)]
/// CHECK: This account will not be checked by Anchor
pub program_account_two: Account<'info, ProgramAccountTwo>,
}
#[account]
pub struct ProgramAccountOne {
owner: Pubkey,
}
#[account]
pub struct ProgramAccountTwo {
owner: Pubkey,
}
Or for custom validation, you can add explicit discriminator checks:
pub fn instruction(ctx: Context<Instruction>) -> Result<()> {
let program_account_one = ctx.accounts.program_account_one.to_account_info();
if program_account_one.owner != ctx.program_id {
return Err(ProgramError::IllegalOwner.into());
}
if ctx.accounts.program_account_one.owner != ctx.accounts.admin.key() {
return Err(ProgramError::InvalidAccountData.into());
}
let data = program_account_one.data.borrow();
// Assume ProgramAccountOne has a discriminator of 8 bytes
let discriminator = &data[..8];
if discriminator != ProgramAccountOne::DISCRIMINATOR {
return Err(ProgramError::InvalidAccountData.into());
}
//..do something
Ok(())
}
Pinocchio
In Pinocchio, implement discriminator checking manually:
let account_data = self.accounts.program_account.try_borrow_data()?;
if account_data[0] != DISCRIMINATOR {
return Err(ProgramError::AccountAlreadyInitialized.into());
}