Skip to content

fix(qjs): REPRL adapter does not reset global state between executions #558

Description

@coygeek

Summary

The QuickJS target's REPRL adapter, as wired up by
Targets/QJS/Patches/Fuzzilli-instrumentation-for-QJS.patch, implements the
REPRL protocol but does not reset JavaScript global state between executions.
The bundled REPRLRun smoke test, which is the same check FuzzilliCli uses
when evaluating whether a target's REPRL implementation is sufficient, fails
four of its eleven assertions on the patched binary. The fuzzer still runs
because the engine process is respawned every maxExecsBeforeRespawn=1000
executions (and JS_FreeContext / JS_FreeRuntime only run when the loop
terminates), so state leaks silently across roughly the first 1000 mutations
of every process lifetime. The patched binary does not advertise this
limitation in the patch comments, in Targets/QJS/README.md, or in the
profile source.

Steps to reproduce

  1. Pin Fuzzilli at the revision recorded in
    Targets/QJS/REVISION's parent Fuzzilli tree (the issue reproduces on
    4f087845c1b1d7362173cfe16114d4a9533503fb and on its parent tip).

  2. Apply Targets/QJS/Patches/Fuzzilli-instrumentation-for-QJS.patch to the
    QuickJS source pinned in Targets/QJS/REVISION (verified against
    2788d71e823b522b178db3b3660ce93689534e6d).

  3. Build QuickJS with the patched source (make qjs on macOS arm64 produced
    a binary with SHA-256 b8342b42335a58dc47d211a9ad86c42751f4c0afe6c7900c516510790095fa30).

  4. Build the bundled REPRLRun from the pinned Fuzzilli source
    (swift build -c release produced a binary with SHA-256
    aeb5bb0576b31b3b204683b7dfae0a52717b03a8b03dd5de5296ce0b53a8cc35).

  5. Run the smoke test against the patched QuickJS:

    printf '' | .build/release/REPRLRun /path/to/qjs --reprl

    Observed output:

    Running REPRL tests...
    Execution of "if (typeof(globalProp) !== 'undefined') throw 'failure'" failed
    Execution of "if (typeof(({}).foo) !== 'undefined') throw 'failure'" failed
    Execution of "async function fail() { throw 42; }; fail()" unexpectedly succeeded
    Execution of "async function fail() { throw 42; }; fail()" unexpectedly succeeded
    Not all tests passed. That means REPRL support likely isn't properly implemented in the target engine
    Enter code to run, then hit enter to execute it
    > Bye
    

Expected behavior

REPRLRun should report All tests passed! against the patched QuickJS
binary. The four failing assertions share the same contract: state set by one
REPRLRun execution must not be visible to the next execution in the same
process. The two global-state assertions test that globalProp and
Object.prototype.foo set in the prior execution are gone; the two
async-promise assertions test that an unhandled promise rejection from the
prior execution is no longer observed as a failure by the parent.

Fuzzilli's own REPRL script-runner documents this contract in
Sources/Fuzzilli/Execution/REPRL.swift: "Read-Eval-Print-Reset-Loop: a
script runner that reuses the same process for multiple scripts, but resets
the global state in between executions." The QJS patch implements only the
"Read-Eval-Print-Loop" half; the "Reset" half is missing.

Actual behavior

Sources/Fuzzilli/Execution/REPRL.swift describes the contract; the QJS
patch in qjs.c does not honor it. The REPRL loop in
qjs.c:619-666 calls eval_buf on the new script and then js_frees the
script buffer between iterations, but JS_NewContext /
JS_NewCustomContext are only called once before the loop, and
JS_FreeContext / JS_FreeRuntime are only called after the loop exits at
qjs.c:711-713. A globalProp or Object.prototype.foo set in one
iteration therefore persists in the next. Promise-rejection state is
similarly not reset between iterations, so the unhandled-promise rejection
from one execution is still active when the next one runs.

The QjsProfile startup tests in
Sources/Fuzzilli/Profiles/QjsProfile.swift only check the
fuzzilli('FUZZILLI_PRINT', 'test') and fuzzilli('FUZZILLI_CRASH', n)
builtins, so a campaign launched through FuzzilliCli still passes its
startup gate and proceeds to fuzz. The defect is therefore invisible to a
campaign smoke test even though it is observable through REPRLRun.

Affected area

  • Targets/QJS/Patches/Fuzzilli-instrumentation-for-QJS.patch, specifically
    the qjs.c REPRL block, which lacks any per-iteration state reset.
  • Targets/QJS/README.md, which tells the user to apply the patch and
    make qjs without mentioning the state-reset limitation.
  • Sources/Fuzzilli/Profiles/QjsProfile.swift, whose startupTests array
    does not include a state-reset check, so a broken state reset does not
    fail the fuzzer's own gate.
  • Sources/REPRLRun/main.swift, whose smoke tests already exercise
    state-reset for global vars, Object.prototype, and unhandled promise
    rejection. The tests are correct; the QJS adapter does not pass them.

Runtime or environment

Fuzzilli source: 4f087845c1b1d7362173cfe16114d4a9533503fb
Fuzzilli build: swift build -c release (Swift 6.3.3, Apple clang 21.0.0)
FuzzilliCli SHA-256: 54fac30ab43503e17411bcb252d6fd974154367bf0a5725102502713610f4797
FuzzILTool SHA-256: 920166ce3b6a955db9db6dae1bacd62dfad5705f57875c14a88194725bd0b393
REPRLRun SHA-256: aeb5bb0576b31b3b204683b7dfae0a52717b03a8b03dd5de5296ce0b53a8cc35

QuickJS source: 2788d71e823b522b178db3b3660ce93689534e6d (Targets/QJS/REVISION)
QuickJS patch: Targets/QJS/Patches/Fuzzilli-instrumentation-for-QJS.patch
QuickJS qjs SHA-256: b8342b42335a58dc47d211a9ad86c42751f4c0afe6c7900c516510790095fa30
QuickJS reports: QuickJS version 2021-03-27

Host: Darwin arm64, macOS 25.5.0
Toolchain: Apple clang 21.0.0, Node v22.23.1

A 200-iteration FuzzilliCli --profile=qjs --maxIterations=200 --timeout=250 --storagePath=qjs-proof --tag=qjs-2788d71e /path/to/qjs campaign started, initialized 29,026 coverage edges, passed
all QjsProfile startup tests, and reported 0 crashes, 0 timeouts, 280
total executions, and 3.83% coverage before exiting cleanly. The defect
remained invisible to the campaign gate.

Evidence

The patch adds the qjs.c REPRL block at qjs.c:283-365. The block
allocates a JSContext and JSRuntime once via
JS_NewContext / JS_NewRuntime before the REPRL read loop and
calls eval_buf inside the loop, with no per-iteration
JS_FreeContext / JS_NewContext cycle. qjs.c:711-713 shows the
JS_FreeContext / JS_FreeRuntime only firing after the loop exits.
qjs.c:660 calls __sanitizer_cov_reset_edgeguards() between iterations,
so coverage counters are reset, but JavaScript-visible state is not.

Sources/REPRLRun/main.swift:60-87 defines the four failing checks:

  • expect_success("if (typeof(globalProp) !== 'undefined') throw 'failure'")
  • expect_success("if (typeof(({}).foo) !== 'undefined') throw 'failure'")
  • expect_failure("async function fail() { throw 42; }; fail()") (twice)

The first two fail because the previous expect_success("globalProp = 42; Object.prototype.foo = \"bar\";") is not undone. The third and fourth fail
because the unhandled-rejection tracker from the prior expect_failure is
still set. The five passing checks (42, throw 42, globalProp = 42; Object.prototype.foo = "bar";, the empty-string REPRL handshake, the basic
async-availability probe) confirm the REPRL protocol itself is wired up
correctly; only the state-reset side of the contract is missing.

A live Fuzzilli issue search for REPRL qjs returned only
#386 (different
failing-execution path) and #359 (closed help request). A search for
state-reset or state reset returned no results.

Impact

A fuzzer that runs the QJS target will see leaked state from prior
mutations and may classify coverage as "interesting" when it is really a
manifestation of state that survived from a previous execution. Coverage
signal quality is reduced; differential fuzzer results against an
--exactSemantics reference may be hard to reproduce; minified .fzil
artifacts may not round-trip through a fresh engine because the same
program can behave differently depending on prior execution history. None
of this is visible from FuzzilliCli terminal statistics, which is why
the defect is not caught by the existing campaign lifecycle.

The defect is not a security vulnerability in Fuzzilli or QuickJS; it is a
fuzzer-quality defect in the QJS adapter. The Fuzzilli REPRL smoke test
that exposes it ships in this repository (Sources/REPRLRun/main.swift)
and is the natural regression check.

Additional context

A red/green closure signal is that the four failing
REPRLRun assertions in Sources/REPRLRun/main.swift all pass when
REPRLRun /path/to/qjs --reprl is invoked against a freshly built
Fuzzilli-patched QuickJS binary on the same Fuzzilli revision, and the
final REPRLRun output ends with All tests passed!. The smoke test
should continue to run against every rebuilt target as a documented
preflight check; a regression test in Tests/ (or a dedicated REPRL
integration test) that exercises the four assertions against every
supported target adapter would prevent a similar omission in the future.

The reproduction is stable on the recorded pins: rebuilding from
4f087845c1b1d7362173cfe16114d4a9533503fb + QuickJS
2788d71e823b522b178db3b3660ce93689534e6d + the unmodified
Targets/QJS/Patches/Fuzzilli-instrumentation-for-QJS.patch reproduces
the same four failures on the same four test strings.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions