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
unsafecode bugs: Memory corruption inunsafeblocks- 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-fuzzVerify:
cargo fuzz --versionSetting Up a Fuzz Target
Initialize
# In your project directory
cargo fuzz initThis creates:
fuzz/
├── Cargo.toml
└── fuzz_targets/
└── fuzz_target_1.rsAdd a New Target
cargo fuzz add parse_configCreates 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_fileOutput
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_seedMinimizing Corpus
# cargo-fuzz corpus minimization
cargo +nightly fuzz cmin parse_configThis 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=noneASAN 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.htmlcargo-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:
cargo fuzz init+cargo fuzz add <target>— setup in 2 minutes- Use
fuzz_target!(|data: &[u8]|for parsers;fuzz_target!(|value: MyStruct|witharbitraryfor business logic - Run with
cargo +nightly fuzz run <target> -- -max_total_time=300 - Failures produce minimizable, reproducible crash inputs
- 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.