nsresourced: detect and clean up registry entries for dead user namespaces (#42070)

The BPF kprobe that fires on user namespace destruction is the only
thing
that triggers registry cleanup, so any time it doesn't run — ring buffer
overflow, kprobe missing, fdstore entry dropped outside our cleanup path
— a registry entry is left behind forever.

Stamp each registry entry with the kernel's unique namespace identifier
(NS_GET_ID, kernel ≥ 6.13) at allocation time. At manager startup, after
the existing fdstore→registry sweep, walk the registry and ask the
kernel
to look each namespace up by id via open_by_handle_at() on nsfs; if the
lookup returns -ESTALE the namespace is gone and we release the entry.
Old entries written before this change carry no identifier and are left
alone.

Add a namespace_open_by_id() helper for the lookup. The kernel restricts
open_by_handle_at() on nsfs to processes in the initial user namespace,
collapsing both permission denials and dead namespaces onto -ESTALE; the
helper refuses early with -EPERM outside the initial user namespace
so callers can tell the two apart.
This commit is contained in:
Daan De Meyer
2026-05-15 21:19:15 +02:00
committed by GitHub
10 changed files with 310 additions and 0 deletions
+65
View File
@@ -23,6 +23,7 @@
#include "stat-util.h"
#include "stdio-util.h"
#include "uid-range.h"
#include "unaligned.h"
#include "user-util.h"
const struct namespace_info namespace_info[_NAMESPACE_TYPE_MAX + 1] = {
@@ -860,6 +861,70 @@ int process_is_owned_by_uid(const PidRef *pidref, uid_t uid) {
}
}
int namespace_open_by_id(uint64_t ns_id) {
int r;
/* Looks up a namespace by its unique boot-stable identifier and returns an O_PATH fd to it.
* Requires kernel ≥ 6.13.
*
* Returns -ESTALE if the namespace no longer exists, or if the kernel refuses the lookup
* for permission reasons. The latter happens outside the initial user namespace: the
* kernel only permits open_by_handle_at() on nsfs when the caller is in the initial user
* and pid namespaces with CAP_SYS_ADMIN, with a narrow exception for lookups of the
* caller's own user namespace and its ancestors. To avoid conflating "namespace is dead"
* with "kernel refused us", we refuse early with -EPERM when we aren't in the initial
* user/pid namespace or missing CAP_SYS_ADMIN and let the caller skip the check. */
if (ns_id == 0)
return -EINVAL;
r = namespace_is_init(NAMESPACE_USER);
if (r < 0)
return r;
if (r == 0)
return -EPERM;
r = namespace_is_init(NAMESPACE_PID);
if (r < 0)
return r;
if (r == 0)
return -EPERM;
r = have_effective_cap(CAP_SYS_ADMIN);
if (r < 0)
return r;
if (r == 0)
return -EPERM;
/* The natural way to write this would be a compound designated initializer:
*
* union { ... } fh = {
* .file_handle.handle_bytes = sizeof(struct nsfs_file_handle),
* .file_handle.handle_type = FILEID_NSFS,
* };
*
* but that only zero-initializes the named struct members of struct file_handle.
* struct file_handle ends with a flexible array (`unsigned char f_handle[]`), whose
* storage comes from the overlapping `space[]` member of the union. Bytes in that storage
* are not covered by the partial struct initializer and end up as stack garbage. Zero the
* entire union first, then fill in the fields explicitly. */
union {
struct file_handle file_handle;
uint8_t space[offsetof(struct file_handle, f_handle) + sizeof(struct nsfs_file_handle)];
} fh = {};
fh.file_handle.handle_bytes = sizeof(struct nsfs_file_handle);
fh.file_handle.handle_type = FILEID_NSFS;
/* The first 8 bytes of struct nsfs_file_handle (see <linux/nsfs.h>, uapi since kernel v6.18)
* are __u64 ns_id; the remaining ns_type/ns_inum fields stay zero so the kernel looks up by
* id alone. The kernel made lookup-by-id-only an explicit ABI guarantee in v6.19 via commit
* 04173501a69e ("nstree: allow lookup solely based on inode"). */
unaligned_write_ne64(fh.file_handle.f_handle, ns_id);
return RET_NERRNO(open_by_handle_at(FD_NSFS_ROOT, &fh.file_handle, O_PATH|O_CLOEXEC));
}
int is_idmapping_supported(const char *path) {
_cleanup_close_ int mount_fd = -EBADF, userns_fd = -EBADF, dir_fd = -EBADF;
int r;
+2
View File
@@ -88,6 +88,8 @@ bool userns_supported(void);
int userns_get_base_uid(int userns_fd, uid_t *ret_uid, gid_t *ret_gid);
int namespace_open_by_id(uint64_t ns_id);
int process_is_owned_by_uid(const PidRef *pidref, uid_t uid);
int is_idmapping_supported(const char *path);
+4
View File
@@ -25,6 +25,10 @@
#define AT_HANDLE_MNT_ID_UNIQUE 0x001 /* Return the u64 unique mount ID. */
#endif
#ifndef FD_NSFS_ROOT
#define FD_NSFS_ROOT -10003 /* Root of the nsfs filesystem */
#endif
/* Defined since glibc-2.42.
* Supported since kernel v5.6 (fddb5d430ad9fa91b49b1d34d0202ffe2fa0e179). */
int openat2_shim(int dfd, const char *filename, const struct open_how *how, size_t usize);
+5
View File
@@ -12,3 +12,8 @@
#define PROC_PID_INIT_INO ((ino_t) UINT32_C(0xEFFFFFFC))
#define PROC_CGROUP_INIT_INO ((ino_t) UINT32_C(0xEFFFFFFB))
#define PROC_TIME_INIT_INO ((ino_t) UINT32_C(0xEFFFFFFA))
/* From kernel-internal include/linux/exportfs.h, not part of uapi. */
#ifndef FILEID_NSFS
#define FILEID_NSFS 0xf1
#endif
+128
View File
@@ -0,0 +1,128 @@
/* SPDX-License-Identifier: GPL-2.0 WITH Linux-syscall-note */
#ifndef __LINUX_NSFS_H
#define __LINUX_NSFS_H
#include <linux/ioctl.h>
#include <linux/types.h>
#define NSIO 0xb7
/* Returns a file descriptor that refers to an owning user namespace */
#define NS_GET_USERNS _IO(NSIO, 0x1)
/* Returns a file descriptor that refers to a parent namespace */
#define NS_GET_PARENT _IO(NSIO, 0x2)
/* Returns the type of namespace (CLONE_NEW* value) referred to by
file descriptor */
#define NS_GET_NSTYPE _IO(NSIO, 0x3)
/* Get owner UID (in the caller's user namespace) for a user namespace */
#define NS_GET_OWNER_UID _IO(NSIO, 0x4)
/* Translate pid from target pid namespace into the caller's pid namespace. */
#define NS_GET_PID_FROM_PIDNS _IOR(NSIO, 0x6, int)
/* Return thread-group leader id of pid in the callers pid namespace. */
#define NS_GET_TGID_FROM_PIDNS _IOR(NSIO, 0x7, int)
/* Translate pid from caller's pid namespace into a target pid namespace. */
#define NS_GET_PID_IN_PIDNS _IOR(NSIO, 0x8, int)
/* Return thread-group leader id of pid in the target pid namespace. */
#define NS_GET_TGID_IN_PIDNS _IOR(NSIO, 0x9, int)
struct mnt_ns_info {
__u32 size;
__u32 nr_mounts;
__u64 mnt_ns_id;
};
#define MNT_NS_INFO_SIZE_VER0 16 /* size of first published struct */
/* Get information about namespace. */
#define NS_MNT_GET_INFO _IOR(NSIO, 10, struct mnt_ns_info)
/* Get next namespace. */
#define NS_MNT_GET_NEXT _IOR(NSIO, 11, struct mnt_ns_info)
/* Get previous namespace. */
#define NS_MNT_GET_PREV _IOR(NSIO, 12, struct mnt_ns_info)
/* Retrieve namespace identifiers. */
#define NS_GET_MNTNS_ID _IOR(NSIO, 5, __u64)
#define NS_GET_ID _IOR(NSIO, 13, __u64)
enum init_ns_ino {
IPC_NS_INIT_INO = 0xEFFFFFFFU,
UTS_NS_INIT_INO = 0xEFFFFFFEU,
USER_NS_INIT_INO = 0xEFFFFFFDU,
PID_NS_INIT_INO = 0xEFFFFFFCU,
CGROUP_NS_INIT_INO = 0xEFFFFFFBU,
TIME_NS_INIT_INO = 0xEFFFFFFAU,
NET_NS_INIT_INO = 0xEFFFFFF9U,
MNT_NS_INIT_INO = 0xEFFFFFF8U,
#ifdef __KERNEL__
MNT_NS_ANON_INO = 0xEFFFFFF7U,
#endif
};
struct nsfs_file_handle {
__u64 ns_id;
__u32 ns_type;
__u32 ns_inum;
};
#define NSFS_FILE_HANDLE_SIZE_VER0 16 /* sizeof first published struct */
#define NSFS_FILE_HANDLE_SIZE_LATEST sizeof(struct nsfs_file_handle) /* sizeof latest published struct */
enum init_ns_id {
IPC_NS_INIT_ID = 1ULL,
UTS_NS_INIT_ID = 2ULL,
USER_NS_INIT_ID = 3ULL,
PID_NS_INIT_ID = 4ULL,
CGROUP_NS_INIT_ID = 5ULL,
TIME_NS_INIT_ID = 6ULL,
NET_NS_INIT_ID = 7ULL,
MNT_NS_INIT_ID = 8ULL,
#ifdef __KERNEL__
NS_LAST_INIT_ID = MNT_NS_INIT_ID,
#endif
};
enum ns_type {
TIME_NS = (1ULL << 7), /* CLONE_NEWTIME */
MNT_NS = (1ULL << 17), /* CLONE_NEWNS */
CGROUP_NS = (1ULL << 25), /* CLONE_NEWCGROUP */
UTS_NS = (1ULL << 26), /* CLONE_NEWUTS */
IPC_NS = (1ULL << 27), /* CLONE_NEWIPC */
USER_NS = (1ULL << 28), /* CLONE_NEWUSER */
PID_NS = (1ULL << 29), /* CLONE_NEWPID */
NET_NS = (1ULL << 30), /* CLONE_NEWNET */
};
/**
* struct ns_id_req - namespace ID request structure
* @size: size of this structure
* @spare: reserved for future use
* @filter: filter mask
* @ns_id: last namespace id
* @user_ns_id: owning user namespace ID
*
* Structure for passing namespace ID and miscellaneous parameters to
* statns(2) and listns(2).
*
* For statns(2) @param represents the request mask.
* For listns(2) @param represents the last listed mount id (or zero).
*/
struct ns_id_req {
__u32 size;
__u32 spare;
__u64 ns_id;
struct /* listns */ {
__u32 ns_type;
__u32 spare2;
__u64 user_ns_id;
};
};
/*
* Special @user_ns_id value that can be passed to listns()
*/
#define LISTNS_CURRENT_USER 0xffffffffffffffff /* Caller's userns */
/* List of all ns_id_req versions. */
#define NS_ID_REQ_SIZE_VER0 32 /* sizeof first published struct */
#endif /* __LINUX_NSFS_H */
+46
View File
@@ -19,6 +19,7 @@
#include "fs-util.h"
#include "log.h"
#include "mkdir.h"
#include "namespace-util.h"
#include "nsresourced-manager.h"
#include "parse-util.h"
#include "pidfd-util.h"
@@ -648,6 +649,51 @@ int manager_startup(Manager *m) {
manager_release_userns_by_inode(m, inode);
}
/* Look for registry entries whose user namespace has died without us getting a BPF
* notification — e.g. because the BPF ring buffer overflowed, the kprobe is missing, or
* something else dropped the fd store entry without going through our cleanup path. Each
* registry entry stores the kernel's unique namespace identifier; ask the kernel to open
* the namespace by that identifier and release the entry if the lookup fails. Entries
* written by older versions don't carry the identifier, and old kernels (or running
* outside the initial user namespace) don't support lookup by it — in those cases we leave
* the entry alone. */
SET_FOREACH(p, registry_inodes) {
uint64_t inode = PTR_TO_UINT32(p);
_cleanup_(userns_info_freep) UserNamespaceInfo *userns_info = NULL;
r = userns_registry_load_by_userns_inode(m->registry_fd, inode, &userns_info);
if (r < 0) {
log_debug_errno(r, "Failed to load registry entry for user namespace %" PRIu64 ", ignoring: %m", inode);
continue;
}
if (userns_info->userns_id == 0)
continue; /* Entry predates ns_id tracking, can't probe authoritatively */
_cleanup_close_ int probe_fd = namespace_open_by_id(userns_info->userns_id);
if (probe_fd >= 0)
continue; /* User namespace is still alive */
/* EPERM/EACCES means we're not in the initial user/pid namespace or missing
* CAP_SYS_ADMIN; ENOTSUP/ENOSYS means the kernel is too old for
* open_by_handle_at() on nsfs. Either way the sweep can't proceed for any
* entry, so bail out rather than logging once per entry. */
if (ERRNO_IS_NEG_PRIVILEGE(probe_fd) || ERRNO_IS_NEG_NOT_SUPPORTED(probe_fd)) {
log_debug_errno(probe_fd, "Cannot detect stale registry entries, skipping: %m");
break;
}
/* Anything else except ESTALE is unexpected — log it but skip just this one. */
if (probe_fd != -ESTALE) {
log_debug_errno(probe_fd, "Failed to probe liveness of user namespace %" PRIu64 " (id %" PRIu64 "), ignoring: %m",
inode, userns_info->userns_id);
continue;
}
log_debug("Registry entry for user namespace %" PRIu64 " (id %" PRIu64 ") refers to a dead namespace, removing.",
inode, userns_info->userns_id);
manager_release_userns_by_inode(m, inode);
}
r = manager_make_listen_socket(m);
if (r < 0)
return r;
+4
View File
@@ -1295,6 +1295,8 @@ static int vl_method_allocate_user_range(sd_varlink *link, sd_json_variant *para
userns_info->owner = peer_uid;
userns_info->userns_inode = userns_st.st_ino;
if (ioctl(userns_fd, NS_GET_ID, &userns_info->userns_id) < 0)
log_debug_errno(errno, "Failed to query userns ID, ignoring: %m");
userns_info->size = p.size;
userns_info->target_uid = p.target;
userns_info->target_gid = (gid_t) p.target;
@@ -1575,6 +1577,8 @@ static int vl_method_register_user_namespace(sd_varlink *link, sd_json_variant *
userns_info->owner = peer_uid;
userns_info->userns_inode = userns_st.st_ino;
if (ioctl(userns_fd, NS_GET_ID, &userns_info->userns_id) < 0)
log_debug_errno(errno, "Failed to query userns ID, ignoring: %m");
r = userns_registry_store(registry_dir_fd, userns_info);
if (r < 0)
+2
View File
@@ -239,6 +239,7 @@ static int userns_registry_load(int dir_fd, const char *fn, UserNamespaceInfo **
{ "owner", SD_JSON_VARIANT_UNSIGNED, sd_json_dispatch_uid_gid, offsetof(UserNamespaceInfo, owner), SD_JSON_MANDATORY },
{ "name", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(UserNamespaceInfo, name), SD_JSON_MANDATORY },
{ "userns", SD_JSON_VARIANT_UNSIGNED, sd_json_dispatch_uint64, offsetof(UserNamespaceInfo, userns_inode), SD_JSON_MANDATORY },
{ "usernsId", SD_JSON_VARIANT_UNSIGNED, sd_json_dispatch_uint64, offsetof(UserNamespaceInfo, userns_id), 0 },
{ "size", SD_JSON_VARIANT_UNSIGNED, sd_json_dispatch_uint32, offsetof(UserNamespaceInfo, size), 0 },
{ "start", SD_JSON_VARIANT_UNSIGNED, sd_json_dispatch_uid_gid, offsetof(UserNamespaceInfo, start_uid), 0 },
{ "target", SD_JSON_VARIANT_UNSIGNED, sd_json_dispatch_uid_gid, offsetof(UserNamespaceInfo, target_uid), 0 },
@@ -565,6 +566,7 @@ int userns_registry_store(int dir_fd, UserNamespaceInfo *info) {
SD_JSON_BUILD_PAIR_UNSIGNED("owner", info->owner),
SD_JSON_BUILD_PAIR_STRING("name", info->name),
SD_JSON_BUILD_PAIR_UNSIGNED("userns", info->userns_inode),
SD_JSON_BUILD_PAIR_CONDITION(info->userns_id != 0, "usernsId", SD_JSON_BUILD_UNSIGNED(info->userns_id)),
SD_JSON_BUILD_PAIR_CONDITION(info->size > 0, "size", SD_JSON_BUILD_UNSIGNED(info->size)),
SD_JSON_BUILD_PAIR_CONDITION(uid_is_valid(info->start_uid), "start", SD_JSON_BUILD_UNSIGNED(info->start_uid)),
SD_JSON_BUILD_PAIR_CONDITION(uid_is_valid(info->target_uid), "target", SD_JSON_BUILD_UNSIGNED(info->target_uid)),
+1
View File
@@ -29,6 +29,7 @@ typedef struct UserNamespaceInfo {
uid_t owner;
char *name;
uint64_t userns_inode;
uint64_t userns_id; /* Unique namespace identifier from NS_GET_ID, 0 if unavailable */
uint32_t size;
uid_t start_uid;
uid_t target_uid;
+53
View File
@@ -1,8 +1,10 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#include <fcntl.h>
#include <linux/nsfs.h>
#include <sched.h>
#include <stdlib.h>
#include <sys/ioctl.h>
#include <sys/prctl.h>
#include <sys/socket.h>
#include <sys/stat.h>
@@ -282,6 +284,57 @@ TEST(userns_get_base_uid) {
ASSERT_ERROR(userns_get_base_uid(fd, &base_uid, &base_gid), ENOMSG);
}
TEST(namespace_open_by_id) {
/* Try our own user namespace first to see if the kernel exposes ns_id at all. */
_cleanup_close_ int userns_fd = ASSERT_OK_ERRNO(open("/proc/self/ns/user", O_RDONLY|O_CLOEXEC));
uint64_t ns_id;
int r = RET_NERRNO(ioctl(userns_fd, NS_GET_ID, &ns_id));
if (ERRNO_IS_NEG_NOT_SUPPORTED(r))
return (void) log_tests_skipped("NS_GET_ID is not supported by this kernel");
ASSERT_OK(r);
/* namespace_open_by_id() refuses with -EPERM outside the initial user/pid namespace, since
* the kernel restricts open_by_handle_at() on nsfs to the initial userns and pidns and to
* CAP_SYS_ADMIN. */
_cleanup_close_ int opened = namespace_open_by_id(ns_id);
if (opened == -EPERM)
return (void) log_tests_skipped("not in initial user namespace or missing CAP_SYS_ADMIN");
if (IN_SET(opened, -EOPNOTSUPP, -EINVAL))
return (void) log_tests_skipped("nsfs lookup by ns_id is not supported by this kernel");
ASSERT_OK(opened);
struct stat orig_st, opened_st;
ASSERT_OK_ERRNO(fstat(userns_fd, &orig_st));
ASSERT_OK_ERRNO(fstat(opened, &opened_st));
ASSERT_EQ(orig_st.st_ino, opened_st.st_ino);
opened = safe_close(opened);
ASSERT_ERROR(namespace_open_by_id(0), EINVAL);
_cleanup_close_ int transient_fd = userns_acquire_empty();
if (ERRNO_IS_NEG_NOT_SUPPORTED(transient_fd) || ERRNO_IS_NEG_PRIVILEGE(transient_fd))
return (void) log_tests_skipped("cannot acquire userns for transient lookup test");
ASSERT_OK(transient_fd);
uint64_t transient_id;
ASSERT_OK_ERRNO(ioctl(transient_fd, NS_GET_ID, &transient_id));
ASSERT_NE(transient_id, ns_id);
opened = ASSERT_OK(namespace_open_by_id(transient_id));
struct stat transient_st, transient_opened_st;
ASSERT_OK_ERRNO(fstat(transient_fd, &transient_st));
ASSERT_OK_ERRNO(fstat(opened, &transient_opened_st));
ASSERT_EQ(transient_st.st_ino, transient_opened_st.st_ino);
opened = safe_close(opened);
/* Close the only reference. The namespace is now dead — lookup must fail. */
transient_fd = safe_close(transient_fd);
ASSERT_ERROR(namespace_open_by_id(transient_id), ESTALE);
}
TEST(process_is_owned_by_uid) {
int r;