Security findings: BigInt interrupt-bypass CPU DoS (verified) and 5 other issues — commit 04be246
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 viewsSuggested 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.
Source: bellard/quickjs