Home · ASPLOS 2025 · Compilers & Programming Languages

ClosureX: Compiler Support for Correct Persistent Fuzzing

Rishi Ranjan, Ian Paterson and Matthew Hicks

Conference
ASPLOS 2025
Published
2025
Pages
151-163
Citations
0

Fuzzing finds bugs by hammering a program with many test inputs, and the more it runs per second the more bugs it finds — so the fastest strategy is persistent fuzzing, reusing one process for every test case by looping instead of exiting, which removes the huge cost of creating a fresh process each time; but reusing the process lets state from one test case (changed globals, leftover heap, open files) leak into the next, causing missed and false crashes; this paper (Virginia Tech — Rishi Ranjan, Ian Paterson, Matthew Hicks) presents ClosureX, a set of LLVM compiler passes that track and undo exactly the state a test case changed, restoring only that before the next run — keeping persistent speed while being correct, running over 3.5 times faster than AFL++ and finding 15 zero-day bugs.

The argument in one glance

  1. Problem

    The fastest way to fuzz is also incorrect

    Fuzzing hunts for bugs by generating many test inputs, running each against a program, and watching for crashes; it is so effective that Microsoft, Google, and the U.S. government require it. A central fact is that throughput drives results: the more test cases you run per second, the more bugs you find. Much of the per-test-case cost is not the program itself but the machinery around it, especially creating a fresh process for each input — allocating memory, loading the binary, initializing, and tearing down — which can cost as much as running the test. So the highest-performance strategy is persistent fuzzing: instead of starting a new process per test case, reuse a single process for all of them by looping back to the start when one finishes. That eliminates all the process-management overhead. But it introduces a correctness problem. Because the same process is reused, whatever a test case changed — global variables, memory it allocated on the heap, files it opened — stays changed when the next test case runs. This leftover state makes the program semantically inconsistent from one test case to the next, causing missed crashes (a real bug hidden by stale state), false crashes (a crash that a fresh process would not produce), and generally unreliable results.

  2. What people did

    Reset everything with a fresh process, or reset nothing and loop

    Existing fuzzers sit at different points on a state-restoration continuum. At one end, fork/exec or a fork server starts each test case from a fresh (or freshly forked) process, so every bit of state is reset and correctness is guaranteed — but a process is created per test case. Kernel-based snapshots reset process state faster using operating-system support. At the other end, persistent fuzzing resets nothing and just loops in one process, which is fastest but incorrect.

  3. Why it fell short

    The whole continuum forces a choice between speed and correctness

    Every existing point trades one for the other. Forking a fresh process per test case is correct but pays full process-management cost on every input, which is on par with the execution cost itself, so it caps throughput. Kernel-based snapshotting is faster but depends on specific operating-system versions — it works on some Linux releases and breaks on others — and is not portable. Persistent fuzzing is the fastest by far, but it is incorrect, because state leaks across test cases and corrupts the results with missed and false crashes. The reason correct approaches are slow is that they reset the whole process state indiscriminately, when in fact each test case only touches a small, specific part of it. What is missing is a mechanism that resets only the state a given test case actually changed — fine-grained rather than all-or-nothing — so it can have persistent fuzzing's speed and a fresh process's correctness at the same time, and do so portably across operating systems.

  4. This paper's idea

    Track and undo exactly the state each test case changes, via compiler passes

    ClosureX is a fuzzing execution mechanism that sits at a new point on the state-restoration continuum: it resets only the test-case-execution-specific state — the state a given test case actually modified — rather than the whole process (heavy) or nothing (persistent, incorrect). This fine-grained restoration gives near-persistent performance with the correctness of full state restoration. ClosureX is built as a set of LLVM compiler passes that instrument the target program so that, after each test case, exactly what changed is undone before the next runs. A GlobalPass moves the target's writable global variables into a separate memory section, so the fuzzer can track which globals a test case modified and restore them to their initial values. A custom memory manager tracks heap allocations (malloc, calloc, realloc) made during a test case and reclaims them, since persistent fuzzing never exits to let the operating system reap leaked memory. An ExitPass renames the target's main and intercepts its exit and return paths, so that instead of the process exiting (which would end the persistent loop) ClosureX loops back and resets state, handling both normal returns and exit flows; open file descriptors and other execution-specific state are reset too. Delivered as LLVM passes that integrate with the popular fuzzer AFL++, ClosureX works across operating systems and for both applications and libraries. On ten popular open-source targets it maintains semantic correctness while increasing test-case execution rate by over 3.5 times on average versus AFL++, and it discovered 15 zero-day bugs (4 assigned CVEs).

Why this problem is worth solving

Fuzzing has become a mandatory pillar of software security, and its effectiveness is bottlenecked by how fast it can run test cases.

Fuzzing works by feeding a program a flood of generated, often malformed inputs and watching for crashes that reveal bugs; modern coverage-guided fuzzers keep the inputs that reach new code, which makes the search enormously more effective than random testing. It is now standard practice, required by major companies and governments for hardening software. The key lever on a fuzzer’s success is throughput: research consistently shows that executing more test cases per unit time directly raises the rate of bug discovery, because fuzzing is fundamentally a search that benefits from more attempts. There are only two ways to raise throughput — make each program execution faster, or cut the time spent on everything around the execution. That surrounding cost is surprisingly large. For short-running targets, the overhead of managing the process for each test case — creating it, mapping its memory, loading and initializing the binary, and tearing it down afterward — can rival or exceed the cost of actually running the input. So how a fuzzer manages process state between test cases is not a minor detail; it is one of the dominant factors in overall throughput, and therefore in how many bugs get found. This is why squeezing out process-management overhead, without breaking correctness, directly translates into more security bugs discovered.

The hard part is that removing that overhead has, until now, meant sacrificing correctness.

The most aggressive way to eliminate process-management cost is persistent fuzzing: run every test case inside one long-lived process, looping back to the start after each input instead of exiting. This deletes essentially all the per-test-case process overhead, which is why it is the fastest strategy. The catch is state. A program accumulates state as it runs — it writes to global variables, allocates memory on the heap, opens files and sockets — and in a normal run the operating system wipes all of that clean when the process exits. In persistent fuzzing the process never exits, so those changes survive into the next test case. That is a correctness disaster for a fuzzer: leftover state can suppress a crash that a bug should have caused (a missed crash), or trigger a crash that a fresh run never would (a false crash), so the fuzzer’s findings can no longer be trusted. The obvious remedy is to reset the state, and that is exactly what the correct mechanisms do — fork a fresh process, or snapshot and restore the whole process image — but they reset everything, indiscriminately, which is why they are slow or tied to particular operating-system versions. The deep issue is granularity: any given test case only modifies a small slice of the process state, yet the correct approaches restore all of it. Bridging speed and correctness requires resetting just the slice that changed, which is the gap ClosureX fills.

What people did before, and where it broke

  • Fork/exec a fresh process per test case. Start each input in a brand-new process so all state is clean. Where it broke. It pays the full cost of process creation, memory mapping, loading, and initialization on every test case — a cost on par with execution itself — so throughput is capped.
  • Fork server (fork from a post-initialization snapshot). Fork each test case from a process already past startup to avoid re-initializing. Where it broke. It still forks a process per test case, so significant process-management overhead remains.
  • Kernel-based snapshot and restore. Use operating-system support to snapshot and reset process state quickly. Where it broke. It depends on specific operating-system versions — working on some Linux releases and failing on others — so it is not portable, and it still restores broad process state.
  • Persistent fuzzing (reset nothing, loop in one process). Reuse one process for all test cases to remove all process-management cost. Where it broke. State from one test case leaks into the next, so it is semantically incorrect, producing missed crashes, false crashes, and untrustworthy results.
  • The gap. No mechanism reset only the state a given test case actually changed, so fuzzers had to choose between persistent speed and correct results, and portably-fast correctness did not exist.

How ClosureX works

Resetting only what each test case touched

  1. new

  2. new

  3. new

  4. new

  5. new

The idea

Reset only what changed, not the whole process. ClosureX’s foundational move is to place fuzzing at a new point on the state-restoration continuum: capture and undo exactly the fine-grained state a single test case modified, so the process can be reused (fast) yet each test case starts clean (correct). Restoring only the small portion of state that actually changed, rather than snapshotting and restoring everything, is the same principle the work on micro-checkpoints as a data single-level store covered elsewhere on this site is built on, making state capture and rollback cheap by keeping it fine-grained. ClosureX applies that idea to fuzzing, turning heavyweight per-test-case resets into a targeted undo of just the modified globals, heap, and file state.

Use the compiler to instrument the program to reset itself. Rather than relying on the operating system, ClosureX implements its state tracking and restoration as LLVM compiler passes that rewrite the target so it can undo its own changes. Inserting instrumentation at compile time so a program enforces a runtime property it would not otherwise have is the same technique the work on enforcing C and C++ type and scope at runtime for control flow and data flow covered elsewhere on this site relies on, transforming the program during compilation to gain a guarantee at run time. ClosureX uses compiler passes (GlobalPass, ExitPass, a custom allocator) to give a target the ability to reset its execution-specific state, which is why it is portable across operating systems and works for both applications and libraries.

Make bug-hunting faster so it finds more bugs. Ultimately ClosureX exists to raise fuzzing throughput, because more executions mean more bugs, without sacrificing the correctness that makes those bugs trustworthy. Building better tooling to make automated bug discovery more effective is the same goal the work on effective kernel fuzzing with a learned white-box test mutator covered elsewhere on this site pursues, pushing fuzzers to uncover more bugs. Where that work improves which inputs a fuzzer tries, ClosureX improves how fast and correctly it can try them, and the payoff is concrete: 15 previously unknown bugs, four of them serious enough to receive CVE identifiers.

Following a test case through ClosureX

Running two test cases back-to-back in one process, correctly

A trace, drawn from the paper, of how ClosureX keeps persistent fuzzing correct across test cases.

Step 1 of 5

A test case runs and changes state

Inside the single persistent process, a test case executes and, along the way, writes some global variables, allocates heap memory, and opens a file — ordinary side effects that in a fresh process would vanish on exit.

These are exactly the changes that would leak into the next test case.

Why it works

  • Only a small slice of state changes per test case. A single input touches a few globals, some heap, and a few files → so restoring just those is far cheaper than resetting the whole process → so ClosureX keeps near-persistent throughput.
  • Compiler instrumentation makes the program undo itself. The target normally has no way to reset its own state → but LLVM passes add tracking of globals, heap, and exits → so the program can restore exactly what it changed, without operating-system support.
  • Isolating writable globals makes them trackable. Scattered globals are hard to reset selectively → but GlobalPass moves them into one section → so the fuzzer can detect and restore exactly the modified ones.
  • Intercepting exit keeps the loop alive and correct. A test case ending would normally kill the process and force a fork → but ExitPass reroutes returns and exits into a reset → so the process persists and the next test case still starts clean.
  • Correctness restores trust in the results. Persistent fuzzing’s leaked state causes missed and false crashes → but resetting the changed state removes the cross-test-case interference → so ClosureX’s crashes are real, and it found 15 genuine zero-day bugs.

What it costs (trade-offs and scope)

  • It requires recompiling the target with LLVM. ClosureX is a set of compiler passes, so it needs the target’s source (or LLVM bitcode) to instrument globals, heap allocation, and exits; it cannot be applied to a binary you cannot rebuild through LLVM.
  • State tracking adds per-test-case work. Recording and restoring modified globals, heap allocations, and file descriptors is not free, so ClosureX is near-persistent rather than exactly as fast as raw (incorrect) persistent fuzzing; the trade buys correctness for a small overhead versus a large one.
  • Coverage depends on capturing all relevant state. Correctness relies on ClosureX identifying and resetting every kind of execution-specific state that can leak; state it does not track (for example, certain external or kernel-side resources) could still carry over, so its guarantee is scoped to the state its passes handle.
  • It targets user-space application and library fuzzing. ClosureX works across operating systems for applications and libraries via compiler instrumentation; it is not a kernel-snapshot mechanism, and fuzzing targets that fall outside its instrumentation model are out of scope.
  • The gains are measured on specific targets and against AFL++. The over-3.5-times average speedup and the 15 zero-day bugs come from ten open-source targets compared with AFL++; other targets, fuzzers, or workloads could yield different numbers.

The evaluation

Managing process state between fuzzing test cases

PropertyFork serverKernel snapshotPersistent (reset nothing)ClosureX
Correct (no leaked state)
Per-test-case process cost
Portable across operating systems
Works for libraries as well as apps
Throughput and outcome
A comparison of fuzzing process-state management. ClosureX resets only the state each test case changed, giving persistent-level throughput with fresh-process correctness, portably across operating systems and for both applications and libraries — over 3.5 times faster than AFL++ on ten targets while staying correct, and finding 15 zero-day bugs (4 CVEs).Underlined values explain themselves when clicked.

Check yourself

Check your understanding

  1. 1. Why is persistent fuzzing the fastest strategy but also incorrect?

  2. 2. What is ClosureX's key idea for getting both speed and correctness?

  3. 3. How does ClosureX actually track and reset the state, and why is it implemented in the compiler?

  4. 4. Predict: a fuzzing target keeps a running counter in a global variable that changes its behavior over time (e.g., allocating more memory as the counter grows). Under naive persistent fuzzing versus ClosureX, what happens?

Sources

The paper is “ClosureX: Compiler Support for Correct Persistent Fuzzing” by Rishi Ranjan, Ian Paterson, and Matthew Hicks (Virginia Tech), published at ASPLOS 2025, DOI 10.1145/3669940.3707281. It is licensed CC BY-SA.

This explainer is written from the paper’s full text (the authors’ repository copy). Fuzzing is a widely adopted bug-hunting methodology, and increasing fuzzing throughput directly increases the bug-discovery rate. The highest-performance strategy, persistent fuzzing, reuses a single process for all test cases by looping back to the start upon completion instead of exiting, eliminating process creation, initialization, and teardown costs that are on par with execution cost; but it leads to semantically inconsistent program states because process state changes from one test case remain for subsequent test cases, causing missed crashes, false crashes, and incorrectness. ClosureX is a fuzzing execution mechanism at a new point on the state-restoration continuum, resetting only test-case-execution-specific state to provide near-persistent performance with the correctness of heavyweight state restoration. It is implemented as a set of LLVM passes integrated with AFL++: a GlobalPass moves writable globals into a separate section so modified ones can be tracked and restored; a custom memory manager tracks and reclaims heap allocations since the persistent process never exits to let the operating system reap leaked memory; and an ExitPass renames the target’s main and intercepts exit and return flows so the process resets and loops rather than terminating, with file descriptors and other state also reset. It works across operating systems and for both applications and libraries. Evaluated on ten popular open-source fuzzing targets, ClosureX maintains semantic correctness while increasing test-case execution rate by over 3.5 times on average versus AFL++, and discovered 15 zero-day bugs (4 CVEs). No numbers beyond the paper’s own have been introduced; exact pass details, the state-tracking implementation, and full results should be confirmed in the paper.

Related papers on Compilers & Programming Languages

See all 162 →