fix(seccomp): retry unshare(CLONE_NEWUSER) on EINVAL from a non-empty thread group - #479
fix(seccomp): retry unshare(CLONE_NEWUSER) on EINVAL from a non-empty thread group#479Xiaokebuyu wants to merge 1 commit into
Conversation
… thread group unshare(CLONE_NEWUSER) implies CLONE_THREAD, and check_unshare_flags() rejects that with EINVAL while thread_group_empty() is false. A thread that has already exited stays in the thread group until release_task() runs, but its joiner is woken earlier, in mm_release() — so code that calls unshare right after joining a thread can land inside that window. The standalone apply-seccomp binary cannot hit this: it spawns no threads, and execve() reaps every other thread of the caller before main() runs (forcing the join-then-exec race 200 times never produced an EINVAL across an exec boundary). The exposed configuration is seccomp.argv0 — a multicall runtime binary running this sequence in-process. Observed with a bun-based embedder on a single-vCPU host: mimalloc parks its mi-scavenger thread during startup, and strace shows clone(CLONE_THREAD), the scavenger's exit(0), the joiner's futex wake, then unshare(CLONE_NEWUSER) = EINVAL 68us later; roughly 1 in 10 sandboxed commands died with "apply-seccomp: unshare(CLONE_NEWUSER): Invalid argument". The retry sleeps rather than yields. Measured on a single-vCPU aarch64 host (Linux 6.1, same clone/join/unshare sequence): no retry failed 200/200; sched_yield() retries lose the race for ~6600 consecutive yields, because the freshly woken joiner sits several milliseconds behind the exiting thread in vruntime and CFS ignores yields until that lead is burned; a nanosleep as short as 1us then succeeded 200/200 within two attempts. Bounded at 50 x 100us so a permanent EINVAL (a kernel built without CONFIG_USER_NS returns it from the unshare_userns stub) still dies, at worst 5ms later. The loop runs before the PR_SET_DUMPABLE(1) flip, so the sleeps never extend the window in which a mode-0111 install is ptrace-able; unshare does not consult dumpable, and the map writes open /proc/self only after dumpable is raised (the mode-0111 path is covered by test/sandbox/execute-only-binary.test.ts). The two unshare(CLONE_NEWPID|CLONE_NEWNS) calls need no retry: neither flag implies CLONE_THREAD, so the thread-group check never runs for them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Field data since filing: the linked issue (anthropics/claude-code#86928) has picked up two independent corroborations — a 4-core x86_64 bare-metal machine (kernel 7.1, nested unprivileged bwrap userns) seeing ~13% of real-workload sandboxed Bash calls fail with this exact error (19/151), and a rootless Docker environment hitting the same. So the race is not specific to single-core, arm64, or kernel 6.1; nested/containerized environments widen the window at any core count. If a regression test would help review: the race itself can't be reproduced through the standalone binary (execve reaps the caller's threads before main runs), but strace fault injection ( |
Problem
Under the
seccomp.argv0configuration, roughly 1 in 10 sandboxed commands died immediately with:Re-running the identical command succeeded. It was unrelated to the command's content (
echo 1hit it).Root cause
unshare(CLONE_NEWUSER)impliesCLONE_THREAD(ksys_unshare()), andcheck_unshare_flags()returnsEINVALwhilethread_group_empty()is false. A thread that has already exited stays in the thread group untilrelease_task()runs, but its joiner is woken earlier, inmm_release(). So a process that joins its last thread and then callsunshare(CLONE_NEWUSER)can still be inside that window.straceof a failing invocation:The standalone binary built from this repo is not affected, and cannot be: it spawns no threads of its own, and
execve()reaps every other thread of the caller (de_thread()) beforemain()runs. I forced the join-then-exec race 200 times and never got anEINVALacross an exec boundary (0/200).The exposed configuration is
seccomp.argv0— a multicall runtime binary that reaches this sequence in-process, moments after its runtime parked a helper thread. The case I hit is a bun-based embedder whose mimalloc allocator starts anmi-scavengerthread at startup.The fix
Bounded retry on
EINVAL, sleeping between attempts.Sleeping is required;
sched_yield()is not sufficient in practice. Measured on a single-vCPU aarch64 host (Linux 6.1) with the same clone/join/unshare sequence:sched_yield(), cap 1000sched_yield(), cap 1000000nanosleep(1us)nanosleep(100us)A freshly futex-woken joiner sits several milliseconds behind the exiting thread in vruntime, and CFS ignores its yields until that lead is burned — so yielding spins for thousands of iterations while a 1us sleep closes the window immediately. The sleep length does not matter; being descheduled at all is what matters.
Counterintuitive property worth noting: an idle machine fails more. With nothing else runnable the joiner is scheduled the instant the futex wakes it, before the dying thread has been reaped. Under CPU load the failure rate drops (29/100 with one competing process here). Multi-core hosts rarely see it at all, which is likely why this has gone unnoticed.
The loop is bounded at 50 × 100us so a permanent
EINVALstill fails: a kernel built withoutCONFIG_USER_NSreturnsEINVALfrom theunshare_userns()stub, and that host dies 5ms later than before (its nested-userns path could never have succeeded anyway).The loop is placed before the
PR_SET_DUMPABLE(1)flip so the sleeps never extend the window in which a mode-0111 install is ptrace-able — the comment above that flip explicitly bounds that exposure to "a few-syscall race window", and a retry loop inside it would have stretched that to milliseconds.unsharedoes not consult dumpable, and the/proc/self/{setgroups,uid_map,gid_map}writes still happen after it is raised. I verified both orderings work, including the mode-0111 path thattest/sandbox/execute-only-binary.test.tscovers.The two
unshare(CLONE_NEWPID|CLONE_NEWNS)calls need no retry: neither flag impliesCLONE_THREAD, so the thread-group check never runs for them. This matches the production strace, where the first unshare in the same window returnsEPERM, notEINVAL.Testing
Built through this repo's own pipeline:
seccomp-unix-block.ccompiled against libseccomp, run for both architectures, the resultingunix-block-bpf.hgenerated exactly asvendor/seccomp/build.tsdoes, thengcc -static -O2 -Wall -Wextraforapply-seccomp.c. Zero warnings (also with-Wpedantic -std=gnu11); stripped size matches the shipped vendored binary.End to end with that binary, i.e. with the real filter rather than a stub:
apply-seccomp /bin/grep Seccomp /proc/self/status→Seccomp: 2,Seccomp_filters: 1socket(AF_UNIX)inside →PermissionError, so the unix-socket block is intactchmod 0111copy runs fine, which is the path the reordered loop had to preserveRetry headroom for the 50-attempt bound, same synthetic sequence, 100us backoff:
So the bound has roughly 25x headroom over the worst case measured. (The loop only waits for
release_task, not for the thread to exit — the join has already returned by then — so a slower-exiting thread does not consume more attempts.)No regression test is included, and I do not think one can be written against the shipped binary: reproducing the race requires a threaded process reaching this code in-process, which the standalone binary structurally cannot do (the 0/200 cross-exec control above). The mechanism is reproducible on its own, though — this program has no dependency on this repo and fails 200/200 on an idle single-CPU host, 0/200 with any post-join sleep:
race.c — standalone reproduction
Notes
seccomp.argv0, but the multicall mode is a supported configuration, so hardening the sequence here seemed worthwhile.dangerouslyDisableSandbox: trueand succeeding.Environment: Linux 6.1.0-52-cloud-arm64 (Debian 12), aarch64, single vCPU, bubblewrap 0.8.0.