diff --git a/man/systemd-nsresourced.service.xml b/man/systemd-nsresourced.service.xml index 6234b00dbd..e3ac1eb0c3 100644 --- a/man/systemd-nsresourced.service.xml +++ b/man/systemd-nsresourced.service.xml @@ -44,7 +44,11 @@ change ownership of them — that would end up owned by a transient UID/GID on a file system outliving the user namespace the range was assigned to. Writes to file systems the user namespace allocated itself are hence permitted, as those are torn down together with it, and so are writes through id-mapped mounts that - translate the transient range into something else before it reaches the disk. + translate the transient range into something else before it reaches the disk. As a stacking file system + 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. 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 05f245020e..35856a5330 100644 --- a/src/bpf/userns-restrict.bpf.c +++ b/src/bpf/userns-restrict.bpf.c @@ -65,6 +65,9 @@ void* bpf_rdonly_cast(const void *, __u32) __ksym; /* The kernel's INVALID_UID/INVALID_GID, i.e. what an unmapped translation results in */ #define UID_GID_INVALID ((uid_t) -1) +/* From include/uapi/linux/magic.h, which vmlinux.h does not provide. */ +#define OVERLAYFS_SUPER_MAGIC 0x794c7630 + struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(max_entries, 1); /* placeholder, configured otherwise by nsresourced */ @@ -385,6 +388,173 @@ int BPF_PROG(userns_restrict_path_link, struct dentry *old_dentry, const struct return validate_mount(new_dir->mnt, ret); } +/* overlayfs' struct ovl_fs and struct ovl_layer are private to the module and hence absent from vmlinux.h + * whenever overlayfs is built as a module. We declare the two fields we need as CO-RE "type flavors": libbpf + * strips the ___local suffix and relocates these against the kernel's real ovl_fs/ovl_layer. This both + * avoids clashing with a vmlinux.h that does carry them (built-in overlayfs) and, combined with + * bpf_core_type_exists() below, lets the program still load when overlayfs is entirely unavailable. + * systemd-nsresourced.service orders itself after modprobe@overlay.service so the module's BTF is present by + * the time this program is loaded. @mnt is documented in the kernel to be the first member of ovl_layer. */ +struct ovl_layer___local { + struct vfsmount *mnt; +} __attribute__((preserve_access_index)); + +struct ovl_fs___local { + struct ovl_layer___local *layers; +} __attribute__((preserve_access_index)); + +/* True if the inclusive range [a, b] overlaps either transient id range. */ +static bool range_hits_transient(uint64_t a, uint64_t b) { + if (a <= DYNAMIC_UID_MAX && DYNAMIC_UID_MIN <= b) + return true; + if (a <= CONTAINER_UID_MAX && CONTAINER_UID_MIN <= b) + return true; + return false; +} + +/* Returns true if the map (a mount idmap's uid_map or gid_map) never records a transient id on disk, so + * nothing recyclable can reach the disk through it. Read in the "up" direction (i.e. as a mount idmap) an + * extent maps input ids [lower_first, lower_first+count) to on-disk ids [first, first+count); we refuse if + * any extent's on-disk range is transient, no matter which input reaches it. Ids no extent covers are left + * unmapped, which fails the write with EOVERFLOW, so they are fine too. An empty map is the identity + * mapping and hence neutralizes nothing. */ +static bool idmap_map_neutralizes_transient(struct uid_gid_map *map) { + struct uid_gid_extent *array; + unsigned n; + + n = map->nr_extents; + + array = uid_gid_map_extents(map, /* up= */ true); + /* NULL means an empty map (identity mapping, neutralizes nothing) or a read failure; refuse either. */ + if (!array) + return false; + + for (unsigned i = 0; i < UID_GID_MAP_MAX_EXTENTS; i++) { + struct uid_gid_extent e; + + if (i >= n) + break; + if (bpf_probe_read_kernel(&e, sizeof(e), array + i) != 0) + return false; + + if (e.count == 0) + continue; + + /* The on-disk ids this extent can record are [first, first+count). If any is transient, + * some write through this mount is recyclable, so the map does not neutralize. */ + if (range_hits_transient(e.first, (uint64_t) e.first + e.count - 1)) + return false; + } + + return true; +} + +/* True if the mount's idmapping maps the whole transient UID and GID ranges away, so a transient client + * writing through it leaves nothing recyclable on disk. A non-idmapped mount maps them through unchanged. */ +static bool mount_neutralizes_transient(struct vfsmount *mnt) { + struct mnt_idmap *idmap; + + idmap = mnt->mnt_idmap; + if (!idmap) + return false; + + return idmap_map_neutralizes_transient(&idmap->uid_map) && + idmap_map_neutralizes_transient(&idmap->gid_map); +} + +/* overlayfs would let a managed user namespace sidestep all of the above: a write to an overlay mount + * creates the real inode on the upper layer via vfs_create(), which reaches only the inode-based LSM hooks, + * not the path-based ones we attach to. The single path hook that does fire sees the overlay mount itself, + * whose superblock belongs to the (managed) mounting namespace and so looks like it dies with it, while the + * upper may be a persistent file system on which our transient IDs then persist. So we judge the upper + * here, at mount time, the only point we can see it. We permit the overlay if its upper dies with the + * managed namespace (e.g. a tmpfs the client set up itself), or if the upper is a persistent file system + * whose mount idmapping maps the transient ranges away so nothing recyclable reaches its disk (as + * mountfsd-style idmapped mounts do); everything else, and any overlay owned by a namespace we do not + * manage, is refused. + * + * We key on whether a writable upper exists (layers[0].mnt), which is fixed for the superblock's lifetime, + * not on the mutable SB_RDONLY flag: an overlay mounted read-only but with an upper can be remounted + * read-write later without re-entering this hook, so it is judged now. A genuinely read-only overlay has no + * upper (layers[0].mnt is NULL) and cannot record anything, so it is left alone. Refusing the mount cannot + * undo the work/index dirs overlayfs already stamped onto the upper during ovl_fill_super(), which runs + * before this hook; it stops all further writes, but those mount-time dirs remain a residual leak. Reaching + * it takes an upperdir and a workdir that already exist on the same persistent, non-idmapped file system and + * that the transient IDs may write to: the client cannot create them itself, since that is what the path + * hooks deny. + * + * We only look at the upper: overlayfs requires the workdir to be under the same mount as the upperdir + * (ovl_get_workdir()), and stamps work/index using the upper mount's idmapping (ovl_upper_mnt_idmap() == + * mnt_idmap(layers[0].mnt)), so the upper's superblock and idmapping already govern the workdir too. */ +SEC("lsm/sb_kern_mount") +int BPF_PROG(userns_restrict_sb_kern_mount, struct super_block *sb, int ret) { + struct ovl_layer___local *layers; + struct user_namespace *managed, *fs_userns; + struct ovl_fs___local *ofs; + struct vfsmount *upper; + void *upper_addr; + int r; + + if (ret != 0) /* propagate earlier error */ + return ret; + + if (sb->s_magic != OVERLAYFS_SUPER_MAGIC) + return 0; + + r = find_managed_userns(sb->s_user_ns, &managed); + if (r < 0) + return r; + if (r == 0) + return 0; + + /* If we cannot read overlayfs' private layout (module not loaded, built without overlayfs at all, + * or the fields we need went away), we cannot locate the upper, so refuse conservatively. */ + if (!bpf_core_type_exists(struct ovl_fs___local) || + !bpf_core_field_exists(struct ovl_fs___local, layers) || + !bpf_core_field_exists(struct ovl_layer___local, mnt)) + return -EPERM; + + /* Walk to the upper one field at a time: a failed read comes back as NULL just like a genuinely + * absent upper does, and only the latter may be allowed. A mounted overlay always has both its + * private data and its layer array, so NULL there means we failed to read them. */ + ofs = (struct ovl_fs___local*) BPF_CORE_READ(sb, s_fs_info); + if (!ofs) + return -EPERM; + + layers = BPF_CORE_READ(ofs, layers); + if (!layers) + return -EPERM; + + upper_addr = BPF_CORE_READ(layers, mnt); /* layers[0] is the upper; .mnt is its first member */ + if (!upper_addr) /* no upper: a genuinely read-only overlay, which can record nothing */ + return 0; + + /* BPF_CORE_READ() lowers to bpf_probe_read_kernel(), so its result is a raw address the verifier + * tracks as a scalar. Re-type it as a BTF pointer, as the retire_userns_sysctls kprobe does with + * its argument, so we may walk and dereference it directly below. */ + upper = bpf_rdonly_cast(upper_addr, bpf_core_type_id_kernel(struct vfsmount)); + fs_userns = upper->mnt_sb->s_user_ns; + + /* Upper dies together with the managed user namespace? Then nothing it carries can outlive the + * transient range. */ + r = userns_is_below(managed, fs_userns); + if (r < 0) + return r; + if (r > 0) + return 0; + + /* The upper outlives the namespace. It is still safe if it is a persistent file system in the + * initial user namespace whose mount idmapping maps the transient ranges away. We refuse anything + * we cannot prove this for, including any non-initial-namespace upper. */ + if (fs_userns->parent) + return -EPERM; + + if (mount_neutralizes_transient(upper)) + return 0; + + return -EPERM; +} + SEC("lsm/task_fix_setgroups") int BPF_PROG(userns_restrict_task_fix_setgroups, struct cred *new_cred, const struct cred *old, int ret) { struct user_namespace *p; diff --git a/src/nsresourced/test-userns-restrict.c b/src/nsresourced/test-userns-restrict.c index 3607a26899..1bddeb70c4 100644 --- a/src/nsresourced/test-userns-restrict.c +++ b/src/nsresourced/test-userns-restrict.c @@ -167,6 +167,183 @@ TEST(userns_restrict) { ASSERT_OK(pidref_wait_for_terminate_and_check("(test)", &pidref, WAIT_LOG)); } +TEST(userns_restrict_overlayfs) { + _cleanup_close_ int userns_fd = -EBADF, idmapped_upper = -EBADF, bad_userns_fd = -EBADF, + bad_upper = -EBADF; + _cleanup_(rm_rf_physical_and_freep) char *t = NULL, *th = NULL, *ti = NULL, *tb = NULL; + _cleanup_(pidref_done_sigkill_wait) PidRef pidref = PIDREF_NULL; + _cleanup_free_ char *idmap = NULL, *bad_idmap = NULL, *hlower = NULL, *hupper = NULL, *hwork = NULL, + *hmerged = NULL; + int r; + + /* overlay writes land on its upper layer via vfs_create(), which the path-based hooks never see, so + * a transient-owned inode could persist there. The policy judges the upper at mount time. An upper + * is allowed when it dies with the namespace (a tmpfs the client set up) or is idmapped to map the + * transient range away; it is refused when it outlives the namespace and records the transient range + * on disk (a plain init-owned upper, or one idmapped so the transient range still reaches its disk). + * We also check that an overlay mounted read-only but with such an upper is refused, since it could + * otherwise be remounted read-write afterwards without re-entering the hook. */ + + ASSERT_OK(mkdtemp_malloc(NULL, &t)); + ASSERT_OK_ERRNO(chown(t, 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)); + + /* Set up the layer dirs for the refuse case on the init-owned file system backing our temporary dir, + * i.e. one that outlives the user namespace. This has to happen here as root: the managed client + * cannot create these itself, as the path hooks already block a transient-owned inode on a file + * system that outlives it. */ + ASSERT_OK(mkdtemp_malloc(NULL, &th)); + ASSERT_OK_ERRNO(chown(th, CONTAINER_UID_MIN, CONTAINER_UID_MIN)); + ASSERT_NOT_NULL(hlower = path_join(th, "lower")); + ASSERT_NOT_NULL(hupper = path_join(th, "upper")); + ASSERT_NOT_NULL(hwork = path_join(th, "work")); + ASSERT_NOT_NULL(hmerged = path_join(th, "merged")); + ASSERT_OK_ERRNO(mkdir(hlower, 0755)); + ASSERT_OK_ERRNO(mkdir(hupper, 0755)); + ASSERT_OK_ERRNO(mkdir(hwork, 0755)); + ASSERT_OK_ERRNO(mkdir(hmerged, 0755)); + ASSERT_OK_ERRNO(chown(hlower, CONTAINER_UID_MIN, CONTAINER_UID_MIN)); + ASSERT_OK_ERRNO(chown(hupper, CONTAINER_UID_MIN, CONTAINER_UID_MIN)); + ASSERT_OK_ERRNO(chown(hwork, CONTAINER_UID_MIN, CONTAINER_UID_MIN)); + ASSERT_OK_ERRNO(chown(hmerged, CONTAINER_UID_MIN, CONTAINER_UID_MIN)); + + /* And an idmapped upper for the third case: a tmpfs created here, i.e. in the initial user namespace + * so that it outlives the managed one, but idmapped through it so our transient range lands on its + * disk as non-transient ids. Set it up as root and attach it at a path the client can point + * upperdir= at. */ + idmapped_upper = ASSERT_OK(make_idmapped_tmpfs_fsmount(userns_fd)); + ASSERT_OK(mkdtemp_malloc(NULL, &ti)); + ASSERT_OK_ERRNO(move_mount(idmapped_upper, "", -EBADF, ti, MOVE_MOUNT_F_EMPTY_PATH)); + + /* And a fourth upper, for the refuse path of the idmap check: an init-owned tmpfs idmapped so that + * it records our transient range on disk unchanged (identity over it), i.e. one that does NOT + * neutralize the range. We create and chown the layer dirs before idmapping it, since afterwards + * neither we (uid 0, left unmapped by the narrow idmap) nor the client (whose transient-owned + * writes to a persistent file system are refused) could. This overlay must be refused. */ + ASSERT_OK(asprintf(&bad_idmap, UID_FMT" "UID_FMT" 1", CONTAINER_UID_MIN, CONTAINER_UID_MIN)); + bad_userns_fd = ASSERT_OK(userns_acquire(bad_idmap, bad_idmap, /* setgroups_deny= */ false)); + bad_upper = ASSERT_OK(make_tmpfs_fsmount()); + ASSERT_OK_ERRNO(mkdirat(bad_upper, "upper", 0755)); + ASSERT_OK_ERRNO(mkdirat(bad_upper, "work", 0755)); + ASSERT_OK_ERRNO(fchownat(bad_upper, "upper", CONTAINER_UID_MIN, CONTAINER_UID_MIN, 0)); + ASSERT_OK_ERRNO(fchownat(bad_upper, "work", CONTAINER_UID_MIN, CONTAINER_UID_MIN, 0)); + ASSERT_OK_ERRNO(mount_setattr(bad_upper, "", AT_EMPTY_PATH, + &(struct mount_attr) { + .attr_set = MOUNT_ATTR_IDMAP, + .userns_fd = bad_userns_fd, + }, sizeof(struct mount_attr))); + ASSERT_OK(mkdtemp_malloc(NULL, &tb)); + ASSERT_OK_ERRNO(move_mount(bad_upper, "", -EBADF, tb, MOVE_MOUNT_F_EMPTY_PATH)); + + r = ASSERT_OK(pidref_safe_fork("(test-ovl)", FORK_DEATHSIG_SIGKILL, &pidref)); + if (r == 0) { + ASSERT_OK(namespace_enter(-EBADF, -EBADF, -EBADF, userns_fd, -EBADF)); + ASSERT_OK_ERRNO(unshare(CLONE_NEWNS)); + ASSERT_OK_ERRNO(mount(NULL, "/", NULL, MS_SLAVE|MS_REC, NULL)); + + /* Hold the overlay layers on a tmpfs of our own, so nothing touches the host. */ + ASSERT_OK_ERRNO(mount("tmpfs", t, "tmpfs", 0, NULL)); + + _cleanup_free_ char *lower = NULL, *lower2 = NULL, *upper = NULL, *work = NULL, + *merged = NULL; + ASSERT_NOT_NULL(lower = path_join(t, "lower")); + ASSERT_NOT_NULL(lower2 = path_join(t, "lower2")); + ASSERT_NOT_NULL(upper = path_join(t, "upper")); + ASSERT_NOT_NULL(work = path_join(t, "work")); + ASSERT_NOT_NULL(merged = path_join(t, "merged")); + ASSERT_OK_ERRNO(mkdir(lower, 0755)); + ASSERT_OK_ERRNO(mkdir(lower2, 0755)); + ASSERT_OK_ERRNO(mkdir(upper, 0755)); + ASSERT_OK_ERRNO(mkdir(work, 0755)); + ASSERT_OK_ERRNO(mkdir(merged, 0755)); + + /* Control: a read-only overlay (no upper layer) is harmless and must be allowed. The kernel + * rejects a single lowerdir with no upper, so stack two lowers. If even this fails, + * unprivileged overlayfs is unavailable on this kernel — treat as a skip. */ + _cleanup_free_ char *ro_opts = NULL; + ASSERT_NOT_NULL(ro_opts = strjoin("lowerdir=", lower, ":", lower2)); + if (mount("overlay", merged, "overlay", MS_RDONLY, ro_opts) < 0) { + /* A read-only overlay has no upper and must be allowed. If our hook (rather than the + * kernel) refused it we would see EPERM, which is a bug, not a reason to skip. */ + ASSERT_TRUE(errno != EPERM); + log_notice_errno(errno, "Unprivileged overlayfs unavailable, skipping: %m"); + _exit(EXIT_SUCCESS); + } + ASSERT_OK_ERRNO(umount(merged)); + + /* The writable overlay's upper is on the tmpfs above, which dies with our user namespace, so + * the BPF-LSM must allow the mount. */ + _cleanup_free_ char *rw_opts = NULL; + ASSERT_NOT_NULL(rw_opts = strjoin("lowerdir=", lower, ",upperdir=", upper, + ",workdir=", work)); + ASSERT_OK_ERRNO(mount("overlay", merged, "overlay", 0, rw_opts)); + + /* A write through the overlay really lands on the upper (our tmpfs), which the path hooks + * never see directly. chown() over the merged mount reaches path_chown, which sees the + * overlay superblock (ours, so it dies with us) and allows it, even to a transient gid. */ + _cleanup_free_ char *f = NULL, *uf = NULL; + ASSERT_NOT_NULL(f = path_join(merged, "f")); + ASSERT_NOT_NULL(uf = path_join(upper, "f")); + safe_close(ASSERT_OK_ERRNO(open(f, O_RDWR|O_CREAT|O_CLOEXEC, 0666))); + ASSERT_OK_ERRNO(access(uf, F_OK)); + ASSERT_OK_ERRNO(fchownat(AT_FDCWD, f, UID_INVALID, 0, 0)); + + ASSERT_OK_ERRNO(umount(merged)); + + /* The same writable overlay, but with its upper on the init-owned host file system that + * outlives our user namespace: the policy must refuse it. Overlayfs sets the upper up via + * vfs_mkdir()/vfs_create(), reaching only the inode hooks, so nothing blocks it before + * sb_kern_mount runs, and the kernel itself permits the mount (verified without the BPF-LSM + * loaded) — hence the EPERM here is sb_kern_mount refusing it. */ + _cleanup_free_ char *bad_opts = NULL; + ASSERT_NOT_NULL(bad_opts = strjoin("lowerdir=", hlower, ",upperdir=", hupper, + ",workdir=", hwork)); + ASSERT_ERROR_ERRNO(mount("overlay", hmerged, "overlay", 0, bad_opts), EPERM); + + /* The same persistent upper, but mounted read-only, must be refused too: it still has an + * upper, so allowing it on the strength of the (mutable) read-only flag would let the client + * remount it read-write afterwards, which does not re-enter this hook. */ + ASSERT_ERROR_ERRNO(mount("overlay", hmerged, "overlay", MS_RDONLY, bad_opts), EPERM); + + /* Then the idmapped upper: its superblock outlives us, but its idmapping maps our + * transient range away, so nothing recyclable reaches its disk and the mount is allowed + * again. The layer dirs are created by us, the transient client, which the idmapping permits + * the same way it permits writes through the mount. */ + _cleanup_free_ char *iupper = NULL, *iwork = NULL, *idmapped_opts = NULL; + ASSERT_NOT_NULL(iupper = path_join(ti, "upper")); + ASSERT_NOT_NULL(iwork = path_join(ti, "work")); + ASSERT_OK_ERRNO(mkdir(iupper, 0755)); + ASSERT_OK_ERRNO(mkdir(iwork, 0755)); + ASSERT_NOT_NULL(idmapped_opts = strjoin("lowerdir=", lower, ",upperdir=", iupper, + ",workdir=", iwork)); + ASSERT_OK_ERRNO(mount("overlay", merged, "overlay", 0, idmapped_opts)); + ASSERT_OK_ERRNO(umount(merged)); + + /* Finally the idmapped upper whose idmapping does NOT map our transient range away (it + * records it on disk unchanged): the mount reaches sb_kern_mount, whose idmap check must + * refuse it. */ + _cleanup_free_ char *bupper = NULL, *bwork = NULL, *bad_idmapped_opts = NULL; + ASSERT_NOT_NULL(bupper = path_join(tb, "upper")); + ASSERT_NOT_NULL(bwork = path_join(tb, "work")); + ASSERT_NOT_NULL(bad_idmapped_opts = strjoin("lowerdir=", lower, ",upperdir=", bupper, + ",workdir=", bwork)); + ASSERT_ERROR_ERRNO(mount("overlay", merged, "overlay", 0, bad_idmapped_opts), EPERM); + + _exit(EXIT_SUCCESS); + } + + ASSERT_OK(pidref_wait_for_terminate_and_check("(test-ovl)", &pidref, WAIT_LOG)); + + /* Detach the extra uppers we attached in our (initial) mount namespace before the temp dirs are + * removed. */ + (void) umount(ti); + (void) umount(tb); +} + 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 diff --git a/units/systemd-nsresourced.service.in b/units/systemd-nsresourced.service.in index 143f9a9a3d..771fdec531 100644 --- a/units/systemd-nsresourced.service.in +++ b/units/systemd-nsresourced.service.in @@ -13,8 +13,8 @@ Documentation=man:systemd-nsresourced.service(8) Requires=systemd-nsresourced.socket Conflicts=shutdown.target Before=sysinit.target shutdown.target -Wants=modprobe@tun.service -After=modprobe@tun.service +Wants=modprobe@tun.service modprobe@overlay.service +After=modprobe@tun.service modprobe@overlay.service DefaultDependencies=no [Service]