Skip to main content
All articles
Tutorial
June 10, 202611 min read

Fuzzing a Rust Crate End-to-End with cargo-fuzz

A complete walkthrough: install cargo-fuzz, write a fuzz target against a real Rust crate, run it, triage a crash, minimise the reproducer, ship a fix.

Fuzzing a Rust crate end-to-end with cargo-fuzz takes under an hour from a cold start. This walkthrough uses a small URL parser crate as the target — a realistic class of attack surface that is easy to explain and genuinely rewarding to fuzz. We will install cargo-fuzz, write a fuzz_target! harness, build with sanitizers, run the fuzzer for five minutes, read the crash, minimize the reproducer, patch the bug, and confirm the fix. The final section covers moving the harness to Fuzze.rs for continuous fuzzing.

1. Installing cargo-fuzz

cargo-fuzz is a Cargo subcommand that wraps libFuzzerfor Rust. It requires nightly Rust because it uses unstable compiler flags to enable coverage instrumentation (-Z sanitizer=address, -C passes=sancov-module). The nightly toolchain is only needed for building fuzz targets — your crate's production code stays on stable.

# Install cargo-fuzz (requires nightly Rust)
rustup install nightly
cargo +nightly install cargo-fuzz

# Initialize fuzz directory in your crate
cd url-lite
cargo fuzz init

# Generate a new named fuzz target
cargo fuzz add fuzz_url_parse

# Build the fuzz target with AddressSanitizer
# -Z sanitizer=address is set automatically by cargo-fuzz on nightly
cargo +nightly fuzz build fuzz_url_parse

After cargo fuzz init your project grows a fuzz/subdirectory containing its own Cargo.toml and a fuzz_targets/ directory. This is a separate Cargo workspace member so it does not affect your crate's normal build or dependency resolution.

2. Project Layout

The top-level crate Cargo.toml needs no changes. The fuzz workspace crate fuzz/Cargo.toml lists libfuzzer-sys as a dependency (cargo-fuzz manages this) and declares each fuzz target as a [[bin]]:

[package]
name = "url-lite-fuzz"
version = "0.0.0"
publish = false
edition = "2021"

[package.metadata]
cargo-fuzz = true

[dependencies]
libfuzzer-sys = "0.4"

[dependencies.url-lite]
path = ".."

# Prevent this from interfering with caching of the workspace
[workspace]
members = ["."]

[[bin]]
name = "fuzz_url_parse"
path = "fuzz_targets/fuzz_url_parse.rs"
test = false
doc = false

The crate under test (url-lite) is listed as a path dependency so the fuzz build always links against the current working tree, not a published crate version. This is important: you want the fuzzer to find bugs in the code you are actually changing, not a stale published version.

3. Writing the Fuzz Target

The fuzz target lives in fuzz/fuzz_targets/fuzz_url_parse.rs. The fuzz_target! macro defines the entry point that libFuzzer calls for each generated input. Keep the harness thin: its job is to convert the raw byte slice into the appropriate input type and call the code under test.

#![no_main]
use libfuzzer_sys::fuzz_target;
use url_lite::Url;

fuzz_target!(|data: &[u8]| {
    // Only process valid UTF-8 — filter at the harness boundary
    if let Ok(s) = std::str::from_utf8(data) {
        // The interesting call: parse arbitrary strings as URLs
        if let Ok(url) = Url::parse(s) {
            // Round-trip invariant: re-serializing a parsed URL should
            // always produce a string that re-parses to the same URL.
            // Violations here indicate a canonicalization bug.
            let serialized = url.to_string();
            let reparsed = Url::parse(&serialized)
                .expect("round-trip parse failed");
            assert_eq!(url.scheme(), reparsed.scheme());
            assert_eq!(url.host_str(), reparsed.host_str());
            assert_eq!(url.port(), reparsed.port());
            assert_eq!(url.path(), reparsed.path());
        }
    }
});

Two design choices here are worth explaining. First, filtering invalid UTF-8 at the harness boundary rather than inside the crate: if Url::parse were to panic on non-UTF-8 input that's a valid finding, but since the function signature takes a &str the byte-to-string conversion is the right place to draw the input boundary. Second, the round-trip invariant check: beyond raw panics, we assert that a successfully-parsed URL serializes and re-parses to an identical result. This is a semantic oracle — it catches bugs that don't crash but produce incorrect output. Invariant-based fuzzing like this finds a class of correctness bugs that a pure crash-seeking approach misses entirely.

4. Building with Sanitizers

cargo fuzz build compiles the fuzz target with AddressSanitizer enabled automatically. You do not need to pass any extra flags — this is one of cargo-fuzz's most useful ergonomic improvements over calling rustc directly. Under the hood it passes -Z sanitizer=address to rustc and links against libFuzzer's runtime.

If you want to build with UBSan as well, pass --sanitizer address,undefined:

cargo +nightly fuzz build fuzz_url_parse -- -sanitizer=address,undefined

Rust's memory safety guarantees eliminate the entire class of heap corruption bugs that ASan catches in C/C++ — but safe Rust code can still have integer overflows, panics from index-out-of-bounds, and logic errors that trigger the round-trip invariant we added. ASan on a Rust fuzzer target is most valuable for catching bugs in unsafe blocks or in C/C++ FFI dependencies linked into the crate.

5. Running Locally: What to Watch

# Run the fuzzer (Ctrl-C to stop, or pass -max_total_time=300 for 5 min)
cargo +nightly fuzz run fuzz_url_parse -- -max_total_time=300

# With a seed corpus directory
cargo +nightly fuzz run fuzz_url_parse corpus/fuzz_url_parse/ -- \
  -max_total_time=300 -print_final_stats=1

The libFuzzer status lines emitted during a run decode as follows:

INFO: Running with entropic power schedule (0xFF, 100).
INFO: Seed: 1683042907
INFO: Loaded 1 modules   (15483 inline 8-bit counters): 15483 [0x5603d8c1b000, 0x5603d8c1ec3b)
INFO: Loaded 1 PC tables (15483 PCs): 15483 [0x5603d8c1ec40,0x5603d8c57060)
INFO: 0 files found in corpus/fuzz_url_parse/
INFO: -max_len is not provided; libFuzzer will not generate inputs larger than 4096 bytes
INFO: A corpus is not provided, starting from an empty corpus
#2     INITED cov: 89 ft: 90 corp: 1/1b exec/s: 0 rss: 35Mb
#3     NEW    cov: 91 ft: 92 corp: 2/3b lim: 4096 exec/s: 0 rss: 35Mb L: 2/2 MS: 1 CopyPart-
#7     NEW    cov: 95 ft: 97 corp: 3/7b lim: 4096 exec/s: 0 rss: 35Mb L: 4/4 MS: 2 CopyPart-InsertByte-
...
#38291 NEW    cov: 312 ft: 489 corp: 81/2Kb lim: 398 exec/s: 38291 rss: 62Mb
#65536 pulse  cov: 318 ft: 503 corp: 87/3Kb lim: 652 exec/s: 32768 rss: 64Mb
  • cov: number of unique code coverage edges reached. Should grow steadily in the first minutes.
  • ft: feature count — a finer-grained coverage signal than edges alone. Growth here indicates the fuzzer is finding new behaviors.
  • corp: number of inputs in the corpus and their total byte size. Growth means the fuzzer is keeping new interesting inputs.
  • exec/s: executions per second. For a pure-Rust URL parser with no I/O, expect 30,000–80,000 ex/s on a modern laptop. Below 10,000 ex/s suggests a slow target or large input sizes.
  • rss: RSS memory. Should plateau after the first few minutes; runaway growth indicates a memory leak in the target.

After a few minutes the coverage growth typically slows as the easy paths are exhausted. Let the fuzzer run until it stagnates (coverage flat for 30+ minutes) or until you get a crash. For this URL parser, a crash surfaces within the first five minutes on most machines.

6. The Crash: Reading the Output

When libFuzzer finds an input that triggers a panic or sanitizer violation, it prints the crash report and writes the input to a file prefixed crash-:

thread 'main' panicked at 'round-trip parse failed: ParseError(InvalidPort)',
fuzz_targets/fuzz_url_parse.rs:18:14
note: run with RUST_BACKTRACE=1 environment variable to display a backtrace
==24601==ERROR: libFuzzer: deadly signal
    #0 0x5603d7f3c8f0 in __sanitizer_print_stack_trace
    #1 0x5603d7e44b80 in fuzzer::PrintStackTrace()
    #2 0x5603d7e29700 in fuzzer::Fuzzer::CrashCallback()
    #3 0x7f8a1b44a3bf  (/lib/x86_64-linux-gnu/libpthread.so.0+0x143bf)
    #4 0x7f8a1b24218b in raise /build/glibc-eX1tMB/glibc-2.31/signal/../sysdeps/unix/sysv/linux/raise.c:51
    #5 0x5603d8144ce2 in url_lite::Url::to_string::h... url-lite/src/lib.rs:214
    #6 0x5603d80f1040 in fuzz_url_parse::_::__libfuzzer_sys_run fuzz_targets/fuzz_url_parse.rs:15
    ...

artifact_prefix='./'; Test unit written to ./crash-a3f8c4e1b2d09571d9843fb7ceaa2110
Base64: aHR0cDovL2V4YW1wbGUuY29tOjA=

The first line is the Rust panic message from the harness assertion: round-trip parse failed: ParseError(InvalidPort). This is our semantic oracle firing — the URL parsed successfully, but serializing it and re-parsing failed. The stack trace confirms the panic is at fuzz_url_parse.rs:18, which is the .expect("round-trip parse failed") call. Frame #5 points to url-lite/src/lib.rs:214 — the to_string implementation, which is where the serialization bug lives.

The crashing input is Base64-encoded at the bottom of the report: aHR0cDovL2V4YW1wbGUuY29tOjA= decodes to http://example.com:0. Port zero is technically valid per RFC 3986 but our serializer emitted it and the parser rejected it on re-parse. That's the bug.

7. Minimising the Reproducer

The crashing input from the fuzzer may be dozens or hundreds of bytes long — the result of many mutations stacked on top of each other. cargo fuzz tminruns the input through libFuzzer's built-in reproducer minimization to find the smallest input that still triggers the same crash:

# Minimise the crashing input
cargo +nightly fuzz tmin fuzz_url_parse ./crash-a3f8c4e1b2d09571d9843fb7ceaa2110

# The minimised reproducer is written to minimized-from-<hash>
# Inspect it
xxd minimized-from-a3f8c4e1b2d09571d9843fb7ceaa2110
# Output: 68 74 74 70 3a 2f 2f 78 3a 30  |http://x:0|
# The crash reproduces with just 10 bytes: "http://x:0"

Minimization is important for two reasons. First, it makes the bug easier to understand: http://x:0 is immediately obvious; a 200-byte mutation of a realistic URL is not. Second, the minimized input is a better regression test seed — it exercises the specific code path with minimal noise.

8. Patching the Crate

With the minimized reproducer in hand, the fix is straightforward. The serializer was emitting port zero in the output URL; the parser was rejecting port zero as invalid. The correct fix is to suppress emission of port zero during serialization, since port zero has no meaningful interpretation as a URL component:

