diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index 9c8edd880..dd2a91656 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -5365,6 +5365,87 @@ extension IntegrationSuite { } } + func testExecJoinsInitNamespaces() async throws { + let id = "test-exec-joins-init-namespaces" + + // An exec must land in exactly the namespaces the container's init + // process is in. The namespace identity check (`/proc/self/ns/*` vs + // `/proc/1/ns/*`, PID 1 being the container init as seen from inside + // its own PID namespace) is the real invariant: it catches any + // namespace the exec path forgets, not just the one that regressed. + // + // `kernel.shm_rmid_forced` is asserted alongside it because it is what + // consumers actually observe. IPC-namespaced sysctls are resolved + // against the *reading* process's IPC namespace, so an exec left in the + // guest's root IPC namespace reads the guest default (0) rather than + // the value applied to the container — the shape of the CRI conformance + // failure "should support safe sysctls", which reads such a sysctl back + // over ExecSync. + // + // `net` is expected to match too: LinuxContainer declares no network + // namespace, so both sides sit in the guest root netns today, and + // asserting it guards the exec path if that ever changes. + let probe = """ + exec 2>&1 + set -u + fail=0 + for ns in ipc uts mnt pid cgroup net; do + mine=$(readlink /proc/self/ns/$ns) + init=$(readlink /proc/1/ns/$ns) + if [ "$mine" != "$init" ]; then + echo "NS-FAIL: $ns exec=$mine init=$init" + fail=1 + fi + done + shm=$(cat /proc/sys/kernel/shm_rmid_forced) + if [ "$shm" != "1" ]; then + echo "SYSCTL-FAIL: kernel.shm_rmid_forced=$shm expected 1" + fail=1 + fi + [ "$fail" -eq 0 ] || exit 1 + echo "NS-OK" + """ + + let bs = try await bootstrap(id) + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.sysctl = [ + "kernel.shm_rmid_forced": "1" + ] + config.process.arguments = ["/bin/sleep", "100"] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + let buffer = BufferWriter() + let exec = try await container.exec("ns-probe") { config in + config.arguments = ["/bin/sh", "-c", probe] + config.stdout = buffer + } + + try await exec.start() + let status = try await exec.wait() + try await exec.delete() + + let output = String(data: buffer.data, encoding: .utf8) ?? "" + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "exec namespace probe failed (exit \(status.exitCode)): \(output)") + } + guard output.contains("NS-OK") else { + throw IntegrationError.assert(msg: "expected NS-OK sentinel, got: \(output)") + } + + try await container.kill(.kill) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } + func testNoNewPrivileges() async throws { let id = "test-no-new-privileges" diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index bf11e1cef..9dae84351 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -458,6 +458,9 @@ struct IntegrationSuite: AsyncParsableCommand { // Masked / read-only paths Test("container default masked and read-only paths", testDefaultMaskedAndReadonlyPaths), + // Namespaces + Test("container exec joins init namespaces", testExecJoinsInitNamespaces), + // Stat / Copy Test("container stat", testStat), Test("container copy in", testCopyIn), diff --git a/vminitd/Sources/vmexec/ExecCommand.swift b/vminitd/Sources/vmexec/ExecCommand.swift index b5a87f8e6..aaba76afe 100644 --- a/vminitd/Sources/vmexec/ExecCommand.swift +++ b/vminitd/Sources/vmexec/ExecCommand.swift @@ -69,9 +69,28 @@ struct ExecCommand: ParsableCommand { guard pidFd > 0 else { throw App.Errno(stage: "pidfd_open(\(parentPid))") } + // Join every namespace the container's init process could have been + // placed in by `RunCommand.setupNamespaces` (its `nsTypeToFlag` map). + // An exec that lands in a different namespace than the init process + // silently observes guest-root state instead of the container's — e.g. + // SysV IPC objects, POSIX message queues, and IPC-namespaced sysctls + // (`kernel.shm*`, `kernel.msg*`, `kernel.sem`, `fs.mqueue.*`) all read + // as the guest default without CLONE_NEWIPC here. + // + // setns(2) into a namespace the caller is already in is a no-op, so + // naming a flag the init process never unshared is harmless. Keeping + // this mask a superset of what `setupNamespaces` can unshare is what + // stops the two paths from drifting apart; add to it whenever that map + // grows. + // + // CLONE_NEWUSER is intentionally absent: nothing puts a container in a + // user namespace today, and entering one carries extra constraints + // (single-threaded caller, capabilities re-derived against the target + // namespace) that deserve their own change rather than riding along + // here. try Self.enterNS( pidFd: pidFd, - nsType: CLONE_NEWCGROUP | CLONE_NEWPID | CLONE_NEWUTS | CLONE_NEWNS + nsType: CLONE_NEWCGROUP | CLONE_NEWPID | CLONE_NEWUTS | CLONE_NEWNS | CLONE_NEWIPC ) let processID = fork()