From 59cc4cc49dbcec791c981548ea849578cb445bf2 Mon Sep 17 00:00:00 2001 From: Andrew Halaney Date: Tue, 13 Jan 2026 11:59:41 -0600 Subject: [PATCH] pkg/sys: Let more environments create user namespace as the initial user By restoring the effective capabilities of the thread after setresuid() we can both: 1. Use the go runtime to setup the uid_map now that we have the capabilities to do so in the thread again 2. Enable this on distro's which have restrictions around unprivileged user namespace creation and usage (since the thread is now privileged) Let's do it. See [0] for more details on this topic. Unlike unix::Setresuid()[1], which mimics the glibc implementation and acts on all threads in the process, unix::Cap{s,g}et() are thread local[2] only as we want, so we can use that directly. [0]: https://github.com/containerd/containerd/pull/12317#discussion_r2686960671 [1]: https://github.com/golang/go/blob/e2fef50def98b87107ab963f657d43d7869b8463/src/syscall/syscall_linux.go#L1217 [2]: https://github.com/golang/sys/blob/6fb913b30f367555467f08da4d60f49996c9b17a/unix/zsyscall_linux.go#L524 Signed-off-by: Andrew Halaney --- pkg/sys/unshare_linux.go | 130 +++++++++++++--------------------- pkg/sys/unshare_linux_test.go | 4 -- 2 files changed, 50 insertions(+), 84 deletions(-) diff --git a/pkg/sys/unshare_linux.go b/pkg/sys/unshare_linux.go index de289d8e96..b9077d37b0 100644 --- a/pkg/sys/unshare_linux.go +++ b/pkg/sys/unshare_linux.go @@ -23,53 +23,11 @@ import ( "runtime" "strconv" "strings" - "sync" "syscall" "golang.org/x/sys/unix" ) -var ( - unprivilegedUsernsSupported bool - unprivilegedUsernsSupportedOnce sync.Once -) - -// SupportsUnprivilegedUsernsCreation returns true if creating user namespaces -// as an unprivileged user is supported -func SupportsUnprivilegedUsernsCreation() bool { - unprivilegedUsernsSupportedOnce.Do(func() { - if err := checkUnprivilegedUsernsCreation(); err != nil { - unprivilegedUsernsSupported = false - return - } - unprivilegedUsernsSupported = true - }) - return unprivilegedUsernsSupported -} - -// checkUnprivilegedUsernsCreation tests if we can create a user namespace -// as an unprivileged user. This can fail on systems that deny unprivileged -// user namespaces through various means like AppArmor -func checkUnprivilegedUsernsCreation() error { - // Assume nobody user is unprivileged - nobodyUID := 65534 - var pidfd int - - // CLONE_NEWIPC is a random namespace to unshare to, we just need to verify - // that we can indeed make a user namespace, then unshare into another namespace - // as the target user without hitting any sort of apparmor restrictions - _, pidfd, err := startProcessWithUserNamespace(nobodyUID, syscall.CLONE_NEWIPC) - if err != nil { - return fmt.Errorf("user namespace creation as unprivileged user failed: %w", err) - } - - unix.PidfdSendSignal(pidfd, unix.SIGKILL, nil, 0) - pidfdWaitid(pidfd) - unix.Close(pidfd) - - return nil -} - // UnshareAfterEnterUserns allows to disassociate parts of its execution context // within a user namespace. func UnshareAfterEnterUserns(uidMap, gidMap string, unshareFlags uintptr, f func(pid int) error) (retErr error) { @@ -91,13 +49,8 @@ func UnshareAfterEnterUserns(uidMap, gidMap string, unshareFlags uintptr, f func return err } - // -1 corresponds to no change in setresuid()! - targetUID := -1 - if SupportsUnprivilegedUsernsCreation() { - targetUID = uidMaps[0].HostID - } - - pid, pidfd, err := startProcessWithUserNamespace(targetUID, unshareFlags) + targetUID := uidMaps[0].HostID + pid, pidfd, err := startProcessWithUserNamespace(targetUID, unshareFlags, uidMaps, gidMaps) if err != nil { return err } @@ -114,19 +67,6 @@ func UnshareAfterEnterUserns(uidMap, gidMap string, unshareFlags uintptr, f func pidfdWaitid(pidfd) }() - // Now we can write the uid/gid mappings - uidMapPath := fmt.Sprintf("/proc/%d/uid_map", pid) - uidMapContent := fmt.Sprintf("%d %d %d\n", uidMaps[0].ContainerID, uidMaps[0].HostID, uidMaps[0].Size) - if err := os.WriteFile(uidMapPath, []byte(uidMapContent), 0644); err != nil { - return fmt.Errorf("failed to write UID mapping: %w", err) - } - - gidMapPath := fmt.Sprintf("/proc/%d/gid_map", pid) - gidMapContent := fmt.Sprintf("%d %d %d\n", gidMaps[0].ContainerID, gidMaps[0].HostID, gidMaps[0].Size) - if err := os.WriteFile(gidMapPath, []byte(gidMapContent), 0644); err != nil { - return fmt.Errorf("failed to write GID mapping: %w", err) - } - if f != nil { if err := f(pid); err != nil { return err @@ -181,10 +121,8 @@ func parseIDMapping(mapping string) ([]syscall.SysProcIDMap, error) { // a user namespace. It runs a goroutine on a single thread as targetHostUID // when creating the process and user namespace, ensuring that the kernel // attributes user limits to targetHostUID and not containerd's user. On success -// it returns the pid, pidfd and no error. It is expected that the caller sets -// up the uid_map/gid_map for the pid (this cannot be done as part of os.StartProcess() -// since targetHostUID doesn't have CAP_SETUID) -func startProcessWithUserNamespace(targetHostUID int, unshareFlags uintptr) (int, int, error) { +// it returns the pid, pidfd and no error. +func startProcessWithUserNamespace(targetHostUID int, unshareFlags uintptr, uidMaps, gidMaps []syscall.SysProcIDMap) (int, int, error) { type result struct { pid int pidfd int @@ -194,7 +132,7 @@ func startProcessWithUserNamespace(targetHostUID int, unshareFlags uintptr) (int go func() { runtime.LockOSThread() - pid, pidfd, err := startProcessWithUsernsLocked(targetHostUID, unshareFlags) + pid, pidfd, err := startProcessWithUsernsLocked(targetHostUID, unshareFlags, uidMaps, gidMaps) // If this errored out let the go runtime reap the thread by not unlocking if err == nil { @@ -208,30 +146,42 @@ func startProcessWithUserNamespace(targetHostUID int, unshareFlags uintptr) (int } // startProcessWithUsernsLocked expects the os thread to be locked already. It does -// a setresuid() to the targetHostUID user, spawns a new ptraced process in a new user namespace, -// unshares into unshareFlags within that namespace, and returns the pid, pidfd, and error -// information of that process. On error the process is already killed, and the thread -// is *not* setresuid()'ed back to the original user. In this case the thread should be killed off -// by the caller to avoid using a thread in a bad state -func startProcessWithUsernsLocked(targetHostUID int, unshareFlags uintptr) (int, int, error) { - // -1 means no change in setresuid() - originalEUID := -1 +// 1. setresuid() to the targetHostUID user to attribute further user namespace creations to it +// 2. sets the thread's effective capabilities back to the original user's to allow writing +// to /proc/uid_map as well as to create the user namespace as a "privileged" process in +// some distro's eyes, +// 3. spawns a new ptraced process in a new user namespace +// 4. unshares into unshareFlags within that user namespace +// +// returns the pid, pidfd, and error information of that process. On error the process is already +// killed, and the thread is *not* setresuid()'ed back to the original user. In this case the +// thread should be killed off by the caller to avoid using a thread in a bad state +func startProcessWithUsernsLocked(targetHostUID int, unshareFlags uintptr, uidMaps, gidMaps []syscall.SysProcIDMap) (int, int, error) { + originalEUID := os.Geteuid() - if targetHostUID != -1 { - // We're changing users, so we need to figure out what user to return to - originalEUID = os.Geteuid() + originalCaps, err := getCurrentCaps() + if err != nil { + return -1, -1, fmt.Errorf("failed to read current capabilities: %w", err) } if _, _, errno := syscall.RawSyscall(unix.SYS_SETRESUID, ^uintptr(0), uintptr(targetHostUID), ^uintptr(0)); errno != 0 { return -1, -1, fmt.Errorf("failed to set effective UID: %w", errno) } + err = setCurrentCaps(originalCaps) + if err != nil { + return -1, -1, fmt.Errorf("failed to restore capabilities: %w", err) + } + var pidfd int proc, err := os.StartProcess("/proc/self/exe", []string{"UnshareAfterEnterUserns"}, &os.ProcAttr{ Sys: &syscall.SysProcAttr{ // clone new user namespace first and then unshare - Cloneflags: unix.CLONE_NEWUSER, - Unshareflags: unshareFlags, + Cloneflags: unix.CLONE_NEWUSER, + Unshareflags: unshareFlags, + UidMappings: uidMaps, + GidMappings: gidMaps, + GidMappingsEnableSetgroups: true, // NOTE: It's reexec but it's not heavy because subprocess // be in PTRACE_TRACEME mode before performing execve. Ptrace: true, @@ -264,3 +214,23 @@ func pidfdWaitid(pidfd int) error { return unix.Waitid(unix.P_PIDFD, pidfd, nil, unix.WEXITED, nil) }) } + +type capSnapshot struct { + hdr unix.CapUserHeader + data [2]unix.CapUserData +} + +func getCurrentCaps() (*capSnapshot, error) { + caps := &capSnapshot{} + caps.hdr.Version = unix.LINUX_CAPABILITY_VERSION_3 + + err := unix.Capget(&caps.hdr, &caps.data[0]) + if err != nil { + return nil, err + } + return caps, nil +} + +func setCurrentCaps(caps *capSnapshot) error { + return unix.Capset(&caps.hdr, &caps.data[0]) +} diff --git a/pkg/sys/unshare_linux_test.go b/pkg/sys/unshare_linux_test.go index d53059c2ff..a77358bceb 100644 --- a/pkg/sys/unshare_linux_test.go +++ b/pkg/sys/unshare_linux_test.go @@ -148,10 +148,6 @@ func testUnshareAfterEnterUsernsInvalidFlags(t *testing.T) { func testUnshareAfterEnterUsernsOwnership(t *testing.T) { t.Parallel() - if !SupportsUnprivilegedUsernsCreation() { - t.Skip("unprivileged user namespace creation not supported") - } - uerr := UnshareAfterEnterUserns("0:1000:1", "0:1000:1", syscall.CLONE_NEWIPC, func(pid int) error { nsPath := fmt.Sprintf("/proc/%d/ns/user", pid) nsFile, err := os.OpenFile(nsPath, os.O_RDONLY, 0)