Skip to content

UAF of a suspended coroutine referenced only via a closure#1569

Open
saghul wants to merge 1 commit into
masterfrom
fix-uaf-gc-async-coro
Open

UAF of a suspended coroutine referenced only via a closure#1569
saghul wants to merge 1 commit into
masterfrom
fix-uaf-gc-async-coro

Conversation

@saghul

@saghul saghul commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

No description provided.

@saghul

saghul commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

@bnoordhuis Here is the context for the GC-stress PR. When I added it to txiki.js one of my tests started to fail.

This is the smallest possible repro.

I have an AI generated fix but I'm not super-confident about its correctness here.

@bnoordhuis bnoordhuis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know about this approach. Apart from all the other changes, enlarging JSVarRef is undesirable.

It's only possible when the async function's promise is not awaited, right? What about putting all live promises on a linked list and keep them artificially alive during GC?

Pro: also a useful tool for debugging promise leaks on context destruction.
Con: adds a O(n) step to the GC phase.

Another approach/hack: artificially increase the promise's reference count by one when it's created, then decrement it when it's awaited, then'd, etc. In other words, prevent it from getting reclaimed unless it's consumed by something else.

Comment on lines +7 to +14
// A closure capturing a coroutine local holds an *open* var_ref into the
// coroutine's frame. Open var_refs are not GC objects and are not marked by
// the closure, so the edge closure -> open var_ref -> value is invisible to
// the cycle collector. An ordinary function's frame is a live C-stack root, but
// a suspended coroutine's frame is a heap GC object, so if the only thing
// keeping it reachable is such an escaped closure, the whole still-live
// coroutine used to be collected -- and resuming it, or running the closure,
// then touched freed memory.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this comment LLM-generated? I know how the underlying mechanism works but the comment is worded in a way that still confuses me...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It sure is 😅

@saghul

saghul commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for taking a look! Those sound like good ideas, I'll take a look!

A suspended coroutine (async function, generator or async generator) keeps
its activation frame in a heap-allocated GC object. When a closure captures
one of the coroutine's locals it holds an *open* var_ref that points into
that frame. Open var_refs were not GC objects and were not traced by their
holders, so the edge closure -> var_ref -> coroutine frame was invisible to
the cycle collector: a coroutine reachable only through such a closure was
collected while still live, and resuming it (or running the closure) then
touched freed memory.

Make an open var_ref that captures a coroutine local a GC object holding a
counted reference to the owning coroutine (JSStackFrame.cur_gc_obj) and mark
it from every var_ref holder, so the collector sees the edge and keeps the
suspended coroutine (and the frame the var_ref points into) alive.
@saghul
saghul force-pushed the fix-uaf-gc-async-coro branch from 6469041 to 46a3d0f Compare July 23, 2026 09:49
@saghul

saghul commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

@bnoordhuis I did another pass (cleanup pending). The new approach doesn't extend the size of JSVarRef.

I couldn't get your proposal(s) to work because the bug also affects 2 more case without promises involved: plain generators and the mapped arguments fast path.

Comment thread quickjs.c
(e.g. mapped `arguments`) must stay non-coro even though cur_gc_obj is
later set. Fits existing padding, so JSVarRef does not grow. Cleared
when the var_ref is detached; the ref is released on detach/free. */
uint8_t is_coro;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this implied by !var_ref->detached && var_ref->stack_frame->cur_gc_obj != NULL? If that's the case, it'd be better to remove is_coro and have something like bool js_is_coro(JSVarRef *vr).

Comment thread quickjs.c
if (JS_GC_TYPE(coro) == JS_GC_OBJ_TYPE_ASYNC_FUNCTION)
js_async_function_free(rt, (JSAsyncFunctionData *)coro);
else /* generator / async generator: a plain JS object */
JS_FreeValueRT(rt, JS_MKPTR(JS_TAG_OBJECT, (JSObject *)coro));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe assert that JS_GC_TYPE(coro) == JS_GC_OBJ_TYPE_JS_OBJECT here? Or turn the if/else into a switch statement with a default: abort(); clause?

@saghul
saghul marked this pull request as ready for review July 23, 2026 20:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants