Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions Sources/Integration/ContainerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) ?? "<non-utf8 output>"
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"

Expand Down
3 changes: 3 additions & 0 deletions Sources/Integration/Suite.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
21 changes: 20 additions & 1 deletion vminitd/Sources/vmexec/ExecCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading