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.
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).
quickjs.c:11860,:11958,:12105,:12699toString()are O(n²) with no interrupt poll → CPU DoS that bypassesJS_SetInterruptHandler✅libregexp.c:2740/2774,quickjs.c:48006quickjs.c:47202Math.sumPreciseaccumulator limb int64 overflow → wrong result/UBquickjs.c:1916js_realloc_arraynew_size * elem_sizeint overflow (multi-GB precondition)quickjs.c:57245grow()cross-thread byteLength visibility off-specquickjs.c:37440js_object_listhash-size doubling wraps to 0 at ~2³¹ objectsF-001 — BigInt ops bypass the interrupt handler → CPU DoS (Medium, CWE-400) ✅ verified
Root cause. The interpreter polls
JS_SetInterruptHandleronly everyJS_INTERRUPT_COUNTER_INIT = 10000bytecode instructions (
quickjs.c:512,quickjs.c:7867). A BigInt multiply / divide / pow /toString()is asingle C call — one instruction — regardless of its quadratic cost, so it is never preempted. With no handler
installed (the
JS_NewRuntimedefault) 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_SIZEcap, and ~10⁴ consecutive ops run before the first poll → denial of service.Reproduction.
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_handlerinsidemp_mul_basecase()/mp_divnorm()(or per limb batch), orcap operand sizes.
F-002 — RegExp catastrophic backtracking → ReDoS (Medium, CWE-1333)
Root cause.
lre_check_timeout()(libregexp.c:2740) only preempts ifrt->interrupt_handleris set; a hostthat never calls
JS_SetInterruptHandlergets unbounded backtracking.Impact.
/(a+)+$/-style patterns stall one worker/thread for arbitrarily long on crafted input.Reproduction.
Suggested fix. Internal instruction budget in
lre_execindependent of the host callback(like
fuzz/fuzz_regexp.c).F-003 —
Math.sumPreciseaccumulator limb overflow (Low, CWE-190)Root cause.
sum_precise_add(quickjs.c:47154) doess->acc[p] += a0with|a0| ≤ 2⁵⁶; renormalization onlyevery 250 adds (
quickjs.c:47210) — ~129Number.MAX_VALUEadds overflow the int64 limb.Impact. Wrong result / implementation-defined signed overflow; no memory corruption.
Reproduction.
Suggested fix. Renormalize per add or widen the accumulator limbs.
F-004 —
js_realloc_arrayint overflow (Low, CWE-190)Root cause.
new_size * elem_sizecomputed in 32-bit int (quickjs.c:1914-1916, author's own"XXX: potential arithmetic overflow"); wraps to a small size → callers write
req_sizeelements 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_tarithmetic + sane cap.F-005 — Resizable SAB
grow()cross-thread visibility off-spec (Low, CWE-670)Root cause. Per-SAB
byte_lengthinstead of a shared view (quickjs.c:57242-57252; deviation documentedin-tree at
:57245-57251); memory is preallocated atmaxByteLength.Impact. Two
os.Workerthreads sharing a resizable SAB can observe divergentbyteLengthafter concurrentgrow()— noTypeError, no memory corruption.Reproduction.
// thread A: sab.grow(2); thread B: sab.grow(1) → no TypeError, divergent viewsSuggested fix. Shared atomic
byteLengthor reject concurrentgrow().F-006 —
js_object_listhash-size wrap (Low, CWE-190)Root cause.
new_hash_size *= 2in uint32 wraps to 0 at ~2³¹ objects (quickjs.c:37439-37442) → 0-size hashtable indexed with mask
0xFFFFFFFF(OOB write) or infinite loop. List tracksJS_ReadObjectreferences(
:38202-38208, worker-message path).Impact. Requires a multi-GB bytecode stream with ≥2³¹ distinct objects — practically mitigated.
Suggested fix.
uint64hash 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.