Skip to content

Security: ranman911/vulnerable.so

Security

SECURITY.md

Solana Security: Comprehensive Vulnerability Guide

Table of Contents


Executive Summary

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

Key Security Challenges on Solana

1. Client-Controlled Input

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 AccountInfo structures 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.

2. No Built-in Access Control

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.

3. Account Mutability

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

4. Silent Arithmetic Failures

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.

5. Cross-Program Invocations (CPI)

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 nonReentrant modifier) must be implemented manually

The CEI Pattern (Checks-Effects-Interactions):

  1. Checks: Validate all inputs and permissions
  2. Effects: Update internal state
  3. Interactions: Make external calls (CPI)

Violating this pattern creates reentrancy vulnerabilities.

Impact Summary

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

Vulnerability Overview

All vulnerabilities in this repository stem from insufficient validation of one of three critical aspects:

The Three Pillars of Validation

1. Account Identity - "Is this the expected account?"

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 AccountInfo without ownership checks
  • Using UncheckedAccount without subsequent validation
  • Accepting arbitrary accounts from clients
  • Missing PDA seed verification

2. Authority Relationship - "Does the signer have permission?"

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_signer without checking signer identity
  • Missing has_one constraint in Anchor
  • No comparison between signer and stored admin/authority
  • Accepting any valid signature as authorization

3. Numeric Boundaries - "Are calculations safe?"

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.


Detailed Vulnerability Analysis

1. Missing Account Validation

Severity: 🔴 Critical | CVSS Score: 9.5 | CWE-20: Improper Input Validation

Technical Explanation

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:

  1. Ownership Verification: Does this program own the account?
  2. Type Discrimination: Is this account the expected data structure?
  3. Seed Validation: For PDAs, was this account derived from the correct seeds?
  4. 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

Why It's Dangerous

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:

  1. Create a malicious account with their own address as "admin"
  2. Fund it with the minimum rent-exempt lamports
  3. Call the program's administrative functions, passing this fake config account
  4. The program writes to the attacker's account instead of the real config
  5. 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

How the Attack Works (Step-by-Step)

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 wants

Step 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:

  1. Accepts the attacker's account (marked as writable)
  2. Does NOT verify the account is owned by the program
  3. Does NOT check if it's the expected account type
  4. 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.

How the Fix Prevents the Attack

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 MessageBox type
  • 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:

  1. ✅ Check authority is a signer → PASS
  2. ❌ Verify messageBox address matches PDA(seeds, program_id) → FAIL
    • Expected: find_program_address([b"message", attacker.pubkey], program_id)
    • Received: attackerMaliciousAccount (random address)
  3. Transaction rejected with ConstraintSeeds error

Even if the attacker creates a valid PDA:

  1. ✅ PDA validation → PASS
  2. ❌ Check messageBox.owner == program_idFAIL if account not initialized
  3. ❌ Check discriminator matches MessageBoxFAIL if wrong type
  4. ❌ 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)

Code Comparison

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 AccountInfo instead of typed Account<T>
  • No ownership verification (account.owner != program_id is 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:

  1. Typed account: Account<'info, MessageBox> ensures ownership and discriminator
  2. PDA validation: seeds + bump ensures deterministic address
  3. Authority binding: has_one = authority links stored field to signer
  4. Signer requirement: Signer<'info> enforces transaction signature
  5. Input validation: Length check prevents buffer issues
  6. Safe reallocation: Managed by Anchor with proper bounds
  7. Error handling: Custom errors provide clear failure reasons

Best Practices and Recommendations

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 UncheckedAccount usage?

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
});

2. Incorrect Authority Check

Severity: 🔴 Critical | CVSS Score: 9.0 | CWE-862: Missing Authorization

Technical Explanation

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:

  1. The account's public key matches a signature in the transaction
  2. The signature is cryptographically valid

What Signer<'info> does NOT guarantee:

  1. The signer has administrative privileges
  2. The signer matches an "admin" or "owner" field in program state
  3. The signer is authorized for this specific operation
  4. 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.

Why It's Dangerous

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:

  1. Economic manipulation: Attacker sets interest rate to 0%, allowing free borrowing
  2. Protocol insolvency: Lenders receive no yield, liquidity providers withdraw
  3. Market manipulation: Attacker borrows maximum amount with zero cost
  4. 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)

How the Attack Works (Step-by-Step)

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:

  1. ✅ Validate config is owned by program → PASS
  2. ✅ Validate caller is a signer → PASS (attacker signed)
  3. ❌ Check config.admin == caller.key()NEVER PERFORMED
  4. ✅ Write new_fee to config.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.

How the Fix Prevents the Attack

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::Unauthorized

