js_dispose_resources (quickjs.c:54131) has a use-after-free. When DisposableStack.prototype[Symbol.dispose] runs, it frees each resource's value/method inside its LIFO loop but does not shrink resource_count or clear the freed slots until the loop finishes, while js_disposable_stack_mark (quickjs.c:54329) marks resources[0 .. resource_count). A disposer that triggers a GC therefore makes the marker traverse already-freed resources → reference-count underflow / use-after-free.
Reproduces on master d950d55, -O2 -g (and with -fsanitize=address).
PoC (deterministic)
const s = new DisposableStack();
s.adopt({}, () => { gc(); }); // disposed last (i=0); its disposer runs the GC
s.use({ [Symbol.dispose]() {} }); // disposed first (i=1); freed before the adopt disposer runs
s.dispose();
gc() is only used to make the timing deterministic — any disposer that allocates enough to trigger cycle collection hits the same UAF.
Backtrace
quickjs.c:7323: gc_decref_child: Assertion `JS_REF_COUNT(p) > 0' failed.
#7 gc_decref_child quickjs.c:7323
#8 JS_MarkValue quickjs.c:7198
#9 js_disposable_stack_mark quickjs.c:54329 # marks resources[0 .. resource_count)
#10 gc_decref quickjs.c:7344
#11 JS_RunGC quickjs.c:7445
#12 js_gc qjs.c:209 # gc() invoked inside the disposer
#16 JS_Call quickjs.c:20855 # js_dispose_resources calling the disposer
#17 js_dispose_resources quickjs.c:54131 # LIFO loop, i=0; the i=1 slot is already freed
In a release build (asserts off) this is a use-after-free / heap corruption rather than an abort.
Fix
The dispose loop frees res->value/res->method but leaves the slot dangling until the loop ends, so a re-entrant GC mark reads freed values. Clear each slot right after freeing it:
JS_FreeValue(ctx, res->value);
JS_FreeValue(ctx, res->method);
res->value = JS_UNDEFINED;
res->method = JS_UNDEFINED;
Verified: the PoC no longer crashes. js_disposable_stack_mark / js_disposable_stack_clear already handle JS_UNDEFINED.
Credit
xmzyshypnc(@xmzyshypnc) and Yanjie Zhao(@carol233) and Yiyang Liu(@lyyffee)
js_dispose_resources(quickjs.c:54131) has a use-after-free. WhenDisposableStack.prototype[Symbol.dispose]runs, it frees each resource'svalue/methodinside its LIFO loop but does not shrinkresource_countor clear the freed slots until the loop finishes, whilejs_disposable_stack_mark(quickjs.c:54329) marksresources[0 .. resource_count). A disposer that triggers a GC therefore makes the marker traverse already-freed resources → reference-count underflow / use-after-free.Reproduces on master
d950d55,-O2 -g(and with-fsanitize=address).PoC (deterministic)
gc()is only used to make the timing deterministic — any disposer that allocates enough to trigger cycle collection hits the same UAF.Backtrace
In a release build (asserts off) this is a use-after-free / heap corruption rather than an abort.
Fix
The dispose loop frees
res->value/res->methodbut leaves the slot dangling until the loop ends, so a re-entrant GC mark reads freed values. Clear each slot right after freeing it:Verified: the PoC no longer crashes.
js_disposable_stack_mark/js_disposable_stack_clearalready handleJS_UNDEFINED.Credit
xmzyshypnc(@xmzyshypnc) and Yanjie Zhao(@carol233) and Yiyang Liu(@lyyffee)