I was wrong about the sandbox: strace vs. my favorite theory

· aospcuttlefishcrosvmapparmorseccompdebugginglinux


In the previous post I diagnosed why Cuttlefish’s crosvm dies on my Ubuntu 26.04 builder: the seccomp syscall allowlists shipped with android-15.0.0_r36 predate glibc 2.42, a jailed device child makes a syscall the frozen policy doesn’t know, SIGSYS, dead, “Connection reset by peer”. I called the sandbox a liability on bleeding-edge dev boxes and turned it off.

The theory was coherent, matched a real failure class, explained every observation I had — and was wrong. This post is the disproof, the actual root cause, and what it changes about the conclusions. I’m leaving the original up as-is; being wrong in public is only useful if the correction is public too.

The experiment that was supposed to confirm it

The glibc theory implied a clean fix that I wanted anyway: run the whole Cuttlefish host stack in a Debian 12 container. Old glibc (2.36) never emits the new syscalls, so the frozen allowlist holds; the host kernel stays shared, which is fine because kernels happily run old syscalls forever. As a bonus, each AOSP version gets its own userspace era instead of me chasing host-OS compatibility per tag.

That’s not a hack, it turns out — it’s the blessed path. The android-cuttlefish repo’s own package-build container (tools/buildutils/cw/) is already Debian 12, so the debs I’d fought to build on the bare host rebuilt in it without a single one of my patches mattering (the host-built ones needed GLIBC_2.43 and were unusable anywhere else — worth checking with objdump -T | grep GLIBC before shipping debs built on a fresh distro). Runtime image: upstream’s Containerfile with the base swapped to debian:12, AOSP out/ bind-mounted in, launch as uid 1000 so the mounts stay user-owned.

The prediction was explicit: same crosvm binary, same kernel 7.0, glibc 2.36 instead of 2.42 → sandbox works.

crosvm died in one second. Same “failed to create a PCI root hub: failed to create proxy device”, same connection reset. On glibc 2.36.

That’s the moment a theory earns deletion: it finally made a falsifiable prediction and the prediction failed. Whatever killed the children didn’t care which glibc asked. The only common factor left was the machine itself.

strace doesn’t theorize

I’d inferred the SIGSYS instead of observing it — audit logging was off, so the seccomp kill would have been invisible, and the failure shape fit. Time to actually watch. The crosvm in out/host/linux-x86/bin/ is a two-line wrapper script around the real binary, which makes instrumentation trivial:

exec strace -ff -o /tmp/cf-strace/crosvm \
  "$(dirname "$0")/$(uname -m)-linux-gnu/$(basename "$0")" "${@}"

Relaunch, collect the dying child’s trace:

write(2, "crosvm[1]: libminijail[1]: unsha"..., 80) = 80
unshare(CLONE_NEWNS)  = -1 EPERM (Operation not permitted)
...
tgkill(1, 1, SIGABRT) = 0
--- SIGABRT {si_signo=SIGABRT, si_code=SI_TKILL} ---
--- SIGSEGV {si_signo=SIGSEGV, si_code=SI_KERNEL, si_addr=NULL} ---
--- SIGSEGV {si_signo=SIGSEGV, si_code=SI_KERNEL, si_addr=NULL} ---

Three revelations in ten lines:

  1. The killer wasn’t seccomp. The child never lived long enough to install a filter. It died building the jail itself: unshare(CLONE_NEWNS) — minijail creating the mount namespace it needs to pivot_root the device process into /var/empty — returned EPERM.
  2. The child chose to die. After the failed unshare, minijail calls abort(). An error path, not a murder.
  3. And then it couldn’t. The child is pid 1 of its own pid namespace, and an init process ignores default-action signals — including its own SIGABRT. The kernel falls back to injecting SIGSEGV (si_code=SI_KERNEL), which also can’t kill it, forever. The trace held 34 million SIGSEGV deliveries — 6 GB of a process failing to die. That unkillable zombie is what the parent experiences as a hung control tube, and finally reports as “Connection reset by peer”.

So the original post’s error-chain reading was right — the message really was four causal layers from the cause. It just had the wrong cause at the end of the chain.

The real culprit

Why does unshare(CLONE_NEWNS) fail for a process that does the same thing successfully on every Google-supported distro?

$ sysctl kernel.apparmor_restrict_unprivileged_userns
kernel.apparmor_restrict_unprivileged_userns = 1

Since 24.04, Ubuntu denies unprivileged user-namespace creation unless the binary carries an AppArmor profile granting userns — that’s why apparmor_parser loads exception profiles named chrome and firefox at boot. Minijail’s jail setup is exactly the dance this restriction targets: create a user namespace to gain CAP_SYS_ADMIN inside it, then unshare the mount namespace. crosvm ships no exception profile, so: EPERM, abort, zombie, connection reset.

The verification needs no Cuttlefish at all:

$ unshare --map-root-user --mount true
unshare: write failed /proc/self/uid_map: Operation not permitted

And it explains the failed container experiment perfectly: the sysctl is kernel-global, and a container swaps userspace, not the kernel. The container user was exactly as unprivileged as I was. My glibc fix never had a chance to matter — though the container test was still the most valuable thing I did, because its failure is what killed the wrong theory.

The fix

sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
echo kernel.apparmor_restrict_unprivileged_userns=0 \
  | sudo tee /etc/sysctl.d/99-cuttlefish-userns.conf

This restores the upstream kernel default — which is what Debian ships, which is why Cuttlefish “just works” on Google’s supported distros and never worked on my box, bare or containerized. After that, launch_cvd boots with the sandbox on: every crosvm device child shows Seccomp: 2 in /proc/<pid>/status, adb attaches, and --enable_sandbox=false retires. (One in-container quirk: pass --enable_sandbox=true explicitly there — the auto-detection quietly resolves to off inside a container, and a silently disabled sandbox is exactly the kind of thing this saga teaches you to verify rather than assume.)

Two notes on doing this responsibly:

Reconciling with the previous post

What survives, what dies:

What I’m taking away, round two