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-fuzzVerify:
cargo fuzz --versionSetting 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.rsThe 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_1Corpus 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=60Add 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:5This report tells you:
heap-buffer-overflow: reading past the end of a heap allocationREAD of size 4: tried to read 4 bytes- Stack trace: the bug is in
parse_headerat line 89, called fromparseat line 45 - Allocation site: the buffer was allocated in
parse_headerat 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-abcdef1234Common 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 1Regression 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.