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.
#[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.
#[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.
Static analysis can only reach part of this class: an unchecked authority and a permissionless-by-design one are the same code, and only the protocol says which is which. VaultLint therefore reports the narrow, checkable case — an unproven authority written into an account the same instruction creates — as VL001. The rest is a review question, not a lint.
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.
// Manually deserializing without checking the owner
let vault = Vault::try_from_slice(&account.data.borrow())?;
#[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. Note that owner means two unrelated things in Solana code — the owning program, and the wallet field inside a deserialised SPL token account — and only the first is a security boundary. This is VL002, the only VaultLint rule that fails a build by default.
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. Using
bump = <expr>in an Anchor constraint makes Anchor callcreate_program_addresswith the value you supply and verify only that the address matches — it never checks that the bump is canonical. If that value is caller-supplied, an attacker can pass any bump that produces a valid-looking address. The safe form is a barebumpwith no=, which makes Anchor derive the canonical value itself viafind_program_address.
#[account(
seeds = [b"vault", user.key().as_ref()],
bump = vault.bump,
)]
pub vault: Account<'info, Vault>,
Be precise about what this does, because it is easy to state incorrectly: with bump = vault.bump, Anchor calls create_program_address with the bump you supplied and checks only that the account's address matches. It does not check that the bump is canonical. The form is safe because of where the value came from — vault.bump was written once at init, from Anchor's own find_program_address result, into an account only your program can modify. Feed the same constraint a caller-supplied value instead, such as an #[instruction] argument, and it becomes the vulnerability. A bare bump with no = is the safest form of all: Anchor derives the canonical value itself.
VaultLint reports the dangerous case as VL004 — non-canonical PDA bump.
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.
// 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):
require_keys_eq!(token_program.key(), anchor_spl::token::ID);
Watch for the case that looks safe and is not: an SDK builder such as spl_token::instruction::transfer takes the token program id as its first argument, so passing an unverified account there is this same bug wearing a helper's clothes. VaultLint reports a program id read off a caller-supplied account as VL005.
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.
vault.balance = vault.balance - amount; // underflows silently in release mode
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. Set overflow-checks = true under [profile.release] as well — one line that covers the whole workspace, including code you have not read. It has to go in the workspace root manifest; Cargo ignores [profile.*] anywhere else, so a member crate that sets it has changed nothing. That is VL003.
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.
#[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:
- 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. - Use checked arithmetic everywhere on security-relevant values.
- 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: a security linter that reads Rust and Anchor programs the way an auditor would. It does not cover every class on this page — it ships five rules, aimed at missing owner checks, unproven authority on initialization, non-canonical PDA bumps, CPIs to a program id the caller controls, and release-mode arithmetic that wraps. 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 automates five of the checks above and reports the file, the line, and the fix. It's free and open source — one command to install.
Get VaultLint on crates.io