From 3323a202042ae70cb8e59993328bf37c3e678791 Mon Sep 17 00:00:00 2001 From: Ricardo Branco Date: Tue, 20 Oct 2026 21:41:14 +0200 Subject: [PATCH 1/2] Support running with no_new_privs The child was started with CLONE_NEWUSER before its uid_map was written, so the exec dropped all the caps, and the child re-executed itself after the maps were written to gain them. With no_new_privs set, the re-exec cannot gain caps, so the child ended up waiting forever for a message from the parent. Start the child with all the caps as ambient caps, so it keeps them across the exec and does not need to re-execute. The re-execution is kept as a fallback for kernels without ambient caps (older than 4.3). The child now always does the handshake with the parent once, and clears the inheritable and ambient caps afterwards so they are not inherited by the workload. Fixes #551 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ricardo Branco --- pkg/child/child.go | 50 ++++++++++++++++++++++++++++++++++++-------- pkg/parent/parent.go | 22 +++++++++++++++++++ 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/pkg/child/child.go b/pkg/child/child.go index eefc9ea9..2542157b 100644 --- a/pkg/child/child.go +++ b/pkg/child/child.go @@ -314,16 +314,40 @@ func hasCaps() (bool, error) { return data.Effective != 0, nil } -// gainCaps gains the caps inside the user namespace. -// The caps are gained on re-execution after the child's uid_map and gid_map are fully written. -func gainCaps() error { - pid := os.Getpid() +// dropInheritableAndAmbientCaps prevents the ambient caps (set by the parent) from leaking to the workload. +func dropInheritableAndAmbientCaps() error { + // EINVAL means that the kernel does not support ambient caps (added in Linux 4.3) + if err := unix.Prctl(unix.PR_CAP_AMBIENT, unix.PR_CAP_AMBIENT_CLEAR_ALL, 0, 0, 0); err != nil && err != unix.EINVAL { + return fmt.Errorf("failed to clear the ambient caps: %w", err) + } + hdr := unix.CapUserHeader{ + Version: unix.LINUX_CAPABILITY_VERSION_3, + } + var data [2]unix.CapUserData + if err := unix.Capget(&hdr, &data[0]); err != nil { + return fmt.Errorf("failed to get the current caps: %w", err) + } + data[0].Inheritable, data[1].Inheritable = 0, 0 + if err := unix.Capset(&hdr, &data[0]); err != nil { + return fmt.Errorf("failed to clear the inheritable caps: %w", err) + } + return nil +} + +func reexecCountEnvName(pid int) string { pidns, err := statPIDNS(pid) if err != nil { logrus.WithError(err).Debug("Failed to stat pidns (negligible when unsharing pidns)") pidns = 0 } - envName := fmt.Sprintf("_ROOTLESSKIT_REEXEC_COUNT_%d_%d", pidns, pid) + return fmt.Sprintf("_ROOTLESSKIT_REEXEC_COUNT_%d_%d", pidns, pid) +} + +// gainCaps gains the caps inside the user namespace. +// The caps are gained on re-execution after the child's uid_map and gid_map are fully written. +func gainCaps() error { + pid := os.Getpid() + envName := reexecCountEnvName(pid) logrus.Debugf("Re-executing the RootlessKit child process (PID=%d) to gain the caps", pid) var envValueInt int @@ -378,9 +402,11 @@ func Child(opt Opt) error { msg *messages.Message err error ) - if ok, err := hasCaps(); err != nil { - return err - } else if !ok { + // The parent starts the child with ambient caps, so the child usually has the caps + // already, even when no_new_privs is set. Otherwise the caps are gained by re-execution. + // A re-executed child has already completed the handshake below. + reexecEnvName := reexecCountEnvName(os.Getpid()) + if os.Getenv(reexecEnvName) == "" { _, err = messages.WaitFor(pipeR, messages.Name(messages.ParentHello{})) if err != nil { return err @@ -399,11 +425,17 @@ func Child(opt Opt) error { if err != nil { return err } - + } + if ok, err := hasCaps(); err != nil { + return err + } else if !ok { // gainCaps re-executes the process on success and only returns on failure, // so reaching here always means an error. return fmt.Errorf("failed to gain the caps inside the user namespace: %w", gainCaps()) } + if err := dropInheritableAndAmbientCaps(); err != nil { + return err + } if opt.MountProcfs { if err := mountProcfs(); err != nil { diff --git a/pkg/parent/parent.go b/pkg/parent/parent.go index 8fa96251..8ae475e7 100644 --- a/pkg/parent/parent.go +++ b/pkg/parent/parent.go @@ -177,6 +177,9 @@ func Parent(opt Opt) error { cmd.SysProcAttr = &syscall.SysProcAttr{ Pdeathsig: syscall.SIGKILL, Cloneflags: syscall.CLONE_NEWUSER | syscall.CLONE_NEWNS, + // The child keeps the caps across exec, even when no_new_privs is set + // (where the child cannot gain the caps by re-execution after uid_map is written). + AmbientCaps: allCaps(), } if opt.NetworkDriver != nil { @@ -545,3 +548,22 @@ func InitStateDir(stateDir string) error { } return nil } + +// allCaps returns all the capabilities supported by the kernel, +// or nil if the kernel does not support ambient caps (added in Linux 4.3). +func allCaps() []uintptr { + if _, err := unix.PrctlRetInt(unix.PR_CAP_AMBIENT, unix.PR_CAP_AMBIENT_IS_SET, 0, 0, 0); err == unix.EINVAL { + return nil + } + last := unix.CAP_LAST_CAP + if b, err := os.ReadFile("/proc/sys/kernel/cap_last_cap"); err == nil { + if n, err := strconv.Atoi(strings.TrimSpace(string(b))); err == nil { + last = n + } + } + caps := make([]uintptr, 0, last+1) + for i := 0; i <= last; i++ { + caps = append(caps, uintptr(i)) + } + return caps +} From de7d793594328a5de9723be60277f085ed4ca073 Mon Sep 17 00:00:00 2001 From: Ricardo Branco Date: Tue, 20 Oct 2026 21:53:05 +0200 Subject: [PATCH 2/2] Add integration test for no_new_privs Run rootlesskit under `setpriv --no-new-privs` with unprivileged newuidmap/newgidmap stand-ins (the real ones are setuid or have file caps, which no_new_privs disables), and check that the child gets the caps and that no ambient caps leak into the workload. Without the fix for #551, rootlesskit hangs. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Ricardo Branco --- .github/workflows/main.yaml | 2 ++ hack/integration-no-new-privs.sh | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100755 hack/integration-no-new-privs.sh diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 502645cb..5096abee 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -63,6 +63,8 @@ jobs: run: DOCKER_BUILDKIT=1 docker build -t rootlesskit:test-integration --target test-integration . - name: "Integration test: exit-code" run: docker run --rm --privileged rootlesskit:test-integration ./integration-exit-code.sh + - name: "Integration test: no-new-privs" + run: docker run --rm --privileged rootlesskit:test-integration ./integration-no-new-privs.sh - name: "Integration test: propagation" run: docker run --rm --privileged rootlesskit:test-integration ./integration-propagation.sh - name: "Integration test: propagation (with `mount --make-rshared /`)" diff --git a/hack/integration-no-new-privs.sh b/hack/integration-no-new-privs.sh new file mode 100755 index 00000000..b42bbfdc --- /dev/null +++ b/hack/integration-no-new-privs.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Regression test for https://github.com/rootless-containers/rootlesskit/issues/551 +source $(realpath $(dirname $0))/common.inc.sh + +# The regular newuidmap/newgidmap are setuid or have file caps, so they cannot work with no_new_privs. +# Use unprivileged stand-ins that map only the current UID/GID. +tmp=$(mktemp -d) +trap "rm -rf ${tmp}" EXIT +cat >${tmp}/newuidmap <<'EOS' +#!/bin/sh +echo "$2 $3 $4" >/proc/$1/uid_map +EOS +cat >${tmp}/newgidmap <<'EOS' +#!/bin/sh +echo deny >/proc/$1/setgroups +echo "$2 $3 $4" >/proc/$1/gid_map +EOS +chmod +x ${tmp}/newuidmap ${tmp}/newgidmap +export PATH=${tmp}:${PATH} + +function test_no_new_privs() { + args="$@" + INFO "Testing no_new_privs for args=${args}" + # Without the fix, rootlesskit hangs, so use timeout. + out=$(setpriv --no-new-privs timeout 30 $ROOTLESSKIT $args sh -ec 'echo "uid=$(id -u)"; grep -E "^(NoNewPrivs|CapEff|CapAmb):" /proc/self/status') + echo "${out}" + for expected in "uid=0" "NoNewPrivs:.1" "CapEff:.[0-9a-f]*[1-9a-f][0-9a-f]*$" "CapAmb:.0*$"; do + if ! echo "${out}" | grep -qE "${expected}"; then + ERROR "expected to match ${expected}" + exit 1 + fi + done +} + +test_no_new_privs --pidns=false +test_no_new_privs --pidns=true