Summary
Use-after-free in the cycle garbage collector. async_func_mark (quickjs.c:21077) does not mark a running async function's operand stack: while the function is running sf->cur_sp is NULL (quickjs.c:17999), and the marker skips the stack on the assumption (in the comment) that "a running function cannot be part of a removable cycle". for await (using … of asyncIterable) breaks that assumption. While the async function is running and calling the object's [Symbol.asyncIterator] method (which allocates and can trigger a GC), an object living on its operand stack that is part of a reference cycle is under-counted by the cycle collector and freed by gc_free_cycles, even though the stack still references it → use-after-free.
Environment
quickjs-ng master d950d55 (2026-07-13)
OS : Linux x86_64
Build : clang-21, cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_FLAGS_RELEASE="-O2 -g" (asserts enabled)
Note : this reproduces on the normal/stock build under ordinary GC — it is how the bug was
first hit (intermittent crashes during fuzzing on a stock ASan/-O2 build). Adding
-DFORCE_GC_AT_MALLOC only forces the GC to run at the vulnerable point so the crash
becomes deterministic; the flag is used only for verification and is not required for the bug.
PoC
The reproducer is a fuzzer-found program; the essential structure is a for await + using over an object whose async-iterator method allocates:
async function main() {
const iterable = {};
function asyncIter() {
async function* g() { return g; }
Object.defineProperty(g, "then", { configurable: true, value: asyncIter });
const it = g();
it.next();
return it;
}
iterable[Symbol.asyncIterator] = asyncIter;
for await (using r of iterable) {}
}
main();
This is a GC-timing use-after-free that happens on a normal build: it occurs whenever a GC runs while the async function is running with a cycle-member on its operand stack (this is exactly how it was found — as intermittent crashes during fuzzing on a stock ASan/-O2 build). Building with -DFORCE_GC_AT_MALLOC forces the GC at that point and makes it deterministic (12/12); that flag is used only to verify the root cause, it is not needed to trigger the bug. The exact deterministic reproducer can be provided.
Crash
SIGSEGV in gc_scan_incref_child quickjs.c:7355 (JS_REF_COUNT(p)++ with p == NULL)
The object being marked has shape == NULL and class_id == 0: it was already freed by gc_free_cycles() in an earlier GC but is still reachable, so a later gc_scan dereferences its NULL shape. In a release build (asserts off) this is a use-after-free rather than a clean fault.
Backtrace
Crash (a later GC marks the freed object):
#0 gc_scan_incref_child quickjs.c:7355 # JS_REF_COUNT(p)++ , p == NULL
#1 mark_children quickjs.c:7233 # mark_func(rt, &p->shape->header), p->shape == NULL
#2 gc_scan quickjs.c:7380
#3 JS_RunGC quickjs.c:7448
#4 js_trigger_gc quickjs.c:1658
#5 JS_NewObjectFromShape quickjs.c:6110
#11 js_async_from_sync_iterator_next quickjs.c:55956
... async_func_resume -> promise_reaction_job -> JS_ExecutePendingJob
Where the object was actually freed (premature cycle collection, an earlier GC):
#0 free_object quickjs.c:7033
#1 gc_free_cycles quickjs.c:7408 # cycle collector frees it
#2 js_trigger_gc quickjs.c:1659
#3 JS_NewObjectClass quickjs.c:6297 # allocation inside the iterator method
#4 js_call_c_function quickjs.c:17832
#5 JS_CallInternal quickjs.c:18026
#6 JS_GetIterator2 quickjs.c:16896 # calling obj[Symbol.asyncIterator]()
#8 js_for_of_start quickjs.c:17062 # for-await-of start, in the RUNNING async function
#9 JS_CallInternal quickjs.c:19209
Root cause
async_func_mark (quickjs.c:21077):
if (sf->cur_sp) {
/* if the function is running, cur_sp is not known so we
cannot mark the stack. Marking the variables is not needed
because a running function cannot be part of a removable
cycle */
for(sp = sf->arg_buf; sp < sf->cur_sp; sp++)
JS_MarkValue(rt, *sp, mark_func);
}
sf->cur_sp is set to NULL while the function runs (quickjs.c:17999, "cur_sp is NULL if the function is running"). So when a GC runs while an async function is executing — here inside OP_for_of_start → js_for_of_start → JS_GetIterator2, calling the object's [Symbol.asyncIterator] method, which allocates — the async function's operand stack is not visited by the cycle collector. If a stack temporary there is part of a reference cycle (in the failing case it is a {} captured by a closure JSVarRef), gc_decref drives its refcount to 0 and gc_free_cycles frees it, even though the running function's stack still holds it. A later GC then marks the freed object and dereferences its NULL shape.
Smoking gun: in free_object, during JS_GC_PHASE_REMOVE_CYCLES, the object being freed still has JS_REF_COUNT(p) != 0 — it is still referenced, i.e. it is collected prematurely. Dynamically confirmed on a -DFORCE_GC_AT_MALLOC + instrumented build: the victim is allocated by OP_object (a {} literal), freed by gc_free_cycles, and at free time is held by a JSVarRef with refcount 1.
Impact
Use-after-free reachable directly from script (for await + using). The faulting operation is JS_REF_COUNT(p)++ in gc_scan_incref_child on the freed object's shape pointer; it is NULL only because the freed backing memory happens to be zeroed and was not reused. If that memory is reallocated with attacker-controlled contents before the GC runs, this becomes a controlled increment on an attacker-controlled address (a write primitive).
Suggested fix
The root problem is that cur_sp == NULL is overloaded to mean "the function is running", which also makes the operand stack invisible to the GC. I think a workable fix might be to stop overloading cur_sp for that: keep sf->cur_sp pointing at the live top of stack throughout execution (updated in the interpreter dispatch loop, or at least before any opcode that can re-enter the engine / allocate), and use a separate flag for the running/suspended state that async_func_free checks. async_func_mark's existing for (sp = sf->arg_buf; sp < sf->cur_sp; sp++) would then mark the correct live stack at every GC safepoint.
I tried two narrower variants — marking only args+locals when cur_sp==NULL, and setting cur_sp around the call opcodes — but neither held: many opcodes (OP_for_of_start, property getters, valueOf/Symbol.toPrimitive coercions, …), not just call opcodes, can trigger a GC while cur_sp==NULL, so the live top has to be valid at every safepoint rather than only at call sites. Happy to iterate on the right form of this.
Credit
xmzyshypnc(@xmzyshypnc) and Yanjie Zhao(@carol233) and Yiyang Liu(@lyyffee)
Summary
Use-after-free in the cycle garbage collector.
async_func_mark(quickjs.c:21077) does not mark a running async function's operand stack: while the function is runningsf->cur_spisNULL(quickjs.c:17999), and the marker skips the stack on the assumption (in the comment) that "a running function cannot be part of a removable cycle".for await (using … of asyncIterable)breaks that assumption. While the async function is running and calling the object's[Symbol.asyncIterator]method (which allocates and can trigger a GC), an object living on its operand stack that is part of a reference cycle is under-counted by the cycle collector and freed bygc_free_cycles, even though the stack still references it → use-after-free.Environment
PoC
The reproducer is a fuzzer-found program; the essential structure is a
for await+usingover an object whose async-iterator method allocates:This is a GC-timing use-after-free that happens on a normal build: it occurs whenever a GC runs while the async function is running with a cycle-member on its operand stack (this is exactly how it was found — as intermittent crashes during fuzzing on a stock ASan/
-O2build). Building with-DFORCE_GC_AT_MALLOCforces the GC at that point and makes it deterministic (12/12); that flag is used only to verify the root cause, it is not needed to trigger the bug. The exact deterministic reproducer can be provided.Crash
The object being marked has
shape == NULLandclass_id == 0: it was already freed bygc_free_cycles()in an earlier GC but is still reachable, so a latergc_scandereferences its NULLshape. In a release build (asserts off) this is a use-after-free rather than a clean fault.Backtrace
Crash (a later GC marks the freed object):
Where the object was actually freed (premature cycle collection, an earlier GC):
Root cause
async_func_mark(quickjs.c:21077):sf->cur_spis set toNULLwhile the function runs (quickjs.c:17999, "cur_sp is NULL if the function is running"). So when a GC runs while an async function is executing — here insideOP_for_of_start → js_for_of_start → JS_GetIterator2, calling the object's[Symbol.asyncIterator]method, which allocates — the async function's operand stack is not visited by the cycle collector. If a stack temporary there is part of a reference cycle (in the failing case it is a{}captured by a closureJSVarRef),gc_decrefdrives its refcount to 0 andgc_free_cyclesfrees it, even though the running function's stack still holds it. A later GC then marks the freed object and dereferences its NULLshape.Smoking gun: in
free_object, duringJS_GC_PHASE_REMOVE_CYCLES, the object being freed still hasJS_REF_COUNT(p) != 0— it is still referenced, i.e. it is collected prematurely. Dynamically confirmed on a-DFORCE_GC_AT_MALLOC+ instrumented build: the victim is allocated byOP_object(a{}literal), freed bygc_free_cycles, and at free time is held by aJSVarRefwith refcount 1.Impact
Use-after-free reachable directly from script (
for await+using). The faulting operation isJS_REF_COUNT(p)++ingc_scan_incref_childon the freed object's shape pointer; it isNULLonly because the freed backing memory happens to be zeroed and was not reused. If that memory is reallocated with attacker-controlled contents before the GC runs, this becomes a controlled increment on an attacker-controlled address (a write primitive).Suggested fix
The root problem is that
cur_sp == NULLis overloaded to mean "the function is running", which also makes the operand stack invisible to the GC. I think a workable fix might be to stop overloadingcur_spfor that: keepsf->cur_sppointing at the live top of stack throughout execution (updated in the interpreter dispatch loop, or at least before any opcode that can re-enter the engine / allocate), and use a separate flag for the running/suspended state thatasync_func_freechecks.async_func_mark's existingfor (sp = sf->arg_buf; sp < sf->cur_sp; sp++)would then mark the correct live stack at every GC safepoint.I tried two narrower variants — marking only args+locals when
cur_sp==NULL, and settingcur_sparound the call opcodes — but neither held: many opcodes (OP_for_of_start, property getters,valueOf/Symbol.toPrimitivecoercions, …), not just call opcodes, can trigger a GC whilecur_sp==NULL, so the live top has to be valid at every safepoint rather than only at call sites. Happy to iterate on the right form of this.Credit
xmzyshypnc(@xmzyshypnc) and Yanjie Zhao(@carol233) and Yiyang Liu(@lyyffee)