Viscosity
Review
Part XI · Viscosity in Rust · Day 49

193. Fuzzing the Rust engine

Every fixture passed, and random programs still found five bugs, all of them in both engines. A seeded generator, two oracles, a reducer, and an honest answer to “is it coverage-guided?”

About 18 minutes, including the exercises and quiz
By the end you will be able to
  • Contrast coverage-guided fuzzing with the generator Viscosity uses, and say what each needs.
  • Run scripts/fuzz.mjs, replay one program with --only, and read a failure.
  • Explain line-based delta debugging, and implement it in Rust.
  • Read a coverage measurement, and say what it does and doesn't tell you.

Five bugs past every test

When the fuzzer was written, cargo test was green: 186 of 186 parity fixtures, the node differential, every unit test. Then random programs found five bugs. Here is one, as the fuzzer’s reducer left it (reformatted, and run with node 22):

function local1(m) {
  const ls = [];
  for (let i = 0; i < 64; i++)
    ls.push({ a: i, b: i });
  let t = 0;
  for (let i = 0; i < ls.length; i++) {
    ls[i].b += m;
    t += ls[i].b;
  }
  ls[1].a = (ls.push({ a: 0, b: 0 }), 7);
  t += ls[1].a;
  return t;
}
console.log(local1(0), local1(1));

The original prints 2023 2087. Compiled by the engine as it was before the fix, it printed 2017 2081. The store of 7 was lost. The compiled line was:

ls.a[__vsc_bc(ls, 1, 'a', true)] =
  (ls.push(0, 0), 7);

JavaScript evaluates ls.a before the right-hand side[4]. The 65th push fills the capacity of 64, grow() installs a new, larger column, and the 7 goes into the old one. Chapter 183 showed the fix: __vsc_set(ls, 'a', __vsc_bk(ls, 1), (ls.push(0, 0), 7), 'a'), which checks the index first and stores last.

Nobody writes that test, because nobody thinks of it. That is what fuzzing is for.

Intuition

Two ways to find the weak spot in a maze of corridors. You can hire a writer who invents plausible floor plans and walks each one. Or you can drop a marble, mark every corridor it rolls through, and keep nudging the marbles that reached a corridor nobody had marked.

Two ways to fuzz

Step throughTwo ways to fuzz
A coverage-guided loop with feedback, and Viscosity's generator loop without itcoverage-guided (AFL, libFuzzer)generation-based (fuzz.mjs)yes: keep it (feedback)no feedbackkept inputsmutaterun, countnew edge?seed (S, k)generateTS + Rustnode: bothdiffer?--reduce
A coverage-guided fuzzer starts from a few inputs, often just one. Each round it takes a kept input and changes it a little: flip a bit, insert a byte, copy a piece.
1/6

The second picture is coverage-guided fuzzing. AFL compiles the target with a few instructions at every branch: shared_mem[cur ^ prev]++, a counter per edge. An input whose run reaches a new edge is kept and mutated further; the rest are thrown away[1]. In Rust, cargo fuzz drives libFuzzer the same way: you write a fuzz_target! that takes &[u8], and it needs a nightly compiler for its sanitizer flags[2].

scripts/fuzz.mjs is the first picture. It is generation-based, like Csmith[3]: it knows the grammar and writes valid programs, and nothing from a run flows back.

Definition

Coverage-guided fuzzing mutates inputs and keeps those whose run reaches new coverage (AFL counts branch edges), so the corpus grows toward code no input has reached. A generation-based fuzzer writes each input from a grammar or model instead, with no feedback.

Here is the feedback at toy size. The “bug” needs the bytes FUZ. The blind fuzzer guesses three letters at a time. The guided one changes one byte of an input it kept, and keeps a new input only when it passes a check none passed before:

Try itA toy coverage-guided fuzzer
// The bug hides behind three checks.
// cov[k] records that check k passed.
fn target(b: &[u8], cov: &mut [bool])
    -> bool
{
    let want = b"FUZ";
    for k in 0..3 {
        if b[k] != want[k] {
            return false;
        }
        cov[k] = true;
    }
    true // the "crash"
}

// xorshift64: tiny and seeded
fn next(s: &mut u64) -> usize {
    *s ^= *s << 13;
    *s ^= *s >> 7;
    *s ^= *s << 17;
    *s as usize
}

fn letter(s: &mut u64) -> u8 {
    b'A' + (next(s) % 26) as u8
}

fn blind(s: &mut u64) -> usize {
    for n in 1..=1_000_000 {
        let b: Vec<u8> =
            (0..3).map(|_| letter(s))
                .collect();
        if target(&b, &mut [false; 3]) {
            return n;
        }
    }
    0
}

fn guided(s: &mut u64) -> usize {
    let mut seen = [false; 3];
    let start = b"AAA".to_vec();
    let mut kept = vec![start];
    for n in 1..=1_000_000 {
        // change a byte of a kept input
        let k = next(s) % kept.len();
        let mut b = kept[k].clone();
        b[next(s) % 3] = letter(s);
        let mut cov = [false; 3];
        if target(&b, &mut cov) {
            return n;
        }
        // true > false: a new check
        let new = (0..3)
            .any(|i| cov[i] > seen[i]);
        if new {
            for i in 0..3 {
                seen[i] |= cov[i];
            }
            let t: String = b.iter()
                .map(|&c| c as char)
                .collect();
            println!("  {n}: keep {t}");
            kept.push(b);
        }
    }
    0
}

fn main() {
    let mut s = 0x2545_f491_4f6c_dd1d;
    println!("guided:");
    let g = guided(&mut s);
    println!("guided: FUZ at try {g}");
    let b = blind(&mut s);
    println!("blind: FUZ at try {b}");
}
Open in Rust Playground ↗

With this seed, the guided search kept FAA at try 27 and FUA at try 114, and found FUZ at try 360. The blind one needed 11,524 (one chance in 26³ = 17,576 per try).

Why not bytes? Viscosity’s oracles need programs that run: original against compiled, under node. Almost every mutated byte string is a syntax error, which the engine refuses while parsing, so the analyses never see it.

Running it

Program k of campaign S gets its own seed, mixed from (S, k), so any program can be replayed alone. A run on my machine (a shared cloud VM, the engine at commit 288e497, release build, rustc 1.97; wrapped):

$ node scripts/fuzz.mjs --count 300 --seed 5
  250/300 programs, 0 failing
  300/300 programs, 0 failing
fuzz: seed 5, 300 programs (138
  transformed, 178 may go out of bounds,
  11 with an early error), 0 failing,
  3.3 s

About one program in 20 gets an early error inserted (a redeclared let, a stray break, …), and both engines must reject it.

--seed 5 --only 3 --print prints program 3, which starts (wrapped):

let k = 1;
const show = (v) => (Object.is(v, -0)
  ? '-0' : String(v));
// @viscosity aosoa(8)
const pts = [];
function add0(n) { for (let i = 0; i < n;
  i++) pts.push({ y: NaN,
  b: Math.sin(i) }); }

Each program runs in a fresh vm context[5]. The Rust engine is reached through its CLI, one viscosity build a.js b.js ... -o dir/ per 250 programs, so process starts are paid per batch (chapter 195 makes that call parallel). crates/cli/tests/fuzz.rs runs 200 programs with seed 1 on every cargo test. The campaign behind this chapter was about 50,000 programs: about 8,000 found the five bugs, and 42,000 more, after the fixes and with the generator extended, found nothing.

Shrinking a failure

To see a failure, I put fuzz.mjs as of commit c68daec next to the TypeScript engine from before the fix commit (10b4a63^) and ran --no-rust --count 300 --seed 5 --max-fail 5. It stopped after five failures (wrapped):

FAIL program 31 (--seed 5 --only 31):
  ts-crash
  [ts-crash] Error: internal error:
  overlapping rewrites at 741-801
...
fuzz: seed 5, 91 programs (34
  transformed, 51 may go out of bounds),
  5 failing, 1.7 s (TS only)

Program 90 had 20 lines. --reduce cut it to the two lines at the top of this chapter (I only reformatted them). The reducer is line-based delta debugging: delete a chunk of lines; keep the deletion if the same kind of failure remains; halve the chunk; repeat down to single lines.

It keeps the kind, not the meaning. Program 12 reduced to:

const cells = [];
for (let i = 0; i < 70; i++)
  cells.push({ b: (i * 37) % 11 });
for (let i = 0; i < cells.length; i++) {
  const p = cells[(k++, i)]; s0 += p.b; }

let k and s0 are gone, so the original now throws ReferenceError: k is not defined. The compiled program still differs, so the reduction was accepted: the old emitter wrote const p = k++, i;, a SyntaxError. The bug survived the shrinking; the program around it stopped making sense. Read a reduced program before you trust it.

What the five were

bug                     found as
rhs ran after the check behaviour
store into old column   behaviour
rewrite in push/array   ts-crash
rewrite in an index     ts-crash
(k++, 7) as an index    behaviour

All five were in both engines, so parity was blind to them (chapter 188). The README lists them as bugs 11 to 15, each with a corpus program.

Did coverage plateau?

At commit c68daec, I measured line coverage of the TypeScript engine (core and frontend-js, without the two unused parsers) with c8 10.1.3 and NODE_V8_COVERAGE. Lines run, of 2,614:

66 corpus, extra, e2e programs   2,503
fuzzer, 300 programs (seed 5)    2,430
fuzzer, 3,000 programs (seed 5)  2,433

Ten times the programs ran three more lines. The fuzzer covers fewer lines than the hand-written programs, yet it found what they missed. Each of the five bugs needed a combination: a write whose right-hand side pushes, an access inside a push. Line coverage says a line ran, not with what. AFL counts edges and hit counts for this reason, and a generator gets new combinations only when someone teaches it new constructs, as happened after the first finds.

Under the hood

A coverage-guided harness for the Rust engine is easy to write and hard to make useful. Fed bytes, it would test oxc’s parser. A structure-aware target that builds programs from fuzzer bytes could reach the analyses, but its oracle would have to be Rust-only (no panics, output that re-parses), since node and the TypeScript engine live outside the process. That is design, not code in the repository.

Your turn

ExerciseShrink a failing program

Write reduce(lines, fails), the reducer of fuzz.mjs --reduce. Start with chunks of half the lines (at least 1). Try deleting each chunk in turn; keep a deletion if fails still returns true for what is left, and otherwise move on by one chunk. Repeat the pass until nothing changes, then halve the chunk size, down to 1. Never call fails on an empty program, and keep the lines in order.

fn reduce<'a>(
    mut ls: Vec<&'a str>,
    fails: impl Fn(&[&str]) -> bool,
) -> Vec<&'a str> {
    todo!()
}
Open in Rust Playground ↗

What you learned

  • Coverage-guided fuzzing keeps mutated inputs that reach new edges; a generator like fuzz.mjs writes valid programs with no feedback.
  • A per-program seed makes every failure replayable with --only.
  • Delta debugging shrinks a failure while its kind remains, which can strip away what made the program meaningful.
  • Line coverage plateaued while bugs hid in combinations.

Check your understanding

0/6
  1. 1.

    What does a coverage-guided fuzzer do with an input whose run reached no new edge?

  2. 2.

    Which statement about scripts/fuzz.mjs is accurate?

  3. 3.

    A run reports FAIL program 90 (--seed 5 --only 90). How do you see that program again?

  4. 4.

    Why did the reduced sequence-index program refer to an undeclared k?

  5. 5.

    In the chapter’s measurement, the fuzzer ran fewer TS-engine lines than the hand-written programs. What follows?

  6. 6.

    Why does the parity check alone not find any of the five fuzz finds?

References

  1. [1] Michal Zalewski. Technical “whitepaper” for afl-fuzz (american fuzzy lop, docs/technical_details.txt). american fuzzy lop source distribution, github.com/google/AFL. github.com/google/AFL/blob/master/docs/technical_details.txt (checked 2026-10-31)
  2. [2] The rust-fuzz contributors. Rust Fuzz Book: Fuzzing with cargo-fuzz (setup and tutorial). rust-fuzz.github.io/book; source in github.com/rust-fuzz/book. rust-fuzz.github.io/book/cargo-fuzz.html (checked 2026-10-31)
  3. [3] Xuejun Yang, Yang Chen, Eric Eide, John Regehr. Csmith: a random generator of C programs. github.com/csmith-project/csmith (README). github.com/csmith-project/csmith (checked 2026-10-31)
  4. [4] Ecma International, TC39. ECMAScript Language Specification: Assignment Operators, Runtime Semantics: Evaluation; and PutValue. ECMA-262, current draft; source spec.html in github.com/tc39/ecma262. tc39.es/ecma262/#sec-assignment-operators-runtime-semantics-evaluation (checked 2026-10-31)
  5. [5] Node.js contributors. VM (executing JavaScript), Node.js documentation. nodejs.org. nodejs.org/api/vm.html (checked 2026-10-31)