Fuzzing Rust Code with cargo-fuzz, libFuzzer, and the arbitrary Crate

Fuzzing Rust Code with cargo-fuzz, libFuzzer, and the arbitrary Crate

Rust's memory safety guarantees eliminate an entire class of bugs that make fuzzing critical in C/C++ — but Rust code can still have logic errors, integer overflows, panics, and unexpected behavior. cargo-fuzz brings libFuzzer to Rust with a clean command-line interface, and the arbitrary crate enables structured fuzzing of complex types. Together, they make fuzzing Rust code surprisingly straightforward.

What cargo-fuzz Finds in Rust Code

Since Rust prevents memory corruption by default, fuzzing focuses on:

  • Panics: unwrap(), expect(), array out-of-bounds, integer overflow in debug mode
  • Logic errors: Incorrect output for valid input
  • Infinite loops: Parser hangs on pathological input
  • unsafe code bugs: Memory corruption in unsafe blocks
  • Deserialization panics: Serde, bincode, postcard deserializers
  • Integer arithmetic overflow: In release mode, Rust wraps silently; fuzzers can expose this

Even without memory bugs, the attack surface for Rust code is real.

Installation

Prerequisites

# cargo-fuzz requires nightly Rust (for LLVM sanitizer support)
rustup install nightly

<span class="hljs-comment"># Install cargo-fuzz
cargo install cargo-fuzz

Verify:

cargo fuzz --version

Setting Up a Fuzz Target

Initialize

# In your project directory
cargo fuzz init

This creates:

fuzz/
├── Cargo.toml
└── fuzz_targets/
    └── fuzz_target_1.rs

Add a New Target

cargo fuzz add parse_config

Creates fuzz/fuzz_targets/parse_config.rs.

Write the Fuzz Target

// fuzz/fuzz_targets/parse_config.rs
#![no_main]
use libfuzzer_sys::fuzz_target;
use mylib::ParseConfig;

fuzz_target!(|data: &[u8]| {
    // If this panics, cargo-fuzz reports a crash
    if let Ok(s) = std::str::from_utf8(data) {
        // ParseConfig must never panic on any valid UTF-8 string
        let _ = ParseConfig::from_str(s);
    }
});

The fuzz_target! macro handles the libFuzzer boilerplate. If the closure panics, cargo-fuzz reports it as a crash.

Running the Fuzzer

# Run with nightly (required for cargo-fuzz)
cargo +nightly fuzz run parse_config

<span class="hljs-comment"># Run for a time limit (seconds)
cargo +nightly fuzz run parse_config -- -max_total_time=300

<span class="hljs-comment"># Run with verbose output
cargo +nightly fuzz run parse_config -- -print_final_stats=1

<span class="hljs-comment"># Run with multiple jobs (parallel workers)
cargo +nightly fuzz run parse_config -- -workers=4

<span class="hljs-comment"># Run a specific corpus item
cargo +nightly fuzz run parse_config corpus/parse_config/some_file

Output

INFO: Running with entropic power schedule (0xFF, 100).
INFO: Seed: 1234567890
INFO: Loaded 2 modules (1234 inline 8-bit counters)
INFO: Loaded 2 PC tables
INFO: 3 files found in fuzz/corpus/parse_config
INFO: seed corpus: 3 files, 87 bytes total
...
#3        NEW    cov: 42 ft: 45 corp: 4/99b lim: 4096 exec/s: 0 rss: 28Mb L: 36/36 MS: 2 ...
#32       NEW    cov: 47 ft: 52 corp: 5/103b lim: 4096 exec/s: 0 rss: 29Mb L: 4/36 MS: 4 ...

The arbitrary Crate: Structured Fuzzing

Raw &[u8] fuzzing is great for parsers, but what if you want to fuzz a function that takes a complex struct? That's where arbitrary comes in.

arbitrary provides a derive macro that teaches the fuzzer how to generate valid instances of your types from raw bytes.

Installation

# Cargo.toml (your library)
[dependencies]
arbitrary = { version = "1", features = ["derive"], optional = true }

[features]
fuzz = ["arbitrary"]
# fuzz/Cargo.toml
[dependencies]
mylib = { path = "..", features = ["fuzz"] }
arbitrary = { version = "1", features = ["derive"] }

Deriving Arbitrary

// src/types.rs
use arbitrary::Arbitrary;

