JS_SetInterruptHandler() is the documented API an embedder uses to bound the execution time of untrusted scripts. But std.evalScript() (js_eval_script() in quickjs-libc.c) installs its own interrupt handler on entry and, on exit, resets the runtime's interrupt handler to NULL — it never saves/restores the handler the application had installed. After a single std.evalScript("1") call, the embedder's protection is gone for the rest of the input, and a plain infinite loop then runs forever. (The official qjs CLI installs no interrupt handler, so this is not reproducible from the CLI.)
Repro code
Save the following as repro.js:
std.evalScript("1");
while (true) { }
For comparison, save the following as control.js — the same loop without the evalScript call:
Reproduced with the official fuzz_eval harness (the OSS-Fuzz QuickJS JS-source target, attached as fuzz_eval.c): it evaluates the input via JS_Eval with the std/os modules loaded and JS_SetInterruptHandler() installed to abort after 100 polls. Build options:
clang -O1 -g -D_GNU_SOURCE -fno-omit-frame-pointer -fsanitize=fuzzer,undefined \
-I. fuzz_eval.c quickjs.c quickjs-libc.c libregexp.c libunicode.c dtoa.c \
-o fuzz_eval -lm -lpthread -ldl
Replay the inputs:
$ timeout -s KILL 10s ./fuzz_eval repro.js # hangs; killed by timeout (rc=137)
$ timeout -s KILL 10s ./fuzz_eval control.js # interrupted at once; exits 0
With repro.js the interrupt handler never fires — std.evalScript("1") removed it before the loop started — and the target hangs until killed. With control.js the same handler aborts execution in well under a second (InternalError: interrupted).
Expected behavior
std.evalScript should leave the application's interrupt handler intact: save the current handler/opaque on entry and restore them on exit. An embedder that installed a handler to bound untrusted code must still be protected after any number of evalScript calls.
Actual behavior / root cause
js_eval_script() in quickjs-libc.c (line numbers at the commit below):
if (!ts->recv_pipe && ++ts->eval_script_recurse == 1) {
/* install the interrupt handler */
JS_SetInterruptHandler(JS_GetRuntime(ctx), interrupt_handler, NULL); /* line 1151 */
}
...
if (!ts->recv_pipe && --ts->eval_script_recurse == 0) {
/* remove the interrupt handler */
JS_SetInterruptHandler(JS_GetRuntime(ctx), NULL, NULL); /* line 1169 */
...
Nothing saves the previously installed handler, so line 1169 destroys it. Because the handler is NULL afterwards, __js_poll_interrupts() (quickjs.c:8644) takes the if (rt->interrupt_handler) false branch and never throws, so even unbounded bytecode loops (while (true) {}) are no longer interruptible for the lifetime of the runtime. Nested evalScript calls make it worse: they restore to NULL when the outermost one returns, regardless of what the application had installed in between.
Suggested fix
Capture rt->interrupt_handler/rt->interrupt_opaque before installing the libc handler and restore them on exit, so the application's handler survives std.evalScript.
Version
- QuickJS version/release: quickjs-ng
v0.16.1 dev (master)
- Git commit:
82626eacc81825340736af5a6f363f5ebdf8aede
- Operating system: Ubuntu 24.04 x86_64 (clang 18);
std_evalscript_clobbers_interrupt_repro.zip
JS_SetInterruptHandler()is the documented API an embedder uses to bound the execution time of untrusted scripts. Butstd.evalScript()(js_eval_script()inquickjs-libc.c) installs its own interrupt handler on entry and, on exit, resets the runtime's interrupt handler toNULL— it never saves/restores the handler the application had installed. After a singlestd.evalScript("1")call, the embedder's protection is gone for the rest of the input, and a plain infinite loop then runs forever. (The officialqjsCLI installs no interrupt handler, so this is not reproducible from the CLI.)Repro code
Save the following as
repro.js:For comparison, save the following as
control.js— the same loop without theevalScriptcall:Reproduced with the official
fuzz_evalharness (the OSS-Fuzz QuickJS JS-source target, attached asfuzz_eval.c): it evaluates the input viaJS_Evalwith thestd/osmodules loaded andJS_SetInterruptHandler()installed to abort after 100 polls. Build options:Replay the inputs:
With
repro.jsthe interrupt handler never fires —std.evalScript("1")removed it before the loop started — and the target hangs until killed. Withcontrol.jsthe same handler aborts execution in well under a second (InternalError: interrupted).Expected behavior
std.evalScriptshould leave the application's interrupt handler intact: save the current handler/opaque on entry and restore them on exit. An embedder that installed a handler to bound untrusted code must still be protected after any number ofevalScriptcalls.Actual behavior / root cause
js_eval_script()inquickjs-libc.c(line numbers at the commit below):Nothing saves the previously installed handler, so line 1169 destroys it. Because the handler is
NULLafterwards,__js_poll_interrupts()(quickjs.c:8644) takes theif (rt->interrupt_handler)false branch and never throws, so even unbounded bytecode loops (while (true) {}) are no longer interruptible for the lifetime of the runtime. NestedevalScriptcalls make it worse: they restore toNULLwhen the outermost one returns, regardless of what the application had installed in between.Suggested fix
Capture
rt->interrupt_handler/rt->interrupt_opaquebefore installing the libc handler and restore them on exit, so the application's handler survivesstd.evalScript.Version
v0.16.1dev (master)82626eacc81825340736af5a6f363f5ebdf8aedestd_evalscript_clobbers_interrupt_repro.zip