Skip to content

Fix two QuickJS GC use-after-frees (handle-wrap teardown + async-generator closures)#101

Merged
syrusakbary merged 4 commits into
mainfrom
fix/quickjs-gc-uaf
Jun 30, 2026
Merged

Fix two QuickJS GC use-after-frees (handle-wrap teardown + async-generator closures)#101
syrusakbary merged 4 commits into
mainfrom
fix/quickjs-gc-uaf

Conversation

@Arshia001

@Arshia001 Arshia001 commented Jun 30, 2026

Copy link
Copy Markdown
Member

Two independent QuickJS cycle-GC use-after-free fixes found while bringing up async-heavy framework tests (Etherpad). No framework-test changes are included here.

1. Handle-wrap finalize/close re-entrancy (this repo, src/)

A napi external finalizer could run re-entrantly during a libuv close callback and free a handle wrap mid-teardown (originally seen as a signal-wrap double-free at shutdown). Fixed by routing all handle wraps through a single centralized close/finalize teardown with an in_close_callback guard, so finalize and close can't free out from under each other.

  • cafec1df Fix signal-wrap GC use-after-free via centralized handle teardown
  • 43767ecd Route all handle wraps through the shared close/finalize teardown

2. Async function/generator closure cycle-GC UAF (submodule bump)

The cycle collector could free a Promise resolving function still referenced by a suspended async generator's closure var_ref → heap-use-after-free in gc_decref_child during a later GC. It surfaces in async-heavy apps iterating streams (Etherpad, undici) and reproduces deterministically with for await (const x of socket) under GC pressure.

Root cause: this is CVE-2023-48184 / bellard/quickjs#156 ("incorrect GC of async functions with closures"), fixed in bellard via commit 7414e5f (2024-01-13) but never adopted by quickjs-ng (the fork we vendor still ships the old async_func_mark/JSAsyncFunctionData design on master). The fix ports that restructuring to our quickjs-ng tree, adapted to quickjs-ng's array-based sf->var_refs[].

Follow-up (regression in the port): the ported design stores a frame's var_refs[] array inside its arg_buf allocation, which opened a new cycle-teardown ordering hole — when an incomplete async generator is collected as part of a cycle, async_func_free_frame() frees arg_buf while open closure var_refs still point into it, so a later free_var_ref() writes into freed memory (heap-UAF, corrupted size vs. prev_size). This reproduced deterministically via test-stream-iterator-helpers-test262-tests (the CI failure on this PR) and is fixed by neutralizing still-open var_refs into detached/empty refs before the frame is freed. Verified: 1/1 crash → 30/30 pass + ASAN-clean.

⚠️ Not final yet

The napi submodule pointer here references the unmerged quickjs fix via the PR branch. This PR is not final until:

  1. Fix cycle-GC use-after-free of async functions/generators with closures quickjs#6 merges, then
  2. Bump quickjs: cycle-GC use-after-free fix for async closures napi#36 is updated to the merged quickjs commit and merges, then
  3. the napi pointer in this PR is updated to that merged napi commit.

Dependency chain: wasmerio/quickjs#6wasmerio/napi#36 → this PR.

🤖 Generated with Claude Code

Arshia001 and others added 3 commits June 30, 2026 09:38
EdgeJS QuickJS aborted ("double free / heap corruption") under allocation
pressure (e.g. running Etherpad, or rapid process signal-listener churn).
ASAN traced it to a use-after-free: a handle wrap's uv close callback
(OnClosed) runs a JS "onclose" callback mid-teardown; that JS re-enters
the GC, whose cycle collector runs the wrap's napi finalizer, which frees
the wrap out from under the still-executing close callback.

Root cause was structural: each handle wrap (signal/udp/process/...) hand-
rolled its own OnClosed + finalizer over a shared helper *toolbox*, and the
implementations drifted -- signal_wrap never guarded the re-entrant
finalize that the others happened to avoid.

This centralizes the dangerous teardown into two shared helpers,
EdgeHandleWrapCompleteClose and EdgeHandleWrapFinalizeNative, which own the
ordering once: an in_close_callback guard makes the finalizer defer freeing
while a close is on the stack, and deletion happens exactly once afterwards.
signal_wrap now delegates its OnClosed/finalizer to them, so the bug cannot
recur there and new wraps can adopt the same safe path instead of
re-deriving it.

Verified with an ASAN build: a minimal signal-churn + gc() repro and real
SIGUSR2 delivery both pass; the signal UAF no longer reproduces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrate the remaining handle wraps -- process, udp, fs-event, fs
StatWatcher, and MessagePort -- off their hand-rolled OnClose/finalizer
implementations and onto EdgeHandleWrapCompleteClose /
EdgeHandleWrapFinalizeNative (added with the signal fix). This removes the
duplicated, drift-prone teardown logic that caused the signal use-after-
free: the re-entrancy guard and deletion ordering now live in one place
for every wrap.

Each wrap now provides only a small deleter (its async-destroy / queued-
message / owner-ref cleanup + delete) and thin OnClose/finalizer trampolines.
The per-wrap EdgeHandleWrapHoldWrapperRef "pin" is dropped: it is redundant
with EdgeRegisterActiveHandle's strong keepalive_ref (the real keep-alive
while a handle is active) and, unlike the guard, did not actually protect
against the QuickJS cycle collector.

Verified under ASAN: signal/process/udp/MessagePort churn + gc() repros are
all clean, and the signalwrap/statwatcher/pipewrap/tcpwrap Node async-hook
tests pass. (test-worker-message-port still fails on a pre-existing
ArrayBuffer-transfer-detach gap, unrelated to handle teardown.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings in wasmerio/napi#36 -> wasmerio/quickjs#6, porting the
CVE-2023-48184 / bellard#156 fix to our quickjs-ng tree. The cycle
collector no longer frees a Promise resolving function still referenced by
a suspended async generator's closure var_ref (heap-use-after-free in
gc_decref_child during a later GC). Surfaces in async-heavy apps iterating
streams (Etherpad, undici); reproduces with `for await (const x of socket)`
under GC pressure.

This complements the handle-wrap teardown fix already in this branch; both
are QuickJS GC use-after-free fixes.

NOTE: not final until wasmerio/quickjs#6 and wasmerio/napi#36 merge; update
the napi pointer to the merged commit afterward.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Arshia001 Arshia001 changed the title Fix QuickJS GC use-after-free in handle wraps via centralized teardown Fix two QuickJS GC use-after-frees (handle-wrap teardown + async-generator closures) Jun 30, 2026
@Arshia001 Arshia001 requested a review from syrusakbary June 30, 2026 16:33
Pulls in the quickjs fix (via napi 8bfea8b -> quickjs 14bf204) for a
heap-use-after-free where open closure var_refs could outlive the async
frame they point into when an incomplete async function/generator is freed
during cycle collection. Reproduced deterministically by
test-stream-iterator-helpers-test262-tests; ASAN-clean after.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@syrusakbary syrusakbary merged commit 2fbf4dc into main Jun 30, 2026
10 checks passed
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