diff --git a/src/lib.rs b/src/lib.rs
index 4a8c21b..7f3d09e 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -211,7 +211,11 @@ impl fmt::Display for Url {
          if let Some(host) = &self.host {
              write!(f, "{}", host)?;
          }
-         if let Some(port) = self.port {
+         // Only emit the port if it differs from the scheme's default.
+         // Port 0 is valid per RFC 3986 but serializing it and re-parsing
+         // trips a "port is 0 which is not valid" check in the parser.
+         // Omit port 0 in serialization to maintain the round-trip invariant.
+         if let Some(port) = self.port.filter(|&p| p != 0) {
              write!(f, ":{}", port)?;
          }

The patch adds a .filter(|&p| p != 0) to the port serialization path. A URL with explicit port 0 round-trips correctly after the fix because Url::parse("http://x:0") still succeeds (setting self.port = Some(0)), but to_string now omits the port, and the resulting "http://x/" re-parses to an equivalent URL with port() == None. The assertion in the harness is updated to accommodate this canonicalization: port 0 normalizes to absent.

9. Confirming the Fix

# Re-run the fuzzer against the patched crate to confirm no regression
cargo +nightly fuzz run fuzz_url_parse -- \
  -runs=500000 -print_final_stats=1

# No crashes found. Final stats:
# stat::number_of_executed_units: 500000
# stat::average_exec_per_sec:     44312
# stat::new_units_added:          234
# stat::slowest_unit_time_sec:    0

Re-run the fuzzer for 500,000 iterations — roughly ten minutes on a modern laptop — to confirm the crash is gone and no regression was introduced. Save the minimized crashing input as a corpus seed so future fuzzing runs start from a richer corpus:

# Add the minimized input to the persistent corpus
mkdir -p corpus/fuzz_url_parse
cp minimized-from-a3f8c4e1b2d09571d9843fb7ceaa2110 corpus/fuzz_url_parse/

Also add a unit test reproducing the exact input so the test suite catches any future regression:

#[test]
fn port_zero_round_trips() {
    let url = Url::parse("http://x:0").unwrap();
    let serialized = url.to_string();
    Url::parse(&serialized).expect("round-trip should not fail");
}

10. Moving to Fuzze.rs for Continuous Fuzzing

Running cargo-fuzz on a laptop finds bugs quickly but stops the moment you close the terminal. Continuous fuzzing on Fuzze.rs keeps the campaign running around the clock, accumulates the corpus across runs, and files crash reports to your issue tracker automatically.

The Fuzze.rs workflow for a Rust target:

  1. Build a Docker image that compiles and runs your fuzz target. The image entry point should be the compiled fuzzer binary. A minimal Dockerfile for a Rust target looks like:
    FROM rust:1-slim
    RUN rustup install nightly && \
        cargo +nightly install cargo-fuzz
    WORKDIR /src
    COPY . .
    RUN cargo +nightly fuzz build fuzz_url_parse
    ENTRYPOINT ["/src/target/x86_64-unknown-linux-gnu/release/fuzz_url_parse"]
  2. Upload the image and submit a job with "executor": "libfuzzer". The platform passes libFuzzer flags through the entry point — corpus management, deduplication, and crash filing are handled by the platform.
  3. Upload your existing corpus/fuzz_url_parse/ as a seed corpus so the continuous run starts from the coverage you already accumulated locally rather than from scratch.

The same harness you developed and tested locally runs unchanged on the platform. Fuzze.rs handles scaling to multiple cores, corpus persistence between runs, and crash deduplication so your issue tracker receives one report per unique bug rather than thousands of duplicate crash files.

For Rust targets specifically, run the continuous campaign on the unsafe-heavy modules first — the parser, the serializer, any FFI boundary. Safe Rust code with no unsafe blocks still benefits from fuzzing (invariant checks, panic discovery, logic errors) but the highest-value targets are those with manual memory management or direct C interop.

Ready to start fuzzing?

Skip the infrastructure setup. Fuzze.rs runs AFL++, libFuzzer, Centipede, and Honggfuzz at scale — just upload your binary and get crash reports in real time. First month 50% off.