From 26d273d804a69359b1556a62a19111456c8b34a9 Mon Sep 17 00:00:00 2001 From: Daan De Meyer Date: Sun, 27 Sep 2026 13:04:43 +0200 Subject: [PATCH] nsresourced: Exclude cgroupfs from superblock check cgroupfs has a single shared global superblock but programs running in managed user namespaces still need to be able to write it to make cgroup delegation work. d381a0d9d5df99196b4ada524f02f85ec18033ac broke this by insisting the user namespace owns the superblock. Let's fix this by excluding cgroupfs from this check. (cherry picked from commit 889bc48f101a7cbb56cb4be202e85e1f6d933151) --- man/systemd-nsresourced.service.xml | 6 ++- src/bpf/userns-restrict.bpf.c | 15 +++++++ src/nsresourced/test-userns-restrict.c | 60 ++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/man/systemd-nsresourced.service.xml b/man/systemd-nsresourced.service.xml index 3c662d04ed..04ec02d228 100644 --- a/man/systemd-nsresourced.service.xml +++ b/man/systemd-nsresourced.service.xml @@ -48,7 +48,11 @@ such as overlayfs writes to a separate upper layer that would not be covered by the checks on the overlay mount itself, mounting a writable overlayfs instance is refused for these user namespaces unless its upper layer, too, is torn down together with the user namespace, or - is an id-mapped mount that translates the transient range away before it reaches the disk. + is an id-mapped mount that translates the transient range away before it reaches the disk. The policy + does not cover cgroupfs, whose superblock is shared system-wide and cannot be + id-mapped: the only part of it a transient range owns is the cgroup subtree delegated to the user + namespace (see below), which is torn down together with the user namespace, hence user namespaces may + freely create cgroups below it. systemd-nsresourced automatically ensures that any registered UID ranges show up in the system's NSS database via the User/Group Record diff --git a/src/bpf/userns-restrict.bpf.c b/src/bpf/userns-restrict.bpf.c index 35856a5330..1f266b38bb 100644 --- a/src/bpf/userns-restrict.bpf.c +++ b/src/bpf/userns-restrict.bpf.c @@ -67,6 +67,7 @@ void* bpf_rdonly_cast(const void *, __u32) __ksym; /* From include/uapi/linux/magic.h, which vmlinux.h does not provide. */ #define OVERLAYFS_SUPER_MAGIC 0x794c7630 +#define CGROUP2_SUPER_MAGIC 0x63677270 struct { __uint(type, BPF_MAP_TYPE_HASH); @@ -270,6 +271,14 @@ static int mount_map_id(struct vfsmount *mnt, struct user_namespace *fs_userns, return uid_gid_map_translate(gid ? &fs_userns->gid_map : &fs_userns->uid_map, id, /* up= */ false, ret); } +/* cgroupfs is exempt from this policy: there is a single cgroupfs superblock, owned by the initial user + * namespace (every mount of it shares that one), and it cannot be idmapped. That's fine: DAC makes sure a + * transient range only ever owns the cgroup delegated to it via AddControlGroupToUserNamespace(), which we + * remove along with the client, best-effort (a cgroup that refuses to go away outlives its range). */ +static bool mount_is_exempt(struct vfsmount *v) { + return v->mnt_sb->s_magic == CGROUP2_SUPER_MAGIC; +} + /* Common tail of the inode creation hooks: would the object the calling task is about to create end up * owned by a transient ID on a file system that outlives the user namespace that ID was handed to? */ static int validate_mount(struct vfsmount *v, int ret) { @@ -299,6 +308,9 @@ static int validate_mount(struct vfsmount *v, int ret) { if (r == 0) /* Not provisioned by nsresourced? Then this is none of our business. */ return 0; + if (mount_is_exempt(v)) + return 0; + fs_userns = v->mnt_sb->s_user_ns; /* Does the file system die together with the user namespace? Then nothing it carries can outlive @@ -351,6 +363,9 @@ int BPF_PROG(userns_restrict_path_chown, struct path *path, unsigned long long u if (r == 0) return 0; + if (mount_is_exempt(path->mnt)) + return 0; + r = userns_is_below(managed, path->mnt->mnt_sb->s_user_ns); if (r < 0) return r; diff --git a/src/nsresourced/test-userns-restrict.c b/src/nsresourced/test-userns-restrict.c index 28b9251c01..cbb928de20 100644 --- a/src/nsresourced/test-userns-restrict.c +++ b/src/nsresourced/test-userns-restrict.c @@ -7,6 +7,8 @@ #include #include +#include "cgroup-setup.h" +#include "cgroup-util.h" #include "errno-util.h" #include "fd-util.h" #include "fileio.h" @@ -346,6 +348,64 @@ TEST(userns_restrict_overlayfs) { (void) umount(tb); } +TEST(userns_restrict_cgroupfs) { + _cleanup_close_ int userns_fd = -EBADF, undelegated_fd = -EBADF, delegated_fd = -EBADF; + _cleanup_(pidref_done_sigkill_wait) PidRef pidref = PIDREF_NULL; + _cleanup_free_ char *cg = NULL, *undelegated = NULL, *delegated = NULL, *idmap = NULL; + int r; + + if (cg_is_available() <= 0) + return (void) log_tests_skipped("cgroupfs is not mounted"); + + /* Shifted, as cg_create() and friends prefix what we return here with /sys/fs/cgroup, which in a + * container without its own cgroup namespace is not where the unshifted path is rooted. */ + r = cg_pid_get_path_shifted(0, /* cached_root= */ NULL, &cg); + if (r < 0) + return (void) log_tests_skipped_errno(r, "Failed to determine our own cgroup"); + + ASSERT_NOT_NULL(undelegated = path_join(cg, "test-userns-restrict")); + ASSERT_NOT_NULL(delegated = path_join(undelegated, "delegated")); + + /* Always start clean, in case an earlier run failed halfway through */ + (void) cg_trim(undelegated, /* delete_root= */ true); + + r = cg_create(undelegated); + if (ERRNO_IS_NEG_FS_WRITE_REFUSED(r) || r == -ENOENT) + return (void) log_tests_skipped_errno(r, "Failed to create cgroup %s", undelegated); + ASSERT_OK(r); + + ASSERT_OK(cg_create(delegated)); + + undelegated_fd = ASSERT_OK(cg_path_open(undelegated)); + delegated_fd = ASSERT_OK(cg_path_open(delegated)); + + /* Hand the inner cgroup to the transient range, the way AddControlGroupToUserNamespace() does. */ + ASSERT_OK_ERRNO(fchown(delegated_fd, CONTAINER_UID_MIN, CONTAINER_UID_MIN)); + + ASSERT_OK(asprintf(&idmap, "0 "UID_FMT" 1", CONTAINER_UID_MIN)); + userns_fd = ASSERT_OK(userns_acquire(idmap, idmap, /* setgroups_deny= */ true)); + + ASSERT_OK(userns_restrict_register_by_fd(bpf_obj, userns_fd)); + + r = ASSERT_OK(pidref_safe_fork("(test-cgroup)", FORK_DEATHSIG_SIGKILL, &pidref)); + if (r == 0) { + ASSERT_OK(namespace_enter(-EBADF, -EBADF, -EBADF, userns_fd, -EBADF)); + + /* The delegated cgroup is owned by our transient range, so this must work. */ + ASSERT_OK_ERRNO(mkdirat(delegated_fd, "payload", 0755)); + /* chown() is subject to the same policy, and hence exempt here as well. */ + ASSERT_OK_ERRNO(fchownat(delegated_fd, "payload", UID_INVALID, 0, 0)); + /* Its parent is not ours, so we should get EACCES from DAC. */ + ASSERT_ERROR_ERRNO(mkdirat(undelegated_fd, "payload", 0755), EACCES); + + _exit(EXIT_SUCCESS); + } + + ASSERT_OK(pidref_wait_for_terminate_and_check("(test-cgroup)", &pidref, WAIT_LOG)); + + ASSERT_OK(cg_trim(undelegated, /* delete_root= */ true)); +} + static void write_child_mappings(PidRef *child, int parent_userns_fd) { /* The kernel requires uid_map/gid_map to be written from the parent user namespace of the * target namespace. Fork a helper that joins the parent userns and writes the mappings from