Wormhole and Cashio both trusted the wrong account. A Solana security checklist for Anchor and native programs
In February 2022 Wormhole's Solana program minted 120,000 wETH that nobody had deposited. The signature code itself was fine. The program read the instructions sysvar from an account the caller passed in and never checke
In February 2022 Wormhole's Solana program minted 120,000 wETH that nobody had deposited. The signature code itself was fine. The program read the instructions sysvar from an account the caller passed in and never checked that account's address, so the attacker handed it a fake one saying the guardian signatures had already been verified.
Seven weeks later Cashio lost about $48M the same way, one level deeper. It checked the collateral account against another account, and that one against a third, but nothing tied the bottom of that chain to the real mint. Fake accounts all the way down, and the program minted CASH against them.
On Solana the caller hands your program every account it reads or writes. Most of this list is one question asked in different places: is this the account the program thinks it is? It's the list I'd want a team to run themselves before an audit, and again against what's actually deployed.
Signers, owners and who initializes
Checking that a pubkey matches the stored admin proves nothing. Anyone can pass any pubkey. What matters is whether that key signed.
#[derive(Accounts)]
pub struct SetFee<'info> {
// an AccountInfo here would accept the admin's pubkey from anyone
pub admin: Signer<'info>,
#[account(mut, has_one = admin)]
pub config: Account<'info, Config>,
}
Account<'info, T> also checks that the account is owned by your program and starts with T's discriminator. An AccountInfo or UncheckedAccount checks nothing. It can be owned by any program and hold any bytes, including bytes laid out like your Config (the bug class people call type cosplay). Every one of those in your account structs needs a constraint or a comment saying why it's safe.
Then look at initialize. If anyone can call it first, the first caller picks the admin. Tie it to the upgrade authority:
#[account(constraint = program.programdata_address()? == Some(program_data.key()))]
pub program: Program<'info, MyProgram>,
#[account(constraint = program_data.upgrade_authority_address == Some(authority.key()))]
pub program_data: Account<'info, ProgramData>,
pub authority: Signer<'info>,
Admin handover should be two steps, propose then accept, so a typo in the new key doesn't lock the program forever.
Accounts that look like the right one
-
Sysvars. The Wormhole bug. Use
Sysvar<'info, Clock>or#[account(address = sysvar::instructions::ID)], never a raw account you deserialize yourself. -
PDAs. Seeds need a prefix per account type, plus the user's key when the account belongs to a user:
seeds = [b"vault", user.key().as_ref()], bump. A PDA from static seeds alone is shared by everyone. Use the canonical bump (Anchor'sbumpdoes it) and never take a bump from instruction data. -
The same account twice. Pass one account as both
fromandtoand see what your math does. If two mutable accounts must differ, say so:constraint = from.key() != to.key(). - Chains of checks. The Cashio bug. If account A is trusted because it matches B, find where B is checked. Somewhere at the bottom there has to be a constant, a PDA derivation or a signer.
-
remaining_accounts. Anchor checks nothing there. Owner, type and key are all on you.
CPI
The program you invoke has to be the one you expect. Program<'info, Token> checks the ID; an AccountInfo passed to invoke lets the caller choose the program.
Be careful what you forward. If your program calls a third-party program and passes the user's wallet as a signer, that program can do anything the wallet can inside that instruction: move its lamports, or assign the wallet's account to itself. Use a PDA your program signs for as the authority. If a user signer really has to go through, compare lamports and owner before and after.
After a CPI, Anchor's deserialized copies of the accounts are stale. A vault balance read after a transfer CPI is the old one until you call ctx.accounts.vault.reload()?.
Math
Rust release builds wrap on overflow. Anchor's workspace template sets overflow-checks = true under [profile.release]; a native program, or a workspace where someone dropped that line, wraps silently. Use checked_* on anything that moves value, and u64::try_from(x)? instead of x as u64, since as truncates without a word.
Round against the user: floor what they take out, ceil what they owe. Handle zero on purpose too. The first deposit into an empty pool and a division by a supply of 0 are where share math usually breaks.
Tokens and Token-2022
Every token account needs its mint and owner checked, token::mint = mint, token::authority = user in Anchor. If your program accepts Token-2022, use Interface<'info, TokenInterface> with transfer_checked.
Anyone can create anyone's associated token account, so init on an ATA fails the moment someone creates it first. init_if_needed handles that. Anchor keeps it behind a feature flag because it also runs against accounts that already exist, so check what's in them.
Then the extensions, which change what a token does whatever your program assumes:
-
Transfer fee. The recipient gets less than
amount, so a vault that credits the amount it asked for is wrong from the first deposit. The fee can also change:SetTransferFeeschedules the new rate two epochs ahead, so readTransferFeeConfigfor the current epoch instead of hardcoding it. - Permanent delegate. It can transfer or burn from any account of that mint, your vault included. Orca's Whirlpool program won't open a pool for a mint that has one unless the mint has a token badge (same for transfer hooks and default frozen accounts).
- Transfer hook. Every transfer runs someone else's program, needs extra accounts and compute, and can fail.
- Default account state. New token accounts can start frozen until the freeze authority thaws them.
- CPI Guard. A holder can turn it on, and then your program can't move their tokens through a CPI signed by the owner. It needs a delegate approval instead.
- Mint close authority. A mint with zero supply can be closed, and whoever holds its keypair can create it again at the same address with different settings. Don't cache decimals or extensions from the first time you saw it.
Edge cases that pass review
-
Ed25519 introspection. If you verify a signature by reading the instructions sysvar, check that the instruction at the index you expect really is the Ed25519 program, and that its offsets point at its own data (
u16::MAXfor the instruction indexes). Otherwise the program verified one message and you read another. -
Closing accounts. Draining the lamports isn't closing. Within the same transaction someone can top the account back up, and it lives on with its old data. Anchor's
close = receiverhandles it; a hand-written close has to wipe the data too. - Slippage and deadlines. Every swap, and every deposit priced from a pool, needs a minimum out from the user.
- Compute and heap. Loops over user-controlled lengths run out of compute units, and the default heap is 32 KB.
After you deploy
The audit covers the code you sent. What's live is a program ID, an upgrade authority and a binary.
solana program show <PROGRAM_ID>
solana-verify get-program-hash -u mainnet-beta <PROGRAM_ID>
solana-verify get-executable-hash target/deploy/my_program.so
The first shows who can upgrade the program. If that's a single hot key, that key is the real admin of everything above. The other two should print the same hash when you build reproducibly (solana-verify build) from the audited commit. If they don't match, the program on mainnet isn't the one that was reviewed. I wrote more about that gap in Your audit covers a commit, not your launch.
Any upgrade that changes accounts or constraints means running the sections above again for the instructions it touched.
Adapted from a checklist our team published on zealynx.io, which has the longer version with all 45 checks.
I wrote this with help from AI for drafting and editing. The opinions and the audit experience are mine.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.