Solana program security is account security. Callers choose which accounts and programs enter an instruction; Sealevel enforces locks, ownership write rules, and compute budgets, not your vault balance, admin list, or mint binding.
This page is the conceptual map for Program Security & Auditing - Sealevel attack surface, account validation, signer and owner checks, PDA and seed attacks, reinitialization, arithmetic, CPI and reentrancy risk, and a practical audit workflow on Agave 4.1.1.
Every instruction is an adversarial interface: prove who signed, who owns each account, which PDA and type you touch, then mutate state with checked math and safe CPI order.
Insight: Exploits rarely need a novel crypto break. They substitute a wrong vault, skip a signer, re-init an admin, wrap a balance, or CPI to a malicious program that returns "success."
When to Use This Model: Designing new instructions, reviewing #[derive(Accounts)], prep for external audit, triage of findings, or teaching the Solana threat model.
Limitations/Trade-offs: Anchor constraints catch many classes but not business rules; UncheckedAccount and remaining_accounts reintroduce risk; security reviews do not replace fuzzing or economic design review.
Sealevel runs programs as stateless executables. Durable state lives in accounts the transaction lists up front. Clients (or attackers) supply those accounts, signer flags, and writable flags. The runtime checks that declared signers actually signed and that only the owning program may write account data. It does not know that "this TokenAccount must match vault.mint" or "only authority may withdraw."
That gap is the attack surface:
Client / attacker supplies: program_id | instruction data | AccountMeta[] (keys, is_signer, is_writable)Runtime guarantees: signatures match is_signer | locks from is_writable | owner write rules | CU budgetYour program must guarantee: right identity signed | right owners and types | right PDAs | right amounts | safe CPIs
Fail closed is the default posture: if validation is incomplete, reject with a typed error. Never assume "honest clients" or "our frontend only builds good txs." Frontends and indexers are not part of the trust boundary; on-chain checks are.
Anchor 0.32.1 encodes common checks (Signer, Account<T>, seeds, has_one, Program<T>). That is not a full review. Gaps appear with AccountInfo, UncheckedAccount, remaining_accounts, unguarded init_if_needed, and business rules constraints cannot express.
Threat model for a typical vault or market program:
Attackers pass accounts that look usable: same size, owned by your program, or deserializable into the wrong type. Validation means more than "deserializes."
Checklist per account role:
Owner - expected program (Account<T> for your types; require_keys_eq! for SPL Token, Sysvar, etc.).
Type / discriminator - Anchor account disc or native constant so Vault is never User.
Relational constraints - mint matches vault, token account owner is the vault PDA, config matches market.
Mutability - only accounts you intend to change are mut.
Canonical PDA - seeds + stored bump so the address is derived, not chosen freely.
#[derive(Accounts)]pub struct Deposit<'info> { // `has_one = mint` would require a `mint` account field in this struct; the // explicit mint constraint below covers the linkage without one. #[account(mut, has_one = authority)] pub vault: Account<'info, Vault>, #[account( mut, constraint = user_ata.mint == vault.mint @ ErrorCode::MintMismatch, constraint = user_ata.owner == user.key() @ ErrorCode::WrongAtaOwner, )] pub user_ata: Account<'info, TokenAccount>, pub authority: Signer<'info>, pub user: Signer<'info>, pub token_program: Program<'info, Token>,}
UncheckedAccount and raw AccountInfo skip owner and disc checks until you add them. Treat every such field as a finding until proven validated.
Signer: any path that moves funds, changes authority, closes accounts, or upgrades-sensitive config needs a cryptographic signature from the right key (or a PDA signed via invoke_signed with program-controlled seeds).
Owner: only the owning program may rewrite data. Reading an account your program does not own without checking owner is how type confusion and fake mints enter. For token accounts, owner must be the Token or Token-2022 program you intend; for your state, owner must be your program_id.
Common critical pattern: vault stores authority: Pubkey but the instruction never requires authority as Signer or never ties it with has_one.
Init writes discriminators, authorities, and balances. Re-running init (or init_if_needed without a flag) can reset admin to the attacker or revive a closed account shape.
Prefer one-time init for permanent state. If you need init_if_needed, require !is_initialized (or equivalent) before writing authority fields, and never re-open closed accounts without a deliberate, audited path. On close, zero data / disc and transfer lamports so the account cannot be treated as live state.
BPF release builds wrap on plain +, -, *. Vault math, share minting, fee accrual, and AMM formulas need checked_* (or saturating ops where intentional) and often u128 intermediates before casting back to u64.
Rounding should favor the protocol on user-facing mints and redemptions so dust cannot be farmed into free value. Division by zero and empty-pool edge cases are security bugs, not mere UX.
State after CPI - the callee cannot re-enter your program (the runtime rejects that with ReentrancyNotAllowed), but it can mutate accounts you already deserialized, so your in-memory copy goes stale. A later top-level instruction in the same transaction can also read balances that still look pre-update.
Mitigations: allowlist callees with Program<'info, T> or require_keys_eq!; update program state before external CPI (CEI); avoid untrusted callbacks unless you design explicit flash-loan style debt checks; pass only accounts the callee needs.
vault.balance = vault.balance.checked_sub(amount).ok_or(ErrorCode::Overflow)?;// effects first, then interactiontoken::transfer(cpi_ctx, amount)?;
Solana is not identical to EVM reentrancy, but bad call order and untrusted CPI targets still lose funds.
Oracles are untrusted inputs: check feed owner, staleness, and confidence; fail closed on bad data.
Token-2022 extensions (hooks, permanent delegates) change who can move tokens. Pin the token program ID; do not assume classic SPL Token semantics.
Close and rent: require the right signer and destination; zero data so accounts cannot revive under weak checks. Prefer Anchor close = with constraints.
Upgrade authority is root. Multisig or governance, verifiable builds, and optional immutability after maturity are operational security.
Clients (@solana/kit 7.0.0, wallets) help honest users only. Programs defend against everyone else.
On Agave 4.1.1 (CLI 3.0.10, Anchor 0.32.1, Rust 1.91.1), exercise adversarial account sets in LiteSVM or local validators. Treat every new instruction as a new attack surface.
What is the Sealevel attack surface for a program?
Everything the caller can choose: which accounts and programs appear, who signs, instruction data, and CPI targets. The runtime does not enforce your mint, authority, or balance invariants. See Sealevel Attacks Catalog.
Why are missing signer checks so common and severe?
Without a required Signer (or PDA invoke_signed path), any account key can be named as "authority" while only the fee payer actually signs. That enables unauthorized withdrawals and admin takeover. See Signer & Owner Checks.
What is account substitution?
Passing a different vault, token account, mint, or config than the protocol intends, often still owned by a plausible program. Fix with relational constraints (has_one, mint equality, seeds). See Account Validation Attacks.
How do PDA seed attacks work?
If seeds omit identity fields or bumps are not canonical, attackers derive alternate PDAs your instruction still accepts, impersonating vaults or escrows. Store bumps and pin full seed lists. See PDA & Seed Attacks.
When is reinitialization possible?
When init can run again on live state: init_if_needed without guards, missing discriminators, or closed accounts that are recreated under weak checks. See Reinitialization Attacks.
Does Solana need checked arithmetic?
Yes for value paths. Plain arithmetic can wrap on-chain; share and swap formulas should use checked_* and careful rounding. See Arithmetic & Overflow.
What is CEI in a Solana CPI context?
Checks-effects-interactions: validate, update your program-owned state, then call out. It limits reentrancy-style reads of stale balances during untrusted or recursive CPIs. See CPI & Reentrancy Risks.
How do I validate a CPI target program?
Use Program<'info, Token> (or your known type) or require_keys_eq! against a constant ID. Never CPI to a user-supplied program key without an explicit allowlist.
Are Token and Token-2022 interchangeable for security?
No. Different program IDs and extension behaviors (hooks, transfer fees, delegates) change who can move funds. Pin the program and test extension-enabled mints.
What should an internal pre-audit cover?
Every instruction mapped to catalog classes, negative unit tests, dependency pins, verifiable build notes, and known residual risks. See Audit Workflow.
Does fail-closed mean reject all unknown accounts?
It means reject when required proof is missing or inconsistent: wrong signer, owner, mint, PDA, stale oracle, or unallowlisted program. Optional accounts still need explicit rules if present.
How does security interact with Sealevel parallelism?
Security is per-instruction correctness; parallelism is about lock sets. Do not weaken validation just to pass fewer accounts.
Should upgrade authority be a single hot key?
Avoid for production value at risk. Prefer multisig or governance, monitor upgrades, and consider freezing authority after maturity.
Where do oracles fit in program security?
As untrusted inputs. Check feed owner, staleness, and confidence before using prices. Fail closed on missing or stale data.
What is the fastest way to review a new instruction?
Read Accounts (signers, owners, seeds, relations, init vs mut, program IDs), then handler order (math, writes, CPI), then negative tests for each failure mode.