Skip to content

fix(thread): deep-copy nested closures per worker to stop a cross-thread run_time_cache UAF (#176)#180

Merged
EdmondDantes merged 1 commit into
mainfrom
176-nested-closure-shared-rtc
Jul 4, 2026
Merged

fix(thread): deep-copy nested closures per worker to stop a cross-thread run_time_cache UAF (#176)#180
EdmondDantes merged 1 commit into
mainfrom
176-nested-closure-shared-rtc

Conversation

@EdmondDantes

Copy link
Copy Markdown
Contributor

Fixes #176.

Problem

A closure transferred to a worker thread got a per-worker copy of its top-level op_array, but its nested closures (op_array.dynamic_func_defs) stayed shared with the persistent snapshot — the load-path memcpy only reset the top-level run_time_cache. Instantiated on more than one worker, zend_create_closure writes a per-thread arena run_time_cache pointer into that shared op_array, which is then read from another worker — or after a retired worker's arena is freed (a long-lived coroutine spawned in a ThreadPool bootloader across reload()) — a cross-thread use-after-free that SEGVs.

Confirmed with ASAN + gdb: the top-level handler/bootloader op_arrays are distinct per worker, the nested op_array is the same address on every worker.

Fix (thread.c)

  1. async_thread_create_closure (shared load path — bootloader / entry / submit): self-contain the whole op_array via op_array_to_emalloc only when it carries nested defs, so every nested def becomes worker-local (IMMUTABLE cleared, fresh run_time_cache). Closures without nested definitions keep the cheap borrow — no cost on the hot task path.
  2. op_array_to_emalloc: duplicate the nested static_variables — the snapshot stores it as a persistent immutable array (refcount 2) that destroy_op_array can't free — into a worker-local mutable copy.
  3. closure_transfer_obj: its op_array_to_emalloc is now gated on num_dynamic_func_defs == 0 to avoid a double-copy, since the load path already does the full copy for nested-def closures.

The refcount != NULL early-return in destroy_op_array is why localizing nested defs requires full self-containization; the coroutine-outlives-bootloader lifecycle is handled correctly by refcounting (verified under ASAN).

Verification

  • Original repro: 3/3 SEGV before → clean after.
  • New regression tests thread_pool/078 (bootloader nested-closure coroutine across reload()) and 079 (submitted tasks declaring nested closures per worker) — both SEGV without the fix, pass clean under ASAN.
  • Full thread + thread_pool suite: 153 tests, 0 failed (no regressions).

…ead run_time_cache UAF (#176)

A closure transferred to a worker thread got a per-worker copy of its
top-level op_array, but its nested closures (op_array.dynamic_func_defs)
stayed shared with the persistent snapshot: the load-path memcpy only
reset the top-level run_time_cache. Instantiated on more than one worker,
zend_create_closure writes a per-thread arena run_time_cache pointer into
that shared op_array — read from another worker, or after a retired
worker's arena is freed (a long-lived coroutine spawned in a ThreadPool
bootloader across reload()) — a cross-thread use-after-free that SEGVs.

async_thread_create_closure now self-contains the whole op_array via
op_array_to_emalloc whenever it carries nested definitions, so every
nested def becomes worker-local (immutable cleared, fresh run_time_cache).
op_array_to_emalloc additionally duplicates the nested static_variables
template — the snapshot stores it as a persistent immutable array
(refcount 2), which destroy_op_array cannot free — into a worker-local
mutable copy. The transfer path (closure_transfer_obj) now only detaches
the top-level when there are no nested defs, since the load path already
did the full copy. Closures without nested definitions keep the cheap
borrow and are unaffected.

Adds tests/thread_pool/078 (bootloader nested-closure coroutine across
reload) and 079 (submitted tasks declaring nested closures per worker);
both SEGV without the fix and pass clean under ASAN.
@codecov

codecov Bot commented Jul 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@EdmondDantes EdmondDantes merged commit bd8e246 into main Jul 4, 2026
9 checks passed
@EdmondDantes EdmondDantes deleted the 176-nested-closure-shared-rtc branch July 4, 2026 20:41
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.

Nested closures (dynamic_func_defs) are shared across worker threads on thread transfer → cross-thread run_time_cache use-after-free (SEGV)

1 participant