The First Thing I Look at in Every Solana Program Audit

Robert Reith · Sep 15, 2026 · 6 min read
The First Thing I Look at in Every Solana Program Audit
Tags · security · solana · anchor · audit

I've audited over 100 Solana programs and looked through many more. I've found more bugs than I can count.

Some took days to understand. Others were critical and obvious within a few minutes of opening the repo.

When I first open a program, I usually start with four things: CPIs, account lifecycle, remaining_accounts, and signature verification. These are all places where the program is doing security-critical checks by hand. A lot of the obvious criticals end up in one of those four buckets.

Anchor validates the accounts declared in an instruction context before it runs the handler. Once the code drops down to raw AccountInfos, manual parsing, dynamic CPIs, or the Instructions sysvar, more of the security model becomes the developer's responsibility. Anchor documents that validation order here.

Before reading much code, I usually run something close to this:

rg -n 'invoke(_signed)?|CpiContext::new|new_with_signer|remaining_accounts|Instructions|load_(current_index|instruction_at)_checked|get_instruction_relative|ed25519|secp256(k1|r1)|UncheckedAccount|AccountInfo|try_from_unchecked|init(_if_needed)?|close\s*=|realloc'

Then I start opening matches.

1. Grep every CPI, especially invoke_signed

I search for invoke, invoke_signed, CpiContext::new, new_with_signer, and any local wrappers around them.

A Solana Instruction contains the program_id of the program being called. invoke_signed also lets the caller's PDAs act as signers. Signer and writable privileges propagate into the CPI, while the runtime prevents the callee from escalating beyond the privileges it received. The Solana CPI docs describe those rules.

If the target program isn't pinned, those accounts and privileges go to whichever executable program the caller supplied. If a PDA signed the CPI, the attacker-selected callee receives that signer privilege too. It still has to obey the normal runtime ownership rules, but the authority you forwarded is usually enough to cause trouble.

For a fixed callee, use a typed Program<'info, T>, an appropriate Interface, or an explicit allowlist. Current Anchor docs say Program<'info, T> checks the expected program ID and the executable flag. A generic Program<'info> checks only that the account is executable. That difference matters.

Then I check:

  • Which accounts are writable?
  • Which accounts arrive as signers?
  • Are the signer seeds derived from the state I expect?
  • Is one broad PDA authority reused across unrelated actions?
  • Can any account passed into the CPI alias another account?

Checking the program ID and ignoring the forwarded authority is only half the review.

2. Open every init and close handler

Next I list every init, init_if_needed, realloc, migration, and close.

These handlers define most of the account lifecycle: creation, resizing, migration, and deletion. Small mistakes tend to survive because each handler looks reasonable on its own.

Anchor's init constraint creates an account and sets its discriminator. Its close constraint sends the lamports to a target and resets the account data. Those are the mechanics Anchor provides. The protocol still has to decide who may create or destroy the object, which relationships become permanent, and when the transition is allowed.

For every initializer, I ask:

  • Can the first caller choose the authority?
  • Are the PDA seeds bound to the correct user, mint, market, or parent state?
  • Can a raw initialization path overwrite an existing account?
  • Does every field get initialized?
  • Can default values accidentally represent a valid state?

I give init_if_needed its own pass. It combines a creation path and an existing-account path in one instruction. Anchor's derive documentation explicitly warns about reinitialization attacks and recommends separating initialization from use unless there is a good reason to combine them. Read both flows as different instructions, even when the code puts them in one.

For every close, I trace:

  • who is allowed to close
  • who receives the lamports
  • which state must be settled first
  • whether other accounts still reference the closed account
  • what happens if the same PDA is created again

I treat reallocations and migrations the same way. Check the old schema, the new schema, size math, payer or refund destination, zeroing behavior, and every invariant that must survive the transition.

3. Audit every use of remaining_accounts

Anchor's docs are explicit: remaining_accounts are not deserialized or validated.

So I treat every loop over that slice as a wire-format parser. I write down the expected schema, then check:

  • exact account count
  • group width and ordering
  • owner and discriminator, or the expected native account type
  • signer and writable requirements
  • PDA derivation
  • relationships to the typed accounts
  • token mint and authority
  • duplicate accounts and aliasing
  • every program ID later used for a CPI

If the code parses accounts in groups of three, it must reject any length that isn't divisible by three. A chunks_exact(3) loop that never checks the remainder can silently ignore trailing accounts.

Position is not validation either. "The fourth account is the vault" only means something after the code proves that account is actually the expected vault.

4. Read every signature precompile and Instructions sysvar check

Signature verification code is another place where I regularly find serious bugs.

Solana's Ed25519, Secp256k1, and Secp256r1 precompiles verify signatures as native transaction instructions. They cannot be called through CPI. A program that relies on one normally reads the transaction through the Instructions sysvar and checks that a specific precompile instruction verified the expected signer and message. The precompile formats are documented here.

A valid signature can still authorize the wrong key, the wrong message, or an action that can be replayed.

For example, the Ed25519 instruction contains separate offsets and instruction indexes for the signature, public key, and message. Each value can come from another instruction in the transaction. If the protocol parses the instruction as if everything were inline but never constrains those indexes and offsets, the precompile may verify different bytes from the ones the protocol thinks it verified.

I check:

  • the exact Instructions sysvar address
  • the current instruction index and intended relative position
  • the exact precompile program ID
  • signature count, padding, offsets, indexes, and lengths
  • the exact public key or recovered address
  • the exact message bytes, not just a prefix
  • whether Secp256k1 hashing matches what the signer actually signed
  • domain separation, action type, account addresses, amount, nonce, and expiry
  • whether the same signature can be replayed in another instruction, account, program, or protocol

Use the checked sysvar loaders where possible. load_instruction_at_checked loads an instruction at an absolute transaction index and rejects an account that is not the real Instructions sysvar. You still have to validate the instruction it returns.

A valid signature only authorizes the bytes that were signed. I want to know exactly which bytes those were, what they mean, and where the same signature can be used again.

Only after that do I start digging into protocol math, accounting, and economic invariants. The first pass gives me a map of where the program accepts raw input, forwards authority, creates or destroys state, and trusts transaction-level signature checks. It makes the deeper review much faster.