Skip to content

How Vitiate Works

Vitiate’s fuzzing pipeline has four phases: instrumentation, runtime initialization, the fuzz loop, and crash recovery.

Vitiate instruments your code on-the-fly during Vite’s module transform pipeline. When Vitest loads a module, Vitiate’s Vite plugin intercepts it and runs it through an SWC WASM plugin before it reaches the Node.js runtime. There is no separate build step - instrumentation happens transparently as modules are imported.

The plugin inserts two kinds of instrumentation:

Edge coverage counters - At every branch point (function entry, if/else, loop, switch case, ternary), the plugin inserts a counter increment:

__vitiate_cov[42]++; // edge ID 42

The plugin records both sides of a branch, not just the taken side. When an if has no else, a synthetic not-taken counter is inserted so that “the condition was false” is distinguishable from “the branch was never reached.” Likewise, a loop-exit counter is placed immediately after each loop, distinguishing “the loop ran zero iterations / exited” from “the loop was never reached.” Together these roughly double meaningful branch coverage relative to instrumenting only taken paths.

Each edge gets a deterministic ID derived from the file path, source location, and edge kind, hashed (FNV-1a with an avalanche finalizer) into the coverage map. The coverage map is a fixed-size array (default: 65,536 slots) where each slot counts how many times that edge was hit. Because IDs are hashed into a fixed number of slots, distinct edges can occasionally collide and share a slot; if the number of instrumented edges grows large relative to the map size, Vitiate prints a one-time warning suggesting you raise coverageMapSize.

Comparison tracing - For equality and relational comparisons (==, ===, <, >=, etc.), the plugin inserts a tracing call:

__vitiate_trace_cmp(leftOperand, rightOperand, operationType);

This powers the CmpLog mutation strategy: the engine observes what values are being compared and uses them to generate targeted mutations.

The plugin’s configResolved hook performs early initialization of the coverage map and cmplog globals, guaranteeing they are available before any instrumented code - including inlined dependency modules - can execute. The setup file (@vitiate/core/setup) serves as a fallback, re-initializing the globals if needed:

  • globalThis.__vitiate_cov - A Buffer backed by shared memory. In fuzzing mode, this buffer is allocated by the Rust engine and shared zero-copy between JavaScript and Rust. In regression mode, it is a plain buffer (coverage is tracked but not used for feedback).
  • globalThis.__vitiate_trace_cmp - A function that records comparison operands for the CmpLog system.

The zero-copy shared memory is critical for performance: the Rust engine reads the coverage map directly from the same memory that JavaScript writes to, with no serialization or copying.

Each fuzzing iteration follows this cycle:

  1. Get next input: The Rust engine selects a corpus entry, applies mutations, and returns the mutated bytes to JavaScript via getNextInput().
  2. Reset coverage: The coverage map is zeroed so this iteration’s coverage is measured in isolation.
  3. Run the target: The fuzz target function is called with the input bytes.
  4. Report result: JavaScript calls reportResult() with the outcome (ok, crash, or timeout). The Rust engine reads the coverage map to evaluate feedback.
  5. Evaluate feedback: Each edge’s hit count is classified into an AFL-style bucket (1, 2, 3, 4-7, 8-15, 16-31, 32-127, 128+). If the input produced any (edge, bucket) pair never seen before - reaching an edge for the first time, or hitting a known edge a bucketed number of times it has never been hit - it is added to the corpus. If it caused a crash, it is saved as a solution.

The engine applies several mutation strategies, selected and stacked automatically:

  • Havoc: Random byte-level mutations - bit flips, byte insertions, deletions, substitutions, and block operations. The bread-and-butter strategy that generates most of the corpus growth. Based on AFL’s havoc stage.
  • I2S (Input-to-State) splice: A lightweight comparison-guided mutation that runs after every havoc pass. The __vitiate_trace_cmp() instrumentation captures both operands of every comparison (e.g., if (header === "MAGIC") records the actual input bytes alongside "MAGIC"). I2S picks a random operand pair and splices the expected value into the input at the matching position. This is cheap enough to run on every iteration and helps the fuzzer bypass simple magic-value checks.
  • REDQUEEN: A heavier multi-phase stage that runs after calibration for interesting inputs. First, colorization randomizes bytes that don’t affect the coverage pattern, identifying which input positions are “free”. Then the engine generates targeted mutation candidates using the colorized comparison data. This finds deeper relationships between input bytes and comparison operands than naive I2S. Enabled by default for binary targets; disabled for text targets where Grimoire is more effective.
  • Grimoire: Structure-aware mutations for text-based targets. The engine identifies structural patterns in corpus entries - which bytes affect coverage vs. which are “filler” - through generalization, then mutates while preserving structure. Auto-enabled when corpus entries are valid UTF-8.
  • Unicode: Character-level mutations that operate on Unicode categories and subcategories rather than raw bytes. Useful for targets that process text with locale or encoding sensitivity.
  • JSON: Structure-aware mutations for JSON inputs that operate directly on the byte buffer without parsing into a DOM. Three operators run in a dedicated stage: replacing a string value with a dictionary token, replacing an object key with a dictionary token, and replacing any JSON value with a type-changed alternative (null, true, false, 0, 1, "", [], {}), a quoted dictionary token, or a copy of another value from the same input. Auto-enabled when a majority of the UTF-8 corpus entries look like JSON.

The engine auto-detects whether a target is text-based or binary after accumulating initial corpus entries. Binary targets get REDQUEEN; text targets get Grimoire and Unicode mutations, plus JSON mutations when the corpus looks like JSON. I2S splice and havoc run regardless.

The corpus is managed by the Rust engine using LibAFL’s AflMapFeedback (feature-set admission):

  • An input is “interesting” if it produces any (edge, hit-count-bucket) feature never seen before - reaching a new edge, or hitting a known edge a number of times that lands in an AFL hit-count bucket (1, 2, 3, 4-7, 8-15, 16-31, 32-127, 128-255) never previously observed for that edge, whether higher or lower than earlier counts
  • Interesting inputs are added to the corpus
  • The scheduler favors corpus entries that cover rarely-hit edges (a set-cover minimizer over the coverage map), weighting selection with AFL-style fast power scheduling that prefers inputs fuzzed fewer times and executing quickly
  • Corpus minimization uses set-cover to find the smallest subset that maintains the same total coverage

Vitiate uses a supervisor/child process architecture:

  • The supervisor (parent process) allocates shared memory, spawns the child, and monitors it
  • The child (worker process) runs the actual fuzz loop
  • When the child crashes or is killed (e.g., by a timeout watchdog), the supervisor reads the crashing input from shared memory, writes the crash artifact, and spawns a new child to continue fuzzing

Crash artifacts are automatically minimized: the engine systematically removes bytes from the crashing input to find the smallest input that still triggers the same crash. This makes crash artifacts easier to understand and debug.