#546·quickjs

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

Author: Boreas37Created Aug 6, 2026Updated Aug 6, 2026

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.

javascript
// 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.

javascript
'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.

javascript
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.

javascript
// 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: [email protected] — PoC inputs (crafted inputs, ASan harnesses, timing scripts) available on request.