From 2f3dfdbd2e15d1650e1e1e48eba4704bd878ac64 Mon Sep 17 00:00:00 2001 From: chxmxii Date: Tue, 13 Oct 2026 00:22:33 +0100 Subject: [PATCH] fix(firecracker): VMs outlived their runs and snapshots Every Firecracker run and every snapshot left its VM running. Teardown happens in box __fc, which already runs inside podman's user namespace, and inPodmanNS tried to enter that namespace again through nsenter: setns into the user namespace a process is already in fails with EINVAL, the error went to /dev/null, and crun kill never ran. Only box down, which runs on the host, stopped a VM. Each leaked VM held its RAM and kept its memory snapshot file alive; on the test machine ten of them pinned about 4 GB. inPodmanNS now runs the command directly when the process is already in the namespace. The escape suite gains a check that no VM outlives its run: it fails on the old binary (two VMs left, the snapshot's and the run's) and passes on all three backends with this fix. Present since the Firecracker backend landed in v0.3. Co-Authored-By: Claude Opus 5.5 --- internal/runtime/unshare.go | 8 ++++++++ security/escape-test.sh | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/internal/runtime/unshare.go b/internal/runtime/unshare.go index 987f8fe..134e147 100644 --- a/internal/runtime/unshare.go +++ b/internal/runtime/unshare.go @@ -17,6 +17,14 @@ import ( // pause process exists. The environment podman unshare would set is set the // same way, so helpers that check for it behave identically. func inPodmanNS(argv ...string) *exec.Cmd { + // Already inside it -- `box __fc` and the other helpers run there -- + // the namespace cannot be entered again: setns into the user namespace + // one is in fails with EINVAL. Run argv directly. Without this, a + // Firecracker VM torn down from inside never got its crun kill, and every + // run and snapshot left its VM running. + if os.Getenv("_CONTAINERS_USERNS_CONFIGURED") != "" { + return exec.Command(argv[0], argv[1:]...) + } if pid := pausePID(); pid > 0 { args := []string{"--user", "--mount", "--preserve-credentials", "--target", strconv.Itoa(pid), "--"} cmd := exec.Command("nsenter", append(args, argv...)...) diff --git a/security/escape-test.sh b/security/escape-test.sh index 5f671be..690a46b 100755 --- a/security/escape-test.sh +++ b/security/escape-test.sh @@ -261,6 +261,18 @@ t0=$(date +%s%N); ls / >/dev/null; t1=$(date +%s%N) [ $(( (t1 - t0) / 1000000 )) -lt 1000 ] && pass "host responsive during a guest fork bomb" \ || fail "host slowed by a guest fork bomb" +echo +echo "nothing left behind" +# A run's VM is gone when the run is; only the sandbox that is up stays. +"$BB" run "$NAME" -- true >/dev/null 2>&1 +sleep 1 +case "$BACKEND" in + firecracker) left=$(podman unshare crun --root "${XDG_RUNTIME_DIR:-/tmp}/box/fc" list 2>/dev/null | awk -v up="box-up-$NAME" 'NR>1 && $1 ~ /^box-/ && $1 != up' | wc -l) ;; + krun) left=$(podman unshare krun --root "${XDG_RUNTIME_DIR:-/tmp}/box/krun" list 2>/dev/null | awk -v up="box-up-$NAME" 'NR>1 && $1 ~ /^box-/ && $1 != up' | wc -l) ;; + *) left=$(podman ps -a --format '{{.Names}}' 2>/dev/null | grep "^box-$NAME-" | wc -l) ;; +esac +[ "$left" -eq 0 ] && pass "no VM outlives its run" || fail "$left VM(s) still running after their run ended" + echo if [ "$FAILS" -eq 0 ]; then echo "no escapes."