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
ProgramAccountTwoaccountSetting themselves as the owner in that account's data
Passing their
ProgramAccountTwoas theprogram_account_oneparameterSince both account types have identical
owner: Pubkeystructures, the deserialization succeedsThe attacker becomes the "admin" for operations intended only for
ProgramAccountOneowners
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.0it'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());
}