Skip to content

use-after-free: cycle GC does not mark a running async function's operand stack (async_func_mark), triggerable via for-await-of + using #1570

Description

@xmzyshypnc

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions