DEV Community

ohmygod
ohmygod

Posted on

Fuzzing Solana Programs with Trident: How Ackee's Open-Source Fuzzer Catches Bugs That Unit Tests Miss

Your Anchor program has 100% branch coverage. Every instruction handler has a matching unit test. Clippy is clean. anchor test passes.

Then someone calls withdraw() after deposit() after update_oracle() in the same transaction, and 40,000 SOL vanishes into an attacker's wallet.

Unit tests verify the paths you imagined. Fuzzers find the paths you didn't.

This guide walks through Trident — Ackee Blockchain Security's open-source Rust fuzzer for Solana Anchor programs — and shows you how to catch real vulnerability classes before auditors (or attackers) do.


Why Solana Programs Need Fuzzing

Solana programs are stateful, multi-account, and composable. A single instruction can read from 8+ accounts, each with their own owner, data layout, and lifecycle. The attack surface isn't just "bad input" — it's bad sequences of valid inputs.

Consider three vulnerability classes that unit tests routinely miss:

1. Missing Signer Checks on Authority Transfers

// Vulnerable: admin_update doesn't verify current_admin signed
pub fn admin_update(ctx: Context<AdminUpdate>, new_fee: u64) -> Result<()> {
    ctx.accounts.config.fee_bps = new_fee;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

A unit test that always passes the correct admin signer will never catch this. A fuzzer that randomizes account inputs will find it in seconds.

2. Arithmetic Overflows in Fee Calculations

// Vulnerable: u64 overflow when amount * fee_bps > u64::MAX
let fee = amount.checked_mul(fee_bps)
    .unwrap()
    .checked_div(10_000)
    .unwrap();
Enter fullscreen mode Exit fullscreen mode

You wrote checked_mul, so you think you're safe. But a fuzzer feeding amount = u64::MAX / 2 and fee_bps = 3 will show you that unwrap() panics — and on Solana, a panic during CPI means the outer transaction continues with no fee deducted.

3. Cross-Instruction State Corruption

// Instruction A: deposit collateral
// Instruction B: update oracle price
// Instruction C: borrow against inflated collateral
// All three in one transaction
Enter fullscreen mode Exit fullscreen mode

No individual instruction is buggy. The sequence is the exploit. This is exactly what fuzzers are built to find.


Setting Up Trident

Prerequisites

# Install Trident CLI
cargo install trident-cli

# Verify installation
trident --version

# Navigate to your Anchor project
cd my-anchor-project/

# Initialize Trident in your project
trident init
Enter fullscreen mode Exit fullscreen mode

This creates a trident-tests/ directory with:

  • fuzz_tests/ — your fuzz test files
  • Cargo.toml — fuzzer dependencies
  • Configuration for Honggfuzz integration

Project Structure After Init

my-anchor-project/
├── programs/
│   └── my_program/
│       └── src/lib.rs
├── trident-tests/
│   └── fuzz_tests/
│       ├── fuzz_0/
│       │   ├── accounts_snapshots.rs
│       │   ├── fuzz_instructions.rs
│       │   └── test_fuzz.rs
│       └── Cargo.toml
└── Trident.toml
Enter fullscreen mode Exit fullscreen mode

Writing Your First Fuzz Test

Let's fuzz a simplified lending pool that has a hidden vulnerability.

The Target Program

#[program]
pub mod lending_pool {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>, oracle: Pubkey) -> Result<()> {
        let pool = &mut ctx.accounts.pool;
        pool.authority = ctx.accounts.authority.key();
        pool.oracle = oracle;
        pool.total_deposits = 0;
        pool.total_borrows = 0;
        Ok(())
    }

    pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
        let pool = &mut ctx.accounts.pool;
        // Transfer SOL from user to pool vault
        // ... transfer logic ...
        pool.total_deposits = pool.total_deposits.checked_add(amount)
            .ok_or(ErrorCode::MathOverflow)?;
        let user = &mut ctx.accounts.user_account;
        user.deposited = user.deposited.checked_add(amount)
            .ok_or(ErrorCode::MathOverflow)?;
        Ok(())
    }

    pub fn borrow(ctx: Context<Borrow>, amount: u64) -> Result<()> {
        let pool = &mut ctx.accounts.pool;
        let user = &mut ctx.accounts.user_account;
        let oracle = &ctx.accounts.oracle_account;

        let collateral_value = user.deposited
            .checked_mul(oracle.price)
            .ok_or(ErrorCode::MathOverflow)?;

        let max_borrow = collateral_value / 150; // 150% collateralization

        // BUG: doesn't check user.borrowed, only checks against max_borrow
        require!(amount <= max_borrow, ErrorCode::InsufficientCollateral);

        pool.total_borrows = pool.total_borrows.checked_add(amount)
            .ok_or(ErrorCode::MathOverflow)?;
        user.borrowed = user.borrowed.checked_add(amount)
            .ok_or(ErrorCode::MathOverflow)?;
        Ok(())
    }
}
Enter fullscreen mode Exit fullscreen mode

The bug: borrow() checks if the current request fits under the collateral limit, but doesn't check the cumulative borrowed amount. A user can call borrow() repeatedly, each time borrowing up to max_borrow, draining the pool.

The Fuzz Test

// trident-tests/fuzz_tests/fuzz_0/fuzz_instructions.rs
use trident_client::fuzzing::*;

#[derive(Arbitrary, Debug)]
pub struct DepositData {
    pub amount: u64,
}

#[derive(Arbitrary, Debug)]
pub struct BorrowData {
    pub amount: u64,
}

impl FuzzInstruction for DepositData {
    fn build(
        &self,
        _client: &mut impl FuzzClient,
        _fuzz_accounts: &mut FuzzAccounts,
    ) -> Result<Instruction, FuzzingError> {
        // Build deposit instruction with fuzzed amount
        let deposit_ix = lending_pool::instruction::Deposit {
            amount: self.amount,
        };
        // ... account setup ...
        Ok(ix)
    }
}

impl FuzzInstruction for BorrowData {
    fn build(
        &self,
        _client: &mut impl FuzzClient,
        _fuzz_accounts: &mut FuzzAccounts,
    ) -> Result<Instruction, FuzzingError> {
        let borrow_ix = lending_pool::instruction::Borrow {
            amount: self.amount,
        };
        // ... account setup ...
        Ok(ix)
    }
}
Enter fullscreen mode Exit fullscreen mode

The Invariant Check

This is where fuzzing becomes powerful. Define properties that must always hold:

// In test_fuzz.rs
fn check_invariants(
    pre_state: &PoolState,
    post_state: &PoolState,
    ix: &FuzzInstruction,
) -> Result<(), FuzzingError> {
    // INVARIANT 1: Total borrows must never exceed total deposits
    assert!(
        post_state.total_borrows <= post_state.total_deposits,
        "Protocol insolvency: borrows ({}) > deposits ({})",
        post_state.total_borrows,
        post_state.total_deposits
    );

    // INVARIANT 2: Each user's borrowed amount must respect collateral ratio
    for user in &post_state.users {
        let max_allowed = user.deposited
            .saturating_mul(post_state.oracle_price) / 150;
        assert!(
            user.borrowed <= max_allowed,
            "User {} borrowed {} but collateral only supports {}",
            user.address, user.borrowed, max_allowed
        );
    }

    // INVARIANT 3: Pool vault balance == total_deposits - total_borrows
    assert_eq!(
        post_state.vault_balance,
        post_state.total_deposits.saturating_sub(post_state.total_borrows),
        "Accounting mismatch: vault doesn't match pool state"
    );

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Running the Fuzzer

# Run with Honggfuzz backend (default)
trident fuzz run fuzz_0

# Run for a specific duration
trident fuzz run fuzz_0 -- --run_time 300  # 5 minutes

# Run with specific thread count
trident fuzz run fuzz_0 -- --threads 8
Enter fullscreen mode Exit fullscreen mode

Within seconds, Trident will generate a sequence like:

[CRASH] Invariant violated after 847 iterations
  Sequence:
    1. deposit(amount: 1000000000)     ← Deposit 1 SOL
    2. borrow(amount: 666666666)       ← Borrow ~0.67 SOL (valid)
    3. borrow(amount: 666666666)       ← Borrow again (still passes check!)
    4. borrow(amount: 666666666)       ← Total borrowed: 2 SOL > 1 SOL deposited
  Invariant 2 failed: borrowed 1999999998 but collateral only supports 666666666
Enter fullscreen mode Exit fullscreen mode

The fix:

pub fn borrow(ctx: Context<Borrow>, amount: u64) -> Result<()> {
    // ...
    let total_after_borrow = user.borrowed.checked_add(amount)
        .ok_or(ErrorCode::MathOverflow)?;
    require!(total_after_borrow <= max_borrow, ErrorCode::InsufficientCollateral);
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Manually Guided Fuzzing: Simulating Attacker Behavior

Trident's Manually Guided Fuzzing (MGF) feature lets you constrain the fuzzer to explore specific attack scenarios. Instead of pure random sequences, you model what a sophisticated attacker would do.

Example: Oracle Manipulation + Borrow Drain

#[derive(Debug)]
pub struct OracleManipulationScenario;

impl FuzzScenario for OracleManipulationScenario {
    fn sequence(&self) -> Vec<FuzzStep> {
        vec![
            // Step 1: Normal setup
            FuzzStep::instruction(InitializeData::arbitrary()),
            FuzzStep::instruction(DepositData { amount: 10_000_000_000 }),

            // Step 2: Simulate oracle price spike (attacker-controlled)
            FuzzStep::instruction(UpdateOracleData {
                price: Range(1..u64::MAX),  // Fuzz the price
            }),

            // Step 3: Borrow maximum against inflated collateral
            FuzzStep::instruction(BorrowData {
                amount: Range(1..u64::MAX),
            }),

            // Step 4: Oracle returns to normal
            FuzzStep::instruction(UpdateOracleData {
                price: Range(1..1000),  // Realistic price
            }),

            // Invariant: pool should still be solvent
        ]
    }
}
Enter fullscreen mode Exit fullscreen mode

This approach finds bugs that random fuzzing takes hours to discover, because you're encoding economic attack logic into the sequence.


Real Bugs Found by Fuzzing Solana Programs

Here's a non-exhaustive list of vulnerability classes that Trident and similar fuzzers have caught in production Solana programs:

Bug Class Example Impact
Missing cumulative borrow check Repeated borrows bypass collateral ratio Pool insolvency
Integer truncation in fee math (amount * fee) / 10000 truncates to 0 for small amounts Zero-fee transactions
PDA seed collision Two users with similar seeds get same PDA Account hijacking
Missing close account drain Closing account doesn't zero lamports Rent-exempt lamport theft
Reinitialization initialize callable twice, resetting authority Admin takeover
Clock sysvar dependency Time-locked funds unlocked by passing fake Clock Premature withdrawal

Trident vs Other Solana Security Tools

Tool Type Best For Limitation
Trident Stateful fuzzer Multi-instruction sequences, invariant violations Requires Anchor, setup time
Semgrep Static analysis Pattern matching, known vuln signatures No runtime behavior
FuzzDelSol Binary fuzzer Programs without source code No invariant checks
Anchor test Unit/integration Happy-path verification Misses unexpected sequences
Soteria Static analyzer Quick vulnerability scan High false-positive rate

The ideal pipeline: Semgrep (fast pattern scan) → Trident (stateful fuzzing) → Manual audit (economic logic review).


Practical Tips for Effective Fuzzing

1. Start With Your Protocol's Economic Invariants

Before writing any fuzz code, write down 5-10 properties that must always hold:

  • Total borrows ≤ total deposits × collateralization_ratio
  • User balances sum to pool total
  • Admin authority can only be transferred by current admin
  • Fees are always non-zero for non-zero transactions
  • Closed accounts have zero lamports

These become your assertion functions.

2. Seed Your Corpus

Give the fuzzer realistic starting inputs:

mkdir -p trident-tests/fuzz_tests/fuzz_0/corpus/
# Add known valid transaction sequences as seed inputs
Enter fullscreen mode Exit fullscreen mode

This helps the fuzzer find deep bugs faster instead of spending time on trivially invalid inputs.

3. Fuzz in CI

# .github/workflows/fuzz.yml
name: Fuzz Tests
on:
  pull_request:
    paths: ['programs/**']

jobs:
  fuzz:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: nicetry/setup-solana@v1
      - run: cargo install trident-cli
      - run: trident fuzz run fuzz_0 -- --run_time 600 --exit_upon_crash
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: crash-inputs
          path: trident-tests/fuzz_tests/fuzz_0/cr*/
Enter fullscreen mode Exit fullscreen mode

Run 10 minutes per PR. If it crashes, the crash input is saved as an artifact for reproduction.

4. Fuzz After Every Audit

Auditors miss things. Fuzzers explore differently. The combination is stronger than either alone. After receiving your audit report, write Trident scenarios that test every finding's class of bug — not just the specific instance.


The Bottom Line

Solana's account model makes programs fundamentally harder to test than EVM contracts. Multi-account state, CPI composability, and PDA derivation create an exponential input space that unit tests can't cover.

Trident won't replace auditors. But it will:

  1. Find the "stupid" bugs that waste auditor time (and your money)
  2. Catch regression bugs when you refactor
  3. Validate your invariants across millions of random sequences
  4. Build confidence that your program behaves correctly under adversarial conditions

The DeFi programs that survive 2026 won't be the ones with the most tests. They'll be the ones whose tests explored the most unexpected states.

Start fuzzing. The attackers already are.


This is part of the DeFi Security Research series. Previously: Formal Verification with Halmos/Certora/HEVM, Custom Semgrep Rules for Solana, Custom Slither Detectors.

Top comments (0)