19db053a7d (master): Fix use-after-free of rb_thread_t in the M:N termination epilogue

0 views
Skip to first unread message

Koichi Sasada

unread,
Aug 28, 2026, 12:28:21 AM (4 days ago) Aug 28
to ruby...@g.ruby-lang.org
Koichi Sasada 2026-08-27 23:27:01 +0000 (Thu, 27 Aug 2026)

New Revision: 19db053a7d

https://github.com/ruby/ruby/commit/19db053a7d

Log:
Fix use-after-free of rb_thread_t in the M:N termination epilogue

coroutine_thread_terminated() leaves the Ractor's living set before handing
the scheduler slot over, then keeps using th for the rest of the function:
thread_sched_to_dead_common() reads th->nt and deregisters th from the
barrier, and the epilogue then stashes th->nt and clears th->sched.context.
Off the set th is unreachable, and once deregistered no barrier waits for
it, so a sweep in another Ractor can free it mid-epilogue. The epilogue
reads freed memory, and co_start() dereferences the NULL it finds in
tctx->nt -- SEGV at 0xc8, the offset of dead_co in struct rb_native_thread.

The removal cannot simply move after the handoff: the GC's root scan walks
r->threads.set without the Ractor lock (relying on the barrier), so the
unlink must stay where no walker can run -- while th is still registered
and before a successor is designated. Nor can the th accesses move before
the removal: deregistration itself writes th's running-list node.

Instead, keep the removal where it is and give the dying thread an explicit
root: r->threads.dying_th, marked by the root scan exactly like a set
member, set just before the removal and cleared inside the scheduler-lock
section after the epilogue's last use of th. Clearing under the lock
serialises the epilogue against its successor -- even a dedicated one woken
by to_dead_common() first runs thread_sched_to_running(), which takes the
same lock -- so successive epilogues cannot overwrite each other's slot,
and the successor cannot publish threads.running_ec before the dying
thread has cleared it. Nothing reads th after the clear: the final unlock
avoids the debug-logging wrapper (which reads th->serial), and whether the
designated successor needs the Ractor enqueued is decided while still
holding the lock, since a dedicated successor may run -- and die, freeing
itself -- the moment the lock is released. The atfork paths reset the slot
so a fork taken mid-epilogue cannot leak a stale root into the child. The
slot is accessed with atomic ops: the root scan may read it while another
Ractor's dying thread writes it.

Also initialise tctx->nt, which native_thread_create_shared() left holding
whatever ruby_xmalloc() returned.

Not addressed, pre-existing on master: rb_postponed_job_trigger_for_ractor()
can copy threads.running_ec and dereference it arbitrarily later (the new
root narrows this but cannot protect an already-copied pointer), and a
freshly created dedicated native thread publishes its EC via
ruby_thread_set_native() before taking the scheduler lock.

Two Ractors looping over Thread.new and GC.start crash master in about
three seconds (19 of 20 runs); an ASAN build reports the heap-use-after-free
directly, at the read of th->nt in coroutine_thread_terminated(), freed by
gc_sweep via rb_thread_free_body. With this change ASAN is clean (0/10 vs
7/10 interleaved), as are the reproducer, s7_nested, both branches of the
epilogue under stress, a Ractor/GC soak, btest and test-all on release and
RUBY_DEBUG=1 builds.

Co-Authored-By: Claude Opus 5 <nor...@anthropic.com>

Modified files:
ractor.c
ractor_core.h
thread_pthread.c
thread_pthread_mn.c
Reply all
Reply to author
Forward
0 new messages