The classic Solana exploits
Every Solana exploit in the wild fits the same template: untrusted input arrives, a check that should have caught it doesn't, and the handler proceeds on bad data. The specific check that's missing varies. The shape doesn't. Six patterns cover the great majority of historical incidents on Anchor programs. Learn them, recognize them when you read other people's code, and the constraints that prevent them will become reflexes rather than rules.
The shape of every exploit
The mechanics differ across vulnerability classes, but the structural pattern is identical. Something arrives that you can't trust by default. A check is supposed to catch it. The check is missing or wrong. The handler runs as if the input were trustworthy. Bad things happen.
The rest of this lecture walks the six exploits in this shape. Each one has a code snippet that compiles, runs, and is wrong, paired with the version that fixes it. Most of these are caught by Anchor constraints when used correctly. The bugs come from skipping the constraint, weakening it, or using the wrong account type to begin with.
1. Account type confusion
A handler that accepts an UncheckedAccount or AccountInfo where it should accept a typed Account<'info, T> is trusting whatever the caller passes. There's no discriminator check, no owner check, no verification that the bytes mean what your code thinks they mean.
The vulnerable code:
#[derive(Accounts)]
pub struct Withdraw<'info> {
/// CHECK: trust me, it's the vault
#[account(mut)]
pub vault: UncheckedAccount<'info>,
#[account(mut)]
pub recipient: Signer<'info>,
}
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
let vault_data = ctx.accounts.vault.try_borrow_data()?;
let total = u64::from_le_bytes(vault_data[8..16].try_into().unwrap());
require!(total >= amount, ErrorCode::Insufficient);
// ... transfer lamports out
Ok(())
}The attack: the caller passes any account at all. The handler reads bytes from offsets 8 through 16 and interprets them as a u64 total. If those bytes happen to be large enough, the require passes and the withdrawal proceeds. The attacker picks an account they control that decodes to whatever number suits them.
The fix:
#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(mut, seeds = [b"vault"], bump = vault.bump)]
pub vault: Account<'info, Vault>,
#[account(mut)]
pub recipient: Signer<'info>,
}Account<'info, Vault> does three things at deserialization time: it checks that the account's first 8 bytes match the discriminator hash of the Vault struct, it checks that the account's owner is the current program, and the seeds constraint checks that the account address derives from your program with the expected seeds. All three together make the account unspoofable. Any non-Vault account, any Vault owned by another program, or any account at the wrong derived address fails validation before your handler runs.
The rule: never accept UncheckedAccount for an account whose data you intend to read or whose lamports you intend to move. Reserve it for cases where the account is genuinely just a passthrough, such as a seed source or an opaque destination for a token transfer your code doesn't read.
2. Reinitialization
Calling initialization logic on an account that's already been initialized lets the attacker overwrite state that should be permanent. Anchor's init constraint prevents this by checking that the discriminator is zero before allowing init to proceed. Manual init paths that skip this check are the bug class.
The vulnerable code:
#[derive(Accounts)]
pub struct InitializeVault<'info> {
/// CHECK: manual init, no constraint
#[account(mut)]
pub vault: UncheckedAccount<'info>,
pub payer: Signer<'info>,
pub system_program: Program<'info, System>,
}
pub fn initialize(ctx: Context<InitializeVault>) -> Result<()> {
let vault = &mut ctx.accounts.vault;
let mut data = vault.try_borrow_mut_data()?;
// Write discriminator + admin + counters manually...
data[8..40].copy_from_slice(ctx.accounts.payer.key().as_ref());
Ok(())
}The attack: any caller can invoke initialize on the existing vault account, overwriting the stored admin pubkey with their own. The original admin is gone. The attacker now controls every privileged operation that checks the vault's admin field.
This pattern shows up most often when someone tries to handle account creation without the init constraint, usually because they want custom behavior the macro doesn't expose. The fix is to use init and put the custom behavior elsewhere, never to skip the safety check.
The fix:
#[derive(Accounts)]
pub struct InitializeVault<'info> {
#[account(
init,
payer = payer,
space = 8 + Vault::INIT_SPACE,
seeds = [b"vault"],
bump,
)]
pub vault: Account<'info, Vault>,
#[account(mut)]
pub payer: Signer<'info>,
pub system_program: Program<'info, System>,
}The init constraint fails at validation if the vault account already exists with the right discriminator. Re-initializing the same account is impossible. If you need a re-initialization path on purpose, which is rare but does happen, make it a separate instruction with an explicit auth check, and require the existing admin's signature.
3. Arbitrary CPI
When your handler invokes another program via CPI, you have to know which program you're calling. If the program ID comes from caller-supplied inputs and your code uses it without verification, an attacker substitutes their own program and runs arbitrary code with the signers and accounts you provided.
The vulnerable code:
#[derive(Accounts)]
pub struct Deposit<'info> {
#[account(mut)]
pub user_token: Account<'info, TokenAccount>,
#[account(mut)]
pub vault_token: Account<'info, TokenAccount>,
pub user: Signer<'info>,
/// CHECK: token program
pub token_program: UncheckedAccount<'info>,
}
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
let cpi_accounts = anchor_spl::token::Transfer {
from: ctx.accounts.user_token.to_account_info(),
to: ctx.accounts.vault_token.to_account_info(),
authority: ctx.accounts.user.to_account_info(),
};
let cpi_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
cpi_accounts,
);
anchor_spl::token::transfer(cpi_ctx, amount)?;
Ok(())
}The attack: the caller passes their own program ID in place of the SPL Token program. Their program receives the CPI with the user signer's authority and the user's token account. It can do anything an authorized program could, including transferring the user's tokens to an attacker-controlled wallet instead of the vault. From the user's perspective, they signed a deposit, and lost their balance.
The fix is to pin the program ID:
pub token_program: Program<'info, Token>,Program<'info, Token> checks that the account at this slot has the SPL Token program's exact pubkey. Any other program fails validation. The same pattern applies to every other program you CPI into: Program<'info, AssociatedToken>, Program<'info, System>, or for custom programs, Program<'info, MyOtherProgram>, where MyOtherProgram is the IDL type emitted by the other program's build.
The rule: every program account in your Accounts struct should be Program<'info, T> for some specific T. Never UncheckedAccount. Never AccountInfo.
4. Integer overflow
Rust's debug builds panic on integer overflow. Release builds wrap. Solana programs are compiled with release-style overflow behavior by default. An unchecked += on a u64 silently wraps to zero past u64::MAX, with no error and no log.
The vulnerable code:
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
let vault = &mut ctx.accounts.vault;
vault.total_deposits += amount;
Ok(())
}
pub fn calculate_reward(stake: u64, multiplier: u64) -> u64 {
stake * multiplier
}The attack: an attacker deposits an amount that, when added to the current total, exceeds u64::MAX. The total_deposits field wraps to a small value. If the protocol uses total_deposits to calculate user share, every other user's share just multiplied. If the protocol caps withdrawals by total_deposits, the attacker can now withdraw essentially nothing, but they've corrupted the accounting for everyone.
The reward calculation is even worse. A large stake multiplied by a large multiplier overflows, producing a small number that doesn't match expectations. Or, on platforms where the calculation feeds into a payout, an attacker who can influence either input can engineer an overflow that returns a wildly profitable value.
The fix is checked_* arithmetic on every site:
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
let vault = &mut ctx.accounts.vault;
vault.total_deposits = vault
.total_deposits
.checked_add(amount)
.ok_or(VaultError::Overflow)?;
Ok(())
}
pub fn calculate_reward(stake: u64, multiplier: u64) -> Result<u64> {
stake.checked_mul(multiplier).ok_or(VaultError::Overflow.into())
}checked_add, checked_sub, checked_mul, checked_div each return Option<u64> that's None on overflow. The .ok_or(...)? converts a None into your custom error, which aborts the handler. Every arithmetic site on any type that can overflow, including u64, i64, and u128, must use the checked variant. There are no exceptions worth taking the risk for.
5. Rent-exemption drain
A withdrawal handler that doesn't account for the rent-exempt minimum will either fail in production with a confusing error, or worse, briefly leave a window where the account exists below rent-exempt. Solana's runtime now rejects transactions that leave an account below rent-exempt at instruction boundaries, but the bug class is still worth understanding because the fix is the same.
The vulnerable code:
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
let vault_info = ctx.accounts.vault.to_account_info();
let vault_lamports = vault_info.lamports();
require!(amount <= vault_lamports, VaultError::Insufficient);
**vault_info.try_borrow_mut_lamports()? -= amount;
**ctx.accounts.recipient.try_borrow_mut_lamports()? += amount;
Ok(())
}The attack scenario: the admin sees their vault holds 5 SOL. They call withdraw with amount = 5 SOL. The check 5 SOL <= 5 SOL passes. The transfer goes through. The vault account is now at zero lamports, but its data buffer is still allocated, putting it below the rent-exempt threshold. The transaction either fails at runtime with an error less obvious than a clean require, or, in older Solana versions, the account would have been silently deallocated, allowing an attacker to recreate it at the same address and confuse future operations.
The fix is to compute the available balance correctly:
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
let vault_info = ctx.accounts.vault.to_account_info();
let rent = Rent::get()?;
let rent_exempt_minimum = rent.minimum_balance(vault_info.data_len());
let available = vault_info
.lamports()
.checked_sub(rent_exempt_minimum)
.ok_or(VaultError::BelowRentExempt)?;
require!(amount <= available, VaultError::Insufficient);
**vault_info.try_borrow_mut_lamports()? -= amount;
**ctx.accounts.recipient.try_borrow_mut_lamports()? += amount;
Ok(())
}Rent::get()?.minimum_balance(data_len) returns the exact rent-exempt threshold for an account of that size. Subtracting it from the vault's balance gives the actually-withdrawable amount. Withdrawals greater than this fail with a clear error before the lamport math runs. The vault stays alive at exactly the rent-exempt minimum even after a full drain.
6. The init_if_needed footgun
init_if_needed is convenient: the account is created if it doesn't exist, and re-used if it does. The footgun is in the "re-used" branch. Your handler may have been written assuming the account is fresh with all fields at their default values, but if the account already exists, the fields hold whatever values they had before. Writing your handler as if init always happened produces bugs that range from "user loses their position" to "attacker keeps a flag set across calls."
The vulnerable code:
#[derive(Accounts)]
pub struct CreateOffer<'info> {
#[account(
init_if_needed,
payer = maker,
space = 8 + Offer::INIT_SPACE,
seeds = [b"offer", maker.key().as_ref()],
bump,
)]
pub offer: Account<'info, Offer>,
#[account(mut)]
pub maker: Signer<'info>,
pub system_program: Program<'info, System>,
}
pub fn create_offer(ctx: Context<CreateOffer>, price: u64) -> Result<()> {
let offer = &mut ctx.accounts.offer;
offer.maker = ctx.accounts.maker.key();
offer.price = price;
// assumes offer.accepted is still false (default)
// but if this is a re-use, it might already be true...
Ok(())
}
pub fn accept_offer(ctx: Context<AcceptOffer>) -> Result<()> {
let offer = &mut ctx.accounts.offer;
require!(!offer.accepted, OfferError::AlreadyAccepted);
offer.accepted = true;
// transfer NFT to maker, send SOL to taker
Ok(())
}The attack: the maker creates an offer for 1 SOL. A buyer accepts it, paying 1 SOL and receiving the NFT. Now offer.accepted == true. The maker calls create_offer again with a new price. The init_if_needed branch sees the existing offer, doesn't reset accepted, and the handler writes the new price but leaves accepted = true. The maker calls accept_offer. The check require!(!offer.accepted, ...) fails. So far so good.
But change the example slightly. Suppose create_offer explicitly sets accepted = false as part of its body. Now after the re-use, the offer state is {maker, new_price, accepted = false}, and the original buyer's payment trail is gone. The maker collects again. The original NFT transfer is now disputable. Depending on the order of state changes, an attacker can engineer scenarios where flags get reset incorrectly across re-uses.
The general fix is to detect new-vs-existing explicitly:
pub fn create_offer(ctx: Context<CreateOffer>, price: u64) -> Result<()> {
let offer = &mut ctx.accounts.offer;
// detect first-use: a freshly init'd account has maker == default()
let is_new = offer.maker == Pubkey::default();
require!(is_new, OfferError::OfferExists);
offer.maker = ctx.accounts.maker.key();
offer.price = price;
// No need to reset accepted; is_new guarantees the account was just created.
Ok(())
}The check offer.maker == Pubkey::default() is true if and only if the account was zero-initialized by this instruction. If the account already exists, the maker field will be set, the require fails, and the handler refuses to overwrite. To create a new offer after a previous one closed, the user has to explicitly close the old account first.
The alternative, when you really want lazy creation, is plain init paired with a separate "register" instruction that the user calls once. The donor vault from the first milestone used init_if_needed because the spec required it and we covered the new-vs-existing detection mechanism explicitly. Outside that constrained scenario, prefer plain init when you can.
Six exploits, six fixes
These six cover the great majority of historical Anchor program incidents. There are others, including signer-check omissions where a Signer type slot is accidentally typed as AccountInfo, missing seed validation on PDA accounts, and ownership confusion across CPIs, but the pattern is the same in every case. Some input flows into a privileged operation through a check that's missing, weakened, or wrong.
What you actually do day to day
Two habits prevent most of these in practice. First, default to typed Anchor accounts: Account<'info, T>, Signer<'info>, Program<'info, T>. Reach for UncheckedAccount only when you've decided consciously that you don't need the protection, and write a /// CHECK: comment explaining why. The comment is a forcing function for thinking about whether the unchecked account is actually safe.
Second, treat every piece of caller-supplied data as adversarial until you've verified it. Every account: did Anchor check the discriminator and owner? Every program ID: is it pinned via Program<'info, T>? Every numeric argument: is the arithmetic checked? Every state field after init_if_needed: did you handle the existing-account case? The pattern recognition gets faster with practice, and after the first time you've audited a few real programs, you'll spot the shape of an exploit before you finish reading the function signature.
When something feels off about a piece of code you're reviewing, the question to ask is "what input could make this go wrong?" Walk through the cases. If the answer is "none, because the constraint catches it," the code is probably fine. If the answer is "I'd have to look at how this is called elsewhere," that's the signal to look at how it's called elsewhere. Most exploits in production were preventable by exactly this exercise, done once, by the original author or a careful reviewer.