Account confusion is not an edge case on Solana — it is the dominant exploit class. Account confusion is one of the most dangerous vulnerability classes on Solana, sitting behind the $52M Cashio hack and countless other exploits. Solana programs see accounts as raw bytes. If you don’t manually verify the owner, discriminator, and relevant data fields, an attacker can impersonate a legitimate account and drain your protocol.
This article dissects every layer of that problem: what an account actually is at the runtime level, how missing checks enable substitution attacks, how Anchor’s discriminator pattern closes the gap, and how PDA derivation can be abused when done carelessly. By the end you will have a mental model and a checklist to audit your own programs.
The Solana Account Model, In Depth
On Solana, programs are stateless. All data lives in accounts, and accounts are identified by their 32-byte address. Every account exposed to your program instruction is an AccountInfo struct, which in the Solana runtime looks like this:
// Simplified AccountInfo layout (solana_program crate)
pub struct AccountInfo<'a> {
/// The public key (address) of this account
pub key: &'a Pubkey,
/// True if this account signed the transaction
pub is_signer: bool,
/// True if this account may be written to
pub is_writable: bool,
/// Lamports held by this account (u64, behind a RefCell)
pub lamports: Rc<RefCell<&'a mut u64>>,
/// Raw byte data stored by this account
pub data: Rc<RefCell<&'a mut [u8]>>,
/// The program that *owns* this account
pub owner: &'a Pubkey,
/// True if this account is an executable program
pub executable: bool,
/// The epoch at which this account's rent is next due
pub rent_epoch: Epoch,
}
The five fields that matter most for security are:
| Field | What it means |
|---|---|
key | The address. Anyone can pass any address. |
owner | The program that can write to data. Set by the runtime. |
data | Arbitrary bytes. The runtime has no schema. |
is_signer | Did the private key for key sign this transaction? |
is_writable | Was this account declared writable in the transaction? |
Unlike traditional smart contract platforms where access control is often enforced at the protocol level, Solana’s design allows any account to be passed into a program’s function. This means a malicious actor can inject unexpected or deceptive inputs, attempting to manipulate the program’s behavior. Without strict data validation and access control mechanisms, developers risk exposing their applications to severe vulnerabilities.
Owner vs. Authority
These two concepts are often confused, so it is worth being explicit:
- Owner (
AccountInfo::owner) — the program whose bytecode is the only entity the runtime will permit to mutatedataor debit lamports from this account. It is set by the runtime when the account is created via the System Program. - Authority — a pubkey stored inside
databy convention, representing a user who has permission to invoke privileged instructions. This is entirely a program-level convention, not a runtime concept.
The owner of an account in Solana is by definition a smart contract (a program). The owner data (public key) is contained in the owner field in the account metadata (AccountInfo::owner). If a smart contract contains functionality that is intended to be available to some specific list of accounts, it is necessary to provide validation of the account owner.
The executable Flag
When a program is deployed on Solana, its account has executable: true. In Solana, everything is stored in accounts — executable accounts store compiled programs, while non-executable accounts store data such as user wallets, app state, or NFTs. If your instruction expects to call a program via CPI, you should assert executable == true on that account. Passing a non-executable account in the program ID slot is a common spoofing vector.
Missing Owner Checks and Account Substitution
Solana accounts are owned by smart contracts (programs), and ownership is specified in the account metadata. If a program does not properly validate the account owner, an attacker could substitute their own account, gaining unauthorized access to privileged functionality.
Here is a vulnerable raw Solana program that reads a Vault struct without checking its owner:
// VULNERABLE: No owner check
pub fn withdraw(program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let account_iter = &mut accounts.iter();
let vault_account = next_account_info(account_iter)?;
let destination = next_account_info(account_iter)?;
let authority = next_account_info(account_iter)?;
// ❌ We never check vault_account.owner == program_id
// ❌ We never check authority.is_signer
let vault_data = Vault::try_from_slice(&vault_account.data.borrow())?;
if vault_data.authority != *authority.key {
return Err(ProgramError::InvalidAccountData);
}
// Attacker can pass a fake vault owned by THEIR program
// with vault_data.authority == attacker.key
transfer_lamports(vault_account, destination, amount)?;
Ok(())
}
#[derive(BorshSerialize, BorshDeserialize)]
pub struct Vault {
pub authority: Pubkey,
pub balance: u64,
}
Without owner validation, an attacker could add their spoofed account instead of the expected account. The attacker’s attack path is simple:
- Deploy a program that creates accounts with the same byte layout as
Vault. - Craft a fake
Vaultaccount whereauthorityequals the attacker’s pubkey. - Call
withdraw, passing the fake vault. The authority check passes because the attacker signed with their own key, andvault_data.authority == attacker.key.
The fix requires a single check:
// SECURE: Enforce owner before deserializing
pub fn withdraw(program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let account_iter = &mut accounts.iter();
let vault_account = next_account_info(account_iter)?;
let destination = next_account_info(account_iter)?;
let authority = next_account_info(account_iter)?;
// ✅ Owner check: data can only have been written by our program
if vault_account.owner != program_id {
return Err(ProgramError::IncorrectProgramId);
}
// ✅ Signer check
if !authority.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
let vault_data = Vault::try_from_slice(&vault_account.data.borrow())?;
if vault_data.authority != *authority.key {
return Err(ProgramError::InvalidAccountData);
}
transfer_lamports(vault_account, destination, amount)?;
Ok(())
}
Solana accounts have an owner field that indicates who can write to that account’s data. Without verifying ownership, attackers can supply malicious data.
The Discriminator Pattern: Preventing Type Confusion
Even after you add an owner check, you have a subtler problem. A Solana program may have multiple accounts with different types of data for different purposes. It is important to verify that the account data is of the type that the program expects from the account, as an attacker could use the lack of verification for their own purposes.
Consider a program with two structs, both owned by the same program ID:
#[derive(BorshSerialize, BorshDeserialize)]
pub struct UserProfile {
pub authority: Pubkey, // 32 bytes
pub score: u64, // 8 bytes
}
#[derive(BorshSerialize, BorshDeserialize)]
pub struct AdminConfig {
pub authority: Pubkey, // 32 bytes
pub fee_bps: u64, // 8 bytes — same layout!
}
Without proper type discrimination, one account type could be mistaken for another. Both structs serialize to the exact same 40 bytes. An attacker can create a UserProfile account (which they control) with authority = attacker_key and score = 9999, then pass it as an AdminConfig account. Your owner check passes, your authority check passes, and the attacker now has admin access.
Manual Discriminator Implementation
The solution is to embed a type tag in the first bytes of data:
// Manual discriminator approach (raw programs)
const USER_PROFILE_DISCRIMINATOR: [u8; 8] = [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x00, 0x00, 0x01];
const ADMIN_CONFIG_DISCRIMINATOR: [u8; 8] = [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x00, 0x00, 0x02];
#[derive(BorshSerialize, BorshDeserialize)]
pub struct UserProfile {
pub discriminator: [u8; 8], // First 8 bytes MUST match the constant
pub authority: Pubkey,
pub score: u64,
}
pub fn validate_user_profile(account: &AccountInfo, program_id: &Pubkey) -> Result<UserProfile, ProgramError> {
if account.owner != program_id {
return Err(ProgramError::IncorrectProgramId);
}
let data = UserProfile::try_from_slice(&account.data.borrow())
.map_err(|_| ProgramError::InvalidAccountData)?;
if data.discriminator != USER_PROFILE_DISCRIMINATOR {
return Err(ProgramError::InvalidAccountData);
}
Ok(data)
}
Anchor’s Automatic 8-Byte Discriminator
Account confusions are prevented in Anchor by implicitly assigning each #[account] a type with an 8-byte identifier. The #[account] macro computes this discriminator at compile time as the first 8 bytes of the SHA-256 hash of the string "account:<TypeName>". Anchor prepends a discriminator to each account and checks its type before deserializing it. This is why Anchor requires allocating 8 extra bytes when initializing accounts.
When an account is created, the discriminator is set as the first 8 bytes of the account’s data. When account data is deserialized, the first 8 bytes of account data is checked against the discriminator of the expected account type. If there’s a mismatch, it indicates that the client has provided an unexpected account. This mechanism serves as an account validation check in Anchor programs.
use anchor_lang::prelude::*;
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod vault_program {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let vault = &mut ctx.accounts.vault;
vault.authority = ctx.accounts.authority.key();
vault.balance = 0;
// Anchor automatically writes the discriminator for VaultAccount
// into the first 8 bytes of vault.data
Ok(())
}
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
let vault = &ctx.accounts.vault;
// By the time we're here, Anchor has already:
// 1. Checked vault.owner == program_id
// 2. Checked the first 8 bytes match VaultAccount's discriminator
// 3. Deserialized the remaining bytes into VaultAccount
require!(vault.balance >= amount, VaultError::InsufficientFunds);
// ... transfer logic
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(
init,
payer = authority,
space = 8 + VaultAccount::INIT_SPACE, // 8 bytes for discriminator
)]
pub vault: Account<'info, VaultAccount>,
#[account(mut)]
pub authority: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct Withdraw<'info> {
// Account<'info, VaultAccount> performs:
// - owner check (must be this program)
// - discriminator check (first 8 bytes must match VaultAccount)
// - Borsh deserialization
#[account(mut, has_one = authority)]
pub vault: Account<'info, VaultAccount>,
pub authority: Signer<'info>,
}
#[account]
#[derive(InitSpace)]
pub struct VaultAccount {
pub authority: Pubkey,
pub balance: u64,
}
#[error_code]
pub enum VaultError {
#[msg("Insufficient vault balance")]
InsufficientFunds,
}
Anchor first checks those 8 bytes to ensure they match the discriminator for account:VaultAccount. If they don’t match, it throws an error before any processing happens. This prevents you from accidentally trying to deserialize one account type as another.
Unlike EVM’s rigid standards, Solana gives developers the flexibility to implement their own solutions while frameworks like Anchor provide sensible defaults and powerful abstractions. The 8-byte discriminator isn’t just a random number — it’s part of a thoughtful system that ensures type safety and proper instruction routing in a flexible, high-performance blockchain environment.
PDA Derivation Security
Program Derived Addresses (PDAs) are a cornerstone of Solana’s account model. PDAs are derived by hashing seeds + program ID + bump via SHA-256 until the result is off the Ed25519 curve. The canonical bump is the first value that produces an off-curve address. Because the derived address is off the Ed25519 curve, no private key exists for it, meaning only the owning program can sign for it via invoke_signed.
Seed Collision
Seed collision occurs when two different logical entities in your program produce the same PDA. A naive implementation that does not include a user-identifying seed creates a single global account that all users share:
// VULNERABLE: All users map to the same PDA
// seeds = [b"user_account"] -> single PDA for the entire program
#[derive(Accounts)]
pub struct CreateUserAccount<'info> {
#[account(
init,
payer = user,
space = 8 + UserAccount::INIT_SPACE,
seeds = [b"user_account"], // ❌ No user pubkey in seeds
bump,
)]
pub user_account: Account<'info, UserAccount>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}
The fix is to include a user-unique discriminating value in the seeds:
// SECURE: Per-user PDA scoped to caller
#[derive(Accounts)]
pub struct CreateUserAccount<'info> {
#[account(
init,
payer = user,
space = 8 + UserAccount::INIT_SPACE,
seeds = [b"user_account", user.key().as_ref()], // ✅ User-scoped
bump,
)]
pub user_account: Account<'info, UserAccount>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}
PDA seed collisions — shared PDAs between users or functionalities — are one of the primary critical failures behind major Solana exploits.
Canonical Bump Canonicalization
Bump seed canonicalization refers to using the highest valid bump seed (i.e., canonical bump) when deriving PDAs. Using the canonical bump is a deterministic and secure way to find an address given a set of seeds. Failing to use the canonical bump can lead to vulnerabilities, such as malicious actors creating or manipulating PDAs that compromise program logic or data integrity.
Given a set of seeds, the create_program_address function will produce a valid PDA about 50% of the time. The bump seed is an additional byte added as a seed to “bump” the derived address into valid territory. Since there are 256 possible bump seeds and the function produces valid PDAs approximately 50% of the time, there are many valid bumps for a given set of input seeds.
An attacker can create a PDA with the correct program ID but with a different bump. Without any explicit check against the bump seed itself, the program leaves itself vulnerable to the attacker tricking the program into thinking they’re using the expected PDA when in fact they’re interacting with an illegitimate account.
Here is the vulnerable pattern:
// VULNERABLE: User supplies the bump, enabling non-canonical PDAs
pub fn deposit(ctx: Context<Deposit>, user_bump: u8, amount: u64) -> Result<()> {
// Attacker can craft a PDA with a different (non-canonical) bump
// creating a different account that passes the seeds check
let vault_pda = Pubkey::create_program_address(
&[b"vault", ctx.accounts.user.key().as_ref(), &[user_bump]],
ctx.program_id,
)?;
if vault_pda != ctx.accounts.vault.key() {
return err!(VaultError::InvalidVault);
}
// ... deposit logic
Ok(())
}
The correct approach uses find_program_address and stores the canonical bump:
// SECURE: Store the canonical bump at init time, reuse it on all subsequent instructions
#[account]
#[derive(InitSpace)]
pub struct VaultAccount {
pub authority: Pubkey,
pub balance: u64,
pub bump: u8, // Store canonical bump here
}
#[derive(Accounts)]
pub struct InitVault<'info> {
#[account(
init,
payer = user,
space = 8 + VaultAccount::INIT_SPACE,
seeds = [b"vault", user.key().as_ref()],
bump, // Anchor uses find_program_address → canonical bump automatically
)]
pub vault: Account<'info, VaultAccount>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}
pub fn init_vault(ctx: Context<InitVault>) -> Result<()> {
let vault = &mut ctx.accounts.vault;
vault.authority = ctx.accounts.user.key();
vault.balance = 0;
// Save canonical bump from context — not from user input
vault.bump = ctx.bumps.vault;
Ok(())
}
#[derive(Accounts)]
pub struct Deposit<'info> {
#[account(
mut,
seeds = [b"vault", user.key().as_ref()],
bump = vault.bump, // ✅ Use stored canonical bump — attacker cannot supply a different one
has_one = authority @ VaultError::Unauthorized,
)]
pub vault: Account<'info, VaultAccount>,
pub authority: Signer<'info>,
pub user: SystemAccount<'info>,
}
Anchor enforces the canonical bump for PDA derivations through its seeds and bump constraints, streamlining this entire process to ensure secure and deterministic PDA creation and validation. Always store the bump seed in the account data. This saves 1,500 CUs on every subsequent instruction that accesses the PDA, because the program skips the find_program_address loop.
is_signer and is_writable Constraints
The is_signer Vulnerability
Transactions are signed with a wallet’s private key to ensure authentication, integrity, non-repudiation, and the authorization of a specific transaction by a specific wallet. By requiring transactions to be signed with the sender’s private key, Solana’s runtime can verify that the proper account initiates a transaction and has not been tampered with.
Without this verification, any account that supplies the correct account as an argument can execute a transaction. This could lead to unauthorized access to privileged information, funds, or functionality.
The Wormhole exploit ($320M) occurred because the program checked a pubkey without verifying the is_signer flag. The vulnerability pattern looks like this:
// VULNERABLE: Checks pubkey equality but not is_signer
pub fn admin_action(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult {
let account_iter = &mut accounts.iter();
let config_account = next_account_info(account_iter)?;
let admin_account = next_account_info(account_iter)?;
let config = Config::try_from_slice(&config_account.data.borrow())?;
// ❌ This only checks that the admin pubkey was *included* in the accounts list
// It does NOT verify the admin signed the transaction
if config.admin != *admin_account.key {
return Err(ProgramError::InvalidAccountData);
}
// Attacker passes the admin pubkey as an account without signing
perform_privileged_action()?;
Ok(())
}
// SECURE: Check both identity AND signature
pub fn admin_action(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult {
let account_iter = &mut accounts.iter();
let config_account = next_account_info(account_iter)?;
let admin_account = next_account_info(account_iter)?;
// ✅ Owner check first
if config_account.owner != program_id {
return Err(ProgramError::IncorrectProgramId);
}
// ✅ Signer check — is_signer is set by the runtime, not the caller
if !admin_account.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
let config = Config::try_from_slice(&config_account.data.borrow())?;
if config.admin != *admin_account.key {
return Err(ProgramError::InvalidAccountData);
}
perform_privileged_action()?;
Ok(())
}
In Anchor, Signer<'info> enforces is_signer at the framework level:
#[derive(Accounts)]
pub struct AdminAction<'info> {
pub config: Account<'info, Config>,
// ✅ Anchor will error if admin didn't sign the transaction
pub admin: Signer<'info>,
}
The is_writable Constraint
Neglecting to assert is_writable before mutating an account is a subtler issue. If a caller passes an account as read-only but your program writes to it, the runtime will panic. More importantly, failing to declare accounts as writable in the instruction’s account metadata means a client can pass non-writable accounts that your program silently fails to modify.
// In Anchor, #[account(mut)] enforces is_writable at constraint validation time
#[derive(Accounts)]
pub struct UpdateBalance<'info> {
#[account(mut, has_one = authority)] // ✅ mut enforces is_writable
pub vault: Account<'info, VaultAccount>,
pub authority: Signer<'info>,
}
Account Data Matching Attacks
Beyond ownership and discriminators, programs must validate the content of account data. In the $52M Cashio hack, the protocol accepted any collateral token account without verifying the mint. Attackers deposited worthless tokens and withdrew real $CASH. Token accounts all share the same structure (mint, owner, amount, etc.). If you only check the amount, an attacker can deposit 1 billion tokens they created and withdraw real value.
The has_one Constraint in Anchor
has_one is one of the most powerful Anchor constraints for data matching. It verifies that a field inside the target account’s data matches the key of another account in the same context:
#[derive(Accounts)]
pub struct MintCash<'info> {
#[account(
mut,
has_one = collateral_mint, // ✅ vault.collateral_mint must == collateral_mint.key()
has_one = authority, // ✅ vault.authority must == authority.key()
)]
pub vault: Account<'info, CashVault>,
// Anchor ensures this mint account's key matches vault.collateral_mint
pub collateral_mint: Account<'info, Mint>,
// Anchor ensures this signer's key matches vault.authority
pub authority: Signer<'info>,
// SPL Token constraint: validates this is a valid token account for collateral_mint
#[account(
mut,
token::mint = collateral_mint, // ✅ token account must hold collateral_mint tokens
token::authority = authority,
)]
pub collateral_token_account: Account<'info, TokenAccount>,
pub token_program: Program<'info, Token>,
}
Without the has_one = collateral_mint constraint and the token::mint SPL constraint, an attacker could substitute a collateral account for any mint they control — exactly the Cashio attack vector. As part of the validation process, the contract checked that the token type matched that of the saber_swap.arrow account. However, there was no validation of the mint field within the saber_swap.arrow account. The attacker could make a fake account that allowed the deposit of worthless collateral.
Custom constraint Expressions
For data relationships not covered by has_one, use the constraint attribute:
#[derive(Accounts)]
pub struct ClosePosition<'info> {
#[account(
mut,
has_one = owner,
// Custom constraint: position must be in a closeable state
constraint = position.status == PositionStatus::Settled @ TradeError::PositionNotSettled,
// Additional: validate position belongs to this market
constraint = position.market == market.key() @ TradeError::WrongMarket,
)]
pub position: Account<'info, Position>,
pub market: Account<'info, Market>,
pub owner: Signer<'info>,
}
Account Reuse Across Instructions
A subtle vulnerability arises when the same account can be passed in multiple positions within a single instruction context. Consider a balance transfer between two vaults:
// VULNERABLE: Allows source == destination
#[derive(Accounts)]
pub struct Transfer<'info> {
#[account(mut, has_one = authority)]
pub from_vault: Account<'info, VaultAccount>,
#[account(mut)]
pub to_vault: Account<'info, VaultAccount>,
pub authority: Signer<'info>,
}
pub fn transfer(ctx: Context<Transfer>, amount: u64) -> Result<()> {
// If from_vault and to_vault are the same account, we have a problem:
// deducting from_vault.balance and then adding to to_vault.balance
// references the same underlying data. Depending on borrow semantics,
// this can produce incorrect results or phantom balance increases.
ctx.accounts.from_vault.balance -= amount;
ctx.accounts.to_vault.balance += amount;
Ok(())
}
The fix is to explicitly assert that the two accounts are distinct:
#[derive(Accounts)]
pub struct Transfer<'info> {
#[account(
mut,
has_one = authority,
constraint = from_vault.key() != to_vault.key() @ VaultError::SameAccount,
)]
pub from_vault: Account<'info, VaultAccount>,
#[account(mut)]
pub to_vault: Account<'info, VaultAccount>,
pub authority: Signer<'info>,
}
Solana’s account model means state can be manipulated in ways Ethereum developers don’t expect. Stale data, duplicate accounts, and improper closing all create vulnerabilities.
Anchor Account Macros vs. Raw Solana Programs
The table below summarises what Anchor’s Account<'info, T> buys you automatically compared to raw AccountInfo:
| Check | Raw AccountInfo | Anchor Account<'info, T> |
|---|---|---|
| Owner == program_id | ❌ Manual | ✅ Automatic |
| Discriminator match | ❌ Manual | ✅ Automatic |
| Borsh deserialization | ❌ Manual | ✅ Automatic |
is_signer | ❌ Manual | ✅ via Signer<'info> |
is_writable | ❌ Manual | ✅ |