Solana Security: The $500M+ Lesson
$325 million stolen from Wormhole. $115 million drained from Mango Markets. $52 million vanished from Cashio. These aren't hypothetical scenarios or theoretical attacks. These are real hacks that happened on Solana, affecting real projects, and costing real money to real people.
Between 2021 and 2025, Solana-based protocols have lost over $450 million to exploits, with individual incidents ranging from hundreds of thousands to hundreds of millions of dollars. The DEXX hack in November 2024 alone affected over 9,000 wallets and drained $30 million. In January 2025, the NoOnes bridge exploit siphoned $8 million across multiple chains.
Here's what makes this terrifying: most of these hacks weren't sophisticated zero-days or nation-state attacks. They were simple mistakes. Missing signer checks. Unverified account ownership. Fake accounts accepted as real. The kind of bugs that could have been caught with proper validation.
If you're building on Solana, you're not just writing code. You're handling other people's money. Every missing check is a potential exploit. Every unvalidated account is a backdoor. This guide breaks down 15 critical vulnerabilities that have cost projects millions, shows you exactly how each exploit worked, and teaches you how to fix them before they cost you everything.
The Reality of Solana Security
Solana's architecture is fundamentally different from EVM chains. Programs are stateless. Accounts are passed in by users. Nothing can be trusted by default. This creates unique security challenges that developers coming from Ethereum often miss.
The golden rule: Trust nothing. Not the account passed in. Not its owner. Not the signer. Not the data inside it. Every single thing must be explicitly validated.
The examples below cover 15 vulnerabilities from intermediate to advanced. Each has both a vulnerable and a secure version, with tests that demonstrate the exploit and confirm the fix. They are implemented in Anchor and Pinocchio so you can see how different frameworks handle the same issues.
1. Missing Signer Check
Type: Access Control | Severity: Critical
What Breaks
Your program needs someone to authorize an action (like withdrawing from a vault). You check that the right public key is present in the instruction, but you never verify that the key's owner actually signed the transaction. An attacker can pass anyone's public key without owning it, and your program executes the privileged action anyway.
Real Exploit
In August 2021, a hacker attempted to steal $2 million from Solend by manipulating protocol parameters. They bypassed admin checks by passing the admin's public key without signing with it. The protocol checked "is this the admin's key?" but not "did the admin actually sign this?" The attack was caught before funds were lost, but it exposed a critical flaw that exists in countless programs.
The Attack Pattern
1. Vault stores authority = "AuthPubkey123"
2. Attacker sends instruction with authority account = "AuthPubkey123"
3. Program checks: vault.authority == passed_authority.key ✓
4. Program never checks: passed_authority.is_signer
5. Withdrawal executes, funds gone
The attacker never needed the private key. They just needed to know the public key and pass it through.
How to Fix
Anchor: Use Signer<'info> instead of AccountInfo
pub struct Withdraw<'info> {
#[account(mut)]
pub vault: Account<'info, Vault>,
pub authority: Signer<'info>, // Enforces signature check
}
Pinocchio/Native: Manually verify the signer flag
if !authority.is_signer() {
return Err(ProgramError::MissingRequiredSignature);
}
The fix is simple. The cost of missing it is catastrophic.
2. Missing Owner Check
Type: Access Control | Severity: Critical
What Breaks
Every Solana account has an "owner" field indicating which program controls it. Your program acts on an account (like reading vault data) without checking if your program actually owns it. An attacker creates a fake account with identical data layout but owned by a different program. Your program reads it, trusts it, and gets exploited.
Real Exploit
The Solend exploit in August 2021 involved creating fake lending markets with manipulated parameters. Because the program didn't verify account ownership, the attacker's fake accounts were treated as legitimate protocol accounts. They set liquidation thresholds to 1%, liquidation bonuses to 90%, and attempted to liquidate healthy positions for massive profit.
In the Crema Finance hack (July 2022, $8.8M stolen), the attacker created a fake "Tick" account with false price data. This fake account wasn't owned by Crema's program, but because ownership wasn't validated, it was accepted as real. The attacker used this fake data to claim inflated LP fees through flash loans.
The Attack Pattern
1. Real vault owned by YourProgram, has authority + balance
2. Attacker creates fake vault owned by SystemProgram
3. Fake vault has same data layout, attacker's key as authority
4. Program accepts fake vault, reads attacker's authority
5. Program allows withdrawal, funds stolen
How to Fix
Anchor: Use Account<'info, T> which automatically verifies ownership
#[account(mut)]
pub vault: Account<'info, Vault>, // Checks owner == program_id
Pinocchio/Native: Manually check the owner field
if account.owner() != program_id {
return Err(ProgramError::IllegalOwner);
}
Ownership checks are not optional. They're the foundation of Solana security.
3. Account Data Matching
Type: Account Validation | Severity: High
What Breaks
An account might be the correct type and owned by your program, but it's not the specific account you need. For example, accepting any token account when you specifically need the pool's token account. The attacker substitutes their own account that passes type checks but doesn't match the required relationships.
Real Exploit
This vulnerability enabled the Solend oracle manipulation attack (November 2022, $1.26M stolen). The protocol accepted USDH as collateral but only checked price data from a single Saber pool. The attacker traded between Saber and Orca, manipulating the Saber pool price while keeping costs low. They deposited USDH valued at the inflated price ($8.80 instead of $1.00), borrowed against it, and defaulted on the loan.
The root cause: the oracle only validated that it was reading a price feed, not that it was reading the correct price feed that represented true market prices across multiple sources.
The Attack Pattern
1. Pool expects token account for MintA
2. Attacker passes token account for MintB (which they control)
3. Both are valid token accounts, both owned by TokenProgram
4. Program accepts it because type matches
5. Attacker manipulates their own mint, drains pool
How to Fix
Anchor: Use constraints to validate relationships
#[account(
constraint = user_token.mint == pool.mint,
constraint = user_token.owner == user.key()
)]
pub user_token: Account<'info, TokenAccount>,
Pinocchio/Native: Manually compare public keys
if token_account.mint != expected_mint {
return Err(ProgramError::InvalidAccountData);
}
if token_account.owner != expected_owner {
return Err(ProgramError::InvalidAccountData);
}
Validate not just type, but context. Every relationship matters.
4. Type Cosplay
Type: Account Validation | Severity: Critical
What Breaks
You have multiple account types (Vault, User) with similar data layouts. If certain fields align at the same byte offsets, an attacker can pass one type where another is expected. Your program reads a User's data as if it were a Vault, misinterpreting field meanings entirely.
Real Exploit
While there's no single famous "type cosplay" hack, this vulnerability class has been identified in multiple security audits. Programs that store different account types without discriminators are vulnerable to having accounts misinterpreted. If a User struct and a Vault struct both have a u64 at offset 8, an attacker can create a User with a high value there and pass it as a Vault, making the program think it has more funds than it does.
The Attack Pattern
Vault: [discriminator: 8 bytes][authority: 32 bytes][balance: 8 bytes]
User: [discriminator: 8 bytes][user_key: 32 bytes][points: 8 bytes]
1. Attacker creates User with 1,000,000 points
2. Passes User account to instruction expecting Vault
3. Program reads offset 48 (balance field)
4. Gets 1,000,000 (actually the points field)
5. Allows withdrawal based on fake balance
How to Fix
Anchor: Use discriminators automatically via Account<'info, T>
// Anchor adds 8-byte discriminator to each type automatically
#[account]
pub struct Vault {
pub authority: Pubkey,
pub balance: u64,
}
Pinocchio/Native: Implement and verify discriminators manually
const VAULT_DISCRIMINATOR: [u8; 8] = /* hash("account:Vault") */;
let disc = &account.data[0..8];
if disc != VAULT_DISCRIMINATOR {
return Err(ProgramError::InvalidAccountData);
}
Every account type needs a unique identifier. No exceptions.
5. PDA Bump Seed Canonicalization
Type: Account Validation | Severity: High
What Breaks
Program Derived Addresses (PDAs) are found using seeds and a "bump" value. find_program_address returns the canonical bump (usually 255, the first valid one). But other bumps can also create valid PDAs at different addresses. If your program accepts any valid bump without checking it's canonical, attackers can create shadow PDAs at different addresses for the same logical seeds.
Real Exploit
This vulnerability has been found in numerous audits. If a program stores "vault" PDAs using [b"vault", user_pubkey] as seeds but doesn't enforce the canonical bump, an attacker can:
- Find the canonical PDA at bump 255
- Find another valid PDA at bump 254 (different address)
- Initialize the non-canonical PDA
- Now two "vaults" exist for the same user
- Attacker controls one, can drain funds or create accounting chaos
The impact depends on program logic, but it fundamentally breaks the assumption that "one set of seeds = one address."
The Attack Pattern
Seeds: [b"vault", user_pubkey]
Canonical bump: 255 -> Address A
Non-canonical bump: 254 -> Address B
1. Real vault at Address A, bump 255
2. Attacker creates account at Address B, bump 254
3. Program accepts both as valid (both pass PDA checks)
4. Attacker has shadow vault, can bypass restrictions
How to Fix
Anchor: Store and verify the canonical bump
#[account(
init,
payer = user,
space = 8 + 32 + 1,
seeds = [b"vault", user.key().as_ref()],
bump // Anchor finds and stores canonical bump
)]
pub vault: Account<'info, Vault>,
// Later validations automatically check:
// seeds = [b"vault", user.key().as_ref()],
// bump = vault.bump // Must match stored canonical bump
Pinocchio/Native: Find and store canonical bump explicitly
let (pda, bump) = Pubkey::find_program_address(
&[b"vault", user_pubkey.as_ref()],
program_id
);
// Store bump in account data
vault.bump = bump;
// Later, verify it matches
if account_key != Pubkey::create_program_address(
&[b"vault", user_pubkey.as_ref(), &[vault.bump]],
program_id
)? {
return Err(ProgramError::InvalidSeeds);
}
One PDA per seed set. Enforce it.
6. Account Reinitialization
Type: Account Validation | Severity: Critical
What Breaks
An initialize instruction can be called on an already-initialized account, overwriting its data. An attacker calls initialize on someone else's vault, overwrites the authority field with their key, and takes control of existing funds.
Real Exploit
This was a core vulnerability in early Solana programs before best practices solidified. The pattern showed up in the Slope wallet compromise (though that was ultimately a key exposure issue), where re-initialization concerns were part of the broader security review that followed.
The Attack Pattern
1. User creates vault, authority = User's key
2. Vault accumulates funds
3. Attacker calls initialize on same vault account
4. Program overwrites authority = Attacker's key
5. Attacker now controls user's funds
How to Fix
Anchor: Use init which fails if account exists
#[account(
init, // Fails if account already has data
payer = user,
space = 8 + 32 + 8
)]
pub vault: Account<'info, Vault>,
Pinocchio/Native: Check if already initialized
if vault.is_initialized {
return Err(ProgramError::AccountAlreadyInitialized);
}
// Or check discriminator
if account.data[0..8] != [0u8; 8] {
return Err(ProgramError::AccountAlreadyInitialized);
}
Initialize once. Verify always.
7. Arbitrary CPI
Type: Cross-Program Security | Severity: Critical
What Breaks
Cross-Program Invocations (CPI) let your program call other programs. If you accept the target program ID from user input without validation, an attacker can point it to their malicious program. Your program invokes the fake program with your authority, and the attacker's code runs with your program's privileges.
Real Exploit
While no single massive CPI exploit made headlines, this vulnerability class is found regularly in audits. The danger is particularly acute when programs invoke token transfers or other privileged operations. If the program ID isn't hardcoded or validated, an attacker's program can receive your PDA signatures and drain funds.
The Attack Pattern
1. Program intends to CPI to Token Program for transfer
2. Program accepts token_program account from user
3. Attacker passes their malicious program instead
4. CPI executes to malicious program with PDA signature
5. Malicious program has your program's authority, drains funds
How to Fix
Anchor: Use Program<'info, T> to fix program at compile time
pub struct Transfer<'info> {
pub token_program: Program<'info, Token>, // Must be Token Program
}
Pinocchio/Native: Hardcode and verify program IDs
const TOKEN_PROGRAM_ID: Pubkey = /* known token program */;
if program.key != TOKEN_PROGRAM_ID {
return Err(ProgramError::IncorrectProgramId);
}
Never CPI to user-supplied program IDs. Ever.
8. Integer Overflow
Type: Arithmetic Safety | Severity: High
What Breaks
Arithmetic operations overflow or underflow without panicking. A user withdraws 11 tokens from a balance of 10. With unchecked math, 10 - 11 wraps to maximum value (18 quintillion for u64). User now has infinite balance.
Real Exploit
The Nirvana Finance hack (July 2022, $3.5M stolen) involved arithmetic manipulation. While not a simple overflow, the exploit relied on the protocol's pricing curve calculation being vulnerable to extreme inputs that created artificial price inflation. The attacker used flash loans to manipulate internal accounting, ultimately draining the treasury.
Overflow vulnerabilities have been found in numerous audits across DeFi protocols. They're particularly dangerous in reward calculations, fee distributions, and price computations.
The Attack Pattern
Balance: 10 tokens
Withdrawal: 11 tokens
Unchecked: 10 - 11 = 18446744073709551615 (wraparound)
Checked: 10 - 11 = Error
1. User deposits 10 tokens
2. User withdraws 11 tokens
3. With unchecked math, balance wraps to maximum
4. User can now withdraw infinite tokens
How to Fix
Rust/Anchor: Use checked arithmetic
// Bad
vault.balance = vault.balance - amount;
// Good
vault.balance = vault.balance
.checked_sub(amount)
.ok_or(ProgramError::InsufficientFunds)?;
// Also good
vault.balance = vault.balance
.saturating_sub(amount);
Enable overflow checks in Cargo.toml
[profile.release]
overflow-checks = true
Check every operation. Assume users will try to break math.
9. Closing Accounts Without Clearing Data And Rent Siphoning
Type: State Management | Severity: High
What Breaks
Closing an account transfers its lamports (rent) to another account but doesn't zero its data. The account sits dormant with old data intact. An attacker re-funds it with minimal lamports before garbage collection. The account "revives" with stale authority, state, or permissions, and the attacker can abuse the old data.
Real Exploit
This has been a persistent issue in Solana programs. The Raydium exploit (December 2022, $4.4M stolen) involved compromised admin keys, but during the security review afterward, account closure procedures were scrutinized. Proper account closure prevents many attack vectors that rely on resurrecting old state.
The Attack Pattern
1. User closes vault: transfers lamports, leaves data
2. Account sits with 0 lamports, old authority in data
3. Attacker funds account with 1 lamport in same tx
4. Account "alive" again with stale authority
5. Program treats revived account as valid
How to Fix
Anchor: Use close constraint which zeros data
#[account(
mut,
close = destination // Transfers lamports AND zeros data
)]
pub vault: Account<'info, Vault>,
Pinocchio/Native: Explicitly zero data and transfer lamports
// Zero all data
account.data.fill(0);
// Transfer lamports
**destination.lamports.borrow_mut() += **account.lamports.borrow();
**account.lamports.borrow_mut() = 0;
Dead accounts must stay dead. Zero everything.
10. Duplicate Mutable Accounts
Type: Account Validation | Severity: High
What Breaks
An instruction accepts two mutable accounts (source and destination for a transfer). It doesn't verify they're different. Attacker passes the same account for both. Depending on read/write order, this can double balances, skip fees, or create accounting chaos.
Real Exploit
This vulnerability appeared in the Jet Protocol discovery (December 2021, potential $25M if exploited). The issue involved how the protocol handled position arrays when accounts were closed. While the specific bug was different, it highlighted how account relationship assumptions can be violated when the same account appears multiple times.
The Attack Pattern
Transfer 50 from source to destination
Source = Destination = Same account (balance 100)
Vulnerable code:
source.balance -= 50 // balance = 50
dest.balance += 50 // balance = 100 (if reading stale value)
Result: Created 50 tokens from nothing
How to Fix
Anchor: Add constraint to enforce different accounts
#[account(
mut,
constraint = source.key() != destination.key()
)]
pub source: Account<'info, TokenAccount>,
#[account(mut)]
pub destination: Account<'info, TokenAccount>,
Pinocchio/Native: Explicitly check equality
if source.key() == destination.key() {
return Err(ProgramError::InvalidArgument);
}
Assume users will try every input combination. Defend against all of them.
11. Insecure Randomness
Type: Randomness | Severity: Medium-High
What Breaks
Program needs randomness (lottery, game, NFT reveal) and uses on-chain data like slot number, timestamp, or recent blockhash. These values are public and predictable. Attackers (or validators) simulate outcomes and only submit transactions when they win.
Real Exploit
Numerous NFT mints and gaming applications on Solana have been vulnerable to randomness manipulation. Users can simulate mints locally, check which slots produce rare traits, and only submit during those slots. This turned "random" drops into deterministic farming.
In DeFi, oracle-based games and prediction markets have been manipulated when they relied on predictable on-chain values instead of verifiable random functions.
The Attack Pattern
Game uses: winner = slot % 100 < 10 (10% win rate)
1. Attacker simulates game at current slot: loses
2. Simulates at slot + 1: loses
3. Simulates at slot + 2: wins!
4. Waits for slot + 2, submits transaction
5. Guaranteed win, "random" game broken
How to Fix
DON'T USE:
let random = Clock::get()?.slot % 100; // Predictable
let random = Clock::get()?.unix_timestamp; // Predictable
let random = recent_blockhashes[0]; // Predictable
DO USE:
- Switchboard VRF
- Chainlink VRF
- Commit-reveal schemes
- Off-chain randomness with on-chain verification
// Example: Switchboard VRF
#[account(mut)]
pub vrf: AccountLoader<'info, VrfAccountData>,
// Verify VRF was properly requested and callback received
// Use vrf.result.value as randomness
Additional Solana Security Vulnerabilities to Consider
Based on extensive research into recent Solana exploits and security audits, here are critical additional vulnerabilities that should be included in your security guide:
12. Sysvar Account Validation (CRITICAL)
Priority: HIGH - This caused the $325M Wormhole hack
What It Is
Solana has system accounts called "sysvars" that contain cluster information (Clock, Rent, Instructions, etc.). Programs trust these to get authentic system data. If you don't verify that a sysvar account is actually THE system sysvar, attackers can pass fake sysvar accounts with manipulated data.
Real Exploit
Wormhole Bridge Exploit (February 2022, $325M)
- The bridge used
load_instruction_at()to check if signature verification happened - This function reads instruction data but DOESN'T verify the account is the real Instructions sysvar
- Attacker created fake Instructions sysvar with fake signature verification data
- Bridge thought signatures were verified when they weren't
- Attacker minted 120,000 ETH on Solana and bridged it to Ethereum
The Attack Pattern
1. Program expects Sysvar::Instructions account
2. Attacker creates fake account with same data layout
3. Program uses deprecated load_instruction_at() without checking account ID
4. Fake data is read as authentic system data
5. Critical validation bypassed
How to Fix
Anchor: Use constraints to verify sysvar address
use anchor_lang::prelude::*;
#[derive(Accounts)]
pub struct VerifyInstruction<'info> {
/// CHECK: Verified via constraint
#[account(address = sysvar::instructions::ID)]
pub instruction_sysvar: AccountInfo<'info>,
}
pub fn process(ctx: Context<VerifyInstruction>) -> Result<()> {
// Safe to use - address constraint verified it's the real sysvar
let instruction_data = load_current_index_checked(
&ctx.accounts.instruction_sysvar
)?;
Ok(())
}
Pinocchio/Native: Manually verify sysvar public key
use solana_program::{
sysvar::{instructions, clock, rent},
account_info::AccountInfo,
pubkey::Pubkey,
program_error::ProgramError,
};
pub fn verify_instructions_sysvar(
instructions_account: &AccountInfo,
) -> Result<(), ProgramError> {
// Verify this is the real Instructions sysvar
if instructions_account.key != &instructions::ID {
return Err(ProgramError::InvalidArgument);
}
// Now safe to use
let instruction_data = instructions_account.data.borrow();
// Process instruction data...
Ok(())
}
// For Clock sysvar
pub fn verify_clock_sysvar(
clock_account: &AccountInfo,
) -> Result<(), ProgramError> {
if clock_account.key != &clock::ID {
return Err(ProgramError::InvalidArgument);
}
let clock = Clock::from_account_info(clock_account)?;
// Use clock data safely...
Ok(())
}
Impact: $325M stolen. This is one of the largest DeFi hacks ever.
13. Account Reload After CPI (HIGH SEVERITY)
What It Is
When you make a CPI (cross-program invocation), the called program can modify accounts. BUT Anchor/Rust doesn't automatically update your in-memory copy of the account data. You're operating on stale data.
Real Risk
Found in numerous audits. Programs that:
- Transfer tokens via CPI
- Then check balances
- Make decisions based on old balance data Can be exploited to bypass spending limits, drain funds, or manipulate state.
The Attack Pattern
1. Program loads account balance: 100 tokens
2. Program makes CPI that transfers 50 tokens
3. Program still thinks balance is 100 (stale data)
4. Program allows another withdrawal based on wrong balance
5. Account actually has 50 but program thinks 100
How to Fix
Anchor: Use .reload() method after CPI
use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount, Transfer};
pub fn transfer_and_check(ctx: Context<TransferAndCheck>, amount: u64) -> Result<()> {
let balance_before = ctx.accounts.token_account.amount;
// Make CPI transfer
let cpi_accounts = Transfer {
from: ctx.accounts.token_account.to_account_info(),
to: ctx.accounts.destination.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
};
let cpi_ctx = CpiContext::new(ctx.accounts.token_program.to_account_info(), cpi_accounts);
token::transfer(cpi_ctx, amount)?;
// CRITICAL: Reload to get fresh data after CPI
ctx.accounts.token_account.reload()?;
// Now we have the correct balance
require!(
ctx.accounts.token_account.amount >= MIN_BALANCE,
ErrorCode::BalanceTooLow
);
Ok(())
}
#[derive(Accounts)]
pub struct TransferAndCheck<'info> {
#[account(mut)]
pub token_account: Account<'info, TokenAccount>,
#[account(mut)]
pub destination: Account<'info, TokenAccount>,
pub authority: Signer<'info>,
pub token_program: Program<'info, Token>,
}
Pinocchio/Native: Re-deserialize account after CPI
use solana_program::{
account_info::AccountInfo,
program::{invoke, invoke_signed},
program_error::ProgramError,
};
use spl_token::state::Account as TokenAccount;
pub fn transfer_and_check<'a>(
token_account: &AccountInfo<'a>,
destination: &AccountInfo<'a>,
authority: &AccountInfo<'a>,
token_program: &AccountInfo<'a>,
amount: u64,
) -> Result<(), ProgramError> {
// Make CPI transfer
let transfer_instruction = spl_token::instruction::transfer(
token_program.key,
token_account.key,
destination.key,
authority.key,
&[],
amount,
)?;
invoke(
&transfer_instruction,
&[
token_account.clone(),
destination.clone(),
authority.clone(),
token_program.clone(),
],
)?;
// CRITICAL: Re-deserialize to get fresh data
let token_data = TokenAccount::unpack(&token_account.data.borrow())?;
// Now check with correct balance
if token_data.amount < MIN_BALANCE {
return Err(ProgramError::InsufficientFunds);
}
Ok(())
}
Impact: Found in multiple audits, potential for fund loss when combined with spending limits or balance checks.
14. Rent Siphoning / Account Revival (MEDIUM-HIGH)
What It Is
Beyond just not zeroing data on close, there's a more subtle attack:
- Attacker can keep "closed" accounts alive by adding lamports
- Or prevent account creation by pre-funding the address
- Or drain rent from accounts that drop below rent-exempt threshold
Real Issues
Not a single famous exploit, but found repeatedly in audits and can be combined with other attacks.
Attack Patterns
Pattern 1: Account Revival (covered in your guide)
Already in your guide as "Closing Accounts"
Pattern 2: Account Squatting
1. Program tries to create account at deterministic address
2. Attacker pre-funds that address with 1 lamport
3. Create instruction fails (account already exists)
4. Denial of service, program can't initialize
Pattern 3: Rent Drain
1. Account drops below rent-exempt minimum
2. Solana starts deducting rent per epoch
3. Account slowly drains to zero
4. Account becomes unusable
How to Fix
Anchor: Use init constraints and rent checks
use anchor_lang::prelude::*;
// Prevent account squatting during initialization
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(
init,
payer = user,
space = 8 + 32 + 8,
seeds = [b"vault", user.key().as_ref()],
bump
)]
pub vault: Account<'info, Vault>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
pub rent: Sysvar<'info, Rent>,
}
// Verify rent exemption for critical accounts
pub fn verify_rent_exempt(ctx: Context<CheckRent>) -> Result<()> {
let account = &ctx.accounts.critical_account;
let rent = Rent::get()?;
require!(
rent.is_exempt(account.to_account_info().lamports(), account.to_account_info().data_len()),
ErrorCode::NotRentExempt
);
Ok(())
}
// Proper account closing
#[derive(Accounts)]
pub struct Close<'info> {
#[account(
mut,
close = destination, // Transfers lamports AND zeros data
has_one = authority
)]
pub vault: Account<'info, Vault>,
pub authority: Signer<'info>,
#[account(mut)]
pub destination: SystemAccount<'info>,
}
Pinocchio/Native: Manual checks and proper closing
use solana_program::{
account_info::AccountInfo,
program_error::ProgramError,
rent::Rent,
sysvar::Sysvar,
system_instruction,
program::invoke,
};
// Check if account is uninitialized before creating
pub fn safe_initialize<'a>(
account: &AccountInfo<'a>,
payer: &AccountInfo<'a>,
space: usize,
owner: &Pubkey,
system_program: &AccountInfo<'a>,
) -> Result<(), ProgramError> {
// Prevent account squatting
if account.lamports() > 0 {
return Err(ProgramError::AccountAlreadyInitialized);
}
// Create account
let rent = Rent::get()?;
let lamports = rent.minimum_balance(space);
invoke(
&system_instruction::create_account(
payer.key,
account.key,
lamports,
space as u64,
owner,
),
&[payer.clone(), account.clone(), system_program.clone()],
)?;
Ok(())
}
// Verify rent exemption
pub fn verify_rent_exempt(
account: &AccountInfo,
) -> Result<(), ProgramError> {
let rent = Rent::get()?;
if !rent.is_exempt(account.lamports(), account.data_len()) {
return Err(ProgramError::InsufficientFunds);
}
Ok(())
}
// Properly close account
pub fn close_account<'a>(
account: &AccountInfo<'a>,
destination: &AccountInfo<'a>,
) -> Result<(), ProgramError> {
// Transfer ALL lamports to destination
let dest_starting_lamports = destination.lamports();
**destination.lamports.borrow_mut() = dest_starting_lamports
.checked_add(account.lamports())
.ok_or(ProgramError::ArithmeticOverflow)?;
**account.lamports.borrow_mut() = 0;
// Zero out data to prevent revival
account.data.borrow_mut().fill(0);
// Assign to system program (optional but recommended)
account.assign(&solana_program::system_program::ID);
Ok(())
}
15. Owner Change Without Verification (MEDIUM)
What It Is
Solana accounts have an "owner" field. Some legitimate use cases change ownership (like creating PDAs). But if a program doesn't track or verify ownership changes, attackers can reassign account ownership to bypass checks.
Attack Pattern
1. Program checks account.owner == expected_program at start
2. During CPI, attacker's malicious program changes owner
3. Program continues using account assuming original owner
4. Account now owned by attacker's program
5. Later operations trust wrong owner
How to Fix
Anchor: Verify owner before and after CPI
use anchor_lang::prelude::*;
pub fn safe_cpi_with_owner_check<'info>(
ctx: Context<'_, '_, '_, 'info, SafeCPI<'info>>,
) -> Result<()> {
let account = &ctx.accounts.mutable_account;
// Store owner before CPI
let owner_before = *account.to_account_info().owner;
// Perform CPI (example: some external program call)
// invoke(...)?;
// Verify owner hasn't changed
require!(
*account.to_account_info().owner == owner_before,
ErrorCode::UnexpectedOwnerChange
);
Ok(())
}
#[derive(Accounts)]
pub struct SafeCPI<'info> {
/// CHECK: Owner verified manually
#[account(mut)]
pub mutable_account: AccountInfo<'info>,
}
#[error_code]
pub enum ErrorCode {
#[msg("Account owner changed unexpectedly during CPI")]
UnexpectedOwnerChange,
}
Pinocchio/Native: Manual owner verification
use solana_program::{
account_info::AccountInfo,
program_error::ProgramError,
pubkey::Pubkey,
program::invoke,
};
pub fn safe_cpi_with_owner_check<'a>(
account: &AccountInfo<'a>,
program_to_invoke: &AccountInfo<'a>,
expected_owner: &Pubkey,
) -> Result<(), ProgramError> {
// Verify owner matches expectation
if account.owner != expected_owner {
return Err(ProgramError::IllegalOwner);
}
// Store owner before CPI
let owner_before = *account.owner;
// Perform CPI
invoke(
&some_instruction,
&[account.clone(), program_to_invoke.clone()],
)?;
// Verify owner hasn't changed
if account.owner != &owner_before {
return Err(ProgramError::IllegalOwner);
}
Ok(())
}
// Alternative: Verify owner after any suspicious CPI
pub fn verify_owner_unchanged<'a>(
account: &AccountInfo<'a>,
expected_owner: &Pubkey,
) -> Result<(), ProgramError> {
if account.owner != expected_owner {
return Err(ProgramError::IllegalOwner);
}
Ok(())
}
Note: This is rare but can be combined with reload vulnerabilities.
16. Create Account DoS (MEDIUM)
What It Is
SystemProgram::CreateAccount fails if the account already has lamports. Attacker can grief by sending 1 lamport to your deterministic addresses, blocking initialization.
Attack Pattern
1. Protocol uses deterministic PDA: seeds = [b"vault", user.key]
2. Attacker calculates same address
3. Attacker sends 1 lamport to that address
4. User tries to initialize vault
5. CreateAccount fails (account has balance)
6. User can't use protocol
How to Fix
Anchor: Use init_if_needed with caution or check for existing lamports
use anchor_lang::prelude::*;
// Option 1: Use init_if_needed (be aware of reinitialization risks)
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(
init_if_needed, // Will use existing account if it has lamports
payer = user,
space = 8 + 32 + 8,
seeds = [b"vault", user.key().as_ref()],
bump
)]
pub vault: Account<'info, Vault>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}
// Option 2: Explicitly handle existing accounts
pub fn initialize_safe(ctx: Context<InitializeSafe>) -> Result<()> {
let vault = &mut ctx.accounts.vault;
// If account exists and is initialized, return error
if vault.to_account_info().lamports() > 0 {
require!(!vault.is_initialized, ErrorCode::AlreadyInitialized);
}
// Initialize vault data
vault.authority = ctx.accounts.user.key();
vault.balance = 0;
vault.is_initialized = true;
Ok(())
}
#[account]
pub struct Vault {
pub authority: Pubkey,
pub balance: u64,
pub is_initialized: bool,
}
Pinocchio/Native: Handle existing lamports gracefully
use solana_program::{
account_info::AccountInfo,
program_error::ProgramError,
system_instruction,
program::invoke,
rent::Rent,
sysvar::Sysvar,
};
pub fn initialize_account_safe<'a>(
account: &AccountInfo<'a>,
payer: &AccountInfo<'a>,
space: usize,
owner: &Pubkey,
system_program: &AccountInfo<'a>,
) -> Result<(), ProgramError> {
let rent = Rent::get()?;
let required_lamports = rent.minimum_balance(space);
if account.lamports() > 0 {
// Account has lamports - check if properly initialized
if account.data_len() != space || account.owner != owner {
// Account exists but wrong size/owner
return Err(ProgramError::InvalidAccountData);
}
// Check if account has enough lamports for rent
if account.lamports() < required_lamports {
// Transfer additional lamports needed
let additional = required_lamports - account.lamports();
invoke(
&system_instruction::transfer(payer.key, account.key, additional),
&[payer.clone(), account.clone()],
)?;
}
// Use existing account
} else {
// Account is empty - create normally
invoke(
&system_instruction::create_account(
payer.key,
account.key,
required_lamports,
space as u64,
owner,
),
&[payer.clone(), account.clone(), system_program.clone()],
)?;
}
Ok(())
}
// Alternative: Use allocate + assign if account has lamports
pub fn allocate_and_assign<'a>(
account: &AccountInfo<'a>,
space: usize,
owner: &Pubkey,
) -> Result<(), ProgramError> {
if account.owner != &solana_program::system_program::ID {
return Err(ProgramError::IllegalOwner);
}
// Allocate space
invoke(
&system_instruction::allocate(account.key, space as u64),
&[account.clone()],
)?;
// Assign to program
invoke(
&system_instruction::assign(account.key, owner),
&[account.clone()],
)?;
Ok(())
}
Blockchain data is public. All of it. Use VRF or don't do randomness.
The Complete Security Checklist
Before deploying any Solana program, verify:
Access Control
- [ ] Every privileged account requires signer check (
Signeroris_signer()) - [ ] Every program account is owned by your program (
Account<T>or manual owner check) - [ ] Token accounts validated for correct mint and owner
- [ ] All account relationships explicitly verified with constraints
Account Validation
- [ ] Every account type has unique discriminator
- [ ] PDAs use canonical bump, stored and verified
- [ ] Initialize instructions reject already-initialized accounts
- [ ] No duplicate mutable accounts in transfers or updates
Cross-Program Security
- [ ] All CPI targets use fixed, validated program IDs
- [ ] Never invoke user-supplied program IDs
- [ ] PDA seeds properly scoped to prevent collision
Arithmetic Safety
- [ ] All balance/amount operations use checked arithmetic
- [ ] Overflow checks enabled in release builds
- [ ] Explicit bounds validation before math operations
State Management
- [ ] Closed accounts have data zeroed
- [ ] Lamports properly transferred on close
- [ ] No revival of closed accounts possible
Randomness
- [ ] No use of slot, timestamp, or blockhash for randomness
- [ ] VRF or commit-reveal for any random outcomes
- [ ] All on-chain data treated as public and predictable
Real Talk
Security isn't about perfect code. It's about recognizing that every missing check is money waiting to be stolen. Every unvalidated account is an open vault. Every assumption is a vulnerability.
The programs in this repository show both sides: the vulnerable version that would lose everything, and the secure version that survives. The difference is often a single line of code. But that line is worth millions.
You cannot test your way to security. You cannot hope attackers won't find bugs. You must validate everything, trust nothing, and assume every user is trying to rob you. Because on a blockchain, they probably are.
This isn't paranoia. It's math. Your program is public. The money is real. The attackers are here.
Build accordingly.
Implementation Details
This guide is accompanied by a repository containing:
-
15 Complete Vulnerability Examples
- Each with vulnerable and secure versions
- Implementation in both Anchor and Pinocchio
- Tests demonstrating the exploit and the fix
- Line-by-line annotations explaining what went wrong
-
Real Attack Simulations
- Executable tests that actually perform the attacks
- Proof that vulnerable code fails
- Proof that secure code prevents the attack
-
Comparative Framework Analysis
- Side-by-side Anchor vs Pinocchio implementations
- Framework-specific security features
- When to use each approach
All code is open source and designed for learning. Break it. Fix it. Learn from it. Then write code that can't be broken.
The repository contains the examples. This guide explains why they matter.
https://github.com/mira4sol/solana-security-examples

Top comments (0)