Skip to content

fix(seccomp): retry unshare(CLONE_NEWUSER) on EINVAL from a non-empty thread group - #479

Open
Xiaokebuyu wants to merge 1 commit into
anthropics:mainfrom
Xiaokebuyu:fix/unshare-userns-einval-retry
Open

fix(seccomp): retry unshare(CLONE_NEWUSER) on EINVAL from a non-empty thread group#479
Xiaokebuyu wants to merge 1 commit into
anthropics:mainfrom
Xiaokebuyu:fix/unshare-userns-einval-retry

Conversation

@Xiaokebuyu

Copy link
Copy Markdown

Problem

Under the seccomp.argv0 configuration, roughly 1 in 10 sandboxed commands died immediately with:

apply-seccomp: unshare(CLONE_NEWUSER): Invalid argument

Re-running the identical command succeeded. It was unrelated to the command's content (echo 1 hit it).

Root cause

unshare(CLONE_NEWUSER) implies CLONE_THREAD (ksys_unshare()), and check_unshare_flags() returns 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 a process that joins its last thread and then calls unshare(CLONE_NEWUSER) can still be inside that window.

strace of a failing invocation:

clone(..., CLONE_THREAD|CLONE_CHILD_CLEARTID, ...) = 11810   # runtime starts a thread
[main]  futex(child_tidptr, FUTEX_WAIT_BITSET, ...)          # joins it
[11810] prctl(PR_SET_NAME, "mi-scavenger"); exit(0)          # thread exits
[main]  futex resumed                                        # join returns
[main]  unshare(CLONE_NEWPID|CLONE_NEWNS)      = -1 EPERM    # expected, unprivileged
[main]  unshare(CLONE_NEWUSER)                 = -1 EINVAL   # 68us after the join returned

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()) before main() runs. I forced the join-then-exec race 200 times and never got an EINVAL across 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 an mi-scavenger thread 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:

Strategy Result (200 iterations)
No retry 200/200 EINVAL
Retry after sched_yield(), cap 1000 200/200 EINVAL
Retry after sched_yield(), cap 1000000 succeeds, but only after ~6600 yields (min 6575, max 6663)
Retry after nanosleep(1us) 200/200 OK, ≤2 attempts
Retry after nanosleep(100us) 200/200 OK, ≤2 attempts

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 EINVAL still fails: a kernel built without CONFIG_USER_NS returns EINVAL from the unshare_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. unshare does 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 that test/sandbox/execute-only-binary.test.ts covers.

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. This matches the production strace, where the first unshare in the same window returns EPERM, not EINVAL.

Testing

Built through this repo's own pipeline: seccomp-unix-block.c compiled against libseccomp, run for both architectures, the resulting unix-block-bpf.h generated exactly as vendor/seccomp/build.ts does, then gcc -static -O2 -Wall -Wextra for apply-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/statusSeccomp: 2, Seccomp_filters: 1
  • socket(AF_UNIX) inside → PermissionError, so the unix-socket block is intact
  • a chmod 0111 copy runs fine, which is the path the reordered loop had to preserve

Retry headroom for the 50-attempt bound, same synthetic sequence, 100us backoff:

Condition Result
Idle, 500 iterations 500/500 OK, max 1 retry
3 CPU-bound processes competing on the single vCPU, 500 iterations 500/500 OK, max 2 retries

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
#define _GNU_SOURCE
#include <sched.h>
#include <linux/futex.h>
#include <sys/syscall.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>

static int tid_slot;
static char tstack[65536];
static int thread_fn(void *arg) { (void)arg; return 0; }

int main(int argc, char **argv) {
  int iters = argc > 1 ? atoi(argv[1]) : 200;
  int sleep_us = argc > 2 ? atoi(argv[2]) : 0;   /* post-join delay */
  int fails = 0, ok = 0, other = 0;
  for (int i = 0; i < iters; i++) {
    pid_t pid = fork();
    if (pid == 0) {
      tid_slot = -1;
      /* Same flags glibc's pthread_create/join rely on. */
      int tid = clone(thread_fn, tstack + sizeof(tstack),
                      CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|
                      CLONE_SYSVSEM|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID,
                      NULL, &tid_slot, NULL, &tid_slot);
      if (tid < 0) { perror("clone"); _exit(4); }
      int v;
      while ((v = __atomic_load_n(&tid_slot, __ATOMIC_SEQ_CST)) != 0)
        syscall(SYS_futex, &tid_slot, FUTEX_WAIT, v, NULL, NULL, 0);
      if (sleep_us) usleep(sleep_us);
      int r = unshare(CLONE_NEWUSER);
      _exit(r == 0 ? 0 : (errno == EINVAL ? 1 : 3));
    }
    int st; waitpid(pid, &st, 0);
    int code = WIFEXITED(st) ? WEXITSTATUS(st) : 9;
    if (code == 1) fails++; else if (code == 0) ok++; else other++;
  }
  printf("iters=%d ok=%d EINVAL=%d other=%d (post-join sleep %dus)\n",
         iters, ok, fails, other, sleep_us);
  return 0;
}
$ gcc -O2 race.c -o race
$ ./race 200        # iters=200 ok=0   EINVAL=200 other=0 (post-join sleep 0us)
$ ./race 200 50     # iters=200 ok=200 EINVAL=0   other=0 (post-join sleep 50us)

Notes

  • Embedders can also avoid this by using the standalone binary instead of seccomp.argv0, but the multicall mode is a supported configuration, so hardening the sequence here seemed worthwhile.
  • Filed from the consumer side as Sandboxed Bash intermittently fails: apply-seccomp: unshare(CLONE_NEWUSER): Invalid argument claude-code#86928, which has the same analysis plus the operational impact I saw: the transient failure is indistinguishable from a policy denial in the message the model sees, and in one session the agent responded to it by retrying with dangerouslyDisableSandbox: true and succeeding.

Environment: Linux 6.1.0-52-cloud-arm64 (Debian 12), aarch64, single vCPU, bubblewrap 0.8.0.

… 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>
@Xiaokebuyu

Copy link
Copy Markdown
Author

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 (-e inject=unshare:error=EINVAL:when=…) exercises the retry loop deterministically against the built binary — a single injected EINVAL, the 50-retry boundary, and the fail-closed give-up are all assertable. I have this written in the repo's bun:test style and verified locally; happy to push it to this branch on request.

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.

1 participant