Skip to content
Quantix Security Labs
Primer

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.

Illustration: a Solana program receiving many accounts as inputs, one of which carries the wrong owner.
Contents
  1. Accounts in, accounts out
  2. The checks every account needs
  3. Owner and type confusion
  4. The same instruction in Anchor
  5. PDAs, seeds and canonical bumps
  6. Cross-program invocation
  7. A catalogue of account bugs
  8. What public incidents show
  9. Conclusions
  10. Recommendations
  11. Bibliography

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

Checks each account of a Solana withdraw instruction needsA withdraw instruction receives five accounts chosen by the caller. The authority must be a signer and match the vault's stored authority. The vault must be owned by the program and carry the Vault discriminator. The config account must be re-derived from its seeds with the canonical bump. The user token account must be owned by the SPL Token program, have the vault's mint and be held by the authority. The token program must be the real SPL Token program.Accounts supplied by the callerWhat the program must verify before using each oneauthoritysigneris_signer, and equals vault.authority (has_one)vaultwritableowner = this program; discriminator = VaultconfigPDAre-derive from seeds; canonical bump onlyuser_tokenwritableowner = SPL Token; mint = vault.mint; holder = authoritytoken_programprogramkey = the SPL Token program ID (no arbitrary CPI target)Every account is an input. A check left out is a value the attacker chooses, for example a look-alike vault owned by their own program.
For each account the caller supplies, the property the program must verify before using it.

Five properties cover most cases:

  1. Signer. Accounts whose authority is being exercised must have signed the transaction.
  2. Owner. Accounts whose data the program trusts must be owned by the program that is supposed to write that data.
  3. 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.
  4. Address or derivation. PDAs must be re-derived from their seeds; fixed accounts such as programs and sysvars must match their known addresses.
  5. 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.

withdraw.rs (illustrative, vulnerable)rust
// 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.

withdraw.rs (illustrative, Anchor)rust
// 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

ClassWhat goes wrongCheck
Signer authorisationAuthority named but did not signis_signer / Signer
Account data matchingAccounts do not belong togetherCompare stored keys; has_one, constraint
Owner checksData read from an account the attacker ownsOwner = expected program; Account<T>
Type cosplayOne account type passed as anotherDiscriminator check
InitialisationAn existing account is re-initialisedInitialise once; Anchor init
Arbitrary CPICaller chooses the invoked programCompare program ID; Program<T>
Duplicate mutable accountsSame account passed twice, e.g. as source and destinationRequire distinct keys
Bump seed canonicalisationNon-canonical bump acceptedDerive or store the canonical bump
PDA sharingOne PDA signs for many users or vaultsInclude unique seeds such as the vault key
Closing accountsClosed account revived within the same transactionUse the framework’s close mechanism
Sysvar address checkingLook-alike account passed as a sysvarCompare with the sysvar’s known address
Account-related bug classes, following the naming of the Sealevel Attacks repository

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

  1. Use typed Anchor accounts wherever possible, and justify every UncheckedAccount in its CHECK comment.
  2. Write account relationships as constraints, not as checks scattered through the handler.
  3. Derive PDAs with seeds unique to their purpose and store the canonical bump at initialisation.

Reviewers

  1. For every instruction, list each account and the five properties, and mark how each is enforced.
  2. Write a test that passes a look-alike account owned by another program for every account the program reads.
  3. Check every CPI target and every account reused after a CPI.

Bibliography

Primary sources: standards, official documentation, incident reports and papers.

  1. 1Sealevel Attacks: common Solana security pitfalls(opens in a new tab)coral-xyz, GitHub
  2. 2Program Derived Addresses(opens in a new tab)Solana documentation
  3. 3Account constraints(opens in a new tab)Anchor documentation
  4. 4Anchor documentation(opens in a new tab)Anchor
  5. 5Wormhole Incident Report 02/02/22(opens in a new tab)Wormhole
  6. 6Cashio App Incident Analysis(opens in a new tab)CertiK

Applied on this site

Assessments, case studies and tools that apply this publication.

Want this question asked of your code?

Quantix reviewers apply the same reasoning to production systems. Share what you are building and when it ships.