Heap-buffer-overflow in add_fast_array_element() via String.prototype.split("")
Maintainers usually reply within 2 days
Nobody has claimed this yet.
Assessment
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Newbie friendliness
- 66/100
Research direction
Start in quickjs.c at expand_fast_array() and add_fast_array_element(), then reproduce the supplied String.prototype.split("") case with an AddressSanitizer build. Check the capacity boundary against the write at quickjs.c:9766 and verify the fix removes the reported out-of-bounds write; the issue names no regression test.
Written by the indexing model from the issue text.
Description
Heap-buffer-overflow in add_fast_array_element() via String.prototype.split("")
Affected commit: 712e8c0ca26949477c38f91dbaf955cc48e2f261 (2026-11-02)
Severity: High — 8-byte out-of-bounds write past heap allocation
Reproducibility: 100%
Found by: coverage-guided fuzzing with AddressSanitizer
ASan Stack Trace
==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x616000001188
WRITE of size 8 at 0x616000001188 thread T0
#0 add_fast_array_element quickjs.c:9766
#1 JS_CreateProperty quickjs.c:10361
#2 JS_DefineProperty quickjs.c:10870
#3 JS_DefinePropertyValue quickjs.c:10913
#4 JS_DefinePropertyValueValue quickjs.c:10930
#5 js_string_split quickjs.c:48797
#6 js_call_c_function quickjs.c:18073
#7 JS_CallInternal quickjs.c:18280
#8 JS_CallInternal quickjs.c:18684
#9 JS_CallFree quickjs.c:21306
#10 JS_EvalFunctionInternal quickjs.c:38319
#11 __JS_EvalInternal quickjs.c:38452
#12 JS_Eval quickjs.c:38519
0x616000001188 is 0 bytes past the end of a 520-byte heap region allocated by js_realloc2 (from expand_fast_array).
Root Cause
expand_fast_array() (quickjs.c:9723) — computes new capacity:
static int expand_fast_array(JSContext *ctx, JSObject *p, uint32_t new_len)
{
uint32_t new_size;
size_t slack;
JSValue *new_array_prop;
/* XXX: potential arithmetic overflow */
new_size = max_int(new_len, p->u.array.u1.size * 3 / 2);
new_array_prop = js_realloc2(ctx, p->u.array.u.values,
sizeof(JSValue) * new_size, &slack);
...
p->u.array.u1.size = new_size;
}
add_fast_array_element() (quickjs.c:9749) — capacity check and write:
static inline int add_fast_array_element(JSContext *ctx, JSObject *p, ...)
{
uint32_t new_len = p->u.array.count + 1;
...
if (unlikely(new_len > p->u.array.u1.size)) { // ← boundary: `>` should be `>=`
expand_fast_array(ctx, p, new_len);
}
p->u.array.u.values[new_len - 1] = val; // ← OOB write at line 9766
}
The off-by-one: when p->u.array.u1.size already equals new_len (i.e., the buffer was allocated for exactly new_len elements in a previous expand_fast_array call with no slack returned by js_realloc2), the > check passes without expanding. The subsequent write to values[new_len - 1] uses new_len = count + 1, which is size + 1 — writing one JSValue (8 bytes) past the end of the buffer.
The trigger path via js_string_split repeatedly calls JS_CreateDataPropertyUint32 for each split element, hitting this boundary condition after the array reaches a specific allocation size.
PoC (81 bytes, minimized)
"auuuuuuuuuPpr( a= !d=uitr a= !d=uitrnŰ{ buŰ, buint8/////tbc".split("").r===
To reproduce:
# Build with ASan
clang -fsanitize=address -g -o qjs_asan quickjs.c libregexp.c libunicode.c cutils.c quickjs-libc.c
# Run PoC
./qjs_asan -i poc.js
Suggested Fix
One-line change — use >= instead of > so the buffer is expanded when new_len equals the current capacity:
--- a/quickjs.c
+++ b/quickjs.c
@@ -9763,7 +9763,7 @@ static inline int add_fast_array_element(JSContext *ctx, JSObject *p,
}
}
- if (unlikely(new_len > p->u.array.u1.size)) {
+ if (unlikely(new_len >= p->u.array.u1.size)) {
if (expand_fast_array(ctx, p, new_len)) {
JS_FreeValue(ctx, val);
return -1;
- Dominant language
- C
- Stars
- 11.1k
- Forks
- 1.2k
- Avg merge
- 11h 20m
- Merged PRs (30d)
- 1
Getting set up
This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from bellard/quickjs
-
Difficulty 2/5 1-3 hours Newbie friendliness 74/100
Maintainers usually reply within 2 days
-
Difficulty 5/5 Over a week Newbie friendliness 35/100
Maintainers usually reply within 2 days
-
Difficulty 4/5 3-5 days Newbie friendliness 45/100
Maintainers usually reply within 2 days
-
Difficulty 3/5 1-2 days Newbie friendliness 68/100
Maintainers usually reply within 2 days
-
Difficulty 4/5 3-5 days Newbie friendliness 48/100
Maintainers usually reply within 2 days
Similar issues
-
5700模组网络优先级问题Open
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
-
severity: low
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
luainkernel/lunatik#2372 ·
Maintainers usually reply within 1 day
-
Difficulty 1/5 Under an hour Newbie friendliness 75/100
ly05010419/MacNativeGames#1796 ·
Maintainers usually reply within 1 day
-
ZSTD_decompressStream accepts a Compressed_Block with Block_Size 0 that ZSTD_decompress rejectsOpen
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
jniebuhr/gaggimate#1009 · 1 comment ·
Maintainers usually reply within 1 day