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
-
Pin Fuzzilli at the revision recorded in
Targets/QJS/REVISION's parent Fuzzilli tree (the issue reproduces on
4f087845c1b1d7362173cfe16114d4a9533503fb and on its parent tip).
-
Apply Targets/QJS/Patches/Fuzzilli-instrumentation-for-QJS.patch to the
QuickJS source pinned in Targets/QJS/REVISION (verified against
2788d71e823b522b178db3b3660ce93689534e6d).
-
Build QuickJS with the patched source (make qjs on macOS arm64 produced
a binary with SHA-256 b8342b42335a58dc47d211a9ad86c42751f4c0afe6c7900c516510790095fa30).
-
Build the bundled REPRLRun from the pinned Fuzzilli source
(swift build -c release produced a binary with SHA-256
aeb5bb0576b31b3b204683b7dfae0a52717b03a8b03dd5de5296ce0b53a8cc35).
-
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.
Summary
The QuickJS target's REPRL adapter, as wired up by
Targets/QJS/Patches/Fuzzilli-instrumentation-for-QJS.patch, implements theREPRL protocol but does not reset JavaScript global state between executions.
The bundled
REPRLRunsmoke test, which is the same checkFuzzilliCliuseswhen 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=1000executions (and
JS_FreeContext/JS_FreeRuntimeonly run when the loopterminates), 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 theprofile source.
Steps to reproduce
Pin Fuzzilli at the revision recorded in
Targets/QJS/REVISION's parent Fuzzilli tree (the issue reproduces on4f087845c1b1d7362173cfe16114d4a9533503fband on its parent tip).Apply
Targets/QJS/Patches/Fuzzilli-instrumentation-for-QJS.patchto theQuickJS source pinned in
Targets/QJS/REVISION(verified against2788d71e823b522b178db3b3660ce93689534e6d).Build QuickJS with the patched source (
make qjson macOS arm64 produceda binary with SHA-256
b8342b42335a58dc47d211a9ad86c42751f4c0afe6c7900c516510790095fa30).Build the bundled
REPRLRunfrom the pinned Fuzzilli source(
swift build -c releaseproduced a binary with SHA-256aeb5bb0576b31b3b204683b7dfae0a52717b03a8b03dd5de5296ce0b53a8cc35).Run the smoke test against the patched QuickJS:
Observed output:
Expected behavior
REPRLRunshould reportAll tests passed!against the patched QuickJSbinary. The four failing assertions share the same contract: state set by one
REPRLRunexecution must not be visible to the next execution in the sameprocess. The two global-state assertions test that
globalPropandObject.prototype.fooset in the prior execution are gone; the twoasync-promise assertions test that an unhandled promise rejection from the
prior execution is no longer observed as a failure by the parent.
Fuzzilli's ownREPRLscript-runner documents this contract inSources/Fuzzilli/Execution/REPRL.swift: "Read-Eval-Print-Reset-Loop: ascript 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.swiftdescribes the contract; the QJSpatch in
qjs.cdoes not honor it. The REPRL loop inqjs.c:619-666callseval_bufon the new script and thenjs_frees thescript buffer between iterations, but
JS_NewContext/JS_NewCustomContextare only called once before the loop, andJS_FreeContext/JS_FreeRuntimeare only called after the loop exits atqjs.c:711-713. AglobalProporObject.prototype.fooset in oneiteration 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
QjsProfilestartup tests inSources/Fuzzilli/Profiles/QjsProfile.swiftonly check thefuzzilli('FUZZILLI_PRINT', 'test')andfuzzilli('FUZZILLI_CRASH', n)builtins, so a campaign launched through
FuzzilliClistill passes itsstartup 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, specificallythe
qjs.cREPRL block, which lacks any per-iteration state reset.Targets/QJS/README.md, which tells the user to apply the patch andmake qjswithout mentioning the state-reset limitation.Sources/Fuzzilli/Profiles/QjsProfile.swift, whosestartupTestsarraydoes 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 exercisestate-reset for global vars,
Object.prototype, and unhandled promiserejection. The tests are correct; the QJS adapter does not pass them.
Runtime or environment
A 200-iteration
FuzzilliCli --profile=qjs --maxIterations=200 --timeout=250 --storagePath=qjs-proof --tag=qjs-2788d71e /path/to/qjscampaign started, initialized 29,026 coverage edges, passedall
QjsProfilestartup tests, and reported 0 crashes, 0 timeouts, 280total executions, and 3.83% coverage before exiting cleanly. The defect
remained invisible to the campaign gate.
Evidence
The patch adds the
qjs.cREPRL block atqjs.c:283-365. The blockallocates a
JSContextandJSRuntimeonce viaJS_NewContext/JS_NewRuntimebefore the REPRL read loop andcalls
eval_bufinside the loop, with no per-iterationJS_FreeContext/JS_NewContextcycle.qjs.c:711-713shows theJS_FreeContext/JS_FreeRuntimeonly firing after the loop exits.qjs.c:660calls__sanitizer_cov_reset_edgeguards()between iterations,so coverage counters are reset, but JavaScript-visible state is not.
Sources/REPRLRun/main.swift:60-87defines 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 failbecause the unhandled-rejection tracker from the prior
expect_failureisstill set. The five passing checks (
42,throw 42,globalProp = 42; Object.prototype.foo = "bar";, the empty-string REPRL handshake, the basicasync-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 qjsreturned only#386 (different
failing-execution path) and #359 (closed help request). A search for
state-resetorstate resetreturned 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
--exactSemanticsreference may be hard to reproduce; minified.fzilartifacts 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
FuzzilliCliterminal statistics, which is whythe 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
REPRLRunassertions inSources/REPRLRun/main.swiftall pass whenREPRLRun /path/to/qjs --reprlis invoked against a freshly builtFuzzilli-patched QuickJS binary on the same Fuzzilli revision, and thefinal
REPRLRunoutput ends withAll tests passed!. The smoke testshould continue to run against every rebuilt target as a documented
preflight check; a regression test in
Tests/(or a dedicated REPRLintegration 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+ QuickJS2788d71e823b522b178db3b3660ce93689534e6d+ the unmodifiedTargets/QJS/Patches/Fuzzilli-instrumentation-for-QJS.patchreproducesthe same four failures on the same four test strings.