Solana Program Security: The Vulnerability Classes That EVM Tools Miss

Most auditors arrive at Solana programs with an EVM mental model. They look for reentrancy, integer overflow, missing access control. These concerns translate partially — Solana does have access control vulnerabilities — but the execution model introduces a distinct set of failure modes that EVM tools cannot detect.

This post covers five vulnerability classes that are specific to Solana’s account model: arbitrary cross-program invocations, missing signer verification, account owner validation, type cosplay, and PDA seed collisions. Each one exploits assumptions that don’t exist in the EVM context.

The Solana Account Model

Solana separates code from state. Programs are stateless — they process instructions but own no data. Accounts are data containers — they hold state and are owned by programs. Transactions specify which accounts are signing.

Every instruction handler receives a list of accounts. The program is responsible for validating that those accounts are what the caller claims they are. The runtime validates signatures but validates nothing else automatically. On Solana, the program validates every account it receives. When that validation is absent or incomplete, attackers pass crafted accounts.

Arbitrary Cross-Program Invocation

Solana programs call other programs through the CPI mechanism. The target program ID is passed as an account. If that program ID is not checked against a known value, the attacker substitutes their own program.

// Vulnerable: token_program comes from caller-controlled input
fn transfer_tokens(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
    let token_program = &accounts[3];

    // Invokes whatever program the caller specified
    token::transfer(
        CpiContext::new(token_program.clone(), ...),
        amount,
    )?;
    Ok(())
}

The attacker’s program receives the CPI and can log signer seeds, modify return values, or drain accounts passed to it with signer privilege via invoke_signed.

// Fixed: validate program ID before any CPI
fn transfer_tokens(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
    let token_program = &accounts[3];

    if token_program.key != &spl_token::ID {
        return Err(ProgramError::IncorrectProgramId);
    }

    token::transfer(
        CpiContext::new(token_program.clone(), ...),
        amount,
    )?;
    Ok(())
}

Compare program.key against the expected constant before any CPI. For programs that legitimately call multiple targets, enumerate the allowed program IDs.

Detection: Trace data flow from instruction accounts to CPI call targets. When a program ID reaches invoke or invoke_signed without passing through a comparison against a known constant, flag arbitrary CPI.

Missing Signer Verification

Transactions declare which accounts are signers. But the program must check the is_signer field before trusting that declaration.

// Vulnerable: authority used without checking is_signer
fn update_config(accounts: &[AccountInfo], new_value: u64) -> ProgramResult {
    let config = &accounts[0];
    let authority = &accounts[1];

    let mut config_data = Config::try_from_slice(&config.data.borrow())?;

    // Key comparison without signer check
    // Attacker passes the correct public key without signing
    if config_data.authority != *authority.key {
        return Err(ProgramError::InvalidAccountData);
    }

    config_data.value = new_value;
    config_data.serialize(&mut &mut config.data.borrow_mut()[..])?;

    Ok(())
}

The key comparison passes even when authority.is_signer is false. An attacker who knows the authority public key includes it in the transaction without providing the corresponding signature. The check passes; the operation executes without authorization.

// Fixed: check is_signer first
fn update_config(accounts: &[AccountInfo], new_value: u64) -> ProgramResult {
    let config = &accounts[0];
    let authority = &accounts[1];

    if !authority.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
    }

    let mut config_data = Config::try_from_slice(&config.data.borrow())?;

    if config_data.authority != *authority.key {
        return Err(ProgramError::InvalidAccountData);
    }

    config_data.value = new_value;
    config_data.serialize(&mut &mut config.data.borrow_mut()[..])?;

    Ok(())
}

The key check is necessary but not sufficient. Both the key comparison and the signer check are required together — one without the other leaves an exploitable gap.

Detection: Build a control flow graph of each instruction handler. Track which accounts flow into privileged operations (state writes, CPI calls, authority comparisons). For each such account, check whether an is_signer verification exists in the execution path before the privileged operation.

Account Owner Validation

Each account has an owner field — the program authorized to modify it. The owner field is set by the Solana runtime and cannot be spoofed. Only the owning program can write to an account.

When programs deserialize account data without checking the owner, an attacker passes an account owned by a different program with crafted data:

// Vulnerable: deserializes without ownership check
fn process_stake(accounts: &[AccountInfo]) -> ProgramResult {
    let stake_account = &accounts[0];

    // Attacker can pass any account — including one they control
    let stake_data = StakeRecord::try_from_slice(&stake_account.data.borrow())?;

    reward_tokens(stake_data.owner, stake_data.amount)?;

    Ok(())
}

If StakeRecord has a compatible binary layout with some other account type — or if the attacker crafts bytes that deserialize without error — the program acts on attacker-controlled values.

// Fixed: verify owner before deserializing
fn process_stake(accounts: &[AccountInfo]) -> ProgramResult {
    let stake_account = &accounts[0];

    if stake_account.owner != program_id {
        return Err(ProgramError::IncorrectProgramId);
    }

    let stake_data = StakeRecord::try_from_slice(&stake_account.data.borrow())?;
    reward_tokens(stake_data.owner, stake_data.amount)?;

    Ok(())
}

If the owner is the expected program, the data was written by that program — not by an attacker.

Type Cosplay (Discriminator Spoofing)

Even with owner checks, programs that don’t validate a type discriminator can be tricked into treating one account type as another.

Anchor programs automatically prefix each account with an 8-byte discriminator derived from the account type name. The vulnerability appears when code reads account data without first verifying those leading bytes:

// AdminAccount and UserAccount have the same field layout
#[account]
pub struct AdminAccount {
    pub authority: Pubkey,   // 32 bytes at offset 8 (after discriminator)
    pub permissions: u64,    // 8 bytes at offset 40
}

#[account]
pub struct UserAccount {
    pub owner: Pubkey,       // 32 bytes at offset 8 — same layout
    pub balance: u64,        // 8 bytes at offset 40 — same layout
}

// Without discriminator validation, a UserAccount can be passed
// where an AdminAccount is expected
fn admin_action(ctx: Context<AdminAction>) -> Result<()> {
    let admin = &ctx.accounts.admin_account;
    // If discriminator was not checked, attacker passes their UserAccount
    // as the admin account — and the data reads cleanly
    Ok(())
}

Anchor’s Account<'_, AdminAccount> constraint validates the discriminator automatically during deserialization. Bypasses occur when accounts are deserialized using raw try_from_slice instead of typed deserialization, or when a developer skips the discriminator check for performance.

The fix is to use Anchor’s typed account constraints consistently and avoid bypassing them with raw deserialization on performance-critical paths.

Detection: Scan for account data reads at offsets beyond position 0 without a prior comparison of the leading bytes against an expected discriminator constant. Confidence increases when account types with identical field layouts coexist in the same program.

PDA Seed Collision

Program Derived Addresses are deterministic: they are generated from seeds and a program ID. A seed collision occurs when two logically distinct PDAs can be derived from the same seed combination.

// Vulnerable: user_type is used for authorization but not included in seeds
fn create_user_vault(
    accounts: &[AccountInfo],
    user_type: u8, // 0 = regular, 1 = admin
) -> ProgramResult {
    let vault = &accounts[0];

    // Seeds do not include user_type
    // Regular user vault and admin vault for the same user
    // derive to the same address
    let expected_vault = Pubkey::create_program_address(
        &[b"vault", accounts[1].key.as_ref()],
        program_id,
    )?;

    if vault.key != &expected_vault {
        return Err(ProgramError::InvalidArgument);
    }

    // Attacker creates a vault under the regular user flow
    // then uses it in admin functions — same derived address
    Ok(())
}

The attacker creates a PDA under the regular user flow and presents it where an admin PDA is expected — same seed inputs, same derived address.

// Fixed: include all discriminating context in seeds
fn create_user_vault(
    accounts: &[AccountInfo],
    user_type: u8,
) -> ProgramResult {
    let vault = &accounts[0];

    // user_type in seeds means each role gets a distinct PDA
    let expected_vault = Pubkey::create_program_address(
        &[b"vault", accounts[1].key.as_ref(), &[user_type]],
        program_id,
    )?;

    if vault.key != &expected_vault {
        return Err(ProgramError::InvalidArgument);
    }

    Ok(())
}

Every value that participates in authorization logic must also participate in seed derivation.

Detection: Extract seed arrays from create_program_address and find_program_address calls. Map them to the authorization conditions that depend on the resulting PDA. When authorization logic uses values that do not appear in the corresponding seed array, flag a potential collision.

Bytecode Analysis for Solana Programs

Solana programs compile to BPF (Berkeley Packet Filter) bytecode — a RISC-style instruction set. The analysis pipeline decompiles BPF bytecode through multiple stages: disassembly, control flow graph construction, HIR lifting, and account analysis.

The account analysis stage is the key difference from EVM analysis. It recovers account access patterns, tracks which instruction accounts flow to which operations, and constructs the signer and owner validation graph that drives the vulnerability detectors above.

This approach works regardless of the program’s origin:

  • Programs deployed without publishing an IDL file are analyzed from bytecode alone
  • On-chain programs may differ from the open-source code used in audits — bytecode analysis reflects what actually executes
  • Anchor-generated programs and native programs both compile to BPF and are analyzed identically

Vulnerability Summary

VulnerabilityRoot CauseSeverityDetection Signal
Arbitrary CPINo program ID validation before invokeCriticalCPI target not compared to constant
Missing signer checkis_signer not verified before privileged opCriticalAccount used in write path without signer branch
Account owner validationowner field not verified before deserializationHighDeserialization without prior owner equality check
Type cosplayDiscriminator not checked at deserializationHighData read at offset > 0 without discriminator comparison
PDA seed collisionAuthorization data absent from seed derivationMediumSeed array missing values used in auth conditions

Missing signer checks and arbitrary CPI are the two most commonly exploited vulnerability classes in Solana programs — they often combine, since an arbitrary CPI can forward signer privileges from accounts that weren’t meant to authorize the external call.

References

  1. Solana Program Security Best Practices — Solana Labs
  2. Arbitrary CPI — Anchor Security
  3. Missing Owner Checks — Solana Security Workshop
  4. Type Cosplay — Solana Security Workshop
  5. PDA Sharing — Solana Security Workshop
  6. Sealevel Attacks Repository