cargo-fuzz + libFuzzer: Rust Continuous Fuzzing in GitHub Actions

cargo-fuzz + libFuzzer: Rust Continuous Fuzzing in GitHub Actions

Rust's ownership system and borrow checker eliminate entire categories of memory safety bugs at compile time — but they cannot prevent all bugs. Logic errors, integer overflows (in release mode), incorrect invariant assumptions, panics on unexpected input, and subtle algorithmic mistakes are all still possible. cargo-fuzz brings libFuzzer's coverage-guided fuzzing to Rust projects, and it is remarkably easy to set up. Unlike fuzzing in C, you do not need to configure sanitizers separately — Rust's build toolchain handles it. This post covers everything from initial setup to running continuous fuzzing in GitHub Actions.

Why Fuzz Rust Code

Rust gives you strong guarantees, but those guarantees do not cover everything:

Panics: Rust panics on integer overflow in debug mode, on array out-of-bounds access, on .unwrap() on a None, on .expect() on an Err. In production code that processes untrusted input, panics are denial-of-service vulnerabilities. Fuzzing finds inputs that trigger panics.

Logic errors: The borrow checker ensures memory safety but not correctness. A parser that mishandles a specific byte sequence, a serializer that produces corrupted output for certain inputs, or a cryptographic implementation with a subtle flaw — these require fuzzing to find reliably.

unsafe blocks: Rust's safety guarantees do not extend to unsafe code. Libraries that use unsafe for performance — many parsing libraries do — can have memory corruption bugs that fuzzing with AddressSanitizer will catch.

Real examples: cargo-fuzz has found bugs in png (memory corruption), bincode (panics on malformed input), serde_json (recursion overflow), the Rust standard library itself, and countless crate parsing libraries.

Installation

cargo-fuzz requires nightly Rust (for the unstable libfuzzer-sys features) and a Linux or macOS host:

# Switch to nightly (only for fuzzing, your project can stay stable)
rustup install nightly

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

Verify:

cargo fuzz --version

Setting Up Your First Fuzz Target

In your Rust project root:

# Initialize fuzzing infrastructure
cargo fuzz init

<span class="hljs-comment"># This creates:
<span class="hljs-comment"># fuzz/
<span class="hljs-comment">#   Cargo.toml
<span class="hljs-comment">#   fuzz_targets/
<span class="hljs-comment">#     fuzz_target_1.rs

The generated fuzz_target_1.rs:

#![no_main]
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
    // Fuzz code goes here
});

Replace with your actual code:

#![no_main]
use libfuzzer_sys::fuzz_target;
use mylib::parse_config;

fuzz_target!(|data: &[u8]| {
    if let Ok(s) = std::str::from_utf8(data) {
        // parse_config should handle any string without panicking
        let _ = parse_config(s);
    }
});

Run it:

# Start fuzzing (runs indefinitely until stopped or a crash is found)
cargo fuzz run fuzz_target_1

<span class="hljs-comment"># Fuzz for 60 seconds
cargo fuzz run fuzz_target_1 -- -max_total_time=60

<span class="hljs-comment"># With a seed corpus directory
cargo fuzz run fuzz_target_1 fuzz/corpus/fuzz_target_1/

Structured Fuzzing with arbitrary

The arbitrary crate provides structured fuzzing — deriving fuzz inputs that match your data types automatically:

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

Define your input type with #[derive(Arbitrary)]:

// In your library crate:
use arbitrary::Arbitrary;

#[derive(Arbitrary, Debug)]
pub struct Config {
    pub host: String,
    pub port: u16,
    pub max_connections: u32,
    pub timeout_ms: u64,
    pub protocol: Protocol,
}

#[derive(Arbitrary, Debug)]
pub enum Protocol {
    Http,
    Https,
    Http2,
}

The fuzz target then uses the type directly:

#![no_main]
use libfuzzer_sys::fuzz_target;
use arbitrary::Arbitrary;
use mylib::{Config, ConnectionPool};

fuzz_target!(|config: Config| {
    // The fuzzer generates valid Config structs automatically
    let pool = ConnectionPool::new(config);
    // Test various operations
    let _ = pool.health_check();
    drop(pool);
});

This is dramatically more effective than raw byte fuzzing because the fuzzer generates semantically valid inputs that reach deeper into your code's logic, rather than spending most time being rejected by input validation.

Corpus Management

The corpus is your fuzzer's memory — inputs that cover different code paths are preserved and used as seeds for future mutations. Good corpus management is key to effective fuzzing.

Initialize a corpus from your test fixtures:

mkdir -p fuzz/corpus/fuzz_target_1

<span class="hljs-comment"># Copy existing test inputs
<span class="hljs-built_in">cp tests/fixtures/*.bin fuzz/corpus/fuzz_target_1/
<span class="hljs-built_in">cp tests/fixtures/*.json fuzz/corpus/fuzz_target_1/

<span class="hljs-comment"># Minimize the corpus (remove redundant inputs)
cargo fuzz cmin fuzz_target_1

Corpus minimization removes inputs that are covered by other inputs in the corpus, reducing size while maintaining coverage. Run it periodically:

# Minimize corpus before saving to CI cache
cargo fuzz cmin fuzz_target_1 -- -max_total_time=60

Add interesting inputs as you find them:

# After fixing a crash, add the crash input to the corpus
<span class="hljs-comment"># so the fuzzer never regresses on it
<span class="hljs-built_in">cp artifacts/fuzz_target_1/crash-abcdef fuzz/corpus/fuzz_target_1/

Interpreting AddressSanitizer Output

When cargo-fuzz finds a bug, it writes a reproducer to artifacts/fuzz_target_1/ and prints an ASan report:

==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x...
READ of size 4 at 0x... thread T0
    #0 0x... in mylib::parser::parse_header src/parser.rs:89:5
    #1 0x... in mylib::parser::parse src/parser.rs:45:12
    #2 0x... in fuzz_target_1::_::fuzz_target fuzz/fuzz_targets/fuzz_target_1.rs:7:5
    #3 0x... in rust_fuzzer_test_input ...

Address 0x... is 0 bytes to the right of 16-byte region [...,...)
  allocated by thread T0 here:
    #0 0x... in malloc ...
    #1 0x... in alloc::alloc::alloc ...
    #2 0x... in mylib::parser::parse_header src/parser.rs:82:15

SUMMARY: AddressSanitizer: heap-buffer-overflow src/parser.rs:89:5

This report tells you:

  • heap-buffer-overflow: reading past the end of a heap allocation
  • READ of size 4: tried to read 4 bytes
  • Stack trace: the bug is in parse_header at line 89, called from parse at line 45
  • Allocation site: the buffer was allocated in parse_header at line 82

Reproduce the crash:

# The crash reproducer is in:
<span class="hljs-built_in">ls artifacts/fuzz_target_1/crash-*

<span class="hljs-comment"># Run it to reproduce
cargo fuzz run fuzz_target_1 artifacts/fuzz_target_1/crash-abcdef1234

<span class="hljs-comment"># Or run directly to get the full ASan output:
RUSTFLAGS=<span class="hljs-string">"-Zsanitizer=address" \
cargo +nightly run \
  --manifest-path fuzz/Cargo.toml \
  --bin fuzz_target_1 -- \
  artifacts/fuzz_target_1/crash-abcdef1234

Common Rust Panic Patterns That Fuzzing Finds

Integer overflow in release mode:

// This panics in debug, silently overflows in release
// AddressSanitizer with integer overflow check catches it
fn compute_offset(base: usize, stride: usize, index: usize) -> usize {
    base + stride * index  // overflow possible
}

Fix with checked arithmetic:

fn compute_offset(base: usize, stride: usize, index: usize) -> Option<usize> {
    stride.checked_mul(index)?.checked_add(base)
}

Slice index out of bounds:

fn get_item(data: &[u8], idx: usize) -> u8 {
    data[idx]  // panics if idx >= data.len()
}

Fix:

fn get_item(data: &[u8], idx: usize) -> Option<u8> {
    data.get(idx).copied()
}

Recursive parsing without depth limit:

fn parse_nested(input: &str, depth: usize) -> Result<Value, ParseError> {
    // No depth limit → stack overflow on deeply nested input
    if input.starts_with('[') {
        return Ok(Value::Array(parse_array(&input[1..], depth)?));
    }
    // ...
}

Fix:

const MAX_DEPTH: usize = 128;

fn parse_nested(input: &str, depth: usize) -> Result<Value, ParseError> {
    if depth > MAX_DEPTH {
        return Err(ParseError::TooDeep);
    }
    // ...
}

GitHub Actions Workflow

name: Cargo Fuzz
on:
  schedule:
    - cron: '0 1 * * *'
  push:
    paths:
      - 'src/**'
      - 'fuzz/**'
  workflow_dispatch:
    inputs:
      duration:
        description: 'Fuzzing duration per target (seconds)'
        default: '300'
        required: false

jobs:
  fuzz:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Install nightly Rust
        uses: dtolnay/rust-toolchain@nightly
        with:
          components: rust-src
      
      - name: Install cargo-fuzz
        run: cargo install cargo-fuzz
      
      - name: Restore fuzz corpus
        uses: actions/cache@v3
        with:
          path: fuzz/corpus
          key: fuzz-corpus-${{ hashFiles('src/**') }}-${{ github.run_id }}
          restore-keys: |
            fuzz-corpus-${{ hashFiles('src/**') }}-
            fuzz-corpus-
      
      - name: Run fuzz targets
        run: |
          # Get list of fuzz targets
          TARGETS=$(cargo fuzz list)
          
          for target in $TARGETS; do
            echo "Fuzzing target: $target"
            
            # Create corpus dir if it doesn't exist
            mkdir -p fuzz/corpus/$target
            
            cargo fuzz run $target \
              fuzz/corpus/$target \
              -- \
              -max_total_time=${{ inputs.duration || 300 }} \
              -jobs=2 \
              -rss_limit_mb=2048
          done
      
      - name: Minimize corpus
        if: always()
        run: |
          for target in $(cargo fuzz list); do
            if [ -d "fuzz/corpus/$target" ]; then
              cargo fuzz cmin $target -- -max_total_time=60 || true
            fi
          done
      
      - name: Save updated corpus
        uses: actions/cache/save@v3
        if: always()
        with:
          path: fuzz/corpus
          key: fuzz-corpus-${{ hashFiles('src/**') }}-${{ github.run_id }}
      
      - name: Upload crash artifacts
        uses: actions/upload-artifact@v3
        if: failure()
        with:
          name: fuzz-crashes-${{ github.run_id }}
          path: fuzz/artifacts/
      
      - name: Check for crashes
        if: failure()
        run: |
          echo "Fuzzing found crashes! Check the 'fuzz-crashes' artifact."
          echo "To reproduce locally:"
          echo "  cargo fuzz run <target> fuzz/artifacts/<target>/<crash-file>"
          exit 1

Regression Tests from Crashes

Every crash found by fuzzing should become a permanent regression test:

// tests/fuzz_regressions.rs
// These inputs previously caused crashes — run them on every PR

#[test]
fn regression_empty_input() {
    let result = parse_config("");
    assert!(result.is_err(), "Empty input should return an error");
}

#[test]
fn regression_overflow_input() {
    // This 17-byte input triggered an integer overflow
    // Found by cargo-fuzz 2024-01-15
    let input = b"\xff\xff\xff\xff\x01\x00\x00\x00abc\x00\x00\x00\x00\x00\x00";
    let result = parse_config(std::str::from_utf8(input).unwrap_or(""));
    // Should return error, not panic or overflow
    assert!(result.is_err());
}

Running cargo-fuzz continuously with GitHub Actions, caching the corpus between runs, and converting crashes to regression tests creates a compounding security benefit. Each day's fuzzing builds on the previous day's corpus, reaching deeper code paths and finding subtler bugs. Over weeks and months, your fuzz corpus becomes an extremely comprehensive test suite derived entirely from automated exploration rather than manual test writing.

Rust's memory safety guarantees make cargo-fuzz results mostly logic bugs and panics rather than memory corruption — but those panics can still be denial-of-service vulnerabilities in production services. For any Rust library or service that handles untrusted input, continuous fuzzing with cargo-fuzz is a straightforward addition to your security testing pipeline.

Read more