This article is part of a four-part series on fuzzing in memory-safe languages.
Traditional fuzzers like AFL and libFuzzer have adapted to memory-safe targets, hunting for panics and logic errors instead of memory corruption.
Coverage-guided fuzzing revolutionized the field by using instrumentation to smartly explore program paths. Instead of blind mutation, fuzzers track which code branches they’ve exercised and prioritize inputs that reach new areas.
This approach proves especially valuable in Rust and Go, where crashes are rarer but logic bugs remain abundant.
Rust’s cargo-fuzz provides seamless integration with the Rust ecosystem.
cargo install cargo-fuzz
cargo fuzz init
cargo fuzz add parse_json
A typical fuzz target looks like:
use libfuzzer_sys::fuzz_target;
use serde_json;
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
let _ = serde_json::from_str::<serde_json::Value>(s);
}
});
The libFuzzer engine tracks code coverage and mutates inputs to maximize path exploration. It generates increasingly complex byte sequences, feeding them to the JSON parser until it finds inputs that cause panics or assertion failures.
Go 1.18 introduced native fuzzing support inspired by libFuzzer and minimal setup:
package fuzz
import (
"net/url"
"testing"
)
func FuzzParseURL(f *testing.F) {
f.Add("https://example.com/path?query=value")
f.Fuzz(func(t *testing.T, input string) {
u1, err := url.Parse(input)
if err != nil {
return
}
u2, err := url.Parse(u1.String())
if err != nil {
t.Errorf("Re-parse failed for %q: %v", u1.String(), err)
return
}
if u1.String() != u2.String() {
t.Errorf("Round-trip mismatch: %q vs %q", u1.String(), u2.String())
}
})
}
Run with: go test -fuzz=FuzzParseURL
Modern fuzzers handle complex data structures through custom generators.
Rust’s arbitrary
crate enables structured fuzzing:
use arbitrary::Arbitrary;
#[derive(Arbitrary, Debug)]
struct Config {
timeout: u32,
retries: u8,
endpoints: Vec<String>,
}
fuzz_target!(|config: Config| {
validate_config(&config);
});
This generates valid Config
instances rather than raw bytes, reaching deeper
program logic.
In memory-safe languages, fuzzers typically discover:
panic!()
calls or unwrap()
on None
/Err
.Coverage-guided fuzzers excel at finding shallow bugs quickly because random mutations and branch-coverage feedback are effective at exploring simple decision points. However, they may struggle with deep program states that require a series of specific inputs to reach.
For example, a parser might only exercise certain logic after reading a valid header, followed by a particular sequence of tokens. Random mutations rarely produce these multi-step scenarios, so fuzzers can get stuck exploring the “shallow end” of the state space.
To overcome this, you often combine fuzzing with techniques like structured input generators, grammar-based fuzzing, or state-machine models that encode how to reach deeper logic. These approaches guide the fuzzer through the layers of input validity, unlocking code paths that blind mutation would almost never reach.
For CI integration, fuzzers often run with time limits:
# Rust: 60-second fuzzing run
cargo fuzz run parse_json -- -max_total_time=60
# Go: 30-second fuzzing run
go test -fuzz=FuzzParseURL -fuzztime=30s
Longer campaigns run overnight or in dedicated fuzzing infrastructure, building corpus files that capture interesting inputs for regression testing.