On the EVM, a contract owns its storage and nobody can hand it a different one. On Solana, a program owns nothing at runtime. Every account it reads or writes arrives as a parameter chosen by whoever built the transaction, and the program's only defence is to check each one before trusting it. An attacker reading a Solana program is not looking for reentrancy or a storage collision. They are looking for the one account the program forgot to check, because that account is theirs to supply.

Most Solana findings are not in the business logic; they are in the preconditions the logic assumed and never verified. The account is the wrong type, the wrong owner, not signed, not the one the PDA derivation produces, or the same account passed twice. Each is a complete bypass of whatever the instruction was meant to protect.

This article is for teams writing Rust programs on Solana, with Anchor or without, and for auditors coming from the EVM who need to re-learn where the bugs live.

The account model is the attack surface

A Solana instruction receives a list of AccountInfo values: a public key, a lamport balance, a data buffer, an owner program, and signer and writable flags. The runtime guarantees only that the owner program alone can modify data or debit lamports, that the signer flag is set only if the key signed, and that writable accounts are locked for the transaction. Everything else is the program's responsibility.

So every handler begins with assertions, and the program's security is their completeness. For each account: who should own it, what type its data should be, whether it must have signed, whether it must be a specific address or PDA, and how it relates to the other accounts. Any question left unanswered is a parameter the attacker controls.

Our first pass on any Solana engagement is a matrix: every instruction down the side, every account it takes across the top, the checks performed in each cell. Empty cells are the findings list before we have read any logic.

Missing owner and signer checks

Owner checks

A program deserialises an account's data and acts on the fields. If it does not first confirm the account is owned by itself, an attacker creates an account under their own program, writes whatever bytes they like, and passes it in. A vault with an authority field is the classic target: the attacker fabricates one whose authority is their key.

The check compares account.owner against the program ID before any field is read. For accounts owned by other programs, such as token accounts, the check is against that program's ID, ideally by deserialising through that program's own types.

Signer checks

An instruction acting on behalf of an authority must verify that the authority signed. The failure is comparing authority.key to the stored authority, which proves the right key was named, without checking authority.is_signer, which proves the key consented. Anyone can name any public key in a transaction; without the signer check, an attacker withdraws from every vault by naming its authority.

Both checks are one line each. They are missed because the developer tested with a correct client that always passed the right accounts.

Type confusion and account substitution

Two accounts owned by the same program can have different layouts: a pool and a user position, a config and a vault. If the program deserialises by expected layout rather than by a stored type tag, an attacker passes one type where another is expected. The fields line up by offset, not by meaning. A user-controlled position whose balance bytes match the attacker's key, passed where a config with an admin pubkey at the same offset is expected, makes the attacker admin.

The defence is a discriminator: a type tag at a fixed position in every account, checked before deserialisation. Anchor writes and checks one for typed accounts; raw programs must do the same on every deserialisation path.

Account data matching

Beyond type, accounts have relationships: a token account must belong to the user named in the instruction, a position must reference the pool being traded, a vault must be the one the config points to. The runtime does not know accounts are related, so each relationship must be asserted, or another pool's vault goes into a withdrawal and another user's position into a liquidation. We trace every pubkey stored in account data to the instructions that use it and check that each compares it to the account actually passed.

Duplicate mutable accounts

An instruction that takes two accounts of the same type, such as a transfer between two positions, can be passed the same account for both. The handler reads both, computes new balances, writes both, and the second write overwrites the first: a balance debited then restored, or credited twice. The check compares the two keys before any mutation, on every instruction with two mutable accounts of the same type. Anchor does not add this automatically; it is a constraint the developer must write.

Sysvar spoofing

Older patterns accepted sysvars such as the clock as an account and deserialised it; if the address was not checked against the known sysvar ID, the attacker passed a fabricated account with a chosen timestamp and time-locked logic unlocked on demand. The current pattern reads sysvars through the get() syscall, which cannot be spoofed. Where a sysvar is still passed as an account, the address must be checked, and in Anchor it must be typed as that sysvar.

Anchor versus raw programs

Anchor moves most account checks into declarative constraints on the accounts struct: #[account(mut, has_one = authority, seeds = [...], bump)] expresses ownership, relationship and PDA checks in one place and generates the assertions. It removes the most common failure class, and we recommend it for that reason.

It does not remove the auditor's job; the failures move. UncheckedAccount used where a typed account was needed skips every check. has_one references the wrong field. seeds omit a parameter and match more accounts than intended. init_if_needed without a guard reopens the reinitialisation problem below. constraint = ... expressions are wrong the way any hand-written boolean can be. We read the accounts struct as carefully as the handler, because it is where the security is. Raw programs have no safety net, and the matrix is how we keep hand-written checks tractable.

Arithmetic and rounding

Rust integer arithmetic wraps silently in release builds unless overflow-checks is enabled in the profile, and Solana programs ship in release mode. Bare +, - and * on balances will wrap, and a wrapped subtraction turns a small balance into an enormous one. The defence is the checked_ family with the None case handled as an error, or overflow checks enabled in the release profile. We grep for bare operators on any monetary value and treat each as a finding until shown otherwise.

