$320M Gone Because Nobody Checked Who Owned the Account
The Wormhole bridge exploit didn't require a genius. It required patience and one observation: the verify_signatures instruction wasn't properly validating that the SignatureSet account was created by the expected program. The attacker spoofed guardian signatures, minted 120,000 wETH out of thin air, and bridged it to Ethereum before most people finished their morning coffee. $320M. Gone.
Here's what stings: Rust is a memory-safe language. Solana's runtime has a ton of built-in constraints. And yet projects on Solana get absolutely wrecked at a rate that should make every founder shipping a Solana program nervous. The reason is that Rust protects you from buffer overflows and memory corruption — it does not protect you from logic errors, missing account validation, or arithmetic mistakes that happen to compile perfectly.
If you're building on Solana, auditing a Solana project, or about to ape into a new Solana protocol, you need to understand the actual attack surface. It's different from EVM. It's not harder or easier — it's just different, and most of the content out there still treats Solana security like it's Ethereum with different syntax.
Here's What Actually Happens in Solana Exploits
Solana's account model is fundamentally different from Ethereum's. Every piece of state lives in an account. Programs are stateless — they read from and write to accounts that get passed in at runtime. This is elegant for parallelism. It's a footgun for security, because your program has to validate every single account that gets passed to it. Miss one check and an attacker passes in their own crafted account where yours was expected.
The four vulnerability classes that show up again and again in Solana program audits:
- Missing account ownership checks — You check that an account exists but not that it's owned by the expected program. An attacker passes in a lookalike account they control.
- Missing signer checks — A privileged instruction doesn't verify the caller actually signed the transaction. Anyone can call it.
- Arithmetic overflow/underflow — Rust in release mode doesn't panic on integer overflow by default in older setups. You get silent wrap-around. Token balances, fee calculations, reward math — all exploitable.
- Account confusion / type confusion — Two different account types have the same data layout. An attacker passes in account type B where type A is expected, and the deserialization succeeds silently.
Anchor eliminates some of this friction. But Anchor is not a security silver bullet — it's a framework that enforces patterns, and developers still regularly misconfigure those patterns or bypass them with UncheckedAccount when they shouldn't.
The Code Reality: Missing Owner Check
Here's a simplified version of the pattern that burned Wormhole — a raw Solana program instruction handler that checks a signer but forgets to check account ownership:
Vulnerable Pattern (Raw Solana/Rust)
// Solana program, Rust — VULNERABLE
pub fn process_withdraw(
program_id: &Pubkey,
accounts: &[AccountInfo],
amount: u64,
) -> ProgramResult {
let account_iter = &mut accounts.iter();
let vault_account = next_account_info(account_iter)?;
let authority = next_account_info(account_iter)?;
let destination = next_account_info(account_iter)?;
// ✅ Checks that authority signed — good
if !authority.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// ❌ MISSING: Does vault_account actually belong to this program?
// An attacker can pass in ANY account with matching data layout
// and the deserialization below will succeed
let vault_data = VaultState::try_from_slice(&vault_account.data.borrow())?;
if vault_data.authority != *authority.key {
return Err(ProgramError::InvalidAccountData);
}
// Transfer happens here — attacker controls vault_data.balance
invoke(
&spl_token::instruction::transfer(
token_program.key,
vault_account.key,
destination.key,
authority.key,
&[],
amount,
)?,
&[...],
)?;
Ok(())
}
The authority signer check is there. The data structure check is there. But nobody verified that vault_account is owned by program_id. An attacker crafts their own account, populates it with VaultState bytes where authority is set to their own public key, passes it in, and drains whatever's on the other side.
Fixed Version (Raw Solana/Rust)
// Solana program, Rust — FIXED
pub fn process_withdraw(
program_id: &Pubkey,
accounts: &[AccountInfo],
amount: u64,
) -> ProgramResult {
let account_iter = &mut accounts.iter();
let vault_account = next_account_info(account_iter)?;
let authority = next_account_info(account_iter)?;
let destination = next_account_info(account_iter)?;
if !authority.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// ✅ ADDED: Verify vault_account is owned by this program
if vault_account.owner != program_id {
return Err(ProgramError::IncorrectProgramId);
}
let vault_data = VaultState::try_from_slice(&vault_account.data.borrow())?;
if vault_data.authority != *authority.key {
return Err(ProgramError::InvalidAccountData);
}
// ✅ Also verify amount doesn't exceed stored balance
if amount > vault_data.balance {
return Err(ProgramError::InsufficientFunds);
}
invoke(
&spl_token::instruction::transfer(
token_program.key,
vault_account.key,
destination.key,
authority.key,
&[],
amount,
)?,
&[...],
)?;
Ok(())
}
One line. That's the difference between a functional program and a drained protocol.
The Anchor Version: Better, But Still Dangerous if You're Sloppy
Anchor's Account<'info, T> type handles owner checks automatically — it verifies the account is owned by the program at deserialization time. This is genuinely good. But here's the thing developers get wrong:
// Anchor framework — DANGEROUS pattern
#[derive(Accounts)]
pub struct Withdraw<'info> {
/// CHECK: trust me bro
pub vault: UncheckedAccount<'info>,
#[account(signer)]
pub authority: Signer<'info>,
pub destination: Account<'info, TokenAccount>,
}
See that UncheckedAccount with a /// CHECK: trust me bro comment? That's not a joke — that's literally what I've seen in real production contracts. Anchor requires you to add a doc comment on UncheckedAccount to acknowledge you're skipping validation. Developers write anything in that comment to make the compiler happy, then ship it.
The correct Anchor pattern:
// Anchor framework — CORRECT pattern
#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(
mut,
has_one = authority, // verifies vault.authority == authority.key()
seeds = [b"vault", authority.key().as_ref()],
bump = vault.bump,
)]
pub vault: Account<'info, VaultState>,
pub authority: Signer<'info>,
#[account(mut)]
pub destination: Account<'info, TokenAccount>,
}
The has_one constraint, the PDA seed validation, the bump verification — all of that matters. Skip any one of them and you've re-introduced the exact class of bug Anchor was designed to prevent.
Arithmetic: The Silent Killer in Rust Programs
Everyone knows about reentrancy on EVM. On Solana, the equivalent silent killer is arithmetic. Rust's debug builds panic on overflow. Release builds — which is what goes to mainnet — wrap around silently unless you use checked math explicitly.
// VULNERABLE — silent overflow in release mode (pre-1.65 patterns still common)
let new_balance = user_balance + deposit_amount; // wraps to 0 if overflow
// FIXED — use checked_add, saturating_add, or enable overflow checks
let new_balance = user_balance
.checked_add(deposit_amount)
.ok_or(ErrorCode::ArithmeticOverflow)?;
Add overflow-checks = true to your Cargo.toml profile for the release build as a backstop, but don't rely on it — use checked math explicitly in any code path touching token amounts, rewards, fees, or timestamps.
How I'd Catch This Before It Ships
When I'm walking through a Solana program audit, here's the actual process — not the high-level framework talk, the real thing:
Step 1: Map every instruction handler. List every pub fn process_* or every #[program] instruction. For each one, write down every account it touches and what it expects from that account.
Step 2: For each account, verify three things. Is the owner checked? Is the signer status verified where expected? Is the data discriminator validated (especially in Anchor — ensure nobody can pass a wrong account type with a matching layout)?
Step 3: Trace every arithmetic path. Grep for raw +, -, *, / operations on any numeric type involved in balances or supply. Every one of them should be a checked_* or saturating_* call.
Step 4: Review PDA derivation. PDAs should use unique, non-reusable seeds where the seed inputs are controlled by the program, not the user. If a user-controlled public key is the only seed input, verify you can't manipulate which PDA gets selected.
Step 5: Check for sysvar forgeries. Programs sometimes accept Clock, Rent, or other sysvars as accounts rather than using the built-in accessor. Always use Clock::get()? instead of accepting a clock account — the account-based approach can be spoofed.
Static analysis tools like Soteria and cargo-audit catch known patterns — they're worth running. But they don't catch the economic logic issues. A fee calculation that rounds in the attacker's favor, a reward distribution that's slightly off per transaction but devastating at scale — those require human (or AI-assisted) contextual analysis.
The Counterintuitive Take Most Solana Developers Need to Hear
Everyone building on Solana thinks Anchor makes their program safe. Anchor makes your program safer by default — that's different. The most dangerous Anchor codebases I've seen aren't the ones that skipped Anchor entirely. They're the ones where developers trusted Anchor to handle security and then peppered the codebase with UncheckedAccount shortcuts wherever Anchor's constraints were inconvenient.
Using UncheckedAccount isn't always wrong. But every single instance of it is a place where you own the validation logic, not the framework. Treat each one like a raw Solana program with zero guardrails — because that's exactly what it is.
Before You Ship, Do This
Run your Solana program through SmartContractAuditor.ai before deployment. It flags the exact patterns covered here — missing owner checks, unchecked arithmetic, UncheckedAccount usage, signer validation gaps — and explains them in plain English so you understand what's actually at risk, not just that a flag fired. Paste your program. It takes 30 seconds. Your users' funds are worth 30 seconds.
The 5 Things to Check Right Now
- Grep your codebase for
UncheckedAccount— every instance needs a legitimate written justification and manual validation in the instruction body, not a compiler-silencing comment. - Search for raw arithmetic operators on balance/supply types —
let x = a + bwhereaorbis a token amount should not exist in your codebase. Replace withchecked_add. - Verify every account's owner field in raw programs before deserializing data from it —
if account.owner != program_id { return Err(...) }should be in every handler. - Check your Anchor constraints for
has_onecoverage — every account relationship (authority, mint, pool, etc.) should be verified by a constraint, not just by your instruction logic. - Run Soteria or
cargo auditlocally before any external review — catch the known patterns so your audit time focuses on the logic issues that tools miss.
The Wormhole attacker didn't need to break cryptography. They needed to know that one account validation was missing and that $320M was sitting on the other side. Your program might not hold $320M — but attackers don't skip small targets. They just don't brag about them.