Solana program security: every account is an input
Solana programs receive their state as a list of accounts chosen by the caller. Most serious bugs come from trusting that list. The checks every instruction needs, and how Anchor helps and where it does not.
- Written by
- Quantix Research, Smart Contract Security
- Published
- Reading time
- 6 minutes
- Category
- Smart Contract Vulnerabilities
Primer. Explains a known class of issue. Code is simplified and illustrative, not for production use.

Contents
On the EVM, a contract’s state lives inside the contract, and a function reads it directly. On Solana, a program is stateless code; all state lives in separate accounts, and every instruction receives the accounts it may read or write as a list supplied by the caller. The runtime enforces some rules, such as which program may modify an account’s data, but whether the accounts in the list are the ones the program expects is the program’s problem.
That design is the source of the most common serious Solana bugs. This primer covers the checks every account needs, how the Anchor framework expresses them, where its protection ends, and what public incidents show about the cost of a missing check.
Accounts in, accounts out
Every Solana account has an address, a balance in lamports, a data buffer and an owner, which is the program allowed to modify its data and debit its lamports. A program can read any account passed to it. It can only write to accounts it owns, and it can sign for program derived addresses (PDAs) derived from its own program ID.
An instruction’s account list is chosen by whoever builds the transaction. If a program expects “the vault’s state account” and receives an account with the same data layout created by the attacker, the program will read the attacker’s data unless it checks. Every field it then trusts, an authority, a token account, a price, is a value the attacker wrote.
The checks every account needs
Five properties cover most cases:
- Signer. Accounts whose authority is being exercised must have signed the transaction.
- Owner. Accounts whose data the program trusts must be owned by the program that is supposed to write that data.
- Type. The data must be of the expected type, usually checked with a discriminator, so one of the program’s own account types cannot be passed as another.
- Address or derivation. PDAs must be re-derived from their seeds; fixed accounts such as programs and sysvars must match their known addresses.
- Relationships. Accounts that refer to each other must agree, for example a token account’s mint and holder must match the vault’s stored values.
Owner and type confusion
The native program below contains four common omissions in a few lines. The code is reduced to the essentials.
// Simplified, illustrative native program. Not production code.pub fn withdraw(program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult { let it = &mut accounts.iter(); let vault_state = next_account_info(it)?; // expected: Vault data owned by this program let authority = next_account_info(it)?; let vault_tokens = next_account_info(it)?; // token account held by the vault PDA let destination = next_account_info(it)?; let vault_signer = next_account_info(it)?; // the PDA that signs for vault_tokens let token_program = next_account_info(it)?; // (1) No owner check: anyone can create an account with this exact layout. let vault = Vault::try_from_slice(&vault_state.data.borrow())?; // (2) No is_signer check: knowing the authority's public key is enough. if vault.authority != *authority.key || vault.tokens != *vault_tokens.key { return Err(ProgramError::InvalidAccountData); } // (3) token_program is never compared with spl_token::ID. let ix = spl_token::instruction::transfer( token_program.key, vault_tokens.key, destination.key, vault_signer.key, &[], amount)?; // (4) Seeds do not include the vault, so one PDA signs for every vault. invoke_signed( &ix, &[vault_tokens.clone(), destination.clone(), vault_signer.clone(), token_program.clone()], &[&[b"vault-signer", &[vault.signer_bump]]], )}An attacker creates an account under their own program with the same layout as Vault, writes their own key as authority and the real vault’s token account as tokens, and passes it as vault_state. The authority check passes because the attacker chose the stored authority. The program then signs a transfer from the real token account with its PDA, which it can do because, through omission (4), the signer’s seeds do not depend on which state account was passed. Omission (2) would allow a simpler attack against a real vault: naming its authority without that authority’s signature. Omission (3) lets the attacker substitute a program of their own for the token program.
Type confusion is the same problem inside one program. If a program has two account types with compatible layouts, such as a user record and an admin record, and does not check a discriminator, one can be passed where the other is expected. The Sealevel Attacks repository calls this “type cosplay.”
The same instruction in Anchor
Anchor expresses account requirements declaratively and generates the checks. Account<T> checks the owner and discriminator; Signer checks the signature; Program<T> checks the program ID; constraints such as has_one, seeds and bump check relationships and derivations.
// The same instruction with Anchor account constraints (illustrative).#[derive(Accounts)]pub struct Withdraw<'info> { #[account(has_one = authority, has_one = tokens)] pub vault: Account<'info, Vault>, // owner = this program, discriminator = Vault pub authority: Signer<'info>, // must have signed the transaction #[account(mut)] pub tokens: Account<'info, TokenAccount>, // owner = SPL Token program #[account(mut, token::mint = tokens.mint)] pub destination: Account<'info, TokenAccount>, /// CHECK: only used as a PDA signer; address verified by seeds and stored bump #[account(seeds = [b"vault-signer", vault.key().as_ref()], bump = vault.signer_bump)] pub vault_signer: UncheckedAccount<'info>, pub token_program: Program<'info, Token>, // key = the SPL Token program ID}PDAs, seeds and canonical bumps
A PDA is an address derived from a program ID and a list of seeds that is guaranteed not to lie on the ed25519 curve, so no private key exists for it and only the program can sign for it. Derivation tries bump values from 255 downward and returns the first that produces a valid off-curve address: the canonical bump. Other bump values may also produce valid PDAs for the same seeds.
Two consequences follow. A program that accepts a caller-supplied bump without checking that it is canonical may accept several different accounts as “the” PDA for a set of seeds. And seeds must include enough context to make a PDA unique to its purpose: a vault signer derived only from a constant string is shared by every vault, so authority over one is authority over all. The Sealevel Attacks repository names these bump seed canonicalisation and PDA sharing.
Cross-program invocation
When a program calls another program, it passes accounts and, for PDAs, its signature. If the target program’s ID comes from the caller and is not checked, the attacker can substitute a program that does something else with the signed authority, or simply reports success. Programs should invoke only known program IDs, and Anchor’s Program<T> type enforces that. A related subtlety is that accounts modified by a CPI must be reloaded before the calling program reads them again.
A catalogue of account bugs
| Class | What goes wrong | Check |
|---|---|---|
| Signer authorisation | Authority named but did not sign | is_signer / Signer |
| Account data matching | Accounts do not belong together | Compare stored keys; has_one, constraint |
| Owner checks | Data read from an account the attacker owns | Owner = expected program; Account<T> |
| Type cosplay | One account type passed as another | Discriminator check |
| Initialisation | An existing account is re-initialised | Initialise once; Anchor init |
| Arbitrary CPI | Caller chooses the invoked program | Compare program ID; Program<T> |
| Duplicate mutable accounts | Same account passed twice, e.g. as source and destination | Require distinct keys |
| Bump seed canonicalisation | Non-canonical bump accepted | Derive or store the canonical bump |
| PDA sharing | One PDA signs for many users or vaults | Include unique seeds such as the vault key |
| Closing accounts | Closed account revived within the same transaction | Use the framework’s close mechanism |
| Sysvar address checking | Look-alike account passed as a sysvar | Compare with the sysvar’s known address |
What public incidents show
The largest Solana incidents of 2022 include two clear instances of these classes. In February, Wormhole’s incident report describes signature verification that did not check that the account supplied as the instructions sysvar was the real sysvar, which let the attacker bypass guardian signature checks; this is the sysvar address checking class, discussed further in our bridge analysis. In March, the Cashio stablecoin was exploited when, according to CertiK’s analysis, the collateral validation did not validate the mint field of one account in a chain of accounts, so the attacker supplied fake accounts that passed verification and minted the stablecoin against worthless collateral: an account data matching failure.
Both incidents involved code that performed many checks correctly and missed one. That is the typical shape: account validation is a long list of small conditions, and an attacker needs only the one that is absent. The Solana account validation case study and the Corvid AMM research assessment show how reviewers enumerate those conditions for each instruction.
Conclusions
On Solana every account is an input, and the program must establish for each one that it is the account it claims to be: signed where authority is exercised, owned by the right program, of the right type, at the right address or derivation, and consistent with the accounts around it. Anchor turns most of these checks into declarations, which removes whole categories of omission, but it checks only what is declared.
Recommendations
Developers
- Use typed Anchor accounts wherever possible, and justify every
UncheckedAccountin itsCHECKcomment. - Write account relationships as constraints, not as checks scattered through the handler.
- Derive PDAs with seeds unique to their purpose and store the canonical bump at initialisation.
Reviewers
- For every instruction, list each account and the five properties, and mark how each is enforced.
- Write a test that passes a look-alike account owned by another program for every account the program reads.
- Check every CPI target and every account reused after a CPI.
Bibliography
Primary sources: standards, official documentation, incident reports and papers.
- 1Sealevel Attacks: common Solana security pitfalls(opens in a new tab)coral-xyz, GitHub
- 2Program Derived Addresses(opens in a new tab)Solana documentation
- 3Account constraints(opens in a new tab)Anchor documentation
- 4Anchor documentation(opens in a new tab)Anchor
- 5Wormhole Incident Report 02/02/22(opens in a new tab)Wormhole
- 6Cashio App Incident Analysis(opens in a new tab)CertiK
Applied on this site
Assessments, case studies and tools that apply this publication.






