From a9f3f31a9bb1cb2055bb7ca973701e14b6976941 Mon Sep 17 00:00:00 2001 From: Max042004 Date: Thu, 9 Jul 2026 14:58:07 +0800 Subject: [PATCH] Wake internal condvar parks on exit_group teardown exit_group teardown wakes every worker parked in a guest syscall wait -- thread_interrupt_all kicks threads inside hv_vcpu_run, the wakeup pipe covers epoll/poll, and futex_interrupt_request covers futex waits. Three host-side condvar parks sit outside that set: siblings waiting out the fork barrier (fork_cond in thread_fork_barrier_check), a tracer blocked for a ptrace-stop (ptrace_cond in thread_ptrace_wait), and a tracee held in ptrace-stop (resume_cond in thread_ptrace_stop). None were signalled by teardown and none re-checked proc_exit_group_requested, so a worker parked there slept past thread_join_workers' poll cap, was detached, and was still live when guest memory was unmapped. Worse than the UAF window: a tracer parked on ptrace_cond for a CLONE_THREAD tracee that never stops deadlocked outright, because only VM-clone exits broadcast ptrace_cond -- if the parked thread was main, teardown was never reached and the process hung. Add thread_wake_exit_waiters(), called from every teardown/exit entry point after the exit-group flag is set: sc_exit_group, main()'s post-run-loop join, guest_destroy, and the last-VM-clone-exit trigger in vm_clone_thread_run. It broadcasts fork_cond plus every live per-slot ptrace_cond/resume_cond, and the three wait loops now re-check proc_exit_group_requested on wake. sys_wait4's pid == -1 loop gains the same check before re-arming its 100ms pid_cond quantum, so a tracer redirected out of thread_ptrace_wait does not just re-park one layer down; the -EINTR is never guest-visible because the run loop breaks on the flag first. thread_join_workers also explicitly skips the main thread's slot: its entry only deactivates inside guest_destroy, which runs on the main thread after its own join, so a worker waiting on it would burn the full poll cap for nothing. test-exit-group-teardown constructs each park and races exit_group(42) against it. The "wait" scenario deadlocks without this fix (driver timeout) and exits 42 with it; all three scenarios survive 150 consecutive stress runs. PTRACE_SEIZE on a same-group thread is EPERM on real Linux, so the ptrace scenarios degrade to a plain exit_group(42) under differential testing. Fixes: https://github.com/sysprog21/elfuse/issues/158 --- src/core/guest.c | 12 +- src/main.c | 5 + src/runtime/forkipc.c | 5 + src/runtime/thread.c | 70 ++++++++++- src/runtime/thread.h | 11 ++ src/syscall/proc.c | 10 ++ src/syscall/syscall.c | 6 + tests/manifest.txt | 5 + tests/test-exit-group-teardown.c | 204 +++++++++++++++++++++++++++++++ 9 files changed, 317 insertions(+), 11 deletions(-) create mode 100644 tests/test-exit-group-teardown.c diff --git a/src/core/guest.c b/src/core/guest.c index efc2a18b..02250cea 100644 --- a/src/core/guest.c +++ b/src/core/guest.c @@ -651,17 +651,19 @@ void guest_destroy(guest_t *g) * intact. * * The wake signals cover workers blocked outside hv_vcpu_run: futex waiters - * poll futex_interrupt_requested, and any thread parked in epoll or poll - * wakes off the shared pipe. Without them, host-blocked workers miss the - * hv_vcpus_exit kick (which only affects threads inside hv_vcpu_run) and - * the 100ms join cap in thread_join_workers detaches them, leaving live - * pthreads to crash on the imminent munmap. + * poll futex_interrupt_requested, any thread parked in epoll or poll wakes + * off the shared pipe, and thread_wake_exit_waiters broadcasts the internal + * condvars (fork barrier, ptrace stop/wait). Without them, host-blocked + * workers miss the hv_vcpus_exit kick (which only affects threads inside + * hv_vcpu_run) and the 100ms join cap in thread_join_workers detaches + * them, leaving live pthreads to crash on the imminent munmap. */ if (!proc_exit_group_requested()) proc_request_exit_group(0); futex_interrupt_request(); wakeup_pipe_signal(); thread_interrupt_all(); + thread_wake_exit_waiters(); thread_join_workers(); /* Destroy all remaining worker vCPUs (thread table) before tearing down the * VM. This prevents hv_vm_destroy from racing with active vCPUs that may diff --git a/src/main.c b/src/main.c index 7630876d..62fac804 100644 --- a/src/main.c +++ b/src/main.c @@ -706,6 +706,11 @@ int main(int argc, char **argv) futex_interrupt_request(); wakeup_pipe_signal(); thread_interrupt_all(); + /* Workers parked on internal condvars (fork barrier, ptrace stop/wait) + * see neither the pipe nor the vCPU kick; broadcast so they re-check the + * exit-group flag and terminate before the join below gives up on them. + */ + thread_wake_exit_waiters(); thread_join_workers(); /* Diagnostic counter dump runs before guest_destroy so the shim_data diff --git a/src/runtime/forkipc.c b/src/runtime/forkipc.c index 35e42338..d41eb122 100644 --- a/src/runtime/forkipc.c +++ b/src/runtime/forkipc.c @@ -1288,6 +1288,11 @@ static void *vm_clone_thread_run(void *arg) * harmless no-op. */ thread_interrupt_all(); + /* thread_interrupt_all only reaches threads inside hv_vcpu_run. + * Peers parked on fork_cond or another slot's ptrace_cond/resume_cond + * see neither the vCPU kick nor this exit, so broadcast separately. + */ + thread_wake_exit_waiters(); } hv_vcpu_destroy(vcpu); diff --git a/src/runtime/thread.c b/src/runtime/thread.c index 465de6d6..7cedafe4 100644 --- a/src/runtime/thread.c +++ b/src/runtime/thread.c @@ -22,8 +22,9 @@ #include "runtime/thread.h" #include "debug/log.h" -#include "core/guest.h" /* guest_t (shim_data_base/ipa_base), BLOCK_2MIB */ -#include "hvutil.h" /* vcpu_get_gpr, vcpu_get_sysreg */ +#include "core/guest.h" /* guest_t (shim_data_base/ipa_base), BLOCK_2MIB */ +#include "hvutil.h" /* vcpu_get_gpr, vcpu_get_sysreg */ +#include "syscall/proc.h" /* proc_exit_group_requested */ /* From syscall/signal.h, included here directly to avoid pulling in the full * signal header (macOS defines sa_handler as a macro that conflicts with the @@ -474,6 +475,16 @@ void thread_join_workers(void) THREAD_FOR_EACH (t) { if (t == current_thread || t->join_abandoned) continue; + /* Never wait for the main thread (slot 0): its entry only deactivates + * inside guest_destroy, which runs on the main thread AFTER its own + * thread_join_workers. A worker waiting here (exit_group called from + * a worker) would burn the full cap on it, and the resulting mutual + * wait (worker polling main while main polls the worker) ends in + * pthread_detach on both sides, leaving the loser to touch guest + * memory after guest_destroy unmaps it. + */ + if (t == &thread_table[0]) + continue; /* Inactive slots are included when they still hold an unjoined handle: * a worker that exited on its own shortly before teardown and whose * slot was never reused. Its pthread has terminated (or is in final @@ -709,14 +720,46 @@ int thread_fork_barrier_check(void) pthread_cond_signal(&fork_all_quiesced_cond); } - /* Block until fork is complete */ - while (fork_quiesce_active) + /* Block until fork is complete. Bail out on exit_group: the resume + * broadcast comes from the forking thread, whose progress the teardown + * path does not control, so waiting for it would leave this park outside + * the bounded-wake guarantee. thread_wake_exit_waiters broadcasts + * fork_cond after the flag is set; the caller's run loop re-checks + * proc_exit_group_requested and exits. + */ + while (fork_quiesce_active && !proc_exit_group_requested()) pthread_cond_wait(&fork_cond, &thread_lock); pthread_mutex_unlock(&thread_lock); return 1; } +void thread_wake_exit_waiters(void) +{ + pthread_mutex_lock(&thread_lock); + + /* Fork barrier: siblings parked in thread_fork_barrier_check. Their wait + * loop re-checks proc_exit_group_requested on wake. + */ + pthread_cond_broadcast(&fork_cond); + + /* Ptrace parks: tracers blocked in thread_ptrace_wait (ptrace_cond) and + * tracees blocked in thread_ptrace_stop (resume_cond). Scan every slot + * with live condvars, not just active ones: a tracer may still be parked + * on a slot whose thread was deactivated. ptrace_conds_inited only + * transitions under thread_lock with ptrace_waiters == 0, so broadcasting + * here never touches a destroyed condvar. + */ + THREAD_FOR_EACH (t) { + if (!t->ptrace_conds_inited) + continue; + pthread_cond_broadcast(&t->ptrace_cond); + pthread_cond_broadcast(&t->resume_cond); + } + + pthread_mutex_unlock(&thread_lock); +} + /* Ptrace helpers. */ pthread_mutex_t *thread_get_lock(void) @@ -1037,8 +1080,13 @@ int thread_ptrace_stop(thread_entry_t *t, int sig) /* Wake the tracer (blocked in thread_ptrace_wait) */ pthread_cond_broadcast(&t->ptrace_cond); - /* Block until tracer calls PTRACE_CONT */ - while (t->ptrace_stopped) + /* Block until tracer calls PTRACE_CONT. Bail out on exit_group: only the + * tracer signals resume_cond, and a tracer that exits (or calls + * exit_group itself) will never CONT this stop. thread_wake_exit_waiters + * broadcasts resume_cond; returning 0 sends the caller back to its run + * loop, which re-checks proc_exit_group_requested. + */ + while (t->ptrace_stopped && !proc_exit_group_requested()) pthread_cond_wait(&t->resume_cond, &thread_lock); /* Apply register changes if tracer wrote via SETREGSET */ @@ -1093,6 +1141,16 @@ int64_t thread_ptrace_wait(int64_t tracer_tid, pthread_mutex_lock(&thread_lock); for (;;) { + /* exit_group teardown: the stop/exit notifications that would signal + * ptrace_cond stop arriving once workers are being torn down. Return 0 + * ("no matching children") so the caller falls through and its + * blocking paths re-check proc_exit_group_requested. + */ + if (proc_exit_group_requested()) { + pthread_mutex_unlock(&thread_lock); + return 0; + } + bool found_any = false; /* Any waitable children at all? */ THREAD_FOR_EACH (t) { diff --git a/src/runtime/thread.h b/src/runtime/thread.h index 68dde457..057b98e8 100644 --- a/src/runtime/thread.h +++ b/src/runtime/thread.h @@ -323,6 +323,17 @@ void thread_destroy_all_vcpus(void); */ void thread_interrupt_all(void); +/* Wake workers parked on internal condvars (fork barrier, ptrace stop/wait) + * so exit_group teardown reaches them within a bounded time. hv_vcpus_exit + * only interrupts threads inside hv_vcpu_run, and the wakeup pipe / futex + * interrupt only cover guest syscall waits; a thread parked on fork_cond, + * ptrace_cond, or resume_cond would otherwise sleep past the join cap in + * thread_join_workers and still be live when guest memory is unmapped. + * Callers must set the exit-group flag (proc_request_exit_group) BEFORE + * calling this so woken waiters observe it when they re-check. + */ +void thread_wake_exit_waiters(void); + /* Check if any active thread has sigbit unblocked in its signal mask. Uses * relaxed reads on per-thread blocked fields; false positives (stale blocked=0) * cause a harmless spurious interrupt; false negatives (stale blocked=1) are diff --git a/src/syscall/proc.c b/src/syscall/proc.c index f6f0055f..fb96bd2f 100644 --- a/src/syscall/proc.c +++ b/src/syscall/proc.c @@ -1107,6 +1107,16 @@ int64_t sys_wait4(guest_t *g, return 0; } + /* exit_group teardown: stop re-arming the wait. The 100ms quantum + * below bounds how stale this check can be, mirroring the futex + * wait quanta. The errno is never guest-visible: the run loop + * breaks on the exit-group flag before returning to the guest. + */ + if (proc_exit_group_requested()) { + pthread_mutex_unlock(&pid_lock); + return -LINUX_EINTR; + } + /* Blocking mode: no child exited yet. Wait on condvar for a child * exit notification (signaled by proc_mark_child_exited). Use * timedwait with 100ms timeout as a safety net; the condvar handles diff --git a/src/syscall/syscall.c b/src/syscall/syscall.c index 48840fdf..208bccfe 100644 --- a/src/syscall/syscall.c +++ b/src/syscall/syscall.c @@ -909,6 +909,12 @@ static int64_t sc_exit_group(guest_t *g, proc_request_exit_group((int) x0); wakeup_pipe_signal(); thread_for_each(thread_force_exit_cb, NULL); + /* Workers parked on internal condvars (fork barrier, ptrace stop/wait) + * see neither the pipe nor the vCPU kick; broadcast so they re-check the + * exit-group flag and terminate before the authoritative join in main() + * (or guest_destroy, for the forked-child path) gives up on them. + */ + thread_wake_exit_waiters(); return SC_EXIT_SENTINEL | ((int) x0 & 0xFF); } diff --git a/tests/manifest.txt b/tests/manifest.txt index 8d4382ac..091a5a48 100644 --- a/tests/manifest.txt +++ b/tests/manifest.txt @@ -186,6 +186,11 @@ test-fd-lifecycle [section] Multithreaded fork tests test-mt-fork +[section] Exit-group teardown reachability tests +test-exit-group-teardown wait # expected_rc=42 +test-exit-group-teardown stop # expected_rc=42 +test-exit-group-teardown fork # expected_rc=42 + [section] SysV shared memory tests test-sysv-shm diff --git a/tests/test-exit-group-teardown.c b/tests/test-exit-group-teardown.c new file mode 100644 index 00000000..e346c682 --- /dev/null +++ b/tests/test-exit-group-teardown.c @@ -0,0 +1,204 @@ +/* + * exit_group teardown reachability for internal condvar parks (issue #158) + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * exit_group must terminate the process even when a thread is parked on one + * of the runtime's internal condvars, which neither the wakeup pipe nor the + * hv_vcpus_exit kick can reach: + * + * wait: the main thread blocks in wait4 on a SEIZEd tracee that never + * stops (parked on ptrace_cond in thread_ptrace_wait) while a worker + * calls exit_group. Without the teardown broadcast this deadlocks: + * the tracee exits without signalling ptrace_cond (only VM-clone + * exits do), so the main thread sleeps forever and guest_destroy is + * never reached. The driver timeout turns that hang into a failure. + * + * stop: a tracee is held in ptrace-stop (parked on resume_cond in + * thread_ptrace_stop) when the tracer calls exit_group without + * PTRACE_CONT. Only the tracer's CONT signals resume_cond, so the + * tracee must be woken by the teardown broadcast to exit inside the + * join window instead of sleeping past the memory unmap. + * + * fork: exit_group races a fork loop, so siblings are parked on the fork + * barrier (fork_cond in thread_fork_barrier_check) when the flag is + * set. Their resume normally depends on the forking thread's + * progress; the teardown broadcast frees them directly. + * + * All scenarios must exit with code 42, carried by exit_group from whichever + * thread raced the park. PTRACE_SEIZE on a same-thread-group thread fails + * with EPERM on real Linux (it works on elfuse, where ptrace is scoped to + * the emulated process); the ptrace scenarios treat SEIZE failure as "park + * cannot be constructed" and fall through to a plain exit_group(42) so the + * expected exit code holds under differential testing. + * + * Syscalls: clone(220), ptrace(117), wait4(260), nanosleep(101), + * sched_yield(124), exit(93), exit_group(94) + */ + +#include + +#include "raw-syscall.h" + +#define CLONE_THREAD_FLAGS \ + (0x00010000 | 0x00000100 | 0x00000200 | 0x00000800 | 0x00200000) + +#define PTRACE_SEIZE 0x4206 +#define PTRACE_INTERRUPT 0x4207 + +#define EXIT_CODE 42 + +static char killer_stack[16384] __attribute__((aligned(16))); +static char tracee_stack[16384] __attribute__((aligned(16))); +static char spinner_stack[16384] __attribute__((aligned(16))); + +static volatile int killer_armed = 0; + +static void msleep(long ms) +{ + struct { + long tv_sec, tv_nsec; + } ts = {ms / 1000, (ms % 1000) * 1000000L}; + raw_syscall4(101, (long) &ts, 0, 0, 0); /* nanosleep */ +} + +/* Terminates the process group from a worker thread after a delay, so the + * park under test is in place when the exit-group flag is set. + */ +static int killer_fn(void) +{ + killer_armed = 1; + msleep(200); + raw_syscall1(94, EXIT_CODE); /* exit_group */ + return 0; +} + +/* Spins on sched_yield so the thread is always either inside hv_vcpu_run or + * at a run-loop preemption point (where it can enter ptrace-stop or the fork + * barrier). Never stops, never exits on its own. + */ +static int spin_fn(void) +{ + for (;;) + raw_syscall0(124); /* sched_yield */ + return 0; +} + +static long spawn_thread(char *stack_top, int (*fn)(void)) +{ + long ret = raw_syscall5(220, CLONE_THREAD_FLAGS, (long) stack_top, 0, 0, 0); + if (ret == 0) { + fn(); + raw_syscall1(93, 0); /* exit (not reached; fn never returns) */ + } + return ret; +} + +/* wait: park the main thread on ptrace_cond, then exit_group from a worker */ +static int scenario_wait(void) +{ + if (spawn_thread(killer_stack + sizeof(killer_stack), killer_fn) < 0) + return 1; + while (!killer_armed) + raw_syscall0(124); + + long tracee = spawn_thread(tracee_stack + sizeof(tracee_stack), spin_fn); + if (tracee < 0) + return 1; + + /* SEIZE without INTERRUPT: the tracee keeps spinning and never enters + * ptrace-stop, so the wait4 below has nothing to report and parks on the + * tracee's ptrace_cond. EPERM (real Linux) leaves the tracee untraced; + * wait4 then returns ECHILD immediately and the killer still fires. + */ + raw_syscall4(117, PTRACE_SEIZE, tracee, 0, 0); + + int status = 0; + raw_syscall4(260, tracee, (long) &status, 0, 0); /* wait4: parks here */ + + /* Only reachable when the park was refused or torn down; wait for the + * killer's exit_group to take effect. + */ + for (;;) + msleep(50); + return 0; +} + +/* stop: hold a tracee in ptrace-stop (parked on resume_cond), then + * exit_group from the tracer without ever calling PTRACE_CONT. + */ +static int scenario_stop(void) +{ + long tracee = spawn_thread(tracee_stack + sizeof(tracee_stack), spin_fn); + if (tracee < 0) + return 1; + + if (raw_syscall4(117, PTRACE_SEIZE, tracee, 0, 0) == 0) { + raw_syscall4(117, PTRACE_INTERRUPT, tracee, 0, 0); + /* Blocks until the tracee reaches ptrace-stop, i.e. is parked on + * resume_cond with nobody left to CONT it after this thread exits. + */ + int status = 0; + raw_syscall4(260, tracee, (long) &status, 0, 0); + } + + raw_syscall1(94, EXIT_CODE); /* exit_group with the tracee still stopped */ + return 0; +} + +/* fork: race exit_group against fork barriers quiescing a spinning sibling */ +static int scenario_fork(void) +{ + if (spawn_thread(killer_stack + sizeof(killer_stack), killer_fn) < 0) + return 1; + if (spawn_thread(spinner_stack + sizeof(spinner_stack), spin_fn) < 0) + return 1; + while (!killer_armed) + raw_syscall0(124); + + /* Fork continuously so a barrier is likely in flight when the killer + * fires; each child exits immediately and is reaped to keep the process + * table bounded. The loop only ends when exit_group tears it down. + */ + for (;;) { + /* clone with flags=SIGCHLD: plain fork */ + long child = raw_syscall5(220, 17, 0, 0, 0, 0); + if (child == 0) + raw_syscall1(94, 0); /* child: exit_group */ + if (child < 0) { + msleep(1); + continue; + } + int status = 0; + raw_syscall4(260, (long) (int) child, (long) &status, 0, 0); + } + return 0; +} + +int main(int argc, char **argv) +{ + int rc; + + if (argc < 2) { + static const char usage[] = + "usage: test-exit-group-teardown wait|stop|fork\n"; + raw_syscall3(64, 2, (long) usage, sizeof(usage) - 1); /* write */ + return 2; + } + + if (!strcmp(argv[1], "wait")) + rc = scenario_wait(); + else if (!strcmp(argv[1], "stop")) + rc = scenario_stop(); + else if (!strcmp(argv[1], "fork")) + rc = scenario_fork(); + else + rc = 2; + + /* Scenarios normally never return: exit_group(42) ends the process. A + * return means setup failed; make that a visible wrong exit code. + */ + raw_syscall1(94, rc); + return rc; +}