solana-program-audit — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited solana-program-audit (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Solana programs have a uniquely large attack surface because the runtime gives you no defaults. Every account that flows into an instruction is just bytes plus a pubkey — the program is responsible for verifying ownership, signer-ness, type, and any relational invariant between accounts. There is no msg.sender, no per-storage-slot access control, and no automatic type tagging on raw AccountInfos. The result is that the most expensive Solana exploits in history (Wormhole $325M, Mango $114M, Cashio $52M) were not novel cryptography breaks — they were missing one-line checks. Anchor closes most of these gaps via constraints, but only if you use them and understand what they prove.
Install or have access to:
avm install 0.30.1 && avm use 0.30.1) — required for current constraint syntax including init_if_needed feature flag.cargo install cargo-audit) — flags vulnerable crates in Cargo.lock.unsafe usage in dependencies.semgrep --config p/solana covers the common Sealevel patterns.cargo-otter for IDL diffing and program comparison.Work top-down. Don't open a single instruction until you've mapped the whole program — most exploits chain across instructions.
Accounts struct, every signer, and every CPI call. A whiteboard or a markdown table is fine. This is the artifact you audit against.Account<'info, T> checks owner automatically. Raw AccountInfo / UncheckedAccount does not — you must add #[account(owner = expected_program::ID)] or check account.owner manually. SPL token accounts must be owned by the Token program; mints must be owned by the Token program.address = some_program::ID or explicit pubkey check). Confirm signer seeds passed to invoke_signed only authorize what this program legitimately controls. Squads, Mango, and Jupiter integrations are common CPI footguns.+, -, *, / on a balance, share, price, or fee must be checked_* (returns Option), saturating_* (clamps), or a fixed-point lib like spl-math or fixed. Mango's $114M loss was a price-impact arithmetic bug. overflow-checks = true in Cargo.toml is not enough — release builds may strip it; explicit checked ops are required.CLOSED_ACCOUNT_DISCRIMINATOR ([u8; 8] of 0xff). Use Anchor's close = recipient constraint where possible. A non-zeroed account can be revived inside the same transaction and reused.init_if_needed is a yellow flag. Verify the post-condition: after the call, all fields hold values the caller is authorized to set, regardless of whether the account is new or existing. Prefer separate init and update instructions.cargo audit, semgrep --config p/solana, Sec3 X-ray. Treat their output as a starting point, not an answer — most static analyzers miss account-confusion bugs because they're shape-correct.solana-verify verify-from-repo against the on-chain program ID. An audit of source that doesn't match deployment is theater.Each pitfall below shows the vulnerable shape and the fix. Code is Anchor 0.30+.
The Wormhole bridge lost $325M because it accepted a signature-verification account without verifying that the on-chain instruction actually called the secp256k1 sig-verify precompile in the same transaction. The same class shows up any time a program reads a pubkey from an account and trusts it without checking is_signer.
// VULNERABLE — no signer check on `authority`
#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(mut)]
pub vault: Account<'info, Vault>,
/// CHECK: trusted... right?
pub authority: AccountInfo<'info>,
#[account(mut)]
pub recipient: SystemAccount<'info>,
}
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
require_keys_eq!(ctx.accounts.vault.authority, ctx.accounts.authority.key());
// attacker passes authority pubkey without signing
transfer_from_vault(&ctx.accounts, amount)
}// FIXED — Anchor `Signer<'info>` enforces is_signer at deserialize
#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(mut, has_one = authority)]
pub vault: Account<'info, Vault>,
pub authority: Signer<'info>,
#[account(mut)]
pub recipient: SystemAccount<'info>,
}Anchor Account<'info, T> checks owner. UncheckedAccount / AccountInfo does not. SPL token accounts deserialized via spl_token::state::Account::unpack will succeed on any 165-byte buffer — including one written by a malicious program.
// VULNERABLE
let token_account = spl_token::state::Account::unpack(&ctx.accounts.user_token.data.borrow())?;
// attacker passes a fake account owned by their own program with crafted bytes// FIXED — explicit owner check, or use Anchor's TokenAccount type
require_keys_eq!(*ctx.accounts.user_token.owner, anchor_spl::token::ID);
let token_account = spl_token::state::Account::unpack(&ctx.accounts.user_token.data.borrow())?;
// Better: use Anchor's typed wrapper which checks owner for you
// pub user_token: Account<'info, TokenAccount>,Cashio lost $52M because the program walked a chain of accounts (crate_collateral_tokens → saber_swap.arrow → mint) but never validated the mint at the root. The attacker forged fake accounts at every level. The fix is to anchor every cross-account relationship to a trusted root — usually a has_one constraint or a PDA derivation that includes the trusted pubkey in its seeds.
// VULNERABLE — no link between vault and the mint it claims to hold
#[derive(Accounts)]
pub struct Deposit<'info> {
pub vault: Account<'info, Vault>,
pub collateral_mint: Account<'info, Mint>, // attacker passes any mint
#[account(mut, token::mint = collateral_mint)]
pub user_token: Account<'info, TokenAccount>,
}// FIXED — has_one ties the mint to a value stored on the vault
#[derive(Accounts)]
pub struct Deposit<'info> {
#[account(has_one = collateral_mint)]
pub vault: Account<'info, Vault>,
pub collateral_mint: Account<'info, Mint>,
#[account(mut, token::mint = collateral_mint)]
pub user_token: Account<'info, TokenAccount>,
}Anchor also gives every #[account] struct an 8-byte discriminator, which prevents passing (say) a Vault where a Config is expected — but only if you use the typed Account<'info, T> wrapper, not raw AccountInfo.
init_if_neededinit_if_needed runs init if the account is empty, otherwise treats it as existing. Combined with an instruction that lets a user zero an account (via realloc(0, ..), manual lamport drain, or a sloppy close), an attacker can wipe a victim's account and then "reinit" it under their own ownership.
// VULNERABLE — init_if_needed on a user-keyed account with no signer guard on the existing branch
#[derive(Accounts)]
pub struct OpenOrCreate<'info> {
#[account(
init_if_needed,
payer = user,
space = 8 + UserPos::INIT_SPACE,
seeds = [b"pos", user.key().as_ref()],
bump,
)]
pub position: Account<'info, UserPos>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}
pub fn open_or_create(ctx: Context<OpenOrCreate>, owner: Pubkey) -> Result<()> {
ctx.accounts.position.owner = owner; // overwrites existing owner!
Ok(())
}// FIXED — split into init and update, OR guard the "existing" branch
pub fn open_or_create(ctx: Context<OpenOrCreate>, owner: Pubkey) -> Result<()> {
let pos = &mut ctx.accounts.position;
// If freshly initialized, `owner` is default Pubkey
if pos.owner == Pubkey::default() {
pos.owner = owner;
} else {
require_keys_eq!(pos.owner, ctx.accounts.user.key(), ErrorCode::Unauthorized);
// intentional no-op or scoped update
}
Ok(())
}Prefer two instructions (init_position, update_position) whenever practical.
Calling into "the token program" via an AccountInfo that was never constrained lets an attacker swap in a lookalike program that records the transfer to their own ledger while reporting success.
// VULNERABLE
pub fn transfer<'info>(
token_program: AccountInfo<'info>, // not checked!
from: AccountInfo<'info>,
to: AccountInfo<'info>,
authority: AccountInfo<'info>,
amount: u64,
) -> Result<()> {
let ix = spl_token::instruction::transfer(token_program.key, from.key, to.key, authority.key, &[], amount)?;
invoke(&ix, &[from, to, authority, token_program])?;
Ok(())
}// FIXED — typed Program<'info, Token> constrains the program ID
#[derive(Accounts)]
pub struct Transfer<'info> {
#[account(mut)]
pub from: Account<'info, TokenAccount>,
#[account(mut)]
pub to: Account<'info, TokenAccount>,
pub authority: Signer<'info>,
pub token_program: Program<'info, Token>, // checks address == spl_token::ID
}Anchor's close = recipient constraint zeros the discriminator and drains lamports. Manual close patterns that only drain lamports leave the data intact — and an account with lamports == 0 can be re-funded inside the same transaction (via a CPI) and re-used as if it were live.
// VULNERABLE — drains lamports but leaves data
pub fn close_position(ctx: Context<ClosePosition>) -> Result<()> {
let position = &ctx.accounts.position.to_account_info();
let recipient = &ctx.accounts.recipient.to_account_info();
**recipient.try_borrow_mut_lamports()? += **position.try_borrow_lamports()?;
**position.try_borrow_mut_lamports()? = 0;
Ok(())
}// FIXED — Anchor close constraint zeros discriminator
#[derive(Accounts)]
pub struct ClosePosition<'info> {
#[account(mut, close = recipient, has_one = owner)]
pub position: Account<'info, Position>,
pub owner: Signer<'info>,
#[account(mut)]
pub recipient: SystemAccount<'info>,
}If you must close manually, set the discriminator to [0xff; 8] (the closed-account marker) and zero the data before transferring lamports.
// VULNERABLE — slot is grindable by validators, predictable by anyone
let clock = Clock::get()?;
let winner_idx = (clock.slot % participants.len() as u64) as usize;// FIXED — use Switchboard or ORAO VRF (commit-reveal also acceptable)
// Caller requests randomness in instruction A, callback in instruction B
let randomness = vrf_account.get_result()?; // [u8; 32]
let winner_idx = (u64::from_le_bytes(randomness[..8].try_into().unwrap()) as usize) % participants.len();Mango Markets' $114M exploit hinged on manipulating a perp-futures price feed combined with arithmetic that didn't model the resulting position correctly. Even outside that specific bug, plain a * b / c on u64 overflows for moderate prices.
// VULNERABLE
let value = price * quantity / 1_000_000;// FIXED — checked + widened intermediate, or fixed-point
let value = (price as u128)
.checked_mul(quantity as u128).ok_or(ErrorCode::MathOverflow)?
.checked_div(1_000_000).ok_or(ErrorCode::MathOverflow)?;
let value: u64 = value.try_into().map_err(|_| ErrorCode::MathOverflow)?;Re-deriving a bump with Pubkey::find_program_address on every instruction is expensive (up to ~1500 CU per try) and lets attackers pass a non-canonical bump if you call create_program_address with a user-supplied value.
// VULNERABLE — accepts any bump the caller provides
#[derive(Accounts)]
#[instruction(bump: u8)]
pub struct Use<'info> {
#[account(seeds = [b"vault"], bump = bump)] // attacker chooses bump
pub vault: Account<'info, Vault>,
}// FIXED — store canonical bump at init, read it back on use
#[account]
pub struct Vault { pub bump: u8, /* ... */ }
#[derive(Accounts)]
pub struct Use<'info> {
#[account(seeds = [b"vault"], bump = vault.bump)]
pub vault: Account<'info, Vault>,
}mut constraintAn account that is mutated but not declared mut will silently fail at runtime — but the failure mode in older Anchor versions, and in raw Solana, was to allow the read while silently dropping the write. The fix is mechanical but easy to forget on a copy-paste.
// VULNERABLE
#[derive(Accounts)]
pub struct Increment<'info> {
pub counter: Account<'info, Counter>, // not mut!
}// FIXED
#[derive(Accounts)]
pub struct Increment<'info> {
#[account(mut)]
pub counter: Account<'info, Counter>,
}otter-sec.See references/vulnerability-checklist.md for the comprehensive audit checklist and references/anchor-constraints-cheatsheet.md for an Anchor #[account(...)] constraint reference.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.