moodmosaic

Coverage-guided fuzzing in Rust and Go

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 Evolution

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: cargo-fuzz and libFuzzer

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: Built-in Fuzzing Support

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

Structured Input Generation

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.

What Fuzzers Find Now

In memory-safe languages, fuzzers typically discover:

Limits of Coverage-Guided Fuzzing

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.

Integration into CI

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.


Next: Property-based testing in Rust and Go