Common Solana Program Vulnerabilities and How to Catch Them

Most Solana exploits don't come from exotic cryptography or clever math. They come from a handful of well-understood mistakes that show up again and again — usually because Solana's account model puts the burden of validation on you, the program author, rather than the runtime.

If you write Solana programs in Rust or Anchor, this is the list of bugs worth internalizing. For each one below you'll find why it happens, a vulnerable example, and the fix.

Why Solana programs break differently

On Ethereum, a contract's storage is bound to the contract. On Solana, programs are stateless: all state lives in separate accounts that the caller passes in with each instruction. That design is fast and flexible, but it has a consequence that trips up almost every new Solana developer:

The runtime does not guarantee that an account is what you think it is. Anyone can pass any account into your instruction. If your program doesn't explicitly check that an account is a signer, is owned by the right program, is the right type, or was derived from the right seeds — an attacker will pass one that isn't.

Almost every vulnerability class below is a variation on that single theme: validate the accounts you were handed.

1. Missing signer checks

The most common and most expensive bug. If an account is supposed to authorize an action, it must sign the transaction. If you forget to enforce that, anyone can act as anyone.

Vulnerable
#[derive(Accounts)]
pub struct Withdraw<'info> {
    #[account(mut)]
    pub vault: Account<'info, Vault>,
    /// CHECK: authority is not constrained — anyone can pass any account here
    pub authority: AccountInfo<'info>,
}

Because authority is a plain AccountInfo with no signer constraint, an attacker passes the real owner's public key without their signature and drains the vault.

Fixed
#[derive(Accounts)]
pub struct Withdraw<'info> {
    #[account(mut, has_one = authority)]
    pub vault: Account<'info, Vault>,
    pub authority: Signer<'info>,
}

Signer<'info> forces the account to have signed the transaction, and has_one = authority ensures it's the specific authority stored on the vault — not just any signer.

2. Missing owner checks

Every account has an owner program. If you read data from an account without verifying who owns it, an attacker can hand you a look-alike account they created and fully control.

Anchor's Account<'info, T> checks the owner and the 8-byte discriminator automatically. The bug appears when you drop down to raw AccountInfo or UncheckedAccount and deserialize manually.

Vulnerable
// Manually deserializing without checking the owner
let vault = Vault::try_from_slice(&account.data.borrow())?;
Fixed — let Anchor enforce it
#[account(owner = crate::ID)]
pub vault: Account<'info, Vault>,

If you must use AccountInfo, check account.owner == &crate::ID yourself before trusting a single byte.

3. Account type confusion ("type cosplay")

Two account types with the same memory layout can be swapped for each other if you don't distinguish them. Anchor prevents this with an 8-byte discriminator prepended to every account — but only if you use typed Account<T>. Deserialize raw, and a UserConfig can masquerade as an AdminConfig.

Fix: always use typed Anchor accounts (Account<'info, T>), which verify the discriminator, instead of decoding AccountInfo data by hand.

4. PDA seed and bump mistakes

Program Derived Addresses (PDAs) are deterministic accounts your program controls. Two things go wrong:

  • Seeds not validated. If you accept a PDA without re-deriving it from the expected seeds, an attacker passes a different PDA they can influence.
  • Non-canonical bump. find_program_address returns the canonical bump. If you store a bump and don't validate against the canonical one, an attacker may derive a valid-but-different address with another bump.
Fixed with Anchor constraints
#[account(
    seeds = [b"vault", user.key().as_ref()],
    bump = vault.bump,
)]
pub vault: Account<'info, Vault>,

Anchor re-derives the address from the seeds and checks the stored canonical bump for you. Store the canonical bump at init time and always validate against it.

5. Unchecked / arbitrary CPIs

When your program calls another via a Cross-Program Invocation, you must verify which program you're calling. If the target program is just an account passed in without a check, an attacker substitutes a malicious program that does whatever they want with the accounts you forwarded.

Vulnerable
// The program being invoked is whatever was passed in — never verified
invoke(&ix, &[account_a, account_b, some_program])?;

Fixed: pin the program to a known ID (Anchor's typed CPI does this):

Fixed
require_keys_eq!(token_program.key(), anchor_spl::token::ID);

6. Integer overflow and underflow

This one is Rust-specific and catches people coming from other languages. Solana programs compile in release mode, where arithmetic overflow silently wraps instead of panicking. balance - amount can underflow to a huge number; reward * multiplier can wrap to a small one.

Vulnerable
vault.balance = vault.balance - amount; // underflows silently in release mode
Fixed
vault.balance = vault.balance
    .checked_sub(amount)
    .ok_or(ErrorCode::MathOverflow)?;

Use checked_* (or saturating_* where appropriate) for every arithmetic operation on values that matter. You can also set overflow-checks = true in your Cargo.toml profile, but explicit checked math is clearer and lets you return a proper error.

7. Duplicate mutable accounts

If an instruction takes two accounts of the same type and mutates both, an attacker can pass the same account twice. Logic that assumes they're distinct (e.g. transferring between two vaults) breaks in the attacker's favor.

Fix
#[account(constraint = vault_a.key() != vault_b.key())]

8. Account reinitialization and revival

Closing an account and reopening it, or reinitializing an already-initialized account, can reset state an attacker wants reset. Use Anchor's init (which fails if the account already exists) rather than init_if_needed unless you fully understand the implications, and use the close constraint to close accounts safely rather than zeroing lamports by hand.

How to catch these before you deploy

The pattern across all eight is the same: your program must validate every account it's given. Three habits prevent most of them:

  1. Prefer Anchor's typed accounts and constraints (Signer, Account<T>, has_one, seeds, bump, owner). They turn most of the checks above into declarative one-liners the framework enforces for you.
  2. Use checked arithmetic everywhere on security-relevant values.
  3. Review every AccountInfo / UncheckedAccount / /// CHECK: in your codebase — each one is a place where you've opted out of Anchor's automatic safety and taken the validation burden on yourself.

Manual review catches a lot, but it's slow and it misses things — especially the boring ones, which are exactly the expensive ones. That's the gap VaultLint is built for: an AI security linter that reads Rust and Anchor programs the way an auditor would and flags missing signer checks, PDA mistakes, unsafe CPIs, and overflow before you ship — so a full audit can focus on the hard stuff. It complements a manual audit; it doesn't replace one.

Ship fewer bugs. Catch the common, drainable mistakes in the PR, not on the first mainnet block.

For a step-by-step review pass that puts all of this into practice, see How to Audit an Anchor Program: A Practical Checklist.

Run these checks on every PR.

VaultLint scans Solana programs for exactly the issues above and reports the file, the line, and the fix. Join the waitlist for early access.

Get early access