- Executive Summary
- Vulnerability Overview
- Detailed Vulnerability Analysis
- Testing Methodology
- Security Checklist
- Resources and References
Solana's account model and program architecture introduce unique security challenges that differ significantly from EVM-based blockchains. This document provides an in-depth analysis of five critical vulnerability classes that commonly affect Solana programs, particularly those built with the Anchor framework.
Unlike traditional smart contract platforms where the runtime mediates most interactions, Solana delegates significant responsibility to program developers. Every account reference, every authority check, and every arithmetic operation must be explicitly validated. The absence of these validations creates attack surfaces that can lead to catastrophic failures including complete fund drainage, privilege escalation, and protocol takeover.
This comprehensive guide serves multiple audiences:
- Developers building Solana programs who need to understand common pitfalls
- Security auditors reviewing Solana codebases for vulnerabilities
- Protocol designers establishing security best practices for their teams
- Educators teaching blockchain security concepts
Unlike Ethereum, where the EVM mediates most state access through contract storage mappings, Solana programs receive all account references directly from the client. This fundamental architectural difference means:
- The client constructs the transaction and specifies which accounts to include
- The program receives
AccountInfostructures containing raw account data - There is no implicit validation that the provided account is the "correct" one
- Programs must explicitly verify ownership, type, and derivation of every account
Real-world implication: An attacker can pass any writable account to your program. If you attempt to write to it without ownership checks, you may corrupt accounts belonging to other programs or users.
Solana's runtime does not enforce permission checks automatically. The runtime only verifies:
- Transaction signatures are cryptographically valid
- Accounts marked as signers actually signed the transaction
- Accounts marked as writable are writable by the program owner
What the runtime does NOT check:
- Whether the signer is authorized to perform the action
- Whether the signer matches an "admin" field in program state
- Whether the account being modified is the intended account
- Whether parameter values are within acceptable bounds
Programs must implement all authorization logic explicitly using Anchor constraints or manual checks.
In Solana transactions, accounts can be marked as:
- Writable (
is_writable: true) - Can be modified during transaction execution - Read-only (
is_writable: false) - Cannot be modified
However, this is a transaction-level constraint, not a program-level guarantee. Key issues:
- Any account can be marked writable by the client (if signatures permit)
- Programs must verify they own an account before writing to it
- The Anchor
#[account(mut)]attribute only declares intent, it doesn't validate ownership - Multiple programs can potentially write to the same account in one transaction
Rust's default arithmetic behavior differs between debug and release builds:
Debug mode: Arithmetic overflow/underflow causes a panic (program crash) Release mode: Arithmetic wraps using two's complement (silent failure)
Since Solana programs are deployed in release mode, a balance of 10 lamports minus 11 lamports doesn't fail—it wraps to 18,446,744,073,709,551,615 lamports. This is catastrophic in financial applications.
Critical insight: Every production Solana program must use checked arithmetic methods (checked_add, checked_sub, checked_mul, checked_div) or enable overflow checks in Cargo.toml.
Solana programs can call other programs via Cross-Program Invocations. This powerful feature enables composability but introduces reentrancy risks:
- The called program executes arbitrary code
- The called program can invoke back into your program
- State updates made before the CPI can be exploited if the external program re-enters
- Traditional reentrancy guards (like Ethereum's
nonReentrantmodifier) must be implemented manually
The CEI Pattern (Checks-Effects-Interactions):
- Checks: Validate all inputs and permissions
- Effects: Update internal state
- Interactions: Make external calls (CPI)
Violating this pattern creates reentrancy vulnerabilities.
| Vulnerability | Severity | CVSS Score | Likelihood | Impact |
|---|---|---|---|---|
| Missing Account Validation | 🔴 Critical | 9.5 | High | Complete account corruption, privilege escalation, arbitrary state manipulation |
| Incorrect Authority Check | 🔴 Critical | 9.0 | High | Unauthorized protocol parameter changes, admin takeover, fund theft |
| Unsafe Arithmetic | 🟠 High | 8.5 | Medium | Balance corruption, infinite minting, economic collapse, fund drainage |
| CPI Reentrancy | 🟠 High | 8.0 | Medium | Fund draining, state inconsistency, double-spend attacks |
| Signer Privilege Escalation | 🔴 Critical | 9.0 | High | Unauthorized admin actions, protocol takeover, configuration manipulation |
CVSS Scoring Methodology:
- Critical (9.0-10.0): Direct fund loss or complete protocol compromise
- High (7.0-8.9): Significant impact requiring specific conditions
- Medium (4.0-6.9): Limited impact or requires complex attack chains
Likelihood Assessment:
- High: Vulnerability is easy to exploit and commonly found in audits
- Medium: Requires specific conditions or moderate attacker sophistication
- Low: Requires advanced techniques or rare conditions
All vulnerabilities in this repository stem from insufficient validation of one of three critical aspects:
Vulnerabilities: #1 Missing Account Validation, #4 CPI Reentrancy
Questions every program must answer:
- Does this program own this account? (
account.owner == program_id) - Is this the correct account type? (discriminator check)
- Is this account derived from expected seeds? (PDA validation)
- Is this account's address the one we expect? (hardcoded or computed)
Common failures:
- Using
AccountInfowithout ownership checks - Using
UncheckedAccountwithout subsequent validation - Accepting arbitrary accounts from clients
- Missing PDA seed verification
Vulnerabilities: #2 Incorrect Authority Check, #5 Signer Privilege Escalation
Questions every program must answer:
- Did this account sign the transaction? (
account.is_signer) - Does the signer match the stored authority? (
signer.key() == stored_authority) - Is the signer authorized for this specific action? (role-based access control)
- Are parameter values within acceptable bounds? (input validation)
Common failures:
- Checking
is_signerwithout checking signer identity - Missing
has_oneconstraint in Anchor - No comparison between signer and stored admin/authority
- Accepting any valid signature as authorization
Vulnerability: #3 Unsafe Arithmetic
Questions every program must answer:
- Can this arithmetic operation overflow? (result > type maximum)
- Can this operation underflow? (result < type minimum)
- Are input values within acceptable ranges? (validation)
- Will this calculation produce the correct result? (precision, rounding)
Common failures:
- Using
+,-,*,/operators in release mode - Missing
checked_add,checked_sub,checked_mul,checked_div - No bounds validation on user inputs
- Assuming arithmetic will panic on overflow
Understanding these three validation categories helps auditors systematically review Solana programs and provides a mental framework for secure development.
Severity: 🔴 Critical | CVSS Score: 9.5 | CWE-20: Improper Input Validation
In the Solana programming model, programs are stateless. All state is stored in separate account structures, and clients must provide explicit references to every account a program needs to access. This creates a fundamental security requirement: programs must validate every account they receive.
The vulnerability arises when developers use raw AccountInfo or UncheckedAccount types without performing the following critical checks:
- Ownership Verification: Does this program own the account?
- Type Discrimination: Is this account the expected data structure?
- Seed Validation: For PDAs, was this account derived from the correct seeds?
- Authority Binding: Does the account's stored authority match the expected signer?
When these checks are omitted, attackers can substitute arbitrary accounts, leading to:
- Corruption of unrelated program state
- Privilege escalation by replacing configuration accounts
- Data manipulation in accounts owned by other programs
- Bypassing intended access controls
Real-world context: Imagine a decentralized exchange (DEX) that stores a global fee configuration in an account. This account contains:
- Admin public key (who can modify settings)
- Fee percentage (basis points)
- Treasury wallet address (where fees are sent)
If the program accepts an AccountInfo for this config without validating its address or ownership, an attacker can:
- Create a malicious account with their own address as "admin"
- Fund it with the minimum rent-exempt lamports
- Call the program's administrative functions, passing this fake config account
- The program writes to the attacker's account instead of the real config
- In subsequent calls, if the program reads from this malicious account, the attacker controls protocol parameters
Historical precedent: Several Solana protocols have suffered exploits from account substitution vulnerabilities, resulting in millions of dollars in losses. The vulnerability is particularly dangerous because:
- It requires minimal technical sophistication to exploit
- No cryptographic keys are compromised
- The attack leaves minimal on-chain evidence
- Users may not detect the manipulation until funds are drained
Let's examine the vulnerable code from example1.rs:
#[derive(Accounts)]
pub struct SetMessageVuln<'info> {
#[account(mut)]
pub any_unchecked: AccountInfo<'info>,
}
pub fn set_message(ctx: Context<SetMessageVuln>, msg: String) -> Result<()> {
let mut data = ctx.accounts.any_unchecked.try_borrow_mut_data()?;
data[..msg.len()].copy_from_slice(msg.as_bytes());
Ok(())
}Attack sequence:
Step 1: Reconnaissance
# Attacker identifies the program's treasury config account
solana account <PROGRAM_ID>
# Discovers TreasuryConfig PDA at address: 9x7K...
# Structure: { admin: Pubkey, treasury: Pubkey, fee_bps: u16 }Step 2: Craft malicious account
// Attacker creates account with specific data layout
const attackerPubkey = new PublicKey("AttackerWallet...");
const maliciousData = Buffer.alloc(64);
attackerPubkey.toBuffer().copy(maliciousData, 0); // First 32 bytes = admin
// Remaining bytes = whatever the attacker wantsStep 3: Execute substitution
// Attacker calls set_message with their malicious account
await program.methods
.setMessage(maliciousData.toString())
.accounts({
anyUnchecked: attackerMaliciousAccount, // ← SUBSTITUTED ACCOUNT
})
.rpc();Step 4: Exploitation The program:
- Accepts the attacker's account (marked as writable)
- Does NOT verify the account is owned by the program
- Does NOT check if it's the expected account type
- Writes the attacker's data directly to bytes
Result: The attacker has now corrupted an account. If this was the treasury config, they control the admin key.
The secure implementation uses multiple defensive layers:
#[account]
pub struct MessageBox {
pub authority: Pubkey,
pub message: String,
}
#[derive(Accounts)]
#[instruction(message: String)]
pub struct SetMessageSafe<'info> {
#[account(
mut,
seeds = [b"message", authority.key().as_ref()],
bump,
has_one = authority,
realloc = 8 + 32 + 4 + message.len(),
realloc::payer = authority,
realloc::zero = false,
)]
pub message_box: Account<'info, MessageBox>,
#[account(mut)]
pub authority: Signer<'info>,
pub system_program: Program<'info, System>,
}
pub fn set_message_safe(ctx: Context<SetMessageSafe>, message: String) -> Result<()> {
require!(message.len() <= 280, CustomError::MessageTooLong);
let message_box = &mut ctx.accounts.message_box;
message_box.message = message;
Ok(())
}Defense mechanisms:
1. Typed Account (Account<'info, MessageBox>):
- Anchor automatically verifies the account is owned by the current program
- Anchor checks the 8-byte discriminator matches
MessageBoxtype - Provides compile-time type safety for field access
2. PDA Seeds Constraint:
seeds = [b"message", authority.key().as_ref()],
bump,- Ensures the account address is deterministically derived
- Attacker cannot substitute arbitrary accounts—the address must match the PDA
- The PDA calculation is:
find_program_address(&[b"message", authority_key], program_id)
3. Authority Binding (has_one = authority):
- Anchor generates:
require_keys_eq!(message_box.authority, authority.key()) - Links the stored authority field to the signer account
- Prevents attackers from using accounts with different authority values
4. Input Validation:
require!(message.len() <= 280, CustomError::MessageTooLong);- Ensures message length is within acceptable bounds
- Prevents buffer overflow or excessive storage allocation
5. Explicit Signer Requirement:
pub authority: Signer<'info>,- Forces the authority to sign the transaction
- Combined with
has_one, creates two-factor validation
Why the attack now fails:
When an attacker tries to substitute an account:
await program.methods
.setMessageSafe("malicious")
.accounts({
messageBox: attackerMaliciousAccount, // ← ATTACK ATTEMPT
authority: attackerWallet,
})
.rpc();Anchor validation sequence:
- ✅ Check
authorityis a signer → PASS - ❌ Verify
messageBoxaddress matches PDA(seeds, program_id) → FAIL- Expected:
find_program_address([b"message", attacker.pubkey], program_id) - Received:
attackerMaliciousAccount(random address)
- Expected:
- Transaction rejected with
ConstraintSeedserror
Even if the attacker creates a valid PDA:
- ✅ PDA validation → PASS
- ❌ Check
messageBox.owner == program_id→ FAIL if account not initialized - ❌ Check discriminator matches
MessageBox→ FAIL if wrong type - ❌ Check
messageBox.authority == authority.key()→ FAIL if wrong authority
The attack is impossible because:
- The account address is deterministically computed (can't be arbitrary)
- The account must be owned by the program (can't be external)
- The discriminator must match (can't be wrong type)
- The authority must match the signer (can't be someone else's account)
Vulnerable Implementation:
use anchor_lang::prelude::*;
declare_id!("Fg6PaFpoGXkYsidMpWxTWqkWg5Rdp2q6uNQqynEWsJvj");
#[program]
pub mod missing_account_vuln {
use super::*;
pub fn set_message(ctx: Context<SetMessageVuln>, msg: String) -> Result<()> {
// ❌ NO OWNERSHIP CHECK
// ❌ NO TYPE CHECK
// ❌ NO AUTHORITY CHECK
// ❌ NO LENGTH VALIDATION
let mut data = ctx.accounts.any_unchecked.try_borrow_mut_data()?;
data[..msg.len()].copy_from_slice(msg.as_bytes());
Ok(())
}
}
#[derive(Accounts)]
pub struct SetMessageVuln<'info> {
// ⚠️ VULNERABILITY: Raw AccountInfo with only mut constraint
#[account(mut)]
pub any_unchecked: AccountInfo<'info>,
}Problems:
- Uses
AccountInfoinstead of typedAccount<T> - No ownership verification (
account.owner != program_idis never checked) - No discriminator check (could be any account type)
- No PDA seeds validation (could be any address)
- No authority binding (no connection to signer)
- No input validation (message length unchecked)
- Direct memory manipulation (buffer overflow risk)
Secure Implementation:
use anchor_lang::prelude::*;
declare_id!("SecureProgram...");
#[program]
pub mod missing_account_fix {
use super::*;
pub fn set_message_safe(
ctx: Context<SetMessageSafe>,
message: String
) -> Result<()> {
// ✅ INPUT VALIDATION
require!(message.len() <= 280, CustomError::MessageTooLong);
// ✅ SAFE TYPED ACCESS
let message_box = &mut ctx.accounts.message_box;
message_box.message = message;
msg!("Message updated by: {}", ctx.accounts.authority.key());
Ok(())
}
}
#[derive(Accounts)]
#[instruction(message: String)]
pub struct SetMessageSafe<'info> {
#[account(
mut,
// ✅ PDA DERIVATION: Account address must match computed PDA
seeds = [b"message", authority.key().as_ref()],
bump,
// ✅ AUTHORITY BINDING: Stored authority must match signer
has_one = authority @ CustomError::Unauthorized,
// ✅ DYNAMIC REALLOCATION: Safe resizing based on input
realloc = 8 + 32 + 4 + message.len(),
realloc::payer = authority,
realloc::zero = false,
)]
pub message_box: Account<'info, MessageBox>, // ✅ TYPED ACCOUNT
#[account(mut)]
pub authority: Signer<'info>, // ✅ MUST SIGN
pub system_program: Program<'info, System>,
}
#[account]
pub struct MessageBox {
pub authority: Pubkey, // 32 bytes
pub message: String, // 4 + variable
}
#[error_code]
pub enum CustomError {
#[msg("Unauthorized: authority mismatch")]
Unauthorized,
#[msg("Message too long: max 280 characters")]
MessageTooLong,
}Security improvements:
- ✅ Typed account:
Account<'info, MessageBox>ensures ownership and discriminator - ✅ PDA validation:
seeds+bumpensures deterministic address - ✅ Authority binding:
has_one = authoritylinks stored field to signer - ✅ Signer requirement:
Signer<'info>enforces transaction signature - ✅ Input validation: Length check prevents buffer issues
- ✅ Safe reallocation: Managed by Anchor with proper bounds
- ✅ Error handling: Custom errors provide clear failure reasons
1. Always Use Typed Accounts
❌ Avoid:
#[account(mut)]
pub my_account: AccountInfo<'info>,✅ Prefer:
#[account(mut)]
pub my_account: Account<'info, MyAccountType>,2. Validate PDAs with Seeds
❌ Avoid:
#[account(mut)]
pub config: Account<'info, Config>,✅ Prefer:
#[account(
mut,
seeds = [b"config"],
bump,
)]
pub config: Account<'info, Config>,3. Bind Authorities with has_one
❌ Avoid:
pub fn update_config(ctx: Context<UpdateConfig>) -> Result<()> {
// Manual check (easy to forget)
require_keys_eq!(
ctx.accounts.config.admin,
ctx.accounts.signer.key(),
CustomError::Unauthorized
);
// ... rest of function
}✅ Prefer:
#[derive(Accounts)]
pub struct UpdateConfig<'info> {
#[account(mut, has_one = admin)]
pub config: Account<'info, Config>,
pub admin: Signer<'info>,
}4. Validate All Inputs
✅ Always check:
pub fn set_fee(ctx: Context<SetFee>, fee_bps: u16) -> Result<()> {
require!(fee_bps <= 10_000, CustomError::InvalidFee);
// ...
}5. Use UncheckedAccount Only When Necessary
If you must use UncheckedAccount, document why and add explicit checks:
/// CHECK: This account is validated manually because [reason]
#[account(mut)]
pub unchecked: UncheckedAccount<'info>,
pub fn my_function(ctx: Context<MyContext>) -> Result<()> {
// Explicit validation
require_keys_eq!(
ctx.accounts.unchecked.owner,
&system_program::ID,
CustomError::InvalidOwner
);
// ... rest of validation
}6. Leverage Anchor Constraints
Common constraints that prevent this vulnerability:
| Constraint | Purpose | Example |
|---|---|---|
seeds = [..] |
PDA derivation | seeds = [b"vault", user.key().as_ref()] |
has_one = field |
Authority binding | has_one = owner |
owner = program |
Ownership check | owner = token::ID |
constraint = expr |
Custom validation | constraint = vault.balance >= amount |
address = pubkey |
Exact address match | address = expected_config_address |
7. Initialize Accounts Securely
When creating accounts, set the owner and discriminator immediately:
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(
init,
payer = payer,
space = 8 + 32 + 8,
seeds = [b"vault", owner.key().as_ref()],
bump,
)]
pub vault: Account<'info, Vault>,
#[account(mut)]
pub payer: Signer<'info>,
pub owner: Signer<'info>,
pub system_program: Program<'info, System>,
}8. Document Account Expectations
/// CHECK: Token mint account, validated against stored mint address
#[account(
constraint = token_account.mint == vault.token_mint
)]
pub token_mint: UncheckedAccount<'info>,9. Security Checklist for Account Validation
Before deploying, verify for each account:
- Is the account owned by the expected program?
- Is the discriminator checked (for typed accounts)?
- If it's a PDA, are seeds validated?
- If there's a stored authority, is it bound to a signer?
- Are all input parameters validated?
- Are there bounds on data sizes?
- Is the account's address deterministic or validated?
- Have you documented any
UncheckedAccountusage?
10. Testing Recommendations
Write tests that attempt account substitution:
it("should reject account substitution attack", async () => {
const attackerAccount = Keypair.generate();
await expect(
program.methods
.setMessage("malicious")
.accounts({
messageBox: attackerAccount.publicKey, // Wrong account
authority: attacker.publicKey,
})
.rpc()
).to.be.rejectedWith("ConstraintSeeds"); // Or ConstraintHasOne
});Severity: 🔴 Critical | CVSS Score: 9.0 | CWE-862: Missing Authorization
This vulnerability occurs when a program validates that an account is a signer (cryptographic verification) but fails to verify that the signer is authorized to perform the action (business logic verification). In essence, the program answers "Did someone sign this transaction?" but never asks "Is that someone allowed to do this?"
In Solana programs, the Signer<'info> type only guarantees:
- The account's public key matches a signature in the transaction
- The signature is cryptographically valid
What Signer<'info> does NOT guarantee:
- The signer has administrative privileges
- The signer matches an "admin" or "owner" field in program state
- The signer is authorized for this specific operation
- The signer has any relationship to the accounts being modified
This creates a critical gap: any valid Solana wallet can sign transactions. If the program doesn't compare the signer's identity to stored authorization data, any user can execute privileged operations.
Real-world context: Consider a decentralized lending protocol with a global configuration account:
#[account]
pub struct ProtocolConfig {
pub admin: Pubkey, // The protocol owner
pub interest_rate_bps: u16, // Annual interest rate (basis points)
pub liquidation_threshold: u8, // Collateral ratio for liquidations
pub protocol_fee_bps: u16, // Platform fee percentage
}If the set_interest_rate function only checks for a signer without verifying it's the admin:
pub fn set_interest_rate(ctx: Context<SetRate>, new_rate: u16) -> Result<()> {
ctx.accounts.config.interest_rate_bps = new_rate; // ❌ NO AUTHORIZATION
Ok(())
}Exploitation consequences:
- Economic manipulation: Attacker sets interest rate to 0%, allowing free borrowing
- Protocol insolvency: Lenders receive no yield, liquidity providers withdraw
- Market manipulation: Attacker borrows maximum amount with zero cost
- Cascading failure: Protocol loses all TVL (Total Value Locked)
Historical precedent: Multiple DeFi protocols on Solana have suffered from missing authorization checks, including:
- Fee manipulation allowing attackers to avoid trading fees
- Admin privilege escalation leading to unauthorized withdrawals
- Configuration changes that drained liquidity pools
The vulnerability is particularly insidious because:
- Valid transactions are accepted (they have valid signatures)
- On-chain events appear normal (no cryptographic anomaly)
- Detection requires analyzing business logic, not just signatures
- Impact can be delayed (malicious config takes effect over time)
Let's examine the vulnerable code from example2.rs:
#[program]
pub mod incorrect_authority_vuln {
use super::*;
pub fn set_fee(ctx: Context<SetFeeVuln>, new_fee: u16) -> Result<()> {
let config = &mut ctx.accounts.config;
config.fee_bps = new_fee; // ❌ NO AUTHORIZATION CHECK
msg!("Fee updated to: {}", new_fee);
Ok(())
}
}
#[derive(Accounts)]
pub struct SetFeeVuln<'info> {
#[account(mut)]
pub config: Account<'info, Config>,
pub caller: Signer<'info>, // ❌ ANY SIGNER ACCEPTED
}
#[account]
pub struct Config {
pub admin: Pubkey, // ❌ NEVER CHECKED
pub fee_bps: u16,
}Attack sequence:
Step 1: Reconnaissance
# Attacker finds the protocol config account
solana account <PROTOCOL_CONFIG_ADDRESS>
# Output shows:
# Owner: <PROGRAM_ID>
# Data:
# admin: 7xK9... (legitimate admin)
# fee_bps: 30 (current 0.3% fee)Step 2: Craft malicious transaction
// Attacker's wallet
const attackerWallet = Keypair.fromSecretKey(...);
// Malicious fee: set to 10,000 bps (100%) to drain user funds
const maliciousFee = 10000;
// Call set_fee with attacker as signer
const tx = await program.methods
.setFee(maliciousFee)
.accounts({
config: protocolConfigAddress,
caller: attackerWallet.publicKey, // ← ATTACKER'S ADDRESS
})
.signers([attackerWallet]) // ← ATTACKER SIGNS
.rpc();Step 3: Program execution
The program processes the transaction:
- ✅ Validate
configis owned by program → PASS - ✅ Validate
calleris a signer → PASS (attacker signed) - ❌ Check
config.admin == caller.key()→ NEVER PERFORMED - ✅ Write
new_feetoconfig.fee_bps→ PASS
Result: The attacker successfully modified the protocol fee to 100%.
Step 4: Exploitation
Every subsequent trade on the protocol:
// In the trade function (simplified)
let fee_amount = trade_amount * config.fee_bps / 10_000;
// With fee_bps = 10,000: fee_amount = trade_amount * 1.0
// The protocol takes 100% of every trade as "fees"Users lose all funds to the fee mechanism, which the attacker can then withdraw if they've also compromised fee collection.
The secure implementation uses the has_one constraint to create a cryptographic binding between the signer and the stored authority:
#[program]
pub mod incorrect_authority_fix {
use super::*;
pub fn set_fee(ctx: Context<SetFeeSafe>, new_fee: u16) -> Result<()> {
// ✅ INPUT VALIDATION
require!(new_fee <= 10_000, CustomError::InvalidFee);
// ✅ At this point, Anchor has verified:
// 1. admin is a signer
// 2. config.admin == admin.key()
ctx.accounts.config.fee_bps = new_fee;
msg!("Fee successfully updated to: {}", new_fee);
Ok(())
}
}
#[derive(Accounts)]
pub struct SetFeeSafe<'info> {
#[account(
mut,
has_one = admin @ CustomError::Unauthorized // ✅ AUTHORITY BINDING
)]
pub config: Account<'info, Config>,
pub admin: Signer<'info>, // ✅ MUST BE THE STORED ADMIN
}
#[account]
pub struct Config {
pub admin: Pubkey,
pub fee_bps: u16,
}
#[error_code]
pub enum CustomError {
#[msg("The provided admin does not match the config admin.")]
Unauthorized,
#[msg("The fee must be between 0 and 10,000 basis points (100%).")]
InvalidFee,
}Defense mechanisms:
1. The has_one Constraint
has_one = admin @ CustomError::UnauthorizedAnchor automatically generates this validation code:
// Generated by Anchor
if config.admin != admin.key() {
return Err(CustomError::Unauthorized.into());
}This check happens before the instruction function executes.
2. Input Validation
require!(new_fee <= 10_000, CustomError::InvalidFee);Even if the admin is authorized, they can't set absurd values. This prevents:
- Accidental misconfiguration (admin fat-fingers a value)
- Compromised admin key exploitation (limits damage)
- Business logic errors (fees must be ≤ 100%)
3. Signer Requirement
pub admin: Signer<'info>,Combined with has_one, this creates a two-factor check:
- Is this account a signer? (cryptographic proof)
- Does this signer match the stored admin? (business logic proof)
Why the attack now fails:
When the attacker attempts the same exploit:
await program.methods
.setFee(10000)
.accounts({
config: protocolConfigAddress,
admin: attackerWallet.publicKey, // ← ATTACKER AS ADMIN
})
.signers([attackerWallet])
.rpc();Anchor validation sequence:
- ✅ Deserialize
configaccount → PASS - ✅ Verify
configis owned by program → PASS - ✅ Check
adminis a signer → PASS - ❌ Check
config.admin == admin.key()→ FAIL- Expected:
config.admin=7xK9...(legitimate admin) - Received:
admin.key()=Attacker...(attacker's key)
- Expected:
- Return error:
CustomError::Unauthorized - Transaction reverted before instruction execution
The attack is impossible because:
- The signer's identity is validated against stored state
- The
has_oneconstraint runs before business logic - Transaction fails atomically (no partial state changes)
- Only the legitimate admin can modify the configuration
Additional security layer:
Even if an attacker compromises the admin's private key:
require!(new_fee <= 10_000, CustomError::InvalidFee);This limits the damage—they can't set fees above 100%, preventing complete fund drainage.
Vulnerable Implementation:
use anchor_lang::prelude::*;
declare_id!("8qkqX4qzM3jJgWHcDNCDGj9rWWSNeyzZgZhGeDVyCbnP");
#[program]
pub mod incorrect_authority_vuln {
use super::*;
pub fn set_fee(ctx: Context<SetFeeVuln>, new_fee: u16) -> Result<()> {
let config = &mut ctx.accounts.config;
// ❌ CRITICAL VULNERABILITY: No authorization check
// Any signer can modify the fee
config.fee_bps = new_fee;
msg!("Fee updated to: {}", new_fee);
Ok(())
}
}
#[derive(Accounts)]
pub struct SetFeeVuln<'info> {
#[account(mut)]
pub config: Account<'info, Config>,
// ⚠️ VULNERABILITY: Signer without identity verification
// Anchor only checks this account signed the transaction
// Does NOT check if this signer is the admin
pub caller: Signer<'info>,
}
#[account]
pub struct Config {
pub admin: Pubkey, // ❌ Field exists but is never used
pub fee_bps: u16,
}Attack surface:
- No comparison between
callerandconfig.admin - No validation that
callerhas administrative privileges - No bounds checking on
new_fee(could be set to 10,000+ bps) - No logging of authorization failures
- No differentiation between authorized and unauthorized calls
Secure Implementation:
use anchor_lang::prelude::*;
declare_id!("6n2JUX77DpDWSPEwXhSq9bB7AFM1VqC6C5BgtF2Xb1VE");
#[program]
pub mod incorrect_authority_fix {
use super::*;
pub fn set_fee(ctx: Context<SetFeeSafe>, new_fee: u16) -> Result<()> {
// ✅ INPUT VALIDATION
// Even authorized admins must follow business rules
require!(new_fee <= 10_000, CustomError::InvalidFee);
// ✅ AUTHORIZATION COMPLETE
// At this point, Anchor has verified:
// 1. admin signed the transaction (Signer check)
// 2. config.admin == admin.key() (has_one check)
ctx.accounts.config.fee_bps = new_fee;
msg!("Fee successfully updated to: {} by authorized admin", new_fee);
Ok(())
}
}
#[derive(Accounts)]
pub struct SetFeeSafe<'info> {
#[account(
mut,
// ✅ AUTHORITY BINDING
// Generates: require_keys_eq!(config.admin, admin.key())
has_one = admin @ CustomError::Unauthorized
)]
pub config: Account<'info, Config>,
// ✅ AUTHORIZED SIGNER
// Must both:
// 1. Sign the transaction (Signer type)
// 2. Match config.admin (has_one constraint)
pub admin: Signer<'info>,
}
#[account]
pub struct Config {
pub admin: Pubkey, // ✅ Validated via has_one constraint
pub fee_bps: u16, // ✅ Protected by authorization
}
#[error_code]
pub enum CustomError {
#[msg("The provided admin does not match the config admin.")]
Unauthorized,
#[msg("The fee must be between 0 and 10,000 basis points (100%).")]
InvalidFee,
}Security improvements:
- ✅ Authority binding:
has_one = adminlinks signer to stored authority - ✅ Input validation: Fee bounded to reasonable range
- ✅ Error messages: Clear feedback for unauthorized attempts
- ✅ Logging: Audit trail of configuration changes
- ✅ Type safety:
Account<Config>ensures correct data structure - ✅ Fail-fast: Validation before state modification
1. Always Bind Signers to Stored Authorities
❌ Avoid:
pub fn update_settings(ctx: Context<Update>) -> Result<()> {
// Anyone can call this
ctx.accounts.settings.value = new_value;
Ok(())
}
#[derive(Accounts)]
pub struct Update<'info> {
#[account(mut)]
pub settings: Account<'info, Settings>,
pub signer: Signer<'info>, // ❌ Meaningless check
}✅ Prefer:
#[derive(Accounts)]
pub struct Update<'info> {
#[account(
mut,
has_one = authority @ CustomError::Unauthorized
)]
pub settings: Account<'info, Settings>,
pub authority: Signer<'info>, // ✅ Must match stored authority
}2. Validate Input Parameters
Even authorized users should face constraints:
pub fn set_parameters(
ctx: Context<SetParams>,
interest_rate: u16,
liquidation_ratio: u8,
) -> Result<()> {
// Bound checking prevents misconfiguration
require!(
interest_rate <= 10_000,
CustomError::InterestRateTooHigh
);
require!(
liquidation_ratio >= 50 && liquidation_ratio <= 95,
CustomError::InvalidLiquidationRatio
);
// ... update state
}3. Implement Role-Based Access Control (RBAC)
For complex protocols, use role enumerations:
#[account]
pub struct Config {
pub roles: HashMap<Pubkey, Role>,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq)]
pub enum Role {
Admin, // Full control
Operator, // Can pause/unpause
TreasuryManager, // Can modify fee destination
ParameterUpdater, // Can modify non-critical params
}
pub fn require_role(
config: &Config,
signer: &Pubkey,
required_role: Role,
) -> Result<()> {
let user_role = config.roles.get(signer)
.ok_or(CustomError::Unauthorized)?;
require_eq!(*user_role, required_role, CustomError::InsufficientPrivileges);
Ok(())
}4. Use Custom Constraints for Complex Authorization
When has_one isn't sufficient:
#[derive(Accounts)]
pub struct ComplexAuth<'info> {
#[account(
mut,
constraint = is_authorized(&config, &signer.key()) @ CustomError::Unauthorized
)]
pub config: Account<'info, Config>,
pub signer: Signer<'info>,
}
fn is_authorized(config: &Config, signer: &Pubkey) -> bool {
config.admin == *signer ||
config.operators.contains(signer) ||
config.emergency_contacts.contains(signer)
}5. Emit Events for Sensitive Operations
Create an audit trail:
#[event]
pub struct FeeUpdated {
pub old_fee: u16,
pub new_fee: u16,
pub updated_by: Pubkey,
pub timestamp: i64,
}
pub fn set_fee(ctx: Context<SetFee>, new_fee: u16) -> Result<()> {
let old_fee = ctx.accounts.config.fee_bps;
ctx.accounts.config.fee_bps = new_fee;
emit!(FeeUpdated {
old_fee,
new_fee,
updated_by: ctx.accounts.admin.key(),
timestamp: Clock::get()?.unix_timestamp,
});
Ok(())
}6. Implement Multi-Signature for Critical Operations
#[account]
pub struct MultiSigConfig {
pub required_signatures: u8,
pub authorized_signers: Vec<Pubkey>,
pub pending_proposal: Option<Proposal>,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone)]
pub struct Proposal {
pub action: ProposalAction,
pub approvals: Vec<Pubkey>,
pub expiry: i64,
}7. Time-Lock Critical Changes
#[account]
pub struct TimeLockConfig {
pub admin: Pubkey,
pub pending_change: Option<PendingChange>,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone)]
pub struct PendingChange {
pub new_value: ConfigChange,
pub proposed_at: i64,
pub execution_time: i64, // proposed_at + timelock_duration
}
pub fn propose_change(ctx: Context<ProposeChange>, change: ConfigChange) -> Result<()> {
let clock = Clock::get()?;
ctx.accounts.config.pending_change = Some(PendingChange {
new_value: change,
proposed_at: clock.unix_timestamp,
execution_time: clock.unix_timestamp + TIMELOCK_DURATION,
});
Ok(())
}
pub fn execute_change(ctx: Context<ExecuteChange>) -> Result<()> {
let pending = ctx.accounts.config.pending_change
.as_ref()
.ok_or(CustomError::NoPendingChange)?;
let clock = Clock::get()?;
require!(
clock.unix_timestamp >= pending.execution_time,
CustomError::TimeLockNotExpired
);
// Apply the change
// ...
ctx.accounts.config.pending_change = None;
Ok(())
}8. Separate Privileges by Function
Don't use a single "admin" for everything:
#[account]
pub struct ProtocolConfig {
pub super_admin: Pubkey, // Can change admins
pub fee_admin: Pubkey, // Can modify fees
pub pause_authority: Pubkey, // Can pause protocol
pub upgrade_authority: Pubkey, // Can upgrade program
}
#[derive(Accounts)]
pub struct SetFee<'info> {
#[account(mut, has_one = fee_admin)]
pub config: Account<'info, ProtocolConfig>,
pub fee_admin: Signer<'info>, // Only fee_admin, not super_admin
}9. Security Checklist for Authorization
Before deploying, verify:
- Every administrative function has an authorization check
- Signers are bound to stored authority fields (via
has_oneorconstraint) - Input parameters are validated with reasonable bounds
- Sensitive operations emit events for auditing
- Role separation limits blast radius of compromised keys
- Time-locks protect critical configuration changes
- Multi-signature is used for high-value operations
- Error messages don't leak sensitive information
- Authorization checks occur before state modifications
- Tests cover both authorized and unauthorized access attempts
10. Testing Recommendations
Write comprehensive authorization tests:
describe("Authorization Tests", () => {
it("should allow admin to update fee", async () => {
await program.methods
.setFee(50)
.accounts({
config: configAccount,
admin: adminKeypair.publicKey,
})
.signers([adminKeypair])
.rpc();
const config = await program.account.config.fetch(configAccount);
expect(config.feeBps).to.equal(50);
});
it("should reject non-admin fee update", async () => {
const attacker = Keypair.generate();
await expect(
program.methods
.setFee(10000)
.accounts({
config: configAccount,
admin: attacker.publicKey, // Wrong admin
})
.signers([attacker])
.rpc()
).to.be.rejectedWith("Unauthorized");
});
it("should reject out-of-bounds fee", async () => {
await expect(
program.methods
.setFee(15000) // > 10,000 bps
.accounts({
config: configAccount,
admin: adminKeypair.publicKey,
})
.signers([adminKeypair])
.rpc()
).to.be.rejectedWith("InvalidFee");
});
});Severity: 🟠 High | CVSS Score: 8.5 | CWE-190/CWE-191: Integer Overflow/Underflow
Rust's arithmetic behavior differs fundamentally between debug and release builds, creating a hidden danger for Solana programs. Understanding this difference is critical for preventing balance corruption vulnerabilities.
Debug Mode (Development):
- Arithmetic overflow/underflow causes a panic (program crash)
- Example:
let x: u8 = 255; x + 1;→ panic! - Developers often test in debug mode where problems are caught
Release Mode (Production):
- Arithmetic overflow/underflow wraps silently using two's complement
- Example:
let x: u8 = 255; x + 1;→0(no error) - Example:
let x: u8 = 0; x - 1;→255(no error) - Solana programs are deployed in release mode for performance
This behavioral difference creates a testing blind spot. Code that appears safe in development can have catastrophic vulnerabilities in production.
The mathematics of wrapping:
For unsigned integers:
u64 MAX = 18,446,744,073,709,551,615
Addition overflow:
18,446,744,073,709,551,615 + 1 = 0 (wraps around)
Subtraction underflow:
0 - 1 = 18,446,744,073,709,551,615 (wraps around)
For a financial application, this means:
let balance: u64 = 100;
let withdrawal: u64 = 101;
// In release mode, this doesn't fail:
balance = balance - withdrawal; // balance is now 18,446,744,073,709,551,614The user requested to withdraw 101 lamports but only had 100. Instead of rejecting the transaction, the program gives them 18.4 quintillion lamports.
Real-world context: Consider a token vault program that manages user deposits:
#[account]
pub struct UserVault {
pub owner: Pubkey,
pub balance: u64, // Balance in lamports
}
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
ctx.accounts.vault.balance -= amount; // ❌ VULNERABLE
// ... transfer lamports to user
Ok(())
}Exploitation scenarios:
Scenario 1: Underflow Attack
- User deposits 1 SOL (1,000,000,000 lamports)
- User withdraws 1.000000001 SOL (1,000,000,001 lamports)
- Calculation:
1,000,000,000 - 1,000,000,001 = -1 - Result wraps to:
18,446,744,073,709,551,615lamports - User now has effectively infinite balance
- User drains the entire protocol's liquidity
Scenario 2: Overflow in Fee Calculation
let trade_amount: u64 = u64::MAX;
let fee_bps: u64 = 30; // 0.3%
// Attempting to calculate fee
let fee = (trade_amount * fee_bps) / 10_000; // ❌ OVERFLOWWhen trade_amount * fee_bps is calculated:
18,446,744,073,709,551,615 * 30 = 553,402,322,211,286,548,450
This exceeds u64::MAX and wraps around, producing an incorrect fee (possibly even zero), allowing traders to avoid fees entirely.
Scenario 3: Token Minting Exploit
pub fn mint_tokens(ctx: Context<Mint>, amount: u64) -> Result<()> {
ctx.accounts.total_supply += amount; // ❌ VULNERABLE
ctx.accounts.user_balance += amount;
Ok(())
}If total_supply is near u64::MAX, adding more tokens wraps the supply to a small number, breaking the fundamental invariant that individual balances sum to total supply.
Historical precedent:
- Multiple Solana tokens have suffered from arithmetic vulnerabilities
- Flash loan exploits have leveraged overflow to create unbacked tokens
- Fee calculation errors have cost protocols millions in lost revenue
- Balance corruption has led to insolvent vaults and protocol shutdowns
The vulnerability is particularly dangerous because:
- It's invisible in testing (debug mode catches it)
- It's silent in production (no error, just wrong numbers)
- It can be exploited with tiny amounts (1 lamport triggers it)
- Detection requires careful analysis of every arithmetic operation
Let's examine the vulnerable code from example3.rs:
#[account]
pub struct Vault {
pub balance: u64,
pub owner: Pubkey,
}
#[program]
pub mod unsafe_arithmetic_vuln {
use super::*;
pub fn withdraw(ctx: Context<WithdrawVuln>, amount: u64) -> Result<()> {
let vault = &mut ctx.accounts.vault;
// ❌ VULNERABILITY: Standard subtraction operator
// In release mode, this wraps on underflow
vault.balance -= amount;
Ok(())
}
}
#[derive(Accounts)]
pub struct WithdrawVuln<'info> {
#[account(mut, has_one = owner)]
pub vault: Account<'info, Vault>,
pub owner: Signer<'info>,
}Attack sequence:
Step 1: Setup
// Attacker creates a vault and deposits minimal amount
const attacker = Keypair.generate();
const [vaultPDA] = await PublicKey.findProgramAddress(
[Buffer.from("vault"), attacker.publicKey.toBuffer()],
program.programId
);
await program.methods
.deposit()
.accounts({
vault: vaultPDA,
owner: attacker.publicKey,
})
.signers([attacker])
.rpc();
// Attacker deposits 100 lamports
await program.methods
.depositFunds(new BN(100))
.accounts({ vault: vaultPDA, owner: attacker.publicKey })
.signers([attacker])
.rpc();Step 2: Trigger Underflow
// Attacker withdraws MORE than they deposited
await program.methods
.withdraw(new BN(101)) // Request 101 lamports (have only 100)
.accounts({
vault: vaultPDA,
owner: attacker.publicKey,
})
.signers([attacker])
.rpc();Step 3: Program Execution
The program processes the withdrawal:
- Load vault account:
balance = 100 - Execute:
vault.balance -= 101 - In debug mode: Panic! (overflow detected)
- In release mode:
100 - 101 = -1 -1 as u64 = 18,446,744,073,709,551,615 (two's complement) - Vault balance is now
18,446,744,073,709,551,615
Step 4: Drain Protocol
// Attacker now has nearly infinite balance
const vaultAccount = await program.account.vault.fetch(vaultPDA);
console.log(vaultAccount.balance.toString());
// Output: 18446744073709551615
// Attacker can now withdraw all lamports from the program's vault PDA
// Since the program thinks the attacker has 18.4 quintillion lamports,
// it will allow withdrawals until the actual SOL balance is drainedStep 5: Impact
For each withdrawal transaction:
// Program checks (incorrectly):
vault.balance (18.4 quintillion) >= withdrawal_amount ✓ PASS
// Program transfers actual SOL from vault PDA to attacker
// Vault PDA lamports: 1,000,000,000 → 999,999,000 → 999,998,000 → ...
// Eventually:
// Vault PDA lamports: 0 (completely drained)
// vault.balance: still shows 18,446,744,073,708,000,000All legitimate users lose their funds because the vault PDA has been emptied, while the attacker's balance still shows as essentially infinite.
The secure implementation uses checked arithmetic methods that return Option types:
#[program]
pub mod unsafe_arithmetic_fix {
use super::*
;
pub fn withdraw(ctx: Context<WithdrawSafe>, amount: u64) -> Result<()> {
let vault = &mut ctx.accounts.vault;
// ✅ CHECKED ARITHMETIC
// Returns None if the subtraction would underflow
vault.balance = vault
.balance
.checked_sub(amount)
.ok_or(CustomError::InsufficientFunds)?;
Ok(())
}
}
#[derive(Accounts)]
pub struct WithdrawSafe<'info> {
#[account(mut, has_one = owner)]
pub vault: Account<'info, Vault>,
pub owner: Signer<'info>,
}
#[error_code]
pub enum CustomError {
#[msg("The requested withdrawal amount exceeds the vault balance.")]
InsufficientFunds,
}Defense mechanisms:
1. Checked Subtraction (checked_sub)
vault.balance.checked_sub(amount)This method returns:
Some(result)ifbalance >= amount(safe subtraction)Noneifbalance < amount(would underflow)
2. Option Unwrapping (ok_or)
.ok_or(CustomError::InsufficientFunds)?This converts the Option<u64> to Result<u64>:
Some(value)→Ok(value)→ continues executionNone→Err(CustomError::InsufficientFunds)→ exits function
3. Early Return (? operator)
The ? operator:
- If
Ok(value): unwraps and assigns tovault.balance - If
Err(e): immediately returns the error to Solana runtime
Transaction atomicity:
Because the error is returned before the function completes:
- Solana runtime receives the error
- All account changes are reverted (atomic rollback)
- The vault balance remains unchanged (still 100)
- The transaction fails and is not committed
Why the attack now fails:
When the attacker attempts the same exploit:
await program.methods
.withdraw(new BN(101))
.accounts({
vault: vaultPDA,
owner: attacker.publicKey,
})
.signers([attacker])
.rpc();Program execution:
- Load vault:
balance = 100 - Execute
balance.checked_sub(101):100_u64.checked_sub(101) // Returns None (would be -1)
- Execute
ok_or(CustomError::InsufficientFunds):None.ok_or(CustomError::InsufficientFunds) // Returns Err(...)
- The
?operator seesErrand returns immediately - Balance is never updated (still 100)
- Transaction fails with error: "InsufficientFunds"
- Attacker receives error, no state is modified
The attack is impossible because:
- Checked arithmetic detects underflow before it happens
- The program returns an error instead of silently wrapping
- Solana's atomic transactions ensure no partial state updates
- The attacker's balance remains unchanged (100 lamports)
Vulnerable Implementation:
use anchor_lang::prelude::*;
#[account]
pub struct Vault {
pub balance: u64,
pub owner: Pubkey,
}
declare_id!("7Q7L1Srqz1WY5Avzk1kYyqSCDtnznuaCG2qLBVmczWiN");
#[program]
pub mod unsafe_arithmetic_vuln {
use super::*;
pub fn withdraw(ctx: Context<WithdrawVuln>, amount: u64) -> Result<()> {
let vault = &mut ctx.accounts.vault;
// ❌ CRITICAL VULNERABILITY
// Standard arithmetic operator uses wrapping in release mode
// Debug mode: panics on overflow/underflow
// Release mode: wraps silently (production vulnerability)
vault.balance -= amount;
// If we get here, the balance has been corrupted
// No actual SOL transfer shown, but in real implementation
// the program would transfer based on corrupted balance
Ok(())
}
}
#[derive(Accounts)]
pub struct WithdrawVuln<'info> {
#[account(mut, has_one = owner)]
pub vault: Account<'info, Vault>,
pub owner: Signer<'info>,
}Attack surface:
- Uses
-=operator which wraps in release mode - No bounds checking before arithmetic operation
- No validation that
balance >= amount - Silent failure allows corruption to persist
- No error returned to caller when underflow occurs
Secure Implementation:
use anchor_lang::prelude::*;
#[account]
pub struct Vault {
pub balance: u64,
pub owner: Pubkey,
}
declare_id!("5LApMfCVxYv3BPjVAkVnnBYnCTsRmRykGGBqBPdZiZsa");
#[program]
pub mod unsafe_arithmetic_fix {
use super::*;
pub fn withdraw(ctx: Context<WithdrawSafe>, amount: u64) -> Result<()> {
let vault = &mut ctx.accounts.vault;
// ✅ CHECKED ARITHMETIC
// Step 1: checked_sub returns Option<u64>
// - Some(result) if balance >= amount
// - None if balance < amount (would underflow)
//
// Step 2: ok_or converts Option to Result
// - Some(val) → Ok(val)
// - None → Err(CustomError::InsufficientFunds)
//
// Step 3: ? operator handles Result
// - Ok(val) → assigns to vault.balance
// - Err(e) → returns error immediately
vault.balance = vault
.balance
.checked_sub(amount)
.ok_or(CustomError::InsufficientFunds)?;
Ok(())
}
}
#[derive(Accounts)]
pub struct WithdrawSafe<'info> {
#[account(mut, has_one = owner)]
pub vault: Account<'info, Vault>,
pub owner: Signer<'info>,
}
#[error_code]
pub enum CustomError {
#[msg("The requested withdrawal amount exceeds the vault balance.")]
InsufficientFunds,
}Security improvements:
- ✅ Checked arithmetic:
checked_subdetects underflow - ✅ Error handling: Returns explicit error on invalid operation
- ✅ Atomic transactions: Failed checks prevent state updates
- ✅ Clear messaging: User understands why transaction failed
- ✅ Works in all modes: Same behavior in debug and release
1. Always Use Checked Arithmetic Methods
Rust provides checked variants for all arithmetic operations:
❌ Avoid:
let result = a + b; // Wraps on overflow
let result = a - b; // Wraps on underflow
let result = a * b; // Wraps on overflow
let result = a / b; // Panics on division by zero✅ Prefer:
let result = a.checked_add(b).ok_or(ErrorCode::Overflow)?;
let result = a.checked_sub(b).ok_or(ErrorCode::Underflow)?;
let result = a.checked_mul(b).ok_or(ErrorCode::Overflow)?;
let result = a.checked_div(b).ok_or(ErrorCode::DivisionByZero)?;2. Comprehensive Checked Methods
| Method | Purpose | Returns None When |
|---|---|---|
checked_add(rhs) |
Addition | Result > type maximum |
checked_sub(rhs) |
Subtraction | Result < 0 (for unsigned) |
checked_mul(rhs) |
Multiplication | Result > type maximum |
checked_div(rhs) |
Division | Divisor is zero |
checked_rem(rhs) |
Remainder | Divisor is zero |
checked_pow(exp) |
Exponentiation | Result > type maximum |
checked_shl(rhs) |
Left shift | Shift amount >= bit width |
checked_shr(rhs) |
Right shift | Shift amount >= bit width |
3. Alternative Safe Arithmetic Patterns
Saturating Arithmetic (bounds to min/max):
let result = a.saturating_add(b); // If overflow, returns type::MAX
let result = a.saturating_sub(b); // If underflow, returns 0Use when you want to clamp to boundaries instead of failing:
// User reputation can't go below 0 or above MAX
user.reputation = user.reputation.saturating_add(bonus);Wrapping Arithmetic (explicit wrapping):
let result = a.wrapping_add(b); // Explicitly wraps (use with caution)Only use when wrapping is the intended behavior (e.g., hash functions, checksums).
4. Enable Overflow Checks in Cargo.toml
For extra safety, enable overflow checks even in release mode:
[profile.release]
overflow-checks = trueTrade-off: This adds runtime overhead but prevents silent failures. Consider for critical financial applications.
5. Validate Inputs Before Arithmetic
Don't rely solely on checked arithmetic—validate bounds first:
pub fn calculate_fee(amount: u64, fee_bps: u16) -> Result<u64> {
// Validate inputs
require!(fee_bps <= 10_000, CustomError::InvalidFeeBps);
require!(amount > 0, CustomError::ZeroAmount);
// Safe calculation
let fee_numerator = (amount as u128)
.checked_mul(fee_bps as u128)
.ok_or(CustomError::Overflow)?;
let fee = (fee_numerator / 10_000) as u64;
Ok(fee)
}6. Use Wider Types for Intermediate Calculations
Prevent overflow in multiplication before division:
❌ Risky:
// If amount is large, amount * fee_bps might overflow
let fee = (amount * fee_bps) / 10_000;✅ Safe:
// Use u128 for intermediate calculation
let fee = ((amount as u128) * (fee_bps as u128) / 10_000) as u64;Or with checked arithmetic:
let fee = (amount as u128)
.checked_mul(fee_bps as u128)
.and_then(|v| v.checked_div(10_000))
.and_then(|v| u64::try_from(v).ok())
.ok_or(CustomError::CalculationError)?;7. Maintain Invariants
For token programs, always verify:
// Invariant: sum of all balances equals total supply
pub fn mint(ctx: Context<Mint>, amount: u64) -> Result<()> {
let total_supply = ctx.accounts.mint_info.total_supply
.checked_add(amount)
.ok_or(CustomError::SupplyOverflow)?;
let user_balance = ctx.accounts.user_account.balance
.checked_add(amount)
.ok_or(CustomError::BalanceOverflow)?;
ctx.accounts.mint_info.total_supply = total_supply;
ctx.accounts.user_account.balance = user_balance;
Ok(())
}8. Test Edge Cases
Always test boundary conditions:
describe("Arithmetic edge cases", () => {
it("should reject withdrawal exceeding balance", async () => {
// Vault has 100 lamports
await expect(
program.methods.withdraw(new BN(101)).rpc()
).to.be.rejectedWith("InsufficientFunds");
});
it("should handle u64::MAX correctly", async () => {
const maxU64 = new BN("18446744073709551615");
await expect(
program.methods.deposit(maxU64).rpc()
).to.be.rejected; // Should fail, can't add to existing balance
});
it("should handle zero amounts", async () => {
await expect(
program.methods.withdraw(new BN(0)).rpc()
).to.be.rejectedWith("ZeroAmount");
});
});9. Security Checklist for Arithmetic
Before deploying, verify:
- All
+,-,*,/operators replaced withchecked_*variants - Division operations check for zero divisor
- Multiplication uses wider types for intermediate results
- Input parameters are validated with bounds checks
- Invariants are maintained (e.g., total supply = sum of balances)
- Error messages clearly indicate arithmetic failures
- Tests cover min/max boundary conditions
- Release build is tested (not just debug)
- Consider enabling
overflow-checks = truein release profile - Complex calculations use u128 to prevent intermediate overflow
10. Common Patterns
Safe balance update:
account.balance = account.balance
.checked_add(amount)
.ok_or(ErrorCode::Overflow)?;Safe fee calculation:
let fee = (amount as u128)
.checked_mul(fee_bps as u128)
.ok_or(ErrorCode::Overflow)?
.checked_div(10_000)
.ok_or(ErrorCode::Overflow)?
.try_into()
.map_err(|_| ErrorCode::Overflow)?;Safe transfer with fee:
let fee = calculate_fee(amount, FEE_BPS)?;
let recipient_amount = amount
.checked_sub(fee)
.ok_or(ErrorCode::InsufficientAmount)?;
sender.balance = sender.balance
.checked_sub(amount)
.ok_or(ErrorCode::InsufficientFunds)?;
recipient.balance = recipient.balance
.checked_add(recipient_amount)
.ok_or(ErrorCode::Overflow)?;
treasury.balance = treasury.balance
.checked_add(fee)
.ok_or(ErrorCode::Overflow)?;Severity: 🟠 High | CVSS Score: 8.0 | CWE-841: Improper Enforcement of Behavioral Workflow
Cross-Program Invocation (CPI) reentrancy is Solana's equivalent to Ethereum's reentrancy vulnerability, but with important architectural differences. On Solana, programs can call other programs through CPIs, similar to how Ethereum contracts call other contracts. However, Solana's account model creates unique attack surfaces.
Understanding CPI:
When a Solana program makes a CPI:
- Program A invokes Program B with specific accounts
- Control transfers to Program B
- Program B executes arbitrary code
- Program B can invoke Program A (or other programs)
- Control returns to Program A
The vulnerability arises when:
- Program A makes state changes after calling Program B
- Program B (malicious) calls back into Program A
- Program A's second invocation sees stale state from before the first invocation completed
- The second invocation completes first, updating state
- The first invocation completes second, overwriting the state with stale values
This violates the CEI Pattern (Checks-Effects-Interactions):
- Checks: Validate all inputs and preconditions
- Effects: Update internal state
- Interactions: Make external calls (CPI)
Why Solana reentrancy differs from Ethereum:
| Aspect | Ethereum | Solana |
|---|---|---|
| State model | Contract storage | Account data |
| Call semantics | Synchronous call stack | Synchronous CPI |
| Reentrancy guard | Mutex/bool in storage | Mutex/bool in account data |
| Common pattern | ETH transfers trigger fallback | CPI with callback hooks |
| Detection | Check for external calls | Check for CPI + state updates |
On Solana, the attack often involves:
- A "notification" or "callback" CPI that seems benign
- The malicious program re-entering the victim during the callback
- State updates using
saturating_subor similar that appear after CPI - The final state update overwriting correct intermediate state
Real-world context: Consider a vault withdrawal system that notifies an external program after withdrawal:
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
// Transfer lamports to user
**ctx.accounts.vault.to_account_info().try_borrow_mut_lamports()? -= amount;
**ctx.accounts.recipient.to_account_info().try_borrow_mut_lamports()? += amount;
// Notify external program (CPI) ❌ VULNERABILITY
notify_withdrawal(
&ctx.accounts.notification_program,
&ctx.accounts.vault,
amount
)?;
// Update internal balance ❌ AFTER CPI
ctx.accounts.vault_state.balance -= amount;
Ok(())
}Exploitation flow:
- Attacker's vault has 1000 lamports
- Attacker calls
withdraw(500) - Program transfers 500 lamports to attacker
- Program calls
notify_withdrawal()CPI - Malicious notification program calls
withdraw(500)again (reentrancy) - Inner call sees:
vault_state.balance = 1000(not yet updated) - Inner call transfers another 500 lamports (total: 1000 withdrawn)
- Inner call updates:
vault_state.balance = 1000 - 500 = 500 - Inner call completes
- Outer call continues: updates
vault_state.balance = 1000 - 500 = 500 - Final state: Attacker withdrew 1000 lamports but
balanceshows 500
The attacker drained twice their balance because the state update happened after the CPI, allowing the second invocation to see stale state.
Historical precedent:
- Wormhole bridge exploit ($325M) involved cross-program reentrancy
- Multiple Solana lending protocols have been vulnerable to reentrancy
- Flash loan protocols particularly susceptible due to complex call chains
- AMM (Automated Market Maker) pools drained via reentrancy in oracle callbacks
The vulnerability is dangerous because:
- It's subtle—the code looks reasonable at first glance
- Testing may not catch it without adversarial programs
- State updates with
saturating_*methods hide the issue - The attack can be instant (single transaction, multiple invocations)
Let's examine the vulnerable code from example4.rs:
#[account]
pub struct Vault {
pub is_locked: bool,
pub authority: Pubkey,
pub balance: u64,
}
#[program]
pub mod cpi_reentrancy_vuln {
use super::*;
pub fn withdraw(ctx: Context<WithdrawVuln>, amount: u64) -> Result<()> {
let vault_key = ctx.accounts.vault.key();
let recipient_key = ctx.accounts.recipient.key();
// ❌ VULNERABILITY: CPI BEFORE STATE UPDATE
// Call external program (attacker-controlled)
invoke(
&Instruction {
program_id: ctx.accounts.attacker_program.key(),
accounts: vec![...],
data: [0].to_vec(),
},
&[vault_info.clone(), attacker_info],
).ok();
// Transfer lamports
invoke(
&system_instruction::transfer(&vault_key, &recipient_key, amount),
&[vault_info, recipient_info],
)?;
// ❌ STATE UPDATE AFTER CPI
vault.balance = vault.balance.saturating_sub(amount);
Ok(())
}
}Attack sequence:
Step 1: Attacker creates malicious program
// Attacker's reentrant program
#[program]
pub mod attacker_program {
pub fn reentrancy_hook(ctx: Context<Hook>) -> Result<()> {
// This is called by the victim during withdrawal
msg!("Attacker hook called - re-entering victim");
// Re-enter the victim's withdraw function
let cpi_ctx = CpiContext::new(
ctx.accounts.victim_program.to_account_info(),
WithdrawAccounts {
vault: ctx.accounts.vault.to_account_info(),
// ... other accounts
}
);
// Call withdraw AGAIN while first call is still executing
victim::cpi::withdraw(cpi_ctx, REENTRANCY_AMOUNT)?;
msg!("Reentrancy successful");
Ok(())
}
}Step 2: Setup attack
// Attacker deposits 1000 lamports
await victimProgram.methods
.deposit(new BN(1000))
.accounts({ vault: vaultPDA, authority: attacker.publicKey })
.signers([attacker])
.rpc();
// Verify initial state
const vaultBefore = await victimProgram.account.vault.fetch(vaultPDA);
console.log("Initial balance:", vaultBefore.balance.toString()); // 1000Step 3: Execute reentrancy attack
await victimProgram.methods
.withdraw(new BN(600)) // Request 600 lamports withdrawal
.accounts({
vault: vaultPDA,
authority: attacker.publicKey,
recipient: attacker.publicKey,
attackerProgram: attackerProgramId, // Malicious program
systemProgram: SystemProgram.programId,
})
.signers([attacker])
.rpc();Step 4: Execution trace
CALL STACK DEPTH 1: victim::withdraw(600)
├─ vault.balance = 1000 (read from account)
├─ invoke attacker_program::reentrancy_hook()
│ │
│ └─ CALL STACK DEPTH 2: victim::withdraw(600) ← REENTRANCY
│ ├─ vault.balance = 1000 (still unchanged) ← STALE STATE
│ ├─ transfer 600 lamports to attacker
│ ├─ vault.balance = 1000 - 600 = 400 (saturating_sub)
│ └─ return success
│
├─ (back to depth 1)
├─ transfer 600 lamports to attacker (another 600!)
├─ vault.balance = 1000 - 600 = 400 (saturating_sub) ← OVERWRITES
└─ return success
RESULT:
- Attacker withdrew: 600 + 600 = 1200 lamports
- Vault balance: 400 (incorrect, should be -200 or error)
- Actual vault SOL: 1000 - 1200 = -200 (impossible, drained + deficit)
Step 5: Post-attack state
const vaultAfter = await victimProgram.account.vault.fetch(vaultPDA);
console.log("Final balance:", vaultAfter.balance.toString()); // 400
// But the vault PDA actually has negative balance (protocol insolvent)
const actualBalance = await connection.getBalance(vaultPDA);
console.log("Actual SOL:", actualBalance); // 0 or minimal rent-exempt amount
// Attacker received both withdrawals
const attackerBalance = await connection.getBalance(attacker.publicKey);
// Increased by 1200 lamports (not 600)The attack succeeded because:
- State was updated after the CPI
- The reentered call saw the original state (1000)
- Both calls thought they were withdrawing from 1000
- The final state update used stale data
The secure implementation uses multiple defensive techniques:
#[account]
pub struct Vault {
pub is_locked: bool, // ✅ Reentrancy guard
pub authority: Pubkey,
pub balance: u64,
}
#[program]
pub mod cpi_reentrancy_fix {
use super::*;
pub fn withdraw(ctx: Context<WithdrawSafe>, amount: u64) -> Result<()> {
let vault = &mut ctx.accounts.vault;
// ✅ DEFENSE 1: Reentrancy Guard
require!(!vault.is_locked, CustomError::ReentrancyBlocked);
vault.is_locked = true; // Lock BEFORE any external calls
// ✅ DEFENSE 2: State Update BEFORE CPI (CEI Pattern)
vault.balance = vault
.balance
.checked_sub(amount)
.ok_or(CustomError::InsufficientFunds)?;
// ✅ NOW SAFE: External calls happen after state update
invoke(
&Instruction {
program_id: ctx.accounts.attacker_program.key(),
// ...
},
&[vault_info.clone(), attacker_info],
).ok();
invoke(
&system_instruction::transfer(&vault_key, &recipient_key, amount),
&[vault_info, recipient_info],
)?;
// ✅ DEFENSE 3: Unlock after success
vault.is_locked = false;
Ok(())
}
}
#[error_code]
pub enum CustomError {
#[msg("re-entrancy blocked")]
ReentrancyBlocked,
#[msg("insufficient funds")]
InsufficientFunds,
}Defense mechanisms:
1. Reentrancy Guard (Lock)
require!(!vault.is_locked, CustomError::ReentrancyBlocked);
vault.is_locked = true;This creates a mutex:
- First call:
is_locked = false→ check passes → set totrue - Reentered call:
is_locked = true→ check fails → returns error
2. CEI Pattern (State Update Before CPI)
// Update balance BEFORE external calls
vault.balance = vault.balance.checked_sub(amount)?;
// Then make external calls
invoke(...)?;Even if reentrancy occurs:
- First call updates:
balance = 1000 - 600 = 400 - Reentered call sees:
balance = 400(updated, not stale) - Reentered call attempts:
400 - 600 = underflow→ Error
3. Checked Arithmetic
.checked_sub(amount).ok_or(CustomError::InsufficientFunds)?- Prevents silent failures
- Ensures transaction reverts on invalid amounts
- No state corruption from wrapping
Why the attack now fails:
When the attacker attempts reentrancy:
CALL STACK DEPTH 1: victim::withdraw(600)
├─ vault.is_locked = false (check passes)
├─ vault.is_locked = true (lock acquired)
├─ vault.balance = 1000 - 600 = 400 (checked_sub)
├─ invoke attacker_program::reentrancy_hook()
│ │
│ └─ CALL STACK DEPTH 2: victim::withdraw(600) ← REENTRANCY ATTEMPT
│ ├─ vault.is_locked = true (check fails) ← BLOCKED
│ └─ return Err(ReentrancyBlocked)
│
├─ (back to depth 1, reentrancy failed)
├─ transfer 600 lamports to attacker (only once)
├─ vault.is_locked = false (unlock)
└─ return success
RESULT:
- Attacker withdrew: 600 lamports (correct)
- Vault balance: 400 (correct)
- Actual vault SOL: 400 (correct)
Even if we disable the lock to test CEI alone:
CALL STACK DEPTH 1: victim::withdraw(600)
├─ vault.balance = 1000 - 600 = 400 (update first)
├─ invoke attacker_program::reentrancy_hook()
│ │
│ └─ CALL STACK DEPTH 2: victim::withdraw(600)
│ ├─ vault.balance = 400 (updated state, not stale)
│ ├─ 400 - 600 = underflow → Err(InsufficientFunds) ← BLOCKED
│ └─ return error
│
├─ (back to depth 1, reentrancy failed)
└─ return success
RESULT:
- Attacker withdrew: 600 lamports (correct)
- Vault balance: 400 (correct)
The attack is impossible because:
- Reentrancy guard prevents concurrent execution
- State updates before CPI eliminate stale state
- Checked arithmetic prevents invalid calculations
- Transaction atomicity ensures all-or-nothing execution
Vulnerable Implementation:
use anchor_lang::prelude::*;
use anchor_lang::solana_program::program::invoke;
use anchor_lang::solana_program::system_instruction;
#[account]
pub struct Vault {
pub is_locked: bool, // ❌ Exists but not used
pub authority: Pubkey,
pub balance: u64,
}
declare_id!("C4h3LK2unfGWWKBPXn1HULjubwhf66A1VpzwuNFuGqmo");
#[program]
pub mod cpi_reentrancy_vuln {
use super::*;
pub fn withdraw(ctx: Context<WithdrawVuln>, amount: u64) -> Result<()> {
let vault_key = ctx.accounts.vault.key();
let recipient_key = ctx.accounts.recipient.key();
let vault_info = ctx.accounts.vault.to_account_info();
let recipient_info = ctx.accounts.recipient.to_account_info();
let attacker_info = ctx.accounts.attacker_program.to_account_info();
let vault = &mut ctx.accounts.vault;
// ❌ VULNERABILITY 1: CPI before state update
invoke(
&Instruction {
program_id: ctx.accounts.attacker_program.key(),
accounts: vec![...],
data: [0].to_vec(),
},
&[vault_info.clone(), attacker_info],
).ok(); // Ignore errors (for demo)
// ❌ VULNERABILITY 2: Transfer before state update
invoke(
&system_instruction::transfer(&vault_key, &recipient_key, amount),
&[vault_info, recipient_info],
)?;
// ❌ VULNERABILITY 3: State update LAST (stale data)
// If reentrancy occurred, this overwrites correct intermediate state
vault.balance = vault.balance.saturating_sub(amount);
Ok(())
}
}
#[derive(Accounts)]
pub struct WithdrawVuln<'info> {
#[account(mut, has_one = authority)]
pub vault: Account<'info, Vault>,
pub authority: Signer<'info>,
/// CHECK: recipient
#[account(mut)]
pub recipient: AccountInfo<'info>,
/// CHECK: attacker program
pub attacker_program: AccountInfo<'info>,
pub system_program: Program<'info, System>,
}Attack surface:
- No reentrancy guard (lock exists but unused)
- CPI happens before state updates
- Uses
saturating_subwhich hides underflow - State update overwrites intermediate changes
- No early validation of withdrawal amount
Secure Implementation:
use anchor_lang::prelude::*;
use anchor_lang::solana_program::program::invoke;
use anchor_lang::solana_program::system_instruction;
#[account]
pub struct Vault {
pub is_locked: bool, // ✅ Used as reentrancy guard
pub authority: Pubkey,
pub balance: u64,
}
declare_id!("9dWv7gYsJhBKt3vnDnNQfXDSBxPTsCkXbkqVKgfH7C9F");
#[program]
pub mod cpi_reentrancy_fix {
use super::*;
pub fn withdraw(ctx: Context<WithdrawSafe>, amount: u64) -> Result<()> {
let vault_key = ctx.accounts.vault.key();
let recipient_key = ctx.accounts.recipient.key();
let vault_info = ctx.accounts.vault.to_account_info();
let recipient_info = ctx.accounts.recipient.to_account_info();
let attacker_info = ctx.accounts.attacker_program.to_account_info();
let vault = &mut ctx.accounts.vault;
// ✅ DEFENSE 1: Reentrancy guard
require!(!vault.is_locked, CustomError::ReentrancyBlocked);
vault.is_locked = true; // Acquire lock
// ✅ DEFENSE 2: Update state BEFORE CPI (CEI pattern)
vault.balance = vault
.balance
.checked_sub(amount)
.ok_or(CustomError::InsufficientFunds)?;
// ✅ NOW SAFE: External calls after state update
invoke(
&Instruction {
program_id: ctx.accounts.attacker_program.key(),
accounts: vec![...],
data: [0].to_vec(),
},
&[vault_info.clone(), attacker_info],
).ok();
invoke(
&system_instruction::transfer(&vault_key, &recipient_key, amount),
&[vault_info, recipient_info],
)?;
// ✅ DEFENSE 3: Release lock after success
vault.is_locked = false;
Ok(())
}
}
#[derive(Accounts)]
pub struct WithdrawSafe<'info> {
#[account(mut, has_one = authority)]
pub vault: Account<'info, Vault>,
pub authority: Signer<'info>,
/// CHECK: recipient
#[account(mut)]
pub recipient: AccountInfo<'info>,
/// CHECK: attacker program
pub attacker_program: AccountInfo<'info>,
pub system_program: Program<'info, System>,
}
#[error_code]
pub enum CustomError {
#[msg("re-entrancy blocked")]
ReentrancyBlocked,
#[msg("insufficient funds")]
InsufficientFunds,
}Security improvements:
- ✅ Reentrancy guard: Boolean lock prevents concurrent execution
- ✅ CEI pattern: State updated before external calls
- ✅ Checked arithmetic: No silent underflow
- ✅ Lock release: Ensures guard is reset after success
- ✅ Clear errors: Explicit reentrancy detection
1. Always Follow CEI Pattern
pub fn sensitive_operation(ctx: Context<Op>) -> Result<()> {
// ✅ CHECKS: Validate inputs
require!(ctx.accounts.vault.balance >= amount, ErrorCode::InsufficientFunds);
require!(!ctx.accounts.vault.is_locked, ErrorCode::Locked);
// ✅ EFFECTS: Update state
ctx.accounts.vault.is_locked = true;
ctx.accounts.vault.balance -= amount;
// ✅ INTERACTIONS: External calls
invoke_cpi(...)?;
// Unlock after success
ctx.accounts.vault.is_locked = false;
Ok(())
}2. Implement Reentrancy Guards
#[account]
pub struct ProtectedAccount {
pub locked: bool,
// ... other fields
}
// In your instruction:
require!(!account.locked, ErrorCode::Reentrancy);
account.locked = true;
// ... perform operations ...
account.locked = false;3. Use Anchor's Built-in Protection
Anchor 0.30+ provides reentrancy protection via constraints:
#[derive(Accounts)]
pub struct ProtectedContext<'info> {
#[account(
mut,
constraint = !vault.locked @ ErrorCode::Reentrancy
)]
pub vault: Account<'info, Vault>,
}4. Minimize CPI Surface
Only make CPIs when absolutely necessary:
❌ Risky:
// Notify many external programs
for program in notification_programs {
invoke_notification(program)?; // Each is a reentrancy risk
}✅ Safer:
// Batch notifications or use events instead
emit!(WithdrawalEvent {
vault: vault.key(),
amount,
timestamp: Clock::get()?.unix_timestamp,
});5. Validate CPI Target Programs
Don't call arbitrary programs:
#[derive(Accounts)]
pub struct SafeCPI<'info> {
#[account(
constraint = callback_program.key() == APPROVED_CALLBACK_ID
@ ErrorCode::UntrustedProgram
)]
/// CHECK: Validated against whitelist
pub callback_program: AccountInfo<'info>,
}6. Use Read-Only Accounts in CPI
When possible, pass accounts as read-only to prevent modification:
invoke(
&instruction,
&[
vault.to_account_info(), // Read-only (no mut)
recipient.to_account_info(), // Writable if needed
]
)?;7. Test with Malicious Programs
Create attacker programs in your test suite:
// Attacker program that attempts reentrancy
#[program]
pub mod malicious_callback {
pub fn callback(ctx: Context<Callback>) -> Result<()> {
msg!("Attempting reentrancy attack");
// Try to re-enter victim
victim::cpi::withdraw(
CpiContext::new(...),
amount
)?;
Ok(())
}
}it("should block reentrancy attack", async () => {
await expect(
program.methods
.withdraw(new BN(500))
.accounts({
// ...
callbackProgram: maliciousCallbackProgram,
})
.rpc()
).to.be.rejectedWith("ReentrancyBlocked");
});8. Consider Cross-Program Reentrancy
Reentrancy can come from unexpected sources:
Program A → Program B → Program C → Program A (reentrancy)
Protect against this with global locks or by tracking call depth.
9. Emit Events for Forensics
#[event]
pub struct CPIInitiated {
pub caller_program: Pubkey,
pub target_program: Pubkey,
pub timestamp: i64,
}
emit!(CPIInitiated {
caller_program: ctx.program_id,
target_program: external_program.key(),
timestamp: Clock::get()?.unix_timestamp,
});10. Security Checklist for CPI
Before making any CPI, verify:
- State is updated before the CPI (CEI pattern)
- Reentrancy guard is in place (lock/flag)
- CPI target program is validated/whitelisted
- Checked arithmetic is used (no saturating_*)
- Lock is released after successful execution
- Tests include malicious callback programs
- Events are emitted for audit trail
- Read-only accounts aren't modified by CPI
- Cross-program reentrancy is considered
- Failure handling doesn't leave inconsistent state
11. Advanced: Semaphore Pattern
For complex scenarios, use a counter instead of boolean:
#[account]
pub struct Vault {
pub reentrancy_depth: u8,
// ...
}
// At function start:
require!(vault.reentrancy_depth == 0, ErrorCode::Reentrancy);
vault.reentrancy_depth += 1;
// ... operations ...
vault.reentrancy_depth -= 1;This allows nested calls from different contexts while blocking actual reentrancy.
Severity: 🔴 Critical | CVSS Score: 9.0 | CWE-269: Improper Privilege Management
Signer Privilege Escalation is a subtle but critical vulnerability that occurs when a program validates that an account is a signer but fails to validate that the signer should have privileges for the operation. This is closely related to Vulnerability #2 (Incorrect Authority Check) but focuses specifically on the misuse of the Signer<'info> type.
The core issue:
On Solana, the Signer<'info> type only guarantees:
- The account's public key is present in the transaction's signature array
- The cryptographic signature is valid for that public key
The Signer<'info> type does NOT guarantee:
- The signer is an administrator
- The signer has been granted specific permissions
- The signer's identity matches any stored authority field
- The signer has any relationship to the accounts being modified
The dangerous assumption:
Developers sometimes treat Signer as sufficient authorization:
pub fn admin_function(ctx: Context<AdminContext>) -> Result<()> {
// Developer thinks: "There's a signer, must be authorized"
// Reality: ANY wallet can be a signer
// ...
}
#[derive(Accounts)]
pub struct AdminContext<'info> {
pub signer: Signer<'info>, // ❌ No identity check
}This creates a privilege escalation vulnerability where any user can execute administrative functions simply by signing a transaction with their own wallet.
Comparison with missing authority check:
| Aspect | Missing Authority Check (#2) | Signer Privilege Escalation (#5) |
|---|---|---|
| Root cause | Signer not compared to stored admin | Signer presence treated as authorization |
| Attack | Any signer modifies critical config | Any signer executes admin-only functions |
| Detection | Look for missing has_one |
Look for Signer without identity binding |
| Fix | Add has_one = admin constraint |
Bind signer to stored privilege field |
While similar, #5 emphasizes the conceptual error of treating signature validation as privilege validation.
Real-world context: Consider a protocol pause mechanism for emergency situations:
#[account]
pub struct GlobalSettings {
pub owner: Pubkey,
pub paused: bool,
pub last_paused_by: Pubkey,
}
pub fn toggle_pause(ctx: Context<TogglePause>) -> Result<()> {
let settings = &mut ctx.accounts.settings;
settings.paused = !settings.paused; // ❌ NO AUTHORIZATION
settings.last_paused_by = ctx.accounts.signer.key();
Ok(())
}
#[derive(Accounts)]
pub struct TogglePause<'info> {
#[account(mut)]
pub settings: Account<'info, GlobalSettings>,
pub signer: Signer<'info>, // ❌ ANY SIGNER
}Exploitation consequences:
- Protocol DOS: Any user pauses the protocol, preventing all operations
- MEV extraction: Attacker pauses protocol before large trades, manipulates market
- Competitive advantage: Attacker pauses competitors' transactions selectively
- Ransom attacks: Attacker pauses protocol and demands payment to unpause
- Reputation damage: Users lose trust in protocol security
Attack flow:
// Attacker's wallet (any random user)
const attacker = Keypair.generate();
// Attacker pauses the entire protocol
await program.methods
.togglePause()
.accounts({
settings: globalSettingsPDA,
signer: attacker.publicKey, // ← ATTACKER IS SIGNER
})
.signers([attacker]) // ← VALID SIGNATURE
.rpc();
// Protocol is now paused by unauthorized user
const settings = await program.account.globalSettings.fetch(globalSettingsPDA);
console.log("Paused:", settings.paused); // true
console.log("Paused by:", settings.lastPausedBy.toString()); // Attacker's addressHistorical precedent:
- Multiple Solana protocols have had emergency pause functions exploited
- Admin-only functions callable by anyone have led to protocol takeovers
- Configuration manipulation has caused protocol insolvency
- Upgrade authority escalation has allowed malicious program deployment
The vulnerability is particularly dangerous because:
- The code appears to have authorization (there's a
Signer!) - Auditors may overlook it if not looking specifically for identity binding
- The function may work correctly in testing (if test always uses the admin wallet)
- Impact can be immediate and protocol-wide
Let's examine the vulnerable code from example5.rs:
#[account]
pub struct Settings {
pub owner: Pubkey, // ❌ Field exists but never checked
pub paused: bool,
}
#[program]
pub mod signer_privilege_vuln {
use super::*;
pub fn toggle_pause(ctx: Context<TogglePauseVuln>) -> Result<()> {
let settings = &mut ctx.accounts.settings;
// ❌ NO AUTHORIZATION CHECK
// Program verifies 'anyone' signed the transaction
// Program does NOT verify 'anyone' is the owner
settings.paused = !settings.paused;
Ok(())
}
}
#[derive(Accounts)]
pub struct TogglePauseVuln<'info> {
#[account(mut)]
pub settings: Account<'info, Settings>,
// ❌ VULNERABILITY: Signer without identity validation
pub anyone: Signer<'info>,
}Attack sequence:
Step 1: Reconnaissance
# Attacker identifies the protocol settings account
solana account <SETTINGS_PDA>
# Output:
# Owner: <PROGRAM_ID>
# Data:
# owner: 7xK9... (legitimate owner)
# paused: false (protocol is active)Step 2: Craft attack transaction
// Attacker creates a new wallet (not the owner)
const attackerWallet = Keypair.generate();
await airdrop(attackerWallet.publicKey, 1_000_000_000); // 1 SOL for fees
console.log("Legitimate owner:", "7xK9...");
console.log("Attacker:", attackerWallet.publicKey.toString());
// These are DIFFERENT addressesStep 3: Execute privilege escalation
// Attacker calls toggle_pause with their own wallet
const tx = await program.methods
.togglePause()
.accounts({
settings: settingsPDA,
anyone: attackerWallet.publicKey, // ← ATTACKER (not owner)
})
.signers([attackerWallet]) // ← ATTACKER SIGNS
.rpc();
console.log("Transaction signature:", tx);
// Transaction succeeds! ✓Step 4: Program execution
The program processes the transaction:
- ✅ Deserialize
settingsaccount → PASS - ✅ Verify
settingsowned by program → PASS - ✅ Verify
anyoneis a signer → PASS (attacker signed) - ❌ Verify
settings.owner == anyone.key()→ NEVER CHECKED - ✅ Toggle
settings.paused→ PASS (state modified) - Return success
Step 5: Verify exploitation
const settingsAfter = await program.account.settings.fetch(settingsPDA);
console.log("Owner:", settingsAfter.owner.toString()); // Still: 7xK9... (unchanged)
console.log("Paused:", settingsAfter.paused); // Now: true (MODIFIED BY ATTACKER)
// The protocol is paused by an unauthorized user!Step 6: Impact
All protocol functions that check the pause state:
pub fn trade(ctx: Context<Trade>) -> Result<()> {
let settings = &ctx.accounts.settings;
// Check if protocol is paused
require!(!settings.paused, ErrorCode::ProtocolPaused);
// ... trading logic ...
}Now fail for all users:
// Legitimate user attempts to trade
await program.methods.trade(...).rpc();
// Error: ProtocolPaused
// Protocol is completely DOS'd by the attackerThe attack succeeded because the program verified authentication (is this a valid signature?) but not authorization (does this signer have permission?).
The secure implementation binds the signer to the stored owner field:
#[account]
pub struct Settings {
pub owner: Pubkey, // ✅ Used for authorization
pub paused: bool,
}
#[program]
pub mod signer_privilege_fix {
use super::*;
pub fn toggle_pause(ctx: Context<TogglePauseSafe>) -> Result<()> {
// ✅ At this point, Anchor has verified:
// 1. owner signed the transaction (Signer check)
// 2. settings.owner == owner.key() (has_one check)
let settings = &mut ctx.accounts.settings;
settings.paused = !settings.paused;
Ok(())
}
}
#[derive(Accounts)]
pub struct TogglePauseSafe<'info> {
#[account(
mut,
// ✅ AUTHORITY BINDING
// Generates: require_keys_eq!(settings.owner, owner.key())
has_one = owner
)]
pub settings: Account<'info, Settings>,
// ✅ MUST BE THE STORED OWNER
pub owner: Signer<'info>,
}Defense mechanisms:
1. The has_one Constraint
has_one = ownerAnchor automatically generates:
if settings.owner != owner.key() {
return Err(ErrorCode::ConstraintHasOne.into());
}This check executes before the instruction function runs.
2. Signer Type Requirement
pub owner: Signer<'info>,Combined with has_one, this creates bidirectional validation:
Signer: Proves this account signed the transactionhas_one: Proves this signer matches the stored owner
3. Descriptive Naming
Renaming anyone to owner improves code clarity:
anyone: Signer→ implies any signer is acceptableowner: Signer→ implies specific identity required
Why the attack now fails:
When the attacker attempts the same exploit:
await program.methods
.togglePause()
.accounts({
settings: settingsPDA,
owner: attackerWallet.publicKey, // ← ATTACKER AS OWNER
})
.signers([attackerWallet])
.rpc();Anchor validation sequence:
- ✅ Deserialize
settings→ PASS - ✅ Verify
owneris a signer → PASS (attacker signed) - ❌ Check
settings.owner == owner.key()→ FAIL- Expected:
settings.owner=7xK9...(legitimate owner) - Received:
owner.key()=Attacker...(attacker's address)
- Expected:
- Return error:
ConstraintHasOne - Transaction reverted (no state changes)
The attack is impossible because:
- The signer's identity is validated against stored state
- Only the account whose public key matches
settings.ownercan sign - The
has_oneconstraint runs before business logic - Transaction fails atomically (no partial execution)
Even if the attacker knows the owner's address:
// Attacker tries to pass the real owner's address
await program.methods
.togglePause()
.accounts({
settings: settingsPDA,
owner: legitimateOwnerPubkey, // ← REAL OWNER'S ADDRESS
})
.signers([attackerWallet]) // ← But attacker signs
.rpc();This also fails:
owneris marked asSigner<'info>- Solana runtime checks: Is
legitimateOwnerPubkeyin the signature list? - Answer: No (only
attackerWalletsigned) - Error: Account not signer
The attacker would need the actual owner's private key to sign, which they don't have.
Vulnerable Implementation:
use anchor_lang::prelude::*;
#[account]
pub struct Settings {
pub owner: Pubkey, // ❌ Never validated
pub paused: bool,
}
declare_id!("3zX9nuSUXwxLBzME2YkdEYY5EYXPLkZX31kTqsxGTFeo");
#[program]
pub mod signer_privilege_vuln {
use super::*;
pub fn toggle_pause(ctx: Context<TogglePauseVuln>) -> Result<()> {
let settings = &mut ctx.accounts.settings;
// ❌ CRITICAL VULNERABILITY
// Program assumes: "someone signed, must be authorized"
// Reality: ANY wallet can sign their own transaction
settings.paused = !settings.paused;
Ok(())
}
}
#[derive(Accounts)]
pub struct TogglePauseVuln<'info> {
#[account(mut)]
pub settings: Account<'info, Settings>,
// ⚠️ VULNERABILITY: Signer without identity check
// Anchor verifies signature is valid
// Anchor does NOT verify signer is authorized
pub anyone: Signer<'info>,
}Attack surface:
- Signer type used without identity validation
- No comparison between signer and stored owner
- Field name "anyone" reveals lack of authorization
- Any user can execute administrative function
- No role or permission checks
Secure Implementation:
use anchor_lang::prelude::*;
#[account]
pub struct Settings {
pub owner: Pubkey, // ✅ Validated via has_one
pub paused: bool,
}
declare_id!("7YJnb9TMWvHDq6cHruM3aMc2SGte1qPFN3Wf9eKJeNE8");
#[program]
pub mod signer_privilege_fix {
use super::*;
pub fn toggle_pause(ctx: Context<TogglePauseSafe>) -> Result<()> {
// ✅ SECURITY GUARANTEE
// At this point, Anchor has enforced:
// 1. owner.is_signer == true (cryptographic proof)
// 2. settings.owner == owner.key() (authorization proof)
// 3. settings is owned by this program (ownership proof)
let settings = &mut ctx.accounts.settings;
settings.paused = !settings.paused;
msg!("Pause toggled by authorized owner: {}", ctx.accounts.owner.key());
Ok(())
}
}
#[derive(Accounts)]
pub struct TogglePauseSafe<'info> {
#[account(
mut,
// ✅ AUTHORITY BINDING
// Links stored owner field to signer account
has_one = owner
)]
pub settings: Account<'info, Settings>,
// ✅ AUTHORIZED SIGNER
// Must both:
// 1. Sign the transaction (Signer type)
// 2. Match settings.owner (has_one constraint)
pub owner: Signer<'info>,
}Security improvements:
- ✅ Identity binding:
has_one = ownerlinks signer to stored field - ✅ Descriptive naming:
owner(notanyone) clarifies intent - ✅ Automatic validation: Anchor enforces before instruction runs
- ✅ Clear authorization: Only stored owner can execute
- ✅ Audit trail: Logs show authorized owner's address
1. Always Bind Signers to Stored Authorities
❌ Vulnerable:
pub fn admin_function(ctx: Context<Admin>) -> Result<()> {
// ...
}
#[derive(Accounts)]
pub struct Admin<'info> {
pub signer: Signer<'info>, // ❌ No identity check
}✅ Secure:
#[derive(Accounts)]
pub struct Admin<'info> {
#[account(has_one = admin)]
pub config: Account<'info, Config>,
pub admin: Signer<'info>, // ✅ Must match config.admin
}2. Use Descriptive Account Names
Account names should reflect authorization requirements:
❌ Poor naming:
pub signer: Signer<'info>,
pub user: Signer<'info>,
pub caller: Signer<'info>,✅ Clear naming:
pub admin: Signer<'info>,
pub owner: Signer<'info>,
pub authority: Signer<'info>,3. Implement Role-Based Access Control
For complex authorization:
#[account]
pub struct AccessControl {
pub super_admin: Pubkey,
pub admins: Vec<Pubkey>,
pub operators: Vec<Pubkey>,
}
#[derive(Accounts)]
pub struct RequireAdmin<'info> {
#[account(
constraint = is_admin(&access_control, &signer.key())
@ ErrorCode::Unauthorized
)]
pub access_control: Account<'info, AccessControl>,
pub signer: Signer<'info>,
}
fn is_admin(acl: &AccessControl, signer: &Pubkey) -> bool {
acl.super_admin == *signer || acl.admins.contains(signer)
}4. Separate Privileges by Function
Don't use a single admin for everything:
#[account]
pub struct Config {
pub super_admin: Pubkey, // Can change all settings
pub upgrade_authority: Pubkey, // Can upgrade program
pub pause_authority: Pubkey, // Can pause/unpause
pub fee_authority: Pubkey, // Can modify fees
}
#[derive(Accounts)]
pub struct PauseProtocol<'info> {
#[account(mut, has_one = pause_authority)]
pub config: Account<'info, Config>,
pub pause_authority: Signer<'info>, // NOT super_admin
}5. Validate Multi-Signature Requirements
For critical operations:
#[account]
pub struct MultiSigConfig {
pub signers: Vec<Pubkey>,
pub threshold: u8, // Required number of signatures
}
pub fn validate_multisig(
config: &MultiSigConfig,
provided_signers: &[Pubkey],
) -> Result<()> {
let mut valid_count = 0;
for signer in provided_signers {
if config.signers.contains(signer) {
valid_count += 1;
}
}
require!(
valid_count >= config.threshold,
ErrorCode::InsufficientSigners
);
Ok(())
}6. Use Custom Constraints for Complex Logic
When has_one isn't sufficient:
#[derive(Accounts)]
pub struct ComplexAuth<'info> {
#[account(
constraint = can_execute(&config, &signer.key())
@ ErrorCode::Unauthorized
)]
pub config: Account<'info, Config>,
pub signer: Signer<'info>,
}
fn can_execute(config: &Config, signer: &Pubkey) -> bool {
// Complex logic: admin OR (operator AND not paused)
config.admin == *signer ||
(config.operators.contains(signer) && !config.paused)
}7. Emit Authorization Events
Create an audit trail:
#[event]
pub struct PrivilegedActionExecuted {
pub action: String,
pub executor: Pubkey,
pub target: Pubkey,
pub timestamp: i64,
}
pub fn admin_action(ctx: Context<AdminAction>) -> Result<()> {
// ... perform action ...
emit!(PrivilegedActionExecuted {
action: "settings_updated".to_string(),
executor: ctx.accounts.admin.key(),
target: ctx.accounts.settings.key(),
timestamp: Clock::get()?.unix_timestamp,
});
Ok(())
}8. Document Authorization Requirements
Add clear comments:
#[derive(Accounts)]
pub struct UpdateConfig<'info> {
/// Must be the protocol owner. Only the owner can modify global settings.
/// This is enforced by the `has_one = owner` constraint below.
#[account(mut, has_one = owner @ ErrorCode::Unauthorized)]
pub config: Account<'info, Config>,
/// The protocol owner who must sign this transaction.
/// Their public key must match `config.owner`.
pub owner: Signer<'info>,
}9. Test Unauthorized Access
Always test with non-authorized signers:
describe("Authorization tests", () => {
it("should reject non-owner attempting to pause", async () => {
const attacker = Keypair.generate();
await expect(
program.methods
.togglePause()
.accounts({
settings: settingsPDA,
owner: attacker.publicKey, // Wrong owner
})
.signers([attacker])
.rpc()
).to.be.rejectedWith(/ConstraintHasOne|Unauthorized/);
});
it("should allow legitimate owner to pause", async () => {
await program.methods
.togglePause()
.accounts({
settings: settingsPDA,
owner: legitimateOwner.publicKey,
})
.signers([legitimateOwner])
.rpc();
const settings = await program.account.settings.fetch(settingsPDA);
expect(settings.paused).to.be.true;
});
});10. Security Checklist for Signer Privileges
Before deploying, verify:
- Every
Signer<'info>has a purpose (not generic "signer") - Signers are bound to stored authority fields via
has_oneorconstraint - Account names reflect authorization requirements
- Administrative functions require specific authorized signers
- Role separation limits privilege escalation impact
- Multi-signature is used for critical operations
- Authorization events are emitted for audit trail
- Tests cover unauthorized access attempts
- Documentation clearly explains authorization model
- No assumptions that "having a signature" means "having permission"
Comprehensive testing is essential for identifying security vulnerabilities before deployment. This section outlines testing strategies for each vulnerability class.
Anchor provides a robust testing framework built on Mocha and Chai.
Basic test structure:
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { expect } from "chai";
describe("Security Tests", () => {
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.YourProgram as Program<YourProgram>;
let admin: anchor.web3.Keypair;
let attacker: anchor.web3.Keypair;
before(async () => {
admin = anchor.web3.Keypair.generate();
attacker = anchor.web3.Keypair.generate();
// Airdrop SOL for test wallets
await provider.connection.requestAirdrop(
admin.publicKey,
2 * anchor.web3.LAMPORTS_PER_SOL
);
});
// ... tests ...
});Test account substitution attacks:
describe("Account Validation Tests", () => {
it("should reject arbitrary account substitution", async () => {
// Create a malicious account
const maliciousAccount = anchor.web3.Keypair.generate();
await expect(
program.methods
.setMessage("malicious data")
.accounts({
messageBox: maliciousAccount.publicKey, // Wrong account
authority: admin.publicKey,
})
.signers([admin])
.rpc()
).to.be.rejectedWith(/ConstraintSeeds|ConstraintOwner/);
});
it("should accept valid PDA account", async () => {
const [messagePDA] = await anchor.web3.PublicKey.findProgramAddress(
[Buffer.from("message"), admin.publicKey.toBuffer()],
program.programId
);
await program.methods
.setMessage("valid message")
.accounts({
messageBox: messagePDA, // Correct PDA
authority: admin.publicKey,
})
.signers([admin])
.rpc();
const account = await program.account.messageBox.fetch(messagePDA);
expect(account.message).to.equal("valid message");
});
});Test unauthorized access:
describe("Authority Tests", () => {
it("should reject non-admin fee update", async () => {
await expect(
program.methods
.setFee(100)
.accounts({
config: configPDA,
admin: attacker.publicKey, // Not the admin
})
.signers([attacker])
.rpc()
).to.be.rejectedWith(/ConstraintHasOne|Unauthorized/);
});
it("should allow admin fee update", async () => {
await program.methods
.setFee(100)
.accounts({
config: configPDA,
admin: admin.publicKey,
})
.signers([admin])
.rpc();
const config = await program.account.config.fetch(configPDA);
expect(config.feeBps).to.equal(100);
});
it("should reject out-of-bounds fee", async () => {
await expect(
program.methods
.setFee(15000) // > 10,000 bps (100%)
.accounts({
config: configPDA,
admin: admin.publicKey,
})
.signers([admin])
.rpc()
).to.be.rejectedWith(/InvalidFee/);
});
});Test boundary conditions:
describe("Arithmetic Safety Tests", () => {
it("should reject withdrawal exceeding balance", async () => {
// Vault has 100 lamports
await program.methods
.deposit(new anchor.BN(100))
.accounts({ vault: vaultPDA, owner: admin.publicKey })
.signers([admin])
.rpc();
// Try to withdraw 101
await expect(
program.methods
.withdraw(new anchor.BN(101))
.accounts({ vault: vaultPDA, owner: admin.publicKey })
.signers([admin])
.rpc()
).to.be.rejectedWith(/InsufficientFunds/);
});
it("should handle u64 MAX correctly", async () => {
const maxU64 = new anchor.BN("18446744073709551615");
await expect(
program.methods
.deposit(maxU64)
.accounts({ vault: vaultPDA, owner: admin.publicKey })
.signers([admin])
.rpc()
).to.be.rejected; // Should overflow
});
it("should reject zero amount operations", async () => {
await expect(
program.methods
.withdraw(new anchor.BN(0))
.accounts({ vault: vaultPDA, owner: admin.publicKey })
.signers([admin])
.rpc()
).to.be.rejectedWith(/ZeroAmount/);
});
});Create a malicious attacker program:
// tests/programs/malicious-callback/src/lib.rs
use anchor_lang::prelude::*;
#[program]
pub mod malicious_callback {
use super::*;
pub fn reentrancy_hook(ctx: Context<ReentrancyHook>) -> Result<()> {
msg!("Malicious callback attempting reentrancy");
// Attempt to re-enter victim program
let cpi_accounts = victim::cpi::accounts::Withdraw {
vault: ctx.accounts.vault.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
// ...
};
let cpi_ctx = CpiContext::new(
ctx.accounts.victim_program.to_account_info(),
cpi_accounts,
);
victim::cpi::withdraw(cpi_ctx, 500)?;
msg!("Reentrancy succeeded"); // Should never reach here
Ok(())
}
}Test reentrancy protection:
describe("Reentrancy Tests", () => {
it("should block reentrancy attack", async () => {
await expect(
program.methods
.withdraw(new anchor.BN(500))
.accounts({
vault: vaultPDA,
authority: admin.publicKey,
recipient: admin.publicKey,
callbackProgram: maliciousCallbackProgram,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([admin])
.rpc()
).to.be.rejectedWith(/ReentrancyBlocked|Locked/);
});
it("should allow normal withdrawal", async () => {
await program.methods
.withdraw(new anchor.BN(500))
.accounts({
vault: vaultPDA,
authority: admin.publicKey,
recipient: admin.publicKey,
callbackProgram: benignCallbackProgram, // Safe program
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([admin])
.rpc();
const vault = await program.account.vault.fetch(vaultPDA);
expect(vault.balance.toNumber()).to.equal(500);
});
});Test unauthorized signer:
describe("Signer Privilege Tests", () => {
it("should reject unauthorized pause attempt", async () => {
await expect(
program.methods
.togglePause()
.accounts({
settings: settingsPDA,
owner: attacker.publicKey, // Not the owner
})
.signers([attacker])
.rpc()
).to.be.rejectedWith(/ConstraintHasOne|Unauthorized/);
});
it("should allow owner to pause", async () => {
await program.methods
.togglePause()
.accounts({
settings: settingsPDA,
owner: admin.publicKey,
})
.signers([admin])
.rpc();
const settings = await program.account.settings.fetch(settingsPDA);
expect(settings.paused).to.be.true;
});
});Use property-based testing to discover edge cases:
import fc from "fast-check";
describe("Property-Based Tests", () => {
it("withdrawal amount should never exceed balance", async () => {
await fc.assert(
fc.asyncProperty(
fc.nat(1000000), // Random balance
fc.nat(2000000), // Random withdrawal
async (balance, withdrawal) => {
// Setup vault with balance
await program.methods
.setBalance(new anchor.BN(balance))
.rpc();
// Attempt withdrawal
try {
await program.methods
.withdraw(new anchor.BN(withdrawal))
.rpc();
// If succeeded, withdrawal must be <= balance
return withdrawal <= balance;
} catch (e) {
// If failed, withdrawal must be > balance
return withdrawal > balance;
}
}
)
);
});
});Test full workflows:
describe("Integration Tests", () => {
it("should handle complete user lifecycle", async () => {
// 1. Initialize account
await program.methods.initialize().rpc();
// 2. Deposit funds
await program.methods.deposit(new anchor.BN(1000)).rpc();
// 3. Perform operations
await program.methods.trade(new anchor.BN(100)).rpc();
// 4. Withdraw funds
await program.methods.withdraw(new anchor.BN(900)).rpc();
// 5. Close account
await program.methods.close().rpc();
// Verify final state
const account = await provider.connection.getAccountInfo(accountPDA);
expect(account).to.be.null; // Account closed
});
});Use Pinocchio library for rapid iteration:
#[cfg(test)]
mod tests {
use pinocchio::pubkey::Pubkey;
#[test]
fn test_vulnerable_program() {
// Test vulnerable program logic
let vault = Pubkey::new_unique();
let user = Pubkey::new_unique();
// Observe if vulnerability is exploitable
println!("Testing withdrawal with vault: {}, user: {}", vault, user);
}
}Run tests:
cargo testGitHub Actions workflow:
name: Security Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
- name: Install Solana
run: |
sh -c "$(curl -sSfL https://release.solana.com/stable/install)"
echo "$HOME/.local/share/solana/install/active_release/bin" >> $GITHUB_PATH
- name: Install Anchor
run: cargo install --git https://github.com/coral-xyz/anchor --tag v0.32.1 anchor-cli
- name: Build programs
run: anchor build
- name: Run security tests
run: anchor testUse this checklist before deploying any Solana program to production.
- All accounts use typed
Account<'info, T>(not rawAccountInfo) - PDAs have
seedsandbumpconstraints - Account owners are validated (explicitly or via
Accounttype) - Discriminators are checked (via
Accounttype) -
UncheckedAccountusage is documented with/// CHECK:comments - Custom account validation includes explicit ownership checks
- PDA derivation matches expected seeds exactly
- No arbitrary accounts can be substituted by clients
- Every privileged function requires authorization
- Signers are bound to stored authority fields (
has_one) - Admin/owner comparisons use
require_keys_eq!or constraints - Role-based access control is implemented where needed
- Multi-signature requirements are enforced for critical operations
- No functions assume "being a signer" means "being authorized"
- Time-locks protect critical configuration changes
- Privilege separation limits blast radius
- All arithmetic uses
checked_*methods - Division operations check for zero divisor
- Multiplication uses wider types (u128) for intermediate results
- Input parameters have bounds validation
- Invariants are maintained (e.g., total supply = sum of balances)
- No usage of
+,-,*,/operators in financial logic -
saturating_*methods are not used where overflow should error - Edge cases (0, u64::MAX) are tested
- State is updated before external calls (CEI pattern)
- Reentrancy guards (locks/flags) are in place
- CPI target programs are validated/whitelisted
- Checked arithmetic (not
saturating_*) before CPI - Locks are released after successful execution
- Cross-program reentrancy is considered
- Events are emitted for CPI operations
- Failure handling doesn't leave inconsistent state
- Every
Signer<'info>is bound to a stored authority - Account names reflect authorization requirements
- No generic "signer" or "caller" without identity checks
- Administrative functions require specific authorized signers
- Authorization events are emitted
- Tests cover unauthorized access attempts
- All public functions have documentation comments
- Error messages are clear and actionable
- No hardcoded addresses (use constants or config)
- No debug logs in production code
- No
unwrap()orexpect()(use?operator) - Custom errors are defined for all failure cases
- Events are emitted for state changes
- Unit tests cover all instructions
- Tests include unauthorized access attempts
- Boundary conditions are tested (0, MAX, overflow)
- Integration tests cover full workflows
- Adversarial tests include malicious programs
- Property-based tests explore input space
- Tests run in CI/CD pipeline
- Code coverage > 80%
- Programs are built in release mode
- Program IDs match declared IDs
- Upgrade authority is set correctly
- IDL is generated and published
- Deployment is on devnet first
- Security audit is completed
- Bug bounty program is established
- Monitoring and alerting is configured
- Solana Documentation: https://docs.solana.com/
- Anchor Framework: https://www.anchor-lang.com/
- Solana Program Library: https://spl.solana.com/
- Solana Security Best Practices: https://docs.solana.com/developing/programming-model/security
- Neodyme Security Blog: https://blog.neodyme.io/
- Sec3 Blog: https://www.sec3.dev/blog
- Trail of Bits Solana Security Guide: https://github.com/trailofbits/solana-security-guide
- Soteria Security Scanner: https://github.com/otter-sec/soteria
- Anchor Security Documentation: https://www.anchor-lang.com/docs/security
- Solana Cookbook: https://solanacookbook.com/
- Anchor by Example: https://examples.anchor-lang.com/
- Buildspace Solana Course: https://buildspace.so/
- Solana Bootcamp: https://www.youtube.com/watch?v=0P8JeL3TURU
- Pinocchio (Fast Testing Library): https://github.com/anza-xyz/pinocchio - Use as a dependency for rapid testing
- Anchor CLI: https://www.anchor-lang.com/docs/cli
- Solana CLI: https://docs.solana.com/cli
- SPL Token CLI: https://spl.solana.com/token
- Solana Program Library Audits: https://github.com/solana-labs/solana-program-library/tree/master/audit
- Neodyme Audit Database: https://github.com/neodyme-labs/audits
- OtterSec Audits: https://github.com/otter-sec/audit-reports
- CWE (Common Weakness Enumeration): https://cwe.mitre.org/
- OWASP Top 10: https://owasp.org/www-project-top-ten/
- CVSS Calculator: https://www.first.org/cvss/calculator/3.1
- Solana Security Working Group: https://github.com/solana-developers/security-wg
- Anchor Discord: https://discord.gg/anchor
- Solana Stack Exchange: https://solana.stackexchange.com/
- Immunefi: https://immunefi.com/
- HackerOne: https://www.hackerone.com/
- Solana Foundation Bug Bounty: https://solana.com/security
- Rekt News: https://rekt.news/
- Blockchain Threat Intelligence: https://blockthreat.substack.com/
- Solana Exploit Post-Mortems: Various security firm blogs
- "Secure Smart Contract Development on Solana" by Neodyme
- "Anchor Security Patterns" by Coral
- "Common Solana Vulnerabilities" by OtterSec
- Solana Improvement Documents (SIMD): https://github.com/solana-foundation/solana-improvement-documents
Solana's unique architecture provides incredible performance and scalability, but it also introduces security challenges that differ from traditional smart contract platforms. The five vulnerability classes covered in this guide represent the most common and critical security issues found in Solana programs:
- Missing Account Validation - Always validate account ownership, type, and derivation
- Incorrect Authority Check - Bind signers to stored authority fields
- Unsafe Arithmetic - Use checked arithmetic methods to prevent overflow/underflow
- CPI Reentrancy - Follow the CEI pattern and use reentrancy guards
- Signer Privilege Escalation - Never assume signature implies authorization
Key Takeaways:
- Defense in Depth: Layer multiple security mechanisms (type checks + constraints + validation)
- Test Adversarially: Always test with malicious inputs and unauthorized users
- Fail Explicitly: Use checked operations that error rather than silent failures
- Validate Everything: Never trust client-provided account references
- Follow Patterns: Use established patterns like CEI, RBAC, and reentrancy guards
Remember:
Security is not a feature—it's a requirement. Every line of code is a potential vulnerability. Review carefully, test thoroughly, and deploy cautiously.
For questions, contributions, or security disclosures, please refer to the repository's contribution guidelines and security policy.
Document Version: 1.0
Last Updated: 2024
Maintained by: Solana Security Education Initiative
License: MIT