Rounding follows the EVM rules: every division favours someone, and it must favour the protocol. Share calculations, interest accrual, fees and decimal conversions all have a direction, and we check that no sequence of small operations extracts the rounding error. Casts between widths, such as a u128 intermediate truncated to u64, are another silent wrap; try_from with error handling is the correct form.

PDAs and authority

A program-derived address is a public key with no private key, derived from a program ID and seeds. The program signs for it in cross-program invocations by supplying the seeds. PDAs are how programs hold token accounts and act as authorities, and they are where authority bugs concentrate.

Seed collisions and missing seeds

If a PDA's seeds omit a parameter that should make it unique, two logical entities derive the same address. A position seeded by the user but not the pool is shared across pools, and the attacker creates it in one context and uses it in another. Seeds must include every distinguishing field, and the program must verify the passed account matches the derivation, including the bump.

Canonical bumps

find_program_address returns the canonical bump; create_program_address accepts any bump. A program that accepts a caller-supplied bump without checking it is canonical allows multiple valid addresses for the same seeds, and so multiple accounts where there should be one. Store the canonical bump at initialisation and check against it.

Reused signer seeds across CPIs

When a program calls invoke_signed, it signs as the PDA. If one PDA is the authority for many token accounts or downstream programs, any instruction that can be induced to sign with those seeds can act on all of them. A program with one global authority PDA has one key that unlocks everything it holds. Scope PDAs to what they must control, and check that the seeds an instruction signs with correspond to the accounts it is supposed to touch.

Cross-program invocation and privilege escalation

CPI passes accounts and signer privileges to another program. The questions are what the callee is, and what the caller is signing for.

An instruction that accepts the token program as an account and invokes it without confirming the address will invoke whatever program the attacker supplies, which receives the accounts, including any the caller signed for with PDA seeds. Every program ID passed in must be compared to a constant, or typed as that program in Anchor.

Signer privileges propagate: when the caller signs for a PDA and passes it on, the callee can pass it further. Escalation happens when a program signs with a powerful PDA for an invocation that does not need it, and the callee uses that signature for something else. Sign only with the seeds the invocation requires.

Reentrancy is constrained on Solana: a program can only be re-entered by direct self-recursion to a fixed depth, so EVM patterns largely do not apply. What does apply is state read before a CPI and assumed unchanged after; if the callee can modify an account the caller holds, the caller must re-read it.

Reinitialisation and account closing

Reinitialisation

An initialize instruction that writes fresh state without checking whether the account was already initialised lets an attacker reset a live account: a vault's authority becomes their key. The check is a discriminator or flag that must be unset, or in Anchor the init constraint, which fails if the account already has data. init_if_needed reopens this, and every use needs a justification.

Closing and revival

Closing an account means transferring its lamports out so the runtime garbage-collects it at the end of the transaction. If the program only drains lamports and does not zero the data, the account still exists for the rest of the transaction with its old data. An attacker sends lamports back in the same transaction, the runtime does not collect it, and the account is revived with its pre-close state. A closed position with its balance restored is a double withdrawal.

The correct close zeroes the data, sets a closed discriminator, and transfers the lamports, and every other instruction rejects the closed discriminator. Anchor's close constraint does this; raw programs must do all three steps.

Compute budget and upgrade authority

Denial of service

Every transaction has a compute budget, and an instruction that exceeds it fails. An attacker forces that by making the work depend on state they control: a loop over an unbounded list of positions, a vector in account data that grows without limit, a batch size chosen by the caller. An instruction that always fails is, for a liquidation or withdrawal, funds locked. We look for every loop and variable-length structure, ask who controls its size, and check the bound is enforced in the program. Account growth via realloc has a maximum and a rent cost, both attacker-influenced if growth is user-triggered.

The upgrade key

Solana programs are upgradeable by default, and the upgrade authority can replace the code in one transaction with no delay. Until it is a multisig behind a governance process, or removed to make the program immutable, the program's security is that of one key. We ask who holds it, what gates an upgrade, whether users can see one coming, and whether deployment has been rehearsed. A program with a perfect audit and a hot-wallet upgrade authority is one phishing email from arbitrary code. The same applies to any admin PDA whose authority is a single key.

How we audit Solana programs

  • Account matrix: every instruction against every account, with owner, signer, type, PDA and relationship checks per cell.
  • Constraint review: for Anchor, the accounts structs line by line; for raw programs, every assertion against the matrix.
  • Arithmetic sweep: every bare operator and cast on a monetary value.
  • PDA audit: seeds, bumps, and which instructions sign with which seeds.
  • CPI review: every invoked program ID and every set of signer seeds passed.
  • Lifecycle tests: reinitialisation, close-and-revive and duplicate accounts attempted against every instruction.
  • Budget and upgrade: loops and growth bounds, and the key behind the upgrade authority.

High and Critical findings include a transaction against a local validator or a mainnet fork that demonstrates the unauthorised outcome.

Next steps

Build the account matrix for your program before anyone else does. It takes an afternoon and is the most productive exercise in Solana security. Then enable overflow checks in your release profile, replace bare arithmetic on balances, and review every init_if_needed, UncheckedAccount and invoke_signed. For the broader checklist we run on every engagement, see the 2026 smart contract audit checklist, and for the economic patterns that apply on every chain, see DeFi exploit patterns you will see again.

Our Solana audit scope is on the services page; reach the team on Telegram.