Solana Security Patterns: A Deep Dive
40 programs. 10 vulnerabilities. Two frameworks. One very long weekend.
What started as “let me understand this by writing about it” turned into 40 complete programs (20 vulnerable, 20 secure), implemented in both Anchor and Pinocchio, with a test suite that actually exploits the vulnerable versions.
I learn by building. If you do too, this is for you.
Let’s get into it.
Why This Exists
In 2022, over $3 billion was lost to smart contract exploits. Solana, despite its reputation for speed and low fees, is not immune. The Sealevel runtime’s unique account model creates a distinct attack surface that Ethereum developers don’t encounter.
The problem? Most security resources either:
- List vulnerabilities without runnable code
- Show Anchor examples only, ignoring the growing Pinocchio ecosystem
- Don’t prove the attacks actually work
This article is different. Every vulnerability has:
Vulnerable code that compiles and runs
Secure code showing the fix
Exploit tests proving the attack succeeds on vulnerable programs
Defense tests proving the attack fails on secure programs
I built this in both Anchor (the high-level framework) and Pinocchio (the lightweight, zero-dependency alternative) so you can see how security patterns translate across abstraction levels.
The Ten Patterns
Pattern 01: Missing Signer Check
The Setup: You’re building a vault. Users deposit tokens, then can withdraw later. The vault stores an authority pubkey—only this account should be able to withdraw.
The Vulnerability: The program accepts the authority’s pubkey but never verifies they actually signed the transaction.
❌ VULNERABLE FLOW:
1. Attacker reads vault’s authority pubkey from chain
2. Attacker includes that pubkey in their transaction (no signature)
3. Program trusts the pubkey without checking is_signer()
4. Attacker drains the vaultPinocchio Implementation
Vulnerable:
// ❌ No signer check - anyone can pass any pubkey
let authority = accounts.get(1)?;
let amount = parse_amount(data);
transfer(vault, recipient, amount);Secure:
// ✅ Verify the authority actually signed
let authority = accounts.get(1)?;
if !authority.is_signer() {
return Err(ProgramError::MissingRequiredSignature);
}
transfer(vault, recipient, amount);Anchor Implementation
Vulnerable:
#[derive(Accounts)]
pub struct Withdraw<’info> {
// ❌ AccountInfo doesn’t enforce signing
pub authority: AccountInfo<’info>,
#[account(mut)]
pub vault: Account<’info, Vault>,
}Secure:
#[derive(Accounts)]
pub struct Withdraw<’info> {
// ✅ Signer<’info> enforces signature at deserialization
pub authority: Signer<’info>,
#[account(mut)]
pub vault: Account<’info, Vault>,
}The Fix
| Framework | Solution |
|-----------|----------|
| Pinocchio | Check `account.is_signer()` before trusting identity |
| Anchor | Use `Signer<’info>` instead of `AccountInfo<’info>` |Key Insight: Solana’s runtime passes signature status as a flag on each account. If you don’t check it, you’re accepting an unsigned claim of identity.
Pattern 02: Missing Owner Check
The Setup: Your program reads balance data from a token account to gate access. Users with 1000+ tokens get premium features.
The Vulnerability: The program reads account data without verifying which program owns (created) that account.
❌ VULNERABLE FLOW:
1. Attacker creates their own account with fabricated data
2. Account shows balance = 999,999,999 tokens
3. Program reads balance without checking owner == SPL Token
4. Attacker gets premium access without owning any real tokensPinocchio Implementation
Vulnerable:
// ❌ Trusts any account data blindly
let token = accounts.get(0)?;
let balance = parse_balance(token.data()?);
if balance >= REQUIRED_BALANCE {
grant_premium_access();
}Secure:
// ✅ Verify the account is owned by SPL Token program
let token = accounts.get(0)?;
if token.owner() != &spl_token::ID {
return Err(ProgramError::InvalidAccountOwner);
}
let balance = parse_balance(token.data()?);Anchor Implementation
Vulnerable:
#[derive(Accounts)]
pub struct Check<’info> {
// ❌ No owner validation - accepts any account
pub token: AccountInfo<’info>,
}Secure:
#[derive(Accounts)]
pub struct Check<’info> {
// ✅ Account<’_, T> validates owner == T’s program
pub token: Account<’info, TokenAccount>,
}The Fix
| Framework | Solution |
|-----------|----------|
| Pinocchio | Check `account.owner() == &EXPECTED_PROGRAM_ID` |
| Anchor | Use `Account<’info, T>` which auto-validates ownership |Key Insight: In Solana, any program can write arbitrary data to accounts it owns. If you don’t verify ownership, you’re reading potentially malicious data.
Pattern 03: Account Reinitialization
The Setup: Your DeFi protocol has a config account with an `authority` field. The authority can adjust fees, pause trading, etc.
The Vulnerability: The initialization function can be called on an already-initialized account, overwriting the existing authority.
❌ VULNERABLE FLOW:
1. Legitimate admin initializes config with their pubkey as authority
2. Attacker calls initialize() again on the same account
3. Program overwrites authority with attacker’s pubkey
4. Attacker now controls the entire protocolPinocchio Implementation
Vulnerable:
// ❌ No check if already initialized
pub fn initialize(accounts: &[AccountView], data: &[u8]) -> ProgramResult {
let config = accounts.get(0)?;
let mut config_data = config.try_borrow_mut_data()?;
config_data.authority = parse_pubkey(data);
config_data.initialized = true;
Ok(())
}Secure:
// ✅ Reject if already initialized
pub fn initialize(accounts: &[AccountView], data: &[u8]) -> ProgramResult {
let config = accounts.get(0)?;
let config_data = config.try_borrow_data()?;
if config_data.initialized {
return Err(ProgramError::AccountAlreadyInitialized);
}
let mut config_data = config.try_borrow_mut_data()?;
config_data.authority = parse_pubkey(data);
config_data.initialized = true;
Ok(())
}Anchor Implementation
Vulnerable:
#[derive(Accounts)]
pub struct Initialize<’info> {
// ❌ #[account(mut)] allows reinit
#[account(mut)]
pub config: Account<’info, Config>,
}Secure:
#[derive(Accounts)]
pub struct Initialize<’info> {
// ✅ #[account(init, ...)] fails if account exists
#[account(
init,
payer = user,
space = 8 + Config::SIZE
)]
pub config: Account<’info, Config>,
}The Fix
| Framework | Solution |
|-----------|----------|
| Pinocchio | Check `is_initialized` flag before writing |
| Anchor | Use `#[account(init)]` which requires fresh accounts |Key Insight: Anchor’s init constraint is more than convenience—it’s a security feature. It ensures the account is rent-exempt, properly sized, and never existed before.
Pattern 04: Type Confusion
The Setup: Your program has multiple account types: UserAccount, AdminAccount, VaultAccount. Admin accounts can perform privileged operations.
The Vulnerability: Account data is deserialized without verifying the account type, allowing a regular user account to be treated as an admin account.
❌ VULNERABLE FLOW:
1. Attacker creates a UserAccount
2. Attacker calls admin function, passing their UserAccount
3. Program deserializes raw bytes as AdminAccount
4. Fields happen to align—attacker gets admin powersPinocchio Implementation
Vulnerable:
// ❌ Deserializes without checking discriminator
pub fn admin_action(accounts: &[AccountView]) -> ProgramResult {
let admin = accounts.get(0)?;
let admin_data = AdminAccount::try_from_slice(admin.data()?)?;
// Oops! This might actually be a UserAccount
perform_admin_action(admin_data);
}Secure:
// ✅ Check 8-byte discriminator first
pub fn admin_action(accounts: &[AccountView]) -> ProgramResult {
let admin = accounts.get(0)?;
let data = admin.data()?;
if data[0..8] != ADMIN_DISCRIMINATOR {
return Err(ProgramError::InvalidAccountData);
}
let admin_data = AdminAccount::try_from_slice(&data[8..])?;
perform_admin_action(admin_data);
}Anchor Implementation
Vulnerable:
#[derive(Accounts)]
pub struct AdminAction<’info> {
// ❌ UncheckedAccount accepts any type
pub account: UncheckedAccount<’info>,
}Secure:
#[derive(Accounts)]
pub struct AdminAction<’info> {
// ✅ Account<’info, AdminData> validates discriminator
pub account: Account<’info, AdminData>,
}The Fix
| Framework | Solution |
|-----------|----------|
| Pinocchio | Use 8-byte discriminator per account type |
| Anchor | Use `Account<’info, T>` which auto-validates type |Key Insight: Anchor generates 8-byte discriminators from sha256(”account:<struct_name>”). In Pinocchio, you must implement this manually—typically the first 8 bytes of your account data.
Pattern 05: Arithmetic Overflow
The Setup: Users can withdraw tokens from a vault. The balance is a u64.
The Vulnerability: Using wrapping or unchecked arithmetic causes underflow, turning 100 - 200 into a massive positive number.
❌ VULNERABLE FLOW:
1. Vault has 100 tokens
2. Attacker requests withdrawal of 200 tokens
3. balance.wrapping_sub(200) → 18,446,744,073,709,551,516
4. Check `if balance >= required` passes
5. Attacker drains the vault (or worse)Pinocchio Implementation
Vulnerable:
// ❌ Wrapping arithmetic—100 - 200 = massive number
vault.balance = vault.balance.wrapping_sub(amount);
// Now balance = 18,446,744,073,709,551,516Secure:
// ✅ Checked arithmetic returns None on underflow
vault.balance = vault.balance
.checked_sub(amount)
.ok_or(ProgramError::InsufficientFunds)?;Anchor Implementation
Vulnerable:
// ❌ Native subtraction can panic or wrap
vault.balance = vault.balance - amount;Secure:
// ✅ Checked arithmetic with custom error
vault.balance = vault.balance
.checked_sub(amount)
.ok_or(ErrorCode::InsufficientFunds)?;The Fix
| Framework | Solution |
|-----------|----------|
| Both | Always use `checked_add`, `checked_sub`, `checked_mul`, `checked_div` |
| Cargo.toml | Enable `overflow-checks = true` in release profile |Key Insight: Rust’s default release mode disables overflow checks for performance. Solana programs run in release mode. Enable checks explicitly:
[profile.release]
overflow-checks = truePattern 06: Unsafe Account Closing
The Setup: Users can close their vault account to reclaim rent. The program transfers lamports back to the user.
The Vulnerability: The program zeroes lamports but leaves data intact, allowing the account to be “revived” in the same transaction.
❌ VULNERABLE FLOW:
1. User calls close() - lamports move to recipient
2. In same transaction, attacker sends small lamport amount back
3. Account is “revived” with original data still intact
4. Attacker can now access stale/privileged statePinocchio Implementation
Vulnerable:
// ❌ Data not zeroed—can be revived
pub fn close(accounts: &[AccountView]) -> ProgramResult {
let vault = accounts.get(0)?;
let recipient = accounts.get(1)?;
let lamports = vault.lamports();
**vault.try_borrow_mut_lamports()? = 0;
**recipient.try_borrow_mut_lamports()? += lamports;
// Data is still intact!
Ok(())
}Secure:
// ✅ Zero data FIRST, then transfer lamports
pub fn close(accounts: &[AccountView]) -> ProgramResult {
let vault = accounts.get(0)?;
let recipient = accounts.get(1)?;
// Zero ALL data first
vault.try_borrow_mut_data()?.fill(0);
let lamports = vault.lamports();
**vault.try_borrow_mut_lamports()? = 0;
**recipient.try_borrow_mut_lamports()? += lamports;
Ok(())
}Anchor Implementation
Vulnerable:
// ❌ Manual close without data zeroing
pub fn close(ctx: Context<Close>) -> Result<()> {
let vault = &ctx.accounts.vault;
**vault.to_account_info().try_borrow_mut_lamports()? = 0;
Ok(())
}Secure:
#[derive(Accounts)]
pub struct Close<’info> {
// ✅ close constraint handles everything safely
#[account(mut, close = recipient)]
pub vault: Account<’info, Vault>,
#[account(mut)]
pub recipient: SystemAccount<’info>,
}The Fix
| Framework | Solution |
|-----------|----------|
| Pinocchio | Zero data with `.fill(0)` before transferring lamports |
| Anchor | Use `#[account(close = recipient)]` constraint |Key Insight: Anchor’s close constraint zeros account data, transfers all lamports, and assigns ownership to the system program—all in the correct order.
Pattern 07: Arbitrary CPI
The Setup: Your DEX calls the SPL Token program to transfer tokens between accounts.
The Vulnerability: The program accepts a user-provided “token program” account and invokes it without verifying it’s actually SPL Token.
❌ VULNERABLE FLOW:
1. Attacker deploys malicious program that mimics token transfer interface
2. Attacker calls DEX, passing malicious program as “token_program”
3. DEX invokes attacker’s program
4. Malicious program does whatever attacker wantsPinocchio Implementation
Vulnerable:
// ❌ Invokes user-provided program without verification
let token_program = accounts.get(3)?;
let ix = create_transfer_ix(...);
invoke(&ix, &[token_program.clone()])?;Secure:
// ✅ Verify program ID before CPI
let token_program = accounts.get(3)?;
if token_program.key() != &spl_token::ID {
return Err(ProgramError::IncorrectProgramId);
}
let ix = create_transfer_ix(...);
invoke(&ix, &[token_program.clone()])?;Anchor Implementation
Vulnerable:
#[derive(Accounts)]
pub struct Transfer<’info> {
// ❌ No program verification
pub token_program: AccountInfo<’info>,
}
Secure:
#[derive(Accounts)]
pub struct Transfer<’info> {
// ✅ Program<’info, Token> validates program ID
pub token_program: Program<’info, Token>,
}The Fix
| Framework | Solution |
|-----------|----------|
| Pinocchio | Check `program.key() == &expected_program_id` |
| Anchor | Use `Program<’info, T>` for CPI targets |Key Insight: Cross-program invocations inherit the caller’s privileges. An attacker-controlled program can drain funds, modify state, or perform any action your program is authorized to do.
Pattern 08: Duplicate Accounts
The Setup: Your transfer function moves tokens from account A to account B.
The Vulnerability: The program doesn’t check that A and B are different accounts. If they’re the same, the math breaks.
❌ VULNERABLE FLOW:
1. User passes same account as both ‘from’ and ‘to’
2. from.balance -= 100 → balance = 900
3. to.balance += 100 → balance = 1000
4. Net result: user gained 100 tokens from nothingPinocchio Implementation
Vulnerable:
// ❌ No uniqueness check
let from = accounts.get(0)?;
let to = accounts.get(1)?;
from.balance -= amount; // 1000 - 100 = 900
to.balance += amount; // 900 + 100 = 1000 (same account!)
// Net gain: 100 tokens created from nothingSecure:
// ✅ Verify accounts are distinct
let from = accounts.get(0)?;
let to = accounts.get(1)?;
if from.key() == to.key() {
return Err(ProgramError::InvalidArgument);
}
from.balance -= amount;
to.balance += amount;Anchor Implementation
Vulnerable:
#[derive(Accounts)]
pub struct Transfer<’info> {
#[account(mut)]
pub from: Account<’info, Wallet>,
#[account(mut)]
pub to: Account<’info, Wallet>,
}Secure:
#[derive(Accounts)]
pub struct Transfer<’info> {
// ✅ Constraint ensures from != to
#[account(mut, constraint = from.key() != to.key())]
pub from: Account<’info, Wallet>,
#[account(mut)]
pub to: Account<’info, Wallet>,
}The Fix
| Framework | Solution |
|-----------|----------|
| Pinocchio | Check `account_a.key() != account_b.key()` |
| Anchor | Use `constraint` to enforce uniqueness |Key Insight: This bug is subtle because it violates assumptions, not explicit checks. Any time two accounts should be different, verify it explicitly.
Pattern 09: Bump Seed Canonicalization
The Setup: Your program derives PDAs (Program Derived Addresses) for user vaults.
The Vulnerability: Multiple bump values can produce valid PDAs for the same seeds. The program accepts any valid bump instead of the canonical one.
❌ VULNERABLE FLOW:
1. Program uses find_program_address() to get canonical bump = 254
2. Attacker discovers bump = 250 also produces valid PDA
3. Attacker creates account at alternate PDA
4. Program accepts it because create_program_address() succeeds
5. Two “valid” vaults exist for same user—accounting chaosPinocchio Implementation
Vulnerable:
// ❌ Accepts user-provided bump without validation
let user_bump = data[0];
let pda = create_program_address(
&[b”vault”, user.key().as_ref(), &[user_bump]],
program_id
)?;Secure:
// ✅ Derive and store canonical bump
let (pda, canonical_bump) = find_program_address(
&[b”vault”, user.key().as_ref()],
program_id
);
// Store bump in account for future verification
vault_data.bump = canonical_bump;
// On subsequent calls, verify stored bump matches
assert_eq!(provided_bump, vault_data.bump);Anchor Implementation
Vulnerable:
#[derive(Accounts)]
pub struct Create<’info> {
// ❌ No bump validation
#[account(seeds = [b”vault”, user.key().as_ref()])]
pub vault: Account<’info, Vault>,
}Secure:
#[derive(Accounts)]
pub struct Create<’info> {
// ✅ bump = vault.bump validates stored bump
#[account(
seeds = [b”vault”, user.key().as_ref()],
bump = vault.bump
)]
pub vault: Account<’info, Vault>,
}The Fix
| Framework | Solution |
|-----------|----------|
| Pinocchio | Use `find_program_address()`, store bump, verify on access |
| Anchor | Store bump in account, use `bump = account.bump` in seeds |Key Insight: find_program_address() returns the canonical (highest valid) bump. Always use it, store the result, and verify it on subsequent accesses.
Pattern 10: PDA Sharing
The Setup: Your lending protocol has multiple pools, each with a vault PDA.
The Vulnerability: PDA seeds are too generic, causing different pools to share the same vault address.
❌ VULNERABLE FLOW:
1. Pool A vault = PDA([”vault”], program_id) → address X
2. Pool B vault = PDA([”vault”], program_id) → address X (same!)
3. User deposits to Pool A → funds go to address X
4. Attacker withdraws from Pool B → drains address X
5. Attacker stole Pool A deposits via Pool BPinocchio Implementation
Vulnerable:
// ❌ Same seeds for ALL pools
let (vault, _) = find_program_address(
&[b”vault”], // No pool identifier!
program_id
);Secure:
// ✅ Include pool identifier in seeds
let (vault, _) = find_program_address(
&[b”vault”, pool_id.as_ref()],
program_id
);
// Each pool now has unique vault addressAnchor Implementation
Vulnerable:
#[derive(Accounts)]
pub struct Deposit<’info> {
// ❌ Shared across all pools
#[account(seeds = [b”vault”])]
pub vault: Account<’info, Vault>,
pub pool: Account<’info, Pool>,
}Secure:
#[derive(Accounts)]
pub struct Deposit<’info> {
// ✅ Pool-specific vault
#[account(seeds = [b”vault”, pool.key().as_ref()])]
pub vault: Account<’info, Vault>,
pub pool: Account<’info, Pool>,
}The Fix
| Framework | Solution |
|-----------|----------|
| Both | Include ALL identifying context in PDA seeds |Seed Hygiene Checklist:
- User-specific? Include user.key()
- Pool-specific? Include pool.key()
- Token-specific? Include mint.key()
- Time-specific? Include epoch or timestamp
Key Insight: PDAs are deterministic. Same seeds = same address. When in doubt, add more specificity to your seeds.
Anchor vs Pinocchio: When to Use Which
| Aspect | Anchor | Pinocchio |
|--------|--------|-----------|
| Compute Units | ~50-100k per tx typical | ~5-20k per tx |
| Binary Size | ~200-500KB .so | ~20-50KB .so |
| Learning Curve | Gentler | Steeper |
| Boilerplate | Macro-heavy | Manual |
| Security By Default | High (many checks automatic) | Low (all checks manual) |
| Best For | Complex DeFi, rapid prototyping | Performance-critical, gas-sensitive |My Recommendation:
Use Anchor if you’re shipping fast, have complex account relationships, or prioritize readability.
Use Pinocchio if you need maximum efficiency, are compute-constrained, or building infrastructure that will be called millions of times.
The Security Tradeoff: Anchor’s type system catches many vulnerabilities at compile time. Pinocchio requires you to implement all checks manually—but gives you complete control.
Builder’s Learnings
Building 40 programs across two frameworks taught me lessons that aren’t in any documentation.
Pinocchio v0.10 is a Complete Rewrite
If you’re coming from older Pinocchio or solana-program, forget what you know:
// OLD // NEW (v0.10)
AccountInfo → AccountView
Pubkey → Address
account.key() → account.address()
try_borrow_mut_data() → try_borrow_mut()
declare_id!() → (removed)
entrypoint!() → program_entrypoint!()And for BPF builds, you need these three lines in every program:
program_entrypoint!(process_instruction);
default_allocator!();
nostd_panic_handler!();Solana’s Toolchain Lags Behind Rust
I spent 7 hours debugging why Anchor wouldn’t build. The culprit: a transitive dependency (constant_time_eq) updated to Rust edition2024.
Problem: Solana’s bundled cargo (1.84.0) didn’t support edition2024.
Solution: Manually upgrade ~/.cache/solana/v1.51/platform-tools by symlinking to v1.52.
Lesson: When cargo build-sbf fails on ecosystem crates, check if Solana’s bundled cargo is too old.
LiteSVM is the Future of Testing
Forget solana-test-validator. LiteSVM runs entirely in-process, with deterministic block hashes, instant transaction processing, and full control over program execution.
let mut svm = LiteSVM::new();
svm.add_program_from_file(program_id, “program.so”)?;
// Set exact slot, blockhash, account state
svm.set_slot(42);
svm.set_account(pubkey, account_data);
// Execute and inspect
let result = svm.send_transaction(tx);
assert!(result.is_err()); // Expected to fail!My test suite runs 42 exploit simulations in under 2 seconds.
WSL + Windows = Filesystem Pain
Building Solana programs in WSL while code lives on Windows (/mnt/c/...) causes random permission errors.
Solution: Copy test code to WSL-native filesystem (~/).
cp -r /mnt/c/project/tests ~/security-tests
cd ~/security-tests
cargo testHow to Use This Reference
For Developers
1. Read through each pattern before starting your project
2. Use the secure versions as templates
3. Run vulnerable versions to understand why they fail
4. Add the checks to your security checklist
For Educators
1. Each pattern is self-contained—assign them individually
2. Students can modify vulnerable code and run tests
3. Compare Anchor vs Pinocchio to teach abstraction tradeoffs
Final Thoughts
Every vulnerability in this guide has caused real-world losses. The Sealevel runtime is powerful but unforgiving. Missing one check—signer, owner, type, overflow—can drain an entire protocol.
I built this reference because I believe the Solana ecosystem deserves better security education. Not theoretical blog posts, but runnable, testable, provable examples.
Use it. Break it. Learn from it.
And when you’re auditing that next protocol or shipping your own—remember these ten patterns.
Links
- GitHub Repository: https://github.com/AngryPacifist/solana-security-patterns
- Interactive Guide: https://solana-security-patterns.pxxl.click
If you found this article helpful, please consider liking and sharing it.
Connect with me on X: @angry__pacifist