#[derive(Debug, Clone, Arbitrary)]
pub struct Config {
    pub name: String,
    pub timeout_ms: u64,
    pub retry_count: u8,
    pub mode: ExecutionMode,
}

#[derive(Debug, Clone, Arbitrary)]
pub enum ExecutionMode {
    Sequential,
    Parallel { workers: u8 },
    Batch { size: u16, overlap: bool },
}
// fuzz/fuzz_targets/fuzz_config.rs
#![no_main]
use libfuzzer_sys::fuzz_target;
use mylib::Config;

fuzz_target!(|config: Config| {
    // The fuzzer generates valid Config structs and mutates them
    // Any panic here = bug
    let result = process_config(&config);
    
    // Optional: test invariants
    if let Ok(output) = result {
        assert!(!output.is_empty(), "Non-empty config should produce output");
    }
});

This is much more effective than raw byte fuzzing for testing business logic.

Custom Arbitrary Implementations

For types with constraints that can't be derived:

use arbitrary::{Arbitrary, Unstructured};

#[derive(Debug, Clone)]
pub struct Email(String);

impl<'a> Arbitrary<'a> for Email {
    fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result<Self> {
        let user: String = u.arbitrary()?;
        let domain: String = u.arbitrary()?;
        
        // Always generate syntactically valid emails
        let email = format!("{}@{}.com", 
            user.chars().filter(|c| c.is_alphanumeric()).take(20).collect::<String>(),
            domain.chars().filter(|c| c.is_alphanumeric()).take(10).collect::<String>()
        );
        
        Ok(Email(email))
    }
}

Real-World Fuzz Targets

Fuzzing a Serialization Library

// fuzz/fuzz_targets/fuzz_serde.rs
#![no_main]
use libfuzzer_sys::fuzz_target;
use mylib::MyStruct;

fuzz_target!(|data: &[u8]| {
    // Test bincode deserialization
    if let Ok(value) = bincode::deserialize::<MyStruct>(data) {
        // Round-trip: re-serialize and re-deserialize
        let serialized = bincode::serialize(&value).unwrap();
        let reparsed: MyStruct = bincode::deserialize(&serialized)
            .expect("round-trip failed");
        
        // Values must be equal
        // (requires PartialEq on MyStruct)
        let re_serialized = bincode::serialize(&reparsed).unwrap();
        assert_eq!(serialized, re_serialized, 
            "re-serialized != serialized after round-trip");
    }
});

Fuzzing an HTTP Parser

#![no_main]
use libfuzzer_sys::fuzz_target;
use httparse::{Request, EMPTY_HEADER};

fuzz_target!(|data: &[u8]| {
    let mut headers = [EMPTY_HEADER; 64];
    let mut req = Request::new(&mut headers);
    
    // httparse::parse_request must never panic on any input
    let _ = req.parse(data);
});

Fuzzing with Differential Testing

// Compare two implementations — they must agree
#![no_main]
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
    if let Ok(s) = std::str::from_utf8(data) {
        let result_v1 = parser_v1::parse(s);
        let result_v2 = parser_v2::parse(s);
        
        match (result_v1, result_v2) {
            (Ok(v1), Ok(v2)) => {
                assert_eq!(v1, v2, 
                    "Parsers disagree on input: {:?}", s);
            }
            (Err(_), Err(_)) => {
                // Both failed — OK, they agree on invalidity
            }
            (Ok(v1), Err(e2)) => {
                panic!("v1 succeeded ({:?}) but v2 failed ({:?}) on: {:?}", 
                    v1, e2, s);
            }
            (Err(e1), Ok(v2)) => {
                panic!("v1 failed ({:?}) but v2 succeeded ({:?}) on: {:?}", 
                    e1, v2, s);
            }
        }
    }
});

Corpus Management

Seeding the Corpus

# Create corpus directory
<span class="hljs-built_in">mkdir -p fuzz/corpus/parse_config

<span class="hljs-comment"># Add seed inputs
<span class="hljs-built_in">echo <span class="hljs-string">'{"name": "test", "timeout": 5000}' > fuzz/corpus/parse_config/seed1
<span class="hljs-built_in">echo <span class="hljs-string">'{}' > fuzz/corpus/parse_config/seed2
<span class="hljs-built_in">printf <span class="hljs-string">'\x00\x01\x02' > fuzz/corpus/parse_config/binary_seed

Minimizing Corpus

# cargo-fuzz corpus minimization
cargo +nightly fuzz cmin parse_config

This runs all corpus items and removes those with duplicate coverage, keeping only the unique ones.

Minimizing a Crash Input

# Minimize a crashing input to the smallest possible reproducer
cargo +nightly fuzz tmin parse_config \
    fuzz/artifacts/parse_config/crash-abc123...

Sanitizers with cargo-fuzz

cargo-fuzz supports all major sanitizers:

# AddressSanitizer (default in cargo-fuzz)
cargo +nightly fuzz run parse_config

<span class="hljs-comment"># MemorySanitizer (uninitialized memory reads)
cargo +nightly fuzz run parse_config \
    --sanitizer=memory

<span class="hljs-comment"># UndefinedBehaviorSanitizer (catches UB in unsafe code)
cargo +nightly fuzz run parse_config \
    --sanitizer=undefined

<span class="hljs-comment"># No sanitizer (fastest, for logic bugs only)
cargo +nightly fuzz run parse_config \
    --sanitizer=none

ASAN is enabled by default. For unsafe code, also run with UBSan to catch undefined behavior.

Checking for unsafe Panics

Rust's unsafe blocks bypass safety checks. Fuzz them specifically:

#![no_main]
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
    if data.len() < 4 {
        return;
    }
    
    // Test unsafe parsing code
    let result = unsafe {
        // Your unsafe code here
        my_unsafe_parser(data.as_ptr(), data.len())
    };
    
    // Verify the result makes sense
    assert!(result >= 0 || result == -1, 
        "unexpected error code: {}", result);
});

CI Integration

# .github/workflows/fuzz.yml
name: Fuzz

on:
  push:
    branches: [main]
  schedule:
    - cron: '0 4 * * *'

jobs:
  fuzz:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@nightly
      
      - name: Install cargo-fuzz
        run: cargo install cargo-fuzz
      
      - name: Fuzz parse_config (2 minutes)
        run: cargo +nightly fuzz run parse_config -- -max_total_time=120
      
      - name: Fuzz serde_roundtrip (2 minutes)
        run: cargo +nightly fuzz run fuzz_serde -- -max_total_time=120
      
      - name: Upload artifacts on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: fuzz-artifacts
          path: fuzz/artifacts/

Listing and Managing Targets

# List all fuzz targets
cargo +nightly fuzz list

<span class="hljs-comment"># Show coverage for a target
cargo +nightly fuzz coverage parse_config

<span class="hljs-comment"># View coverage report
cargo +nightly fuzz coverage parse_config
llvm-cov show target/x86_64-unknown-linux-gnu/coverage/... --format=html > coverage.html

cargo-fuzz vs Other Rust Fuzzers

Tool Approach Best For
cargo-fuzz libFuzzer-based Most Rust fuzzing, best ecosystem
honggfuzz-rs HonggFuzz-based Alternative mutation engine
afl.rs AFL++-based Process isolation, different coverage
proptest Property-based Logical properties, readable

cargo-fuzz is the standard choice — best maintained, best community support.

Beyond Fuzzing: Production Monitoring

cargo-fuzz finds panics and logic bugs in your Rust library code. Production Rust services fail for different reasons: service dependencies, configuration errors, operational issues at scale.

HelpMeTest runs continuous end-to-end tests against your live Rust services 24/7. Write tests in plain English — no Rust code required. Catches the failures that cargo-fuzz can't: slow APIs, broken integrations, workflow regressions that only appear with real user data.

Summary

Rust's safety guarantees don't make fuzzing unnecessary — they shift the focus from memory safety to logic correctness. cargo-fuzz makes this easy:

  1. cargo fuzz init + cargo fuzz add <target> — setup in 2 minutes
  2. Use fuzz_target!(|data: &[u8]| for parsers; fuzz_target!(|value: MyStruct| with arbitrary for business logic
  3. Run with cargo +nightly fuzz run <target> -- -max_total_time=300
  4. Failures produce minimizable, reproducible crash inputs
  5. Commit crash inputs as regression tests

The arbitrary crate is the secret weapon — it enables fuzzing of any Rust type, including complex business logic structs, without writing custom generators. If your type can be #[derive(Arbitrary)], it can be fuzzed.

Read more