Fuzz Testing - External Tooling
Coverage-guided fuzz testing (or fuzzing) feeds millions of mutated byte sequences into a target function while monitoring compiler-inserted code-coverage instrumentation. Whenever a mutated input reaches a previously unexplored branch (if, match, or loop edge), the fuzzer saves that input to its corpus and mutates it further to uncover panics, integer overflows, out-of-memory allocations, infinite loops, and unsafe memory errors.
Coverage-Guided Fuzzing Loop (`cargo-fuzz` / LLVM libFuzzer):
+---> [ Seed Corpus ]
| |
| v
| [ Mutate Bytes ] (bit flips, magic ints, dictionary tokens)
| |
| v
| [ fuzz_target!(|data: &[u8]| { ... }) ]
| |
| +------+-------------------------------+
| | |
| Panics / OOM / Sanitizer Fault? New Branch Coverage Reached?
| | |
| v +-- YES --> Add to Corpus --+
| Save crash artifact to |
| `fuzz/artifacts/<target>/crash-*` <-----------------------------------+
Why Fuzzing Matters Even in Safe Rust
Although safe Rust eliminates buffer overflows and use-after-free bugs, untrusted network payloads or malformed files can still trigger severe denial-of-service vulnerabilities in safe code:
- Unchecked slice indexing (
&data[0..len]) panicking whenlen > data.len(). - UTF-8 char boundary panics (
&text[0..4]) when slicing multi-byte UTF-8 characters. - Arithmetic overflow panics (or logic bugs from wrapping in release mode).
- Allocation bombs (OOM): Reading an untrusted 4-byte length header (
0x7FFF_FFFF) and immediately callingVec::with_capacity(len)before verifying the remaining input size. - Stack overflow or quadratic CPU hangs on deeply nested brackets (
[[[[...).
Setting Up cargo-fuzz (libfuzzer-sys)
The standard Rust fuzzing workflow uses cargo-fuzz, which wraps LLVM's libFuzzer engine and AddressSanitizer (ASAN):
# 1. Install cargo-fuzz
cargo install cargo-fuzz
# 2. Initialize the `fuzz/` directory in your crate
cargo fuzz init
# 3. Add a named fuzz target
cargo fuzz add packet_parser
# 4. Run the fuzzer (requires nightly toolchain for sanitizer instrumentation)
cargo +nightly fuzz run packet_parser
A fuzz target in fuzz/fuzz_targets/packet_parser.rs receives a raw &[u8] slice (or a structured type via the arbitrary crate's #[derive(Arbitrary)]) and must never panic on any input:
// Inside `fuzz/fuzz_targets/packet_parser.rs`:
// #![no_main]
// use libfuzzer_sys::fuzz_target;
//
// fuzz_target!(|data: &[u8]| {
// let _ = my_crate::parse_packet(data);
// });
Hardening a Parser Against Fuzz-Discovered Bugs
Consider a binary packet parser. A naive parser might index slices directly or allocate based on an untrusted header; a fuzz-hardened parser uses .get(...), checked_add, and a strict MAX_PAYLOAD cap, accompanied by a unit test that replays historical crash inputs:
#[derive(Debug, PartialEq, Eq)]
pub struct Packet<'a> {
pub version: u8,
pub payload: &'a [u8],
}
#[derive(Debug, PartialEq, Eq)]
pub enum PacketError {
TruncatedHeader,
UnsupportedVersion(u8),
PayloadTooLarge(usize),
TruncatedPayload { expected: usize, available: usize },
}
const MAX_PAYLOAD_LEN: usize = 4096;
pub fn parse_packet(data: &[u8]) -> Result<Packet<'_>, PacketError> {
// Never index `data[0..3]` directly; use `.get()` so empty slices cannot panic
let header = data.get(0..3).ok_or(PacketError::TruncatedHeader)?;
let version = header[0];
if version != 1 {
return Err(PacketError::UnsupportedVersion(version));
}
let len = u16::from_be_bytes([header[1], header[2]]) as usize;
if len > MAX_PAYLOAD_LEN {
return Err(PacketError::PayloadTooLarge(len));
}
let end = 3usize.checked_add(len).ok_or(PacketError::PayloadTooLarge(len))?;
let payload = data.get(3..end).ok_or(PacketError::TruncatedPayload {
expected: len,
available: data.len() - 3,
})?;
Ok(Packet { version, payload })
}
fn main() {
// Replay edge-case inputs that commonly crash unhardened parsers
let corpus: &[&[u8]] = &[
b"", // Empty input
b"\x01\xFF\xFF", // 65,535-byte length claim with 0 payload bytes
b"\x01\x00\x05hello", // Valid 5-byte packet
b"\x01\x00\x05hel", // Truncated payload
b"\x99\x00\x00", // Invalid version
];
for sample in corpus {
// Must never panic on any byte sequence
let _ = parse_packet(sample);
}
assert_eq!(
parse_packet(b"\x01\x00\x05hello"),
Ok(Packet {
version: 1,
payload: b"hello",
})
);
println!("Fuzz-hardened parser handled all {} corpus inputs without panicking", corpus.len());
}
Fuzz-hardened parser handled all 5 corpus inputs without panicking
Whenever cargo-fuzz discovers a crash artifact in fuzz/artifacts/<target>/crash-<hash>, convert those bytes into a permanent regression unit test (#[test] fn regression_crash_<hash>()) so standard cargo test runs verify the fix on every commit.