Skip to content

Security findings: BigInt interrupt-bypass CPU DoS (verified) and 5 other issues — commit 04be246 #546

Description

@Boreas37

Security findings — bellard/quickjs

Pinned commit: 04be246001599f5995fa2f2d8c91a0f198d3f34c (VERSION 2026-06-04)
Audit type: authorized defensive review (static analysis + PoC verification in an isolated sandbox)

Findings marked ✅ verified were confirmed with proofs of concept. PoC inputs available on request
(contact at the bottom).

ID Severity CWE Location Title
F-001 Medium CWE-400 quickjs.c:11860, :11958, :12105, :12699 BigInt arithmetic/toString() are O(n²) with no interrupt poll → CPU DoS that bypasses JS_SetInterruptHandler
F-002 Medium CWE-1333 libregexp.c:2740/2774, quickjs.c:48006 RegExp catastrophic backtracking interruptible only if the host installed a handler → ReDoS
F-003 Low CWE-190 quickjs.c:47202 Math.sumPrecise accumulator limb int64 overflow → wrong result/UB
F-004 Low CWE-190 quickjs.c:1916 js_realloc_array new_size * elem_size int overflow (multi-GB precondition)
F-005 Low CWE-670 quickjs.c:57245 Resizable SAB grow() cross-thread byteLength visibility off-spec
F-006 Low CWE-190 quickjs.c:37440 js_object_list hash-size doubling wraps to 0 at ~2³¹ objects

F-001 — BigInt ops bypass the interrupt handler → CPU DoS (Medium, CWE-400) ✅ verified

Root cause. The interpreter polls JS_SetInterruptHandler only every JS_INTERRUPT_COUNTER_INIT = 10000
bytecode instructions (quickjs.c:512, quickjs.c:7867). A BigInt multiply / divide / pow / toString() is a
single C call — one instruction — regardless of its quadratic cost, so it is never preempted. With no handler
installed (the JS_NewRuntime default) it is fully uninterruptible.

Impact. Untrusted JS (server-side eval, sandboxed workers) can pin a core for seconds per op at the
JS_BIGINT_MAX_SIZE cap, and ~10⁴ consecutive ops run before the first poll → denial of service.

Reproduction.

// single op, ~1 s of unbroken CPU at the size cap
let a = BigInt('0x' + 'F'.repeat(240000));
let s = a.toString();

// sustained DoS
let a = BigInt('0x' + 'F'.repeat(100000));
let b = BigInt('0x' + 'F'.repeat(50000));
for (let i = 0; i < 10000; i++) { let r = a / b; }

PoC (verified). With a counting interrupt handler: 300 BigInt divisions (100k-digit operands) = 4.96 s CPU
with 1 handler poll
, while 25 000 plain increments = 0.001 s with 5 polls (~5000× gap). O(n²) scaling
measured: division 50k→100k→200k digits = 5→18→70 ms; toString() 20k→160k digits = 7→448 ms.

Suggested fix. Poll rt->interrupt_handler inside mp_mul_basecase()/mp_divnorm() (or per limb batch), or
cap operand sizes.


F-002 — RegExp catastrophic backtracking → ReDoS (Medium, CWE-1333)

Root cause. lre_check_timeout() (libregexp.c:2740) only preempts if rt->interrupt_handler is set; a host
that never calls JS_SetInterruptHandler gets unbounded backtracking.

Impact. /(a+)+$/-style patterns stall one worker/thread for arbitrarily long on crafted input.

Reproduction.

'a'.repeat(100000) + '!'.match(/(a+)+$/);

Suggested fix. Internal instruction budget in lre_exec independent of the host callback
(like fuzz/fuzz_regexp.c).


F-003 — Math.sumPrecise accumulator limb overflow (Low, CWE-190)

Root cause. sum_precise_add (quickjs.c:47154) does s->acc[p] += a0 with |a0| ≤ 2⁵⁶; renormalization only
every 250 adds (quickjs.c:47210) — ~129 Number.MAX_VALUE adds overflow the int64 limb.

Impact. Wrong result / implementation-defined signed overflow; no memory corruption.

Reproduction.

Math.sumPrecise(Array(140).fill(Number.MAX_VALUE));

Suggested fix. Renormalize per add or widen the accumulator limbs.


F-004 — js_realloc_array int overflow (Low, CWE-190)

Root cause. new_size * elem_size computed in 32-bit int (quickjs.c:1914-1916, author's own
"XXX: potential arithmetic overflow"); wraps to a small size → callers write req_size elements past it.

Impact. Latent heap overflow; requires ~11–22 GB attacker-driven allocation (default malloc_limit=-1),
practically mitigated on typical hosts.

Suggested fix. size_t arithmetic + sane cap.


F-005 — Resizable SAB grow() cross-thread visibility off-spec (Low, CWE-670)

Root cause. Per-SAB byte_length instead of a shared view (quickjs.c:57242-57252; deviation documented
in-tree at :57245-57251); memory is preallocated at maxByteLength.

Impact. Two os.Worker threads sharing a resizable SAB can observe divergent byteLength after concurrent
grow() — no TypeError, no memory corruption.

Reproduction.

// thread A: sab.grow(2);  thread B: sab.grow(1)  → no TypeError, divergent views

Suggested fix. Shared atomic byteLength or reject concurrent grow().


F-006 — js_object_list hash-size wrap (Low, CWE-190)

Root cause. new_hash_size *= 2 in uint32 wraps to 0 at ~2³¹ objects (quickjs.c:37439-37442) → 0-size hash
table indexed with mask 0xFFFFFFFF (OOB write) or infinite loop. List tracks JS_ReadObject references
(:38202-38208, worker-message path).

Impact. Requires a multi-GB bytecode stream with ≥2³¹ distinct objects — practically mitigated.

Suggested fix. uint64 hash size or a hard object cap.


Reported by security research (authorized defensive audit), 2026-08-06.
Contact: cagricihamza@gmail.com — PoC inputs (crafted inputs, ASan harnesses, timing scripts) available on request.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions