Hacktoberfest 2026: le issue che i maintainer hanno segnato per ottobre, aperte e adatte ai principianti. Sfoglia le issue Hacktoberfest

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

Aperta
#1,570 2 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 2 giorni

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
5/5
Tempo stimato
Più di una settimana
Idoneità per principianti
35/100
Tipo di issue
Bug
Chiarezza
Specificata chiaramente
Stato di attività
Tranquilla
Stack tecnologico
c, javascript
Ambito
compilers

Direzione di ricerca

Start in quickjs.c at async_func_mark, the code that sets cur_sp while the function runs, and gc_free_cycles; trace the for-await-of reproduction through js_for_of_start and JS_GetIterator2. The issue does not name a test file, so first check the project’s existing GC and async-function tests and run the supplied reproducer. Done means the running async function’s live stack remains visible to cycle collection and the reproducer no longer causes a use-after-free.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

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)

Lingua principale
C
Stelle
3.9k
Fork
389
Merge medio
4g 1h
PR unite (30g)
24

Preparare l'ambiente

Questo progetto non fornisce container di sviluppo, Dockerfile né guida per i contributori, quindi l'ambiente è a tuo carico: parti dal suo README e consulta la nostra guida al primo contributo per i passaggi generali.

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di quickjs-ng/quickjs

Tutte le issue di quickjs-ng/quickjs

Issue simili

Altre issue su C

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.