Zokyo Security Research
Fuzzing Solana Programs with Honggfuzz
A hands-on walkthrough: from setting up a Vault program with Pinocchio to writing invariant-based fuzz targets that catch edge-case vulnerabilities before attackers do.

Smart contract security on Solana has historically leaned on manual audits and static analysis. These methods are indispensable, but they share a blind spot: they struggle with edge cases born from unexpected input combinations, exotic state transitions, and arithmetic boundary conditions that are nearly impossible to enumerate by hand.

Fuzzing fills that gap. By bombarding your program with thousands of semi-random inputs per second, a coverage-guided fuzzer like Honggfuzz systematically explores execution paths that no human reviewer would think to test. When paired with explicit invariant checks, fuzzing transforms from a crash-finder into a property-verification engine.

In this guide, we walk through the complete process of fuzzing a realistic Solana Vault program built with the Pinocchio framework. By the end, you will have a working fuzz harness, a set of meaningful invariants, and a clear understanding of how to integrate fuzzing into your own Solana development workflow.

Why Fuzz Solana Programs?

Solana programs operate in an environment where a single exploitable bug can drain millions in seconds. Unlike traditional software, there is no rolling back a transaction once it settles. The stakes are binary: the program is correct or it is not.

Manual code review catches logic errors, access control mistakes, and common anti-patterns. Static analyzers flag known vulnerability signatures. But neither approach can efficiently explore the combinatorial explosion of possible program states.

Consider a vault program that handles deposits, withdrawals, and admin operations. With three instructions, each accepting multiple accounts and parameters, the state space grows exponentially. A fuzzer can explore hundreds of thousands of unique input permutations in minutes, automatically discovering:

  • Arithmetic overflows that slip past manual review
  • Missing account validation that allows unauthorized access
  • State corruption from unexpected instruction ordering
  • Rounding errors in token math that leak value over time
  • Panic conditions from malformed instruction data

In our audit practice at Zokyo, we have found that fuzzing regularly uncovers issues that survived multiple rounds of manual review. It is not a replacement for human analysis - it is a force multiplier.

Tooling Overview

Honggfuzz

Honggfuzz is a security-oriented, feedback-driven fuzzer developed by Google. It uses compile-time instrumentation to track code coverage, meaning it intelligently mutates inputs toward unexplored code paths rather than relying on pure randomness.

Key properties that make it ideal for Solana programs:

  • Coverage-guided: Automatically prioritizes inputs that reach new code
  • Multi-threaded: Scales across CPU cores for high throughput
  • Persistent mode: Avoids process-restart overhead between iterations
  • Crash deduplication: Groups unique crashes by stack trace

For Rust projects, we use honggfuzz-rs, a Rust wrapper that integrates Honggfuzz directly into the Cargo build system.

Pinocchio

Pinocchio is a lightweight, zero-dependency framework for writing Solana programs. Unlike Anchor, which provides a full-featured framework with IDL generation and account deserialization macros, Pinocchio sits closer to the metal. It offers thin abstractions over Solana's native program interface while keeping the binary small and the runtime overhead minimal.

This minimalism makes Pinocchio programs particularly well-suited for fuzzing: there are fewer framework layers between your logic and the instruction data, so the fuzzer's inputs map more directly to actual program behavior.

LiteSVM

LiteSVM is a lightweight Solana virtual machine that provides a fast sandbox for executing program bytecode without touching a real network. For fuzzing, where you need to run thousands of iterations per second, LiteSVM is the preferred execution environment: it spins up quickly, supports account creation, airdrops, and transaction processing, and has minimal overhead compared to a full validator or even solana-program-test.

In practice, each fuzz iteration loads the Vault program from its compiled .so binary into LiteSVM, creates accounts, funds them via airdrop, and processes transactions - all in-memory, all deterministic. This is the approach used in Zokyo's internal fuzzing harnesses for Pinocchio-based programs.

solana-program-test

Solana's official testing framework provides a local BPF runtime environment that accurately simulates on-chain execution. It can also be used inside fuzz harnesses to process transactions with full account state, sysvar access, and CPI support - without needing a live validator. However, for high-throughput fuzzing, LiteSVM is generally preferred due to its lower overhead per iteration.

The Target: A Solana Vault Program

Our fuzz target is a Vault program that accepts SOL deposits and allows proportional withdrawals. It tracks shares using a simple but realistic accounting model:

  • Deposit: User sends SOL to the vault, receives shares proportional to their contribution relative to total vault value
  • Withdraw: User burns shares to reclaim a proportional amount of SOL from the vault
  • Admin: An authority account can pause or unpause the vault

The vault state is stored in a PDA (Program Derived Address) with the following layout:

Rust src/state.rs
pub struct VaultState {
    pub authority: Pubkey,        // Admin who can pause/unpause
    pub total_deposited: u64,    // Total SOL held by vault
    pub total_shares: u64,       // Total shares outstanding
    pub is_paused: bool,          // Emergency brake
    pub bump: u8,                 // PDA bump seed
}

This is intentionally simple. The point is not to build a production vault but to demonstrate a fuzzing workflow that you can adapt to any Solana program.

Project Setup

Prerequisites

Before starting, ensure you have the following installed:

  • Rust toolchain (stable + nightly) via rustup
  • Solana CLI tools (v1.17+)
  • Honggfuzz system dependencies (libunwind-dev, binutils-dev, libblocksruntime-dev on Ubuntu)
  • cargo-hfuzz installed via cargo install honggfuzz

Directory Structure

We organize the project with the program source and fuzz targets in the same repository:

Shell Project layout
solana-vault-fuzz/
  program/
    src/
      lib.rs            # Program entrypoint
      state.rs          # VaultState definition
      instructions/
        deposit.rs      # Deposit handler
        withdraw.rs     # Withdraw handler
        admin.rs        # Pause/unpause
    Cargo.toml
  hfuzz/
    src/
      main.rs           # Fuzz harness entrypoint
      generators.rs     # Input generators
      invariants.rs     # Property checks
    Cargo.toml
  Cargo.toml            # Workspace root

Cargo Configuration

The fuzz harness lives in its own crate within the workspace. Its Cargo.toml depends on the program crate, honggfuzz, and solana-program-test:

TOML hfuzz/Cargo.toml
[package]
name = "vault-fuzz"
version = "0.1.0"
edition = "2021"

[dependencies]
honggfuzz = "0.5"
solana-program-test = "1.17"
solana-sdk = "1.17"
vault-program = { path = "../program" }
arbitrary = { version = "1", features = ["derive"] }
Note

The arbitrary crate lets us derive structured input generation automatically. Instead of parsing raw bytes manually, we define Rust structs and let the crate handle deserialization from the fuzzer's byte stream.

Writing the Fuzz Harness

The fuzz harness is the bridge between Honggfuzz's random byte stream and your Solana program's instruction interface. A well-designed harness converts arbitrary bytes into valid (or intentionally malformed) transactions, executes them against a local runtime, and checks invariants after each operation.

Structured Input Generation

Rather than parsing raw bytes, we define an enum that represents the set of actions the fuzzer can take:

Rust hfuzz/src/generators.rs
use arbitrary::Arbitrary;

#[derive(Arbitrary, Debug)]
pub enum FuzzAction {
    Deposit {
        amount: u64,
        user_index: u8,       // Select from pre-created users
    },
    Withdraw {
        shares: u64,
        user_index: u8,
    },
    Pause,
    Unpause,
    DepositWhilePaused {    // Explicitly test paused state
        amount: u64,
        user_index: u8,
    },
}

#[derive(Arbitrary, Debug)]
pub struct FuzzInput {
    pub actions: Vec<FuzzAction>,
    pub seed: u64,            // For deterministic replay
}

The user_index field maps to a small pool of pre-funded accounts (typically 4-8 users). This keeps the state space manageable while still testing multi-user interactions.

The Harness Entrypoint

The main fuzz loop initializes the Solana test runtime, processes each action in sequence, and runs invariant checks after every instruction:

Rust hfuzz/src/main.rs
use honggfuzz::fuzz;

fn main() {
    loop {
        fuzz!(|input: FuzzInput| {
            let rt = tokio::runtime::Runtime::new().unwrap();
            rt.block_on(async {
                let mut ctx = setup_test_context().await;

                for action in &input.actions {
                    // Snapshot state before instruction
                    let pre_state = snapshot_vault(&ctx).await;

                    // Execute the fuzzed action
                    let result = execute_action(&mut ctx, action).await;

                    // Post-state snapshot
                    let post_state = snapshot_vault(&ctx).await;

                    // Check invariants regardless of success/failure
                    check_invariants(
                        &pre_state,
                        &post_state,
                        action,
                        &result,
                    );
                }
            });
        });
    }
}

The critical design choice here is snapshotting state before and after each action. This lets invariant checks compare the delta rather than just inspecting final state, catching issues like value leakage that only manifest across transitions.

Runtime Optimization: Lazy Initialization

A subtle but critical performance detail: do not spin up a new Tokio async runtime for every fuzz iteration. Instead, use once_cell::sync::Lazy to initialize a single-threaded runtime once and reuse it across all iterations:

Rust hfuzz/src/main.rs (runtime setup)
use once_cell::sync::Lazy;
use tokio::runtime::Runtime;

static RT: Lazy<Runtime> = Lazy::new(|| {
    Runtime::new_current_thread()
        .enable_all()
        .build()
        .unwrap()
});

// Embed the compiled program binary directly
const PROGRAM_BYTECODE: &[u8] = include_bytes!(
    "../../target/deploy/vault_program.so"
);

The single-threaded runtime (new_current_thread) avoids nondeterminism from concurrency and race conditions. The PROGRAM_BYTECODE constant embeds the compiled Vault program (.so file) directly into the fuzzer binary, so each fuzz iteration can load and execute the on-chain code locally without relying on external deployment or file I/O. Without this optimization, the fuzzer would spend significant time on runtime initialization rather than actual program exploration.

Alternative: Raw Byte Parsing

Instead of the arbitrary crate approach shown above, a more direct method is to define a run_case(data: &[u8]) function that receives raw bytes from the fuzzer and manually parses them into structured parameters (deposit amounts, account seeds, bumps). This is closer to how the fuzzer actually operates and gives finer control over input generation, though it requires more boilerplate.

Executing Actions

Each FuzzAction maps to a real Solana transaction. The execution function constructs the instruction data, builds the transaction with proper signers, and submits it to the test runtime:

Rust hfuzz/src/main.rs (continued)
async fn execute_action(
    ctx: &mut TestContext,
    action: &FuzzAction,
) -> Result<(), TransactionError> {
    match action {
        FuzzAction::Deposit { amount, user_index } => {
            let user = &ctx.users[(*user_index as usize) % ctx.users.len()];
            let ix = build_deposit_ix(
                &user.pubkey(),
                &ctx.vault_pda,
                *amount,
            );
            send_tx(ctx, &[ix], &[user]).await
        }
        FuzzAction::Withdraw { shares, user_index } => {
            let user = &ctx.users[(*user_index as usize) % ctx.users.len()];
            let ix = build_withdraw_ix(
                &user.pubkey(),
                &ctx.vault_pda,
                *shares,
            );
            send_tx(ctx, &[ix], &[user]).await
        }
        FuzzAction::Pause => {
            let ix = build_pause_ix(&ctx.authority.pubkey(), &ctx.vault_pda);
            send_tx(ctx, &[ix], &[&ctx.authority]).await
        }
        // ... remaining variants
    }
}

Designing Invariants

Invariants are the soul of a fuzz campaign. Without them, a fuzzer can only detect crashes and panics. With well-chosen invariants, it becomes a property verifier that catches logical correctness violations.

What Makes a Good Invariant?

An effective invariant is a statement that must hold true regardless of the input or the sequence of operations. Good invariants are:

  • Universal: They hold for every possible state, not just expected states
  • Checkable: They can be verified from observable state alone
  • Meaningful: Violations indicate real bugs, not just unusual-but-correct behavior

Vault Invariants

For our Vault program, we define the following invariant set:

Rust hfuzz/src/invariants.rs
pub fn check_invariants(
    pre: &VaultSnapshot,
    post: &VaultSnapshot,
    action: &FuzzAction,
    result: &Result<(), TransactionError>,
) {
    // INV-1: Vault SOL balance must equal total_deposited
    assert_eq!(
        post.vault_lamports,
        post.state.total_deposited,
        "INV-1 violated: lamports != total_deposited"
    );

    // INV-2: Total shares must be zero iff total deposited is zero
    if post.state.total_deposited == 0 {
        assert_eq!(
            post.state.total_shares, 0,
            "INV-2 violated: shares nonzero with zero deposits"
        );
    }

    // INV-3: No value created from nothing
    let total_before = pre.sum_all_balances();
    let total_after = post.sum_all_balances();
    assert_eq!(
        total_before, total_after,
        "INV-3 violated: conservation of value broken"
    );

    // INV-4: Paused vault must reject deposits
    if pre.state.is_paused {
        if let FuzzAction::Deposit { .. } = action {
            assert!(
                result.is_err(),
                "INV-4 violated: deposit succeeded while paused"
            );
        }
    }

    // INV-5: User cannot withdraw more than their share
    if let FuzzAction::Withdraw { shares, user_index } = action {
        if result.is_ok() {
            let user_idx = (*user_index as usize) % pre.user_shares.len();
            assert!(
                *shares <= pre.user_shares[user_idx],
                "INV-5 violated: withdrew more shares than owned"
            );
        }
    }

    // INV-6: Share price must be monotonically non-decreasing
    // (no share dilution attack possible)
    if pre.state.total_shares > 0 && post.state.total_shares > 0 {
        let price_before = pre.state.total_deposited * 1_000_000
            / pre.state.total_shares;
        let price_after = post.state.total_deposited * 1_000_000
            / post.state.total_shares;
        assert!(
            price_after >= price_before,
            "INV-6 violated: share price decreased (dilution)"
        );
    }
}

Let us unpack why each invariant matters:

  • INV-1 (Balance consistency): Catches double-counting, missed debits, or phantom credits
  • INV-2 (Zero-state coherence): Prevents the vault from entering an impossible state where shares exist but no SOL backs them
  • INV-3 (Conservation of value): The most powerful invariant - ensures SOL is never created or destroyed, only transferred between accounts
  • INV-4 (Pause enforcement): Validates the emergency circuit breaker actually works under adversarial input
  • INV-5 (Withdrawal bounds): Catches scenarios where a user can claim more than their proportional share
  • INV-6 (Share price monotonicity): Detects first-depositor attacks and other economic exploits where existing shareholders are diluted
Common Pitfall

Be careful with integer division in invariant checks. Solana programs typically use integer arithmetic, and rounding can cause legitimate one-lamport deviations. Allow for a small epsilon (1-2 lamports) in balance-comparison invariants, or you will get false positives that mask real bugs.

Running the Fuzzer

Basic Execution

With the harness in place, running the fuzzer is straightforward:

$ cd hfuzz $ cargo hfuzz run vault-fuzz ─────────────────────────────────────────────── Honggfuzz v2.6 - starting fuzzing session Threads: 8 | Timeout: 10s Corpus: hfuzz_workspace/vault-fuzz/input ─────────────────────────────────────────────── Iterations: 142,857 | Speed: 2,381/sec Crashes: 0 | Timeouts: 0 Coverage: 847 edges | New: +3 [INFO] No crashes after 60 seconds

Tuning Parameters

Honggfuzz accepts several parameters that meaningfully affect fuzzing quality:

  • -n <threads> - Number of fuzzing threads. Default is the number of CPU cores. For Solana program tests, which are CPU-intensive, using core-count minus two leaves headroom for the OS.
  • -N <iterations> - Run for a fixed number of iterations and stop. Useful for CI integration.
  • --timeout <secs> - Per-input timeout. Set this high enough that legitimate multi-action sequences complete, but low enough to catch infinite loops. 10-30 seconds is typical.
  • --dict <file> - A dictionary of interesting byte sequences. For Solana programs, this can include common account addresses, instruction discriminators, and boundary values like u64::MAX.
  • --exit_upon_crash - Stop immediately when the first invariant violation or crash is found. Useful during development to debug one issue at a time rather than accumulating a pile of crash files.
$ cargo hfuzz run vault-fuzz -- -n 6 --timeout 15 -N 1000000 Running 1,000,000 iterations across 6 threads...
Feature Flags

Some workspace setups use a --features=honggfuzz flag to enable the fuzzing entrypoint conditionally. This keeps the fuzz harness code out of normal builds and lets you compile the same crate for both testing and fuzzing. Combined with --exit_upon_crash, the command becomes: cargo hfuzz run vault-fuzz --features=honggfuzz -- --exit_upon_crash.

Corpus Management

Honggfuzz stores interesting inputs (those that reach new code coverage) in the hfuzz_workspace/vault-fuzz/input directory. Over time, this corpus grows to represent a distilled set of inputs that collectively achieve maximum coverage.

Best practices for corpus management:

  • Seed the corpus with valid instruction sequences. This gives the fuzzer a starting point that exercises core paths immediately, rather than spending time finding valid instruction formats through random mutation.
  • Minimize the corpus periodically using cargo hfuzz run-minimize. This removes inputs that do not contribute unique coverage, keeping the corpus lean and fuzzing fast.
  • Check the corpus into version control. This ensures fuzzing campaigns build on previous work rather than starting fresh.

Analyzing Crashes

When Honggfuzz finds an input that violates an invariant (triggering a panic/assertion failure), it saves the input to hfuzz_workspace/vault-fuzz/crashes/. Each crash file is the raw byte stream that triggered the failure.

Reproducing a Crash

To replay a crash deterministically:

$ cargo hfuzz run-debug vault-fuzz hfuzz_workspace/vault-fuzz/crashes/SIGABRT.PC.*.fuzz thread 'main' panicked at 'INV-3 violated: conservation of value broken' pre_total: 5000000000, post_total: 4999999998 note: run with `RUST_BACKTRACE=1` for a backtrace

The output tells us exactly which invariant was violated and the state delta. In this example, 2 lamports vanished during a withdrawal - a rounding error in the share-to-lamport conversion.

Crash Triage

Not every crash represents the same severity. We categorize findings into tiers:

  • Critical: Conservation of value violations (INV-3), unauthorized access, fund drainage paths
  • High: Share price manipulation (INV-6), pause bypass (INV-4), withdrawal bounds violations (INV-5)
  • Medium: State coherence issues (INV-2), rounding errors that accumulate over many transactions
  • Low: Panics on malformed input (the program should return an error, not panic)

Real-World Findings from Fuzzing

In our auditing practice, fuzzing Solana programs with this approach has uncovered several categories of bugs that are representative of what you should expect to find:

First Depositor Attack

A classic economic exploit where the first user deposits a tiny amount (1 lamport), then donates a large amount directly to the vault's account (bypassing the program). When the second user deposits, their shares are calculated against the inflated vault balance, resulting in fewer shares than expected. The first user then withdraws, draining a disproportionate amount.

INV-6 (share price monotonicity) catches this by detecting that the share price decreased for existing holders relative to the value they deposited.

Integer Truncation in Share Calculation

When computing shares = deposit_amount * total_shares / total_deposited, integer division truncates. Over many deposits and withdrawals, the rounding errors accumulate and the vault retains dust that no shareholder can claim. INV-3 (conservation of value) flags the discrepancy.

The fix: implement rounding logic that consistently rounds in favor of the vault (rounding shares down on deposit, rounding withdrawal amounts down), and include a dust-collection mechanism.

Missing Signer Check on Admin

A Pinocchio program must explicitly verify that the authority account is a signer on admin instructions. The fuzzer found sequences where a non-authority user could unpause the vault, simply because the signer check was missing from the unpause instruction but present on pause. INV-4 detected this when deposits succeeded in a state that should have been paused.

Identical Seed Collision

A concrete finding from our fuzzing harness: Honggfuzz uncovered an edge case caused by identical seeds of 32-byte length. When the fuzzer generates random byte streams, it can produce cases where two separate accounts (e.g., the payer and depositor) are derived using Keypair::from_seed() with the same 32-byte seed slice. This results in both accounts resolving to the same keypair, creating a degenerate transaction where the payer and depositor are the same entity.

While this collision is statistically unlikely in production (2^256 address space), the fuzzer finds it quickly because it explores boundary conditions. The fix is to add a guard in the program or the harness that validates the payer and depositor are distinct accounts before processing. This is the kind of edge case that would never surface in manual testing or unit tests with hardcoded accounts.

Integrating Fuzzing into CI/CD

Fuzzing is most valuable when it runs continuously, not as a one-time exercise. Here is how we integrate it into a GitHub Actions pipeline:

YAML .github/workflows/fuzz.yml
name: Fuzz Tests
on:
  pull_request:
  schedule:
    - cron: '0 2 * * *'  # Nightly at 2 AM

jobs:
  fuzz:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install dependencies
        run: |
          sudo apt-get install -y libunwind-dev
          cargo install honggfuzz
      - name: Restore corpus
        uses: actions/cache@v3
        with:
          path: hfuzz_workspace
          key: fuzz-corpus-${{ hashFiles('program/src/**') }}
      - name: Run fuzzer (10 minutes)
        run: |
          cd hfuzz
          timeout 600 cargo hfuzz run vault-fuzz || true
      - name: Check for crashes
        run: |
          if ls hfuzz_workspace/vault-fuzz/crashes/* &> /dev/null; then
            echo "Crashes found!"
            ls -la hfuzz_workspace/vault-fuzz/crashes/
            exit 1
          fi

Key decisions in this configuration:

  • PR-triggered runs catch regressions before merge, with a time budget of 10 minutes
  • Nightly scheduled runs allow deeper exploration with longer time budgets
  • Corpus caching ensures each run builds on previous discoveries rather than starting from scratch
  • Crash detection fails the pipeline when the fuzzer finds a new invariant violation

Advanced Techniques

Differential Fuzzing

If you maintain a reference implementation (for example, an Anchor version alongside a Pinocchio version of the same program), differential fuzzing runs the same inputs against both and flags any behavioral divergence. This is particularly powerful during migration or rewrite projects.

Stateful Fuzzing with Checkpoints

For programs with complex state machines, consider implementing state checkpoints in your harness. After each action, serialize the full program state. When a crash is found, the checkpoint trail provides the exact state sequence that led to the violation, dramatically simplifying root cause analysis.

Custom Dictionaries

Building a fuzzing dictionary specific to your program accelerates coverage. Include:

  • Instruction discriminators (the 8-byte Anchor discriminator or your custom tag bytes)
  • Common boundary values: 0, 1, u64::MAX, u64::MAX - 1
  • Known account public keys (system program, token program, etc.)
  • Magic numbers from your program's constants

Coverage Analysis

After a fuzzing campaign, export coverage data to identify which parts of your program the fuzzer could not reach. Unreached code often indicates:

  • Input generation gaps (the harness does not produce inputs that trigger certain paths)
  • Dead code that can be removed
  • Error paths that need separate test cases

Common Pitfalls and How to Avoid Them

After fuzzing dozens of Solana programs, we have compiled a list of mistakes that reduce fuzzing effectiveness:

  1. Overly constrained inputs. If your generator only produces valid instructions, the fuzzer cannot test error handling or boundary behavior. Include variants that intentionally submit bad data.
  2. Missing state snapshots. Checking only final state misses transient violations. Always snapshot before and after each action.
  3. Too few invariants. A fuzzer without invariants only finds crashes. Every meaningful property of your program should be an invariant.
  4. Ignoring failed transactions. A failed transaction still changes state (the fee is deducted, slot advances). Invariant checks must run even when the instruction returns an error.
  5. Not seeding the corpus. Starting from an empty corpus means the fuzzer spends its first thousand iterations finding valid instruction formats. Seed it with a handful of known-good sequences.
  6. Running too few iterations. Meaningful fuzzing requires sustained execution. Brief runs (under 100,000 iterations) barely scratch the surface. Aim for millions of iterations over hours or days.

Limitations and Caveats

It is important to be transparent about the current limitations of this approach. Understanding these helps set realistic expectations and guides future improvement:

Coverage Instrumentation Gap

Because Solana programs are compiled to BPF/SBF bytecode and loaded as precompiled .so binaries, the fuzzer cannot instrument them for coverage tracking in the way it instruments native Rust code. This means you cannot yet measure which parts of the Vault code are being hit by the fuzzer's inputs.

Honggfuzz (and other coverage-guided fuzzers) works best when it can observe program behavior through compiler-inserted hooks and collect coverage maps. When fuzzing against an uninstrumented binary, the coverage-guided fuzzer effectively degenerates into an unguided fuzzer for the on-chain program logic itself - it still generates diverse inputs, but it cannot intelligently steer them toward unexplored code paths within the program.

Important

The coverage guidance still applies to the harness code (the Rust wrapper that constructs and submits transactions). This means the fuzzer efficiently explores different transaction shapes, account configurations, and parameter combinations. But it is blind to internal branching within the compiled Solana program itself.

Per-Iteration Setup Overhead

In the simplest harness design, the test environment (LiteSVM instance, accounts, program deployment) is rebuilt for every fuzz iteration. This ensures clean state isolation but adds overhead that limits iteration speed. The once_cell::sync::Lazy pattern for the runtime helps, but the account and program loading still runs each time.

Future improvements could include state snapshots and restoration, allowing the fuzzer to fork from a known-good checkpoint rather than rebuilding from scratch. This would dramatically increase iteration throughput.

Path Forward

Future work should focus on:

  • Adding proper coverage instrumentation to the BPF/SBF build process so the fuzzer can report which branches and code paths it explores within the program logic itself
  • State reuse between iterations via snapshot/restore to increase throughput
  • Multi-instruction sequence fuzzing that maintains state across transactions to discover vulnerabilities that only manifest through specific operation orderings

Closing Thoughts

Fuzzing is not a silver bullet. It does not replace manual code review, formal verification, or thorough unit testing. What it does is systematically explore the regions of your program's state space that humans cannot reach through manual analysis alone.

For Solana programs, where a single bug can mean immediate, irreversible financial loss, this systematic exploration is not optional - it is a necessity. The cost of setting up a fuzz harness is measured in hours. The cost of a missed vulnerability is measured in millions.

At Zokyo, we integrate coverage-guided fuzzing into every audit engagement that involves Solana programs. The approach described in this guide represents our standard methodology, adapted here for public use. We encourage every Solana developer to adopt fuzzing as a core part of their security testing practice.

The code examples in this article are available as a reference implementation. The invariant patterns and harness architecture generalize to any Solana program, regardless of framework.

If you are building on Solana and want help implementing a robust fuzzing pipeline or need a comprehensive security audit, reach out to our team.