Anchor 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:

  1. Is this account a signer? (cryptographic proof)
  2. 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:

  1. ✅ Deserialize config account → PASS
  2. ✅ Verify config is owned by program → PASS
  3. ✅ Check admin is a signer → PASS
  4. Check config.admin == admin.key()FAIL
    • Expected: config.admin = 7xK9... (legitimate admin)
    • Received: admin.key() = Attacker... (attacker's key)
  5. Return error: CustomError::Unauthorized
  6. Transaction reverted before instruction execution

The attack is impossible because:

  • The signer's identity is validated against stored state
  • The has_one constraint 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.

Code Comparison

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 caller and config.admin
  • No validation that caller has 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:

  1. Authority binding: has_one = admin links signer to stored authority
  2. Input validation: Fee bounded to reasonable range
  3. Error messages: Clear feedback for unauthorized attempts
  4. Logging: Audit trail of configuration changes
  5. Type safety: Account<Config> ensures correct data structure
  6. Fail-fast: Validation before state modification

Best Practices and Recommendations

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_one or constraint)
  • 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");
  });
});

3. Unsafe Arithmetic

Severity: 🟠 High | CVSS Score: 8.5 | CWE-190/CWE-191: Integer Overflow/Underflow

Technical Explanation

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,614

The user requested to withdraw 101 lamports but only had 100. Instead of rejecting the transaction, the program gives them 18.4 quintillion lamports.

Why It's Dangerous

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

  1. User deposits 1 SOL (1,000,000,000 lamports)
  2. User withdraws 1.000000001 SOL (1,000,000,001 lamports)
  3. Calculation: 1,000,000,000 - 1,000,000,001 = -1
  4. Result wraps to: 18,446,744,073,709,551,615 lamports
  5. User now has effectively infinite balance
  6. 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;  // ❌ OVERFLOW

When 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

How the Attack Works (Step-by-Step)

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:

  1. Load vault account: balance = 100
  2. Execute: vault.balance -= 101
  3. In debug mode: Panic! (overflow detected)
  4. In release mode:
    100 - 101 = -1
    -1 as u64 = 18,446,744,073,709,551,615 (two's complement)
    
  5. 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 drained

Step 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,000

All legitimate users lose their funds because the vault PDA has been emptied, while the attacker's balance still shows as essentially infinite.

How the Fix Prevents the Attack

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) if balance >= amount (safe subtraction)
  • None if balance < 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 execution
  • NoneErr(CustomError::InsufficientFunds) → exits function

3. Early Return (? operator)

The ? operator:

  • If Ok(value): unwraps and assigns to vault.balance
  • If Err(e): immediately returns the error to Solana runtime

Transaction atomicity:

Because the error is returned before the function completes:

  1. Solana runtime receives the error
  2. All account changes are reverted (atomic rollback)
  3. The vault balance remains unchanged (still 100)
  4. 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:

  1. Load vault: balance = 100
  2. Execute balance.checked_sub(101):
    100_u64.checked_sub(101)  // Returns None (would be -1)
  3. Execute ok_or(CustomError::InsufficientFunds):
    None.ok_or(CustomError::InsufficientFunds)  // Returns Err(...)
  4. The ? operator sees Err and returns immediately
  5. Balance is never updated (still 100)
  6. Transaction fails with error: "InsufficientFunds"
  7. 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)

Code Comparison

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:

  1. Checked arithmetic: checked_sub detects underflow
  2. Error handling: Returns explicit error on invalid operation
  3. Atomic transactions: Failed checks prevent state updates
  4. Clear messaging: User understands why transaction failed
  5. Works in all modes: Same behavior in debug and release

Best Practices and Recommendations

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 0

Use 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 = true

Trade-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 with checked_* 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 = true in 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)?;

4. CPI Reentrancy

Severity: 🟠 High | CVSS Score: 8.0 | CWE-841: Improper Enforcement of Behavioral Workflow

Technical Explanation

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:

  1. Program A invokes Program B with specific accounts
  2. Control transfers to Program B
  3. Program B executes arbitrary code
  4. Program B can invoke Program A (or other programs)
  5. 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):

  1. Checks: Validate all inputs and preconditions
  2. Effects: Update internal state
  3. 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_sub or similar that appear after CPI
  • The final state update overwriting correct intermediate state

Why It's Dangerous

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:

  1. Attacker's vault has 1000 lamports
  2. Attacker calls withdraw(500)
  3. Program transfers 500 lamports to attacker
  4. Program calls notify_withdrawal() CPI
  5. Malicious notification program calls withdraw(500) again (reentrancy)
  6. Inner call sees: vault_state.balance = 1000 (not yet updated)
  7. Inner call transfers another 500 lamports (total: 1000 withdrawn)
  8. Inner call updates: vault_state.balance = 1000 - 500 = 500
  9. Inner call completes
  10. Outer call continues: updates vault_state.balance = 1000 - 500 = 500
  11. Final state: Attacker withdrew 1000 lamports but balance shows 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)

How the Attack Works (Step-by-Step)

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()); // 1000

Step 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:

  1. State was updated after the CPI
  2. The reentered call saw the original state (1000)
  3. Both calls thought they were withdrawing from 1000
  4. The final state update used stale data

How the Fix Prevents the Attack

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 to true
  • 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

Code Comparison

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_sub which 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:

  1. Reentrancy guard: Boolean lock prevents concurrent execution
  2. CEI pattern: State updated before external calls
  3. Checked arithmetic: No silent underflow
  4. Lock release: Ensures guard is reset after success
  5. Clear errors: Explicit reentrancy detection

Best Practices and Recommendations

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.


5. Signer Privilege Escalation

Severity: 🔴 Critical | CVSS Score: 9.0 | CWE-269: Improper Privilege Management

Technical Explanation

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:

  1. The account's public key is present in the transaction's signature array
  2. The cryptographic signature is valid for that public key

The Signer<'info> type does NOT guarantee:

  1. The signer is an administrator
  2. The signer has been granted specific permissions
  3. The signer's identity matches any stored authority field
  4. 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.

Why It's Dangerous

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:

  1. Protocol DOS: Any user pauses the protocol, preventing all operations
  2. MEV extraction: Attacker pauses protocol before large trades, manipulates market
  3. Competitive advantage: Attacker pauses competitors' transactions selectively
  4. Ransom attacks: Attacker pauses protocol and demands payment to unpause
  5. 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 address

Historical 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

How the Attack Works (Step-by-Step)

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 addresses

Step 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:

  1. ✅ Deserialize settings account → PASS
  2. ✅ Verify settings owned by program → PASS
  3. ✅ Verify anyone is a signer → PASS (attacker signed)
  4. Verify settings.owner == anyone.key() → NEVER CHECKED
  5. ✅ Toggle settings.paused → PASS (state modified)
  6. 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 attacker

The attack succeeded because the program verified authentication (is this a valid signature?) but not authorization (does this signer have permission?).

How the Fix Prevents the Attack

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 = owner

Anchor 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 transaction
  • has_one: Proves this signer matches the stored owner

3. Descriptive Naming

Renaming anyone to owner improves code clarity:

  • anyone: Signer → implies any signer is acceptable
  • owner: 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:

  1. ✅ Deserialize settings → PASS
  2. ✅ Verify owner is a signer → PASS (attacker signed)
  3. Check settings.owner == owner.key()FAIL
    • Expected: settings.owner = 7xK9... (legitimate owner)
    • Received: owner.key() = Attacker... (attacker's address)
  4. Return error: ConstraintHasOne
  5. 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.owner can sign
  • The has_one constraint 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:

  • owner is marked as Signer<'info>
  • Solana runtime checks: Is legitimateOwnerPubkey in the signature list?
  • Answer: No (only attackerWallet signed)
  • Error: Account not signer

The attacker would need the actual owner's private key to sign, which they don't have.

Code Comparison

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:

  1. Identity binding: has_one = owner links signer to stored field
  2. Descriptive naming: owner (not anyone) clarifies intent
  3. Automatic validation: Anchor enforces before instruction runs
  4. Clear authorization: Only stored owner can execute
  5. Audit trail: Logs show authorized owner's address

Best Practices and Recommendations

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_one or constraint
  • 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"

Testing Methodology

Comprehensive testing is essential for identifying security vulnerabilities before deployment. This section outlines testing strategies for each vulnerability class.

1. Unit Testing with Anchor

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 ...
});

2. Testing Missing Account Validation

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");
  });
});

3. Testing Incorrect Authority

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/);
  });
});

4. Testing Unsafe Arithmetic

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/);
  });
});

5. Testing CPI Reentrancy

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);
  });
});

6. Testing Signer Privilege Escalation

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;
  });
});

7. Fuzzing and Property-Based Testing

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;
          }
        }
      )
    );
  });
});

8. Integration Testing

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
  });
});

9. Pinocchio Testing (Fast Simulation)

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 test

10. Continuous Testing

GitHub 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 test

Security Checklist

Use this checklist before deploying any Solana program to production.

Pre-Deployment Security Audit

Account Validation

  • All accounts use typed Account<'info, T> (not raw AccountInfo)
  • PDAs have seeds and bump constraints
  • Account owners are validated (explicitly or via Account type)
  • Discriminators are checked (via Account type)
  • UncheckedAccount usage 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

Authority Checks

  • 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

Arithmetic Operations

  • 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

CPI Security

  • 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

Signer Privileges

  • 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

Code Quality

  • 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() or expect() (use ? operator)
  • Custom errors are defined for all failure cases
  • Events are emitted for state changes

Testing

  • 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%

Deployment

  • 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

Resources and References

Official Documentation

Security Resources

Learning Materials

Tools

Audit Reports

Common Vulnerability Databases

Security Communities

Bug Bounty Platforms

Real-World Exploit Analysis

Additional Reading


Conclusion

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:

  1. Missing Account Validation - Always validate account ownership, type, and derivation
  2. Incorrect Authority Check - Bind signers to stored authority fields
  3. Unsafe Arithmetic - Use checked arithmetic methods to prevent overflow/underflow
  4. CPI Reentrancy - Follow the CEI pattern and use reentrancy guards
  5. 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

There aren't any published security advisories