Builder· Lesson 7 of 8· 5 min
Program security: the checks that get skipped
Most Solana exploits are not clever maths. They are a check the program forgot to make because the runtime does not make it. Eight of them, each as the vulnerable code and the fix.
A program receives a list of accounts chosen by whoever built the transaction. The runtime guarantees each account's owner, its signer flag and its writable flag. It does not know what you meant any of them to be. Every exploit below is the gap between “this account signed” and “this is the account that should have signed”.
1. The signer that isn't checked
// vulnerable: `owner` is read but never required to sign
pub struct Withdraw<'info> {
/// CHECK: the bank's owner
pub owner: AccountInfo<'info>,
#[account(mut, has_one = owner)]
pub bank: Account<'info, Bank>,
}
// anyone can pass the real owner's address as `owner` and drain the bank
// fixed: Signer requires a signature from that key
pub struct Withdraw<'info> {
pub owner: Signer<'info>,
#[account(mut, has_one = owner)]
pub bank: Account<'info, Bank>,
}2. The account that isn't what you think
// vulnerable: a raw AccountInfo, deserialised by hand, with no owner or type check
let bank = Bank::try_from_slice(&ctx.accounts.bank.data.borrow()[8..])?;
// the attacker passes an account they control with bytes shaped like a Bank
// fixed: Account<T> checks the owner is this program and the discriminator is Bank's
pub bank: Account<'info, Bank>,
// for accounts owned by other programs, say which program
#[account(token::mint = mint, token::authority = owner)]
pub source: InterfaceAccount<'info, TokenAccount>,3. The CPI that goes somewhere else
// vulnerable: the program to call is whatever the caller passed
/// CHECK: token program
pub token_program: AccountInfo<'info>,
// ...
invoke(&transfer_ix, &[...])?; // the "token program" is the attacker's program, which logs success and moves nothing
// fixed: the type pins the address
pub token_program: Program<'info, Token>, // exactly the Token program
pub token_program: Interface<'info, TokenInterface>, // Token or Token-2022, nothing else4. The PDA that isn't yours
// vulnerable: the vault is trusted because it "is a PDA", but of what?
#[account(mut)]
pub vault: Account<'info, TokenAccount>,
// fixed: derive it, with the canonical bump stored at creation
#[account(
mut,
seeds = [b"vault", lock.key().as_ref()],
bump = lock.vault_bump, // stored, not searched: rejects every non-canonical bump
)]
pub vault: Account<'info, TokenAccount>,Two related traps. Seeds that can collide: [user, amount] where a 1-byte amount followed by a key can equal a different key followed by a different amount — keep seed lengths fixed or prefix them. And accepting any bump: for a given seed set, several bumps can be valid PDAs, so a program that recomputes with find_program_address on every call is fine, but one that accepts a caller-supplied bump without checking it against the canonical one is not.
5. Related accounts that aren't related
// vulnerable: lock and vault are both valid accounts of the right type — but not each other's
pub lock: Account<'info, Lock>,
#[account(mut)]
pub vault: Account<'info, TokenAccount>,
// release(lock_A, vault_B): drains B's vault on A's schedule
// fixed: every relationship the state records is re-checked
#[account(
has_one = vault @ LockError::VaultMismatch,
has_one = recipient @ LockError::NotRecipient,
has_one = mint @ LockError::MintMismatch,
)]
pub lock: Account<'info, Lock>,6. Arithmetic
// vulnerable: release builds without overflow checks; u64 wraps silently
lock.released_amount += amount;
let remaining = lock.total_amount - lock.released_amount; // underflows to u64::MAX
// fixed: explicit, and a build that traps
lock.released_amount = lock.released_amount.checked_add(amount).ok_or(LockError::Overflow)?;
let remaining = lock.total_amount.saturating_sub(lock.released_amount);
// Cargo.toml — make every unchecked `+` a panic instead of a wrap, in release builds too
[profile.release]
overflow-checks = true7. Initialising twice
// vulnerable: init_if_needed on your own state type
#[account(init_if_needed, payer = user, space = 8 + Config::INIT_SPACE, seeds = [b"config"], bump)]
pub config: Account<'info, Config>,
// the handler then sets config.admin = user — a second caller re-runs it and becomes admin
// fixed: `init` fails if the account exists; a separate instruction updates it, gated on the current admin
#[account(init, payer = user, space = 8 + Config::INIT_SPACE, seeds = [b"config"], bump)]
pub config: Account<'info, Config>,
// if init_if_needed is genuinely needed, guard the handler:
require!(config.admin == Pubkey::default(), ConfigError::AlreadyInitialized);8. Closing an account that comes back
// vulnerable: moving the lamports out "closes" it — but the data is still there,
// and a transfer back in the same transaction revives it with its old state
**lock.to_account_info().try_borrow_mut_lamports()? = 0;
// fixed: the close constraint zeroes the data, reassigns the owner to System and moves the lamports
#[account(mut, close = creator, has_one = creator)]
pub lock: Account<'info, Lock>,Two more that are not code
remaining_accountsare completely unchecked. If you iterate them, verify owner, type and relationship for each, as if they were declared.- Anything the caller can pass as a plain number — an amount, a timestamp, an index — is attacker-controlled. Validate against state, not against the other arguments.
Operational security

- Upgradeable means the code can change under users. Fine while it is stated; freeze it with
solana program set-upgrade-authority --finalwhen the design is done. - Unverified: nobody can prove these bytes came from the published source.
solana-verifypublishes the proof. - A multisig upgrade authority: one leaked key cannot swap the program. This is the minimum for anything holding value.
# hand the upgrade authority to a Squads multisig
solana program set-upgrade-authority <PROGRAM_ID> --new-upgrade-authority <MULTISIG_VAULT>
# or make the program immutable, forever
solana program set-upgrade-authority <PROGRAM_ID> --final
# prove the on-chain bytes match a git commit
solana-verify verify-from-repo --program-id <PROGRAM_ID> https://github.com/you/repo --library-name token_locker
# tell researchers where to report: a security.txt baked into the binary
# (the solana-security-txt crate; Explorer shows it on the program page)Solstack's own locker is deployed to devnet only, with exactly this reasoning: real custody logic, not yet reviewed. Token Lock Verifier shows you the program's upgrade authority next to every lock for the same reason.
What to remember
- The runtime checks that a signature exists and who owns an account. Whether they are the right ones is your check.
- Use Signer, Account<T>, Program<T> and Interface<T>. Raw AccountInfo is a decision, and needs a CHECK comment saying why.
- Derive PDAs with stored canonical bumps; pin relationships with has_one; enable overflow checks.
- init, not init_if_needed, on your own state. close, not a lamport drain, to delete.
- Ship with a multisig upgrade authority, a verified build and a security.txt.