Skip to content

[BUG] std.evalScript() permanently removes the application's interrupt handler (no save/restore) #1673

Description

@kid-lxy

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:

while (true) { }

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

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