* [RFC PATCH v2 0/3] vfs: transitive upgrade restrictions for fds
@ 2026-03-26 18:20 Jori Koolstra
2026-03-26 18:20 ` [RFC PATCH v2 1/3] vfs: add support for empty path to openat2(2) Jori Koolstra
` (2 more replies)
0 siblings, 3 replies; 13+ messages in thread
From: Jori Koolstra @ 2026-03-26 18:20 UTC (permalink / raw)
To: Jeff Layton, Chuck Lever, Alexander Aring, Alexander Viro,
Christian Brauner, Jan Kara, Shuah Khan, Greg Kroah-Hartman,
Aleksa Sarai
Cc: Jori Koolstra, Andrew Morton, Mike Rapoport, Liam R . Howlett,
David Hildenbrand, Lorenzo Stoakes, Ethan Tidmore, NeilBrown,
Oleg Nesterov, Penglei Jiang, Kees Cook, Suren Baghdasaryan,
Vlastimil Babka, Amir Goldstein, Namjae Jeon, Mateusz Guzik,
Wei Yang, Bala-Vignesh-Reddy, linux-kernel, linux-fsdevel,
linux-kselftest
Add upgrade restrictions to openat2(). Extend struct open_how to allow
setting transitive restrictions on using file descriptors to open other
files. A use case for this feature is to block services or containers
from re-opening/upgrading an O_PATH file descriptor through e.g.
/proc/<pid>/fd/<nr> or OPENAT2_EMPTY_PATH (if upstreamed) as O_WRONLY.
The implementation idea is this: magic paths like /proc/<pid>/fd/<nr>
(currently the only one of its sort AFAIK) go through nd_jump_link() to
hard set current->nameidata. To include information about the fd
yielding the magic link, we add a new struct jump_how as a parameter.
This struct may include restictions or other metadata attached to the
magic link jump other than the struct path to jump to. So far it has
only one unsigned int field: allowed_upgrades. This is a flag int that
(for now) may be either READ_UPGRADABLE, WRITE_UPGRADABLE, or
DENY_UPGRADES.
The idea is that you can restrict what kind of open flags may be used
to open files in any way using this fd as a starting point
(transitively). The check is enforced in may_open_upgrade(), which is
just the old may_open() with an extra test. To keep this state attached
to the fds, we add a field f_allowed_upgrades to struct file. Then
in do_open(), after success, we compute:
file->f_allowed_upgrades =
op->allowed_upgrades & nd->allowed_upgrades;
where op is the struct open_flags that is build from open_how in
build_open_flags(), and nd->allowed_upgrades is set during path
traversal either in path_init() or nd_jump_link().
The implementation and the idea are a bit rough; it is the first bit of
less trivial work I have done on the kernel, hence the RFC status. I
include some selftests which this patch passes (on my machine at least),
and nothing seems to break on a fresh vng kernel. But obviously there
may be many things I am overlooking.
The original idea for this features comes form the UAPI group kernel
feature idea list [1].
[1] https://github.com/uapi-group/kernel-features?tab=readme-ov-file#upgrade-masks-in-openat2
v2: includes tests and the related OPENAT2_EMPTY_PATH patch in a series.
Jori Koolstra (3):
vfs: add support for empty path to openat2(2)
vfs: transitive upgrade restrictions for fds
selftest: add tests for OPENAT2_EMPTY_PATH and allowed_upgrades
fs/fcntl.c | 4 +-
fs/file_table.c | 2 +
fs/internal.h | 1 +
fs/namei.c | 41 ++-
fs/open.c | 20 +-
fs/proc/base.c | 24 +-
fs/proc/fd.c | 6 +-
fs/proc/internal.h | 4 +-
include/linux/fcntl.h | 11 +-
include/linux/fs.h | 1 +
include/linux/namei.h | 15 +-
include/uapi/linux/openat2.h | 10 +
tools/include/uapi/linux/openat2.h | 53 ++++
tools/testing/selftests/openat2/Makefile | 4 +-
tools/testing/selftests/openat2/helpers.c | 2 +-
tools/testing/selftests/openat2/helpers.h | 40 +--
.../testing/selftests/openat2/upgrade_test.c | 242 ++++++++++++++++++
17 files changed, 417 insertions(+), 63 deletions(-)
create mode 100644 tools/include/uapi/linux/openat2.h
create mode 100644 tools/testing/selftests/openat2/upgrade_test.c
--
2.53.0
^ permalink raw reply [flat|nested] 13+ messages in thread
* [RFC PATCH v2 1/3] vfs: add support for empty path to openat2(2)
2026-03-26 18:20 [RFC PATCH v2 0/3] vfs: transitive upgrade restrictions for fds Jori Koolstra
@ 2026-03-26 18:20 ` Jori Koolstra
2026-03-27 6:26 ` Aleksa Sarai
2026-03-30 12:12 ` Jeff Layton
2026-03-26 18:20 ` [RFC PATCH v2 2/3] vfs: transitive upgrade restrictions for fds Jori Koolstra
2026-03-26 18:20 ` [RFC PATCH v2 3/3] selftest: add tests for OPENAT2_EMPTY_PATH and allowed_upgrades Jori Koolstra
2 siblings, 2 replies; 13+ messages in thread
From: Jori Koolstra @ 2026-03-26 18:20 UTC (permalink / raw)
To: Jeff Layton, Chuck Lever, Alexander Aring, Alexander Viro,
Christian Brauner, Jan Kara, Shuah Khan, Greg Kroah-Hartman,
Aleksa Sarai
Cc: Jori Koolstra, Andrew Morton, Mike Rapoport, Liam R . Howlett,
David Hildenbrand, Lorenzo Stoakes, Ethan Tidmore, NeilBrown,
Oleg Nesterov, Penglei Jiang, Kees Cook, Suren Baghdasaryan,
Vlastimil Babka, Amir Goldstein, Namjae Jeon, Mateusz Guzik,
Wei Yang, Bala-Vignesh-Reddy, linux-kernel, linux-fsdevel,
linux-kselftest
To get an operable version of an O_PATH file descriptor, it is possible
to use openat(fd, ".", O_DIRECTORY) for directories, but other files
currently require going through open("/proc/<pid>/fd/<nr>"), which
depends on a functioning procfs.
This patch adds the OPENAT2_EMPTY_PATH flag to openat2(2). If passed,
LOOKUP_EMPTY is set at path resolve time.
Note: This implies that you cannot rely anymore on disabling procfs from
being mounted (e.g. inside a container without procfs mounted and with
CAP_SYS_ADMIN dropped) to prevent O_PATH fds from being re-opened
read-write.
Signed-off-by: Jori Koolstra <jkoolstra@xs4all.nl>
---
fs/fcntl.c | 4 ++--
fs/open.c | 11 +++++------
include/linux/fcntl.h | 5 ++++-
include/uapi/linux/openat2.h | 4 ++++
4 files changed, 15 insertions(+), 9 deletions(-)
diff --git a/fs/fcntl.c b/fs/fcntl.c
index beab8080badf..d9ae3c71edfe 100644
--- a/fs/fcntl.c
+++ b/fs/fcntl.c
@@ -1169,8 +1169,8 @@ static int __init fcntl_init(void)
* Exceptions: O_NONBLOCK is a two bit define on parisc; O_NDELAY
* is defined as O_NONBLOCK on some platforms and not on others.
*/
- BUILD_BUG_ON(20 - 1 /* for O_RDONLY being 0 */ !=
- HWEIGHT32(
+ BUILD_BUG_ON(21 - 1 /* for O_RDONLY being 0 */ !=
+ HWEIGHT64(
(VALID_OPEN_FLAGS & ~(O_NONBLOCK | O_NDELAY)) |
__FMODE_EXEC));
diff --git a/fs/open.c b/fs/open.c
index 91f1139591ab..e019ddecc73c 100644
--- a/fs/open.c
+++ b/fs/open.c
@@ -1160,12 +1160,12 @@ struct file *kernel_file_open(const struct path *path, int flags,
EXPORT_SYMBOL_GPL(kernel_file_open);
#define WILL_CREATE(flags) (flags & (O_CREAT | __O_TMPFILE))
-#define O_PATH_FLAGS (O_DIRECTORY | O_NOFOLLOW | O_PATH | O_CLOEXEC)
+#define O_PATH_FLAGS (O_DIRECTORY | O_NOFOLLOW | O_PATH | O_CLOEXEC | OPENAT2_EMPTY_PATH)
inline struct open_how build_open_how(int flags, umode_t mode)
{
struct open_how how = {
- .flags = flags & VALID_OPEN_FLAGS,
+ .flags = ((unsigned int) flags) & VALID_OPEN_FLAGS,
.mode = mode & S_IALLUGO,
};
@@ -1185,9 +1185,6 @@ inline int build_open_flags(const struct open_how *how, struct open_flags *op)
int lookup_flags = 0;
int acc_mode = ACC_MODE(flags);
- BUILD_BUG_ON_MSG(upper_32_bits(VALID_OPEN_FLAGS),
- "struct open_flags doesn't yet handle flags > 32 bits");
-
/*
* Strip flags that aren't relevant in determining struct open_flags.
*/
@@ -1281,6 +1278,8 @@ inline int build_open_flags(const struct open_how *how, struct open_flags *op)
lookup_flags |= LOOKUP_DIRECTORY;
if (!(flags & O_NOFOLLOW))
lookup_flags |= LOOKUP_FOLLOW;
+ if (flags & OPENAT2_EMPTY_PATH)
+ lookup_flags |= LOOKUP_EMPTY;
if (how->resolve & RESOLVE_NO_XDEV)
lookup_flags |= LOOKUP_NO_XDEV;
@@ -1362,7 +1361,7 @@ static int do_sys_openat2(int dfd, const char __user *filename,
if (unlikely(err))
return err;
- CLASS(filename, name)(filename);
+ CLASS(filename_flags, name)(filename, op.lookup_flags);
return FD_ADD(how->flags, do_file_open(dfd, name, &op));
}
diff --git a/include/linux/fcntl.h b/include/linux/fcntl.h
index a332e79b3207..d1bb87ff70e3 100644
--- a/include/linux/fcntl.h
+++ b/include/linux/fcntl.h
@@ -7,10 +7,13 @@
/* List of all valid flags for the open/openat flags argument: */
#define VALID_OPEN_FLAGS \
+ /* lower 32-bit flags */ \
(O_RDONLY | O_WRONLY | O_RDWR | O_CREAT | O_EXCL | O_NOCTTY | O_TRUNC | \
O_APPEND | O_NDELAY | O_NONBLOCK | __O_SYNC | O_DSYNC | \
FASYNC | O_DIRECT | O_LARGEFILE | O_DIRECTORY | O_NOFOLLOW | \
- O_NOATIME | O_CLOEXEC | O_PATH | __O_TMPFILE)
+ O_NOATIME | O_CLOEXEC | O_PATH | __O_TMPFILE | \
+ /* upper 32-bit flags (openat2(2) only) */ \
+ OPENAT2_EMPTY_PATH)
/* List of all valid flags for the how->resolve argument: */
#define VALID_RESOLVE_FLAGS \
diff --git a/include/uapi/linux/openat2.h b/include/uapi/linux/openat2.h
index a5feb7604948..c34f32e6fa96 100644
--- a/include/uapi/linux/openat2.h
+++ b/include/uapi/linux/openat2.h
@@ -40,4 +40,8 @@ struct open_how {
return -EAGAIN if that's not
possible. */
+/* openat2(2) exclusive flags are defined in the upper 32 bits of
+ open_how->flags */
+#define OPENAT2_EMPTY_PATH 0x100000000 /* (1ULL << 32) */
+
#endif /* _UAPI_LINUX_OPENAT2_H */
--
2.53.0
^ permalink raw reply [flat|nested] 13+ messages in thread
* [RFC PATCH v2 2/3] vfs: transitive upgrade restrictions for fds
2026-03-26 18:20 [RFC PATCH v2 0/3] vfs: transitive upgrade restrictions for fds Jori Koolstra
2026-03-26 18:20 ` [RFC PATCH v2 1/3] vfs: add support for empty path to openat2(2) Jori Koolstra
@ 2026-03-26 18:20 ` Jori Koolstra
2026-03-27 6:20 ` Aleksa Sarai
2026-03-26 18:20 ` [RFC PATCH v2 3/3] selftest: add tests for OPENAT2_EMPTY_PATH and allowed_upgrades Jori Koolstra
2 siblings, 1 reply; 13+ messages in thread
From: Jori Koolstra @ 2026-03-26 18:20 UTC (permalink / raw)
To: Jeff Layton, Chuck Lever, Alexander Aring, Alexander Viro,
Christian Brauner, Jan Kara, Shuah Khan, Greg Kroah-Hartman,
Aleksa Sarai
Cc: Jori Koolstra, Andrew Morton, Mike Rapoport, Liam R . Howlett,
David Hildenbrand, Lorenzo Stoakes, Ethan Tidmore, NeilBrown,
Oleg Nesterov, Penglei Jiang, Kees Cook, Suren Baghdasaryan,
Vlastimil Babka, Amir Goldstein, Namjae Jeon, Mateusz Guzik,
Wei Yang, Bala-Vignesh-Reddy, linux-kernel, linux-fsdevel,
linux-kselftest, wangzijie
Add upgrade restrictions to openat2(). Extend struct open_how to allow
setting transitive restrictions on using file descriptors to open other
files. A use case for this feature is to block services or containers
from re-opening/upgrading an O_PATH file descriptor through e.g.
/proc/<pid>/fd/<nr> as O_WRONLY.
The idea for this features comes form the UAPI group kernel feature idea
list [1].
[1] https://github.com/uapi-group/kernel-features?tab=readme-ov-file#upgrade-masks-in-openat2
Signed-off-by: Jori Koolstra <jkoolstra@xs4all.nl>
---
fs/file_table.c | 2 ++
fs/internal.h | 1 +
fs/namei.c | 41 +++++++++++++++++++++++++++++++++---
fs/open.c | 9 ++++++++
fs/proc/base.c | 24 +++++++++++++++------
fs/proc/fd.c | 6 +++++-
fs/proc/internal.h | 4 +++-
include/linux/fcntl.h | 6 +++++-
include/linux/fs.h | 1 +
include/linux/namei.h | 15 ++++++++++++-
include/uapi/linux/openat2.h | 6 ++++++
11 files changed, 101 insertions(+), 14 deletions(-)
diff --git a/fs/file_table.c b/fs/file_table.c
index aaa5faaace1e..b98038009fd2 100644
--- a/fs/file_table.c
+++ b/fs/file_table.c
@@ -196,6 +196,8 @@ static int init_file(struct file *f, int flags, const struct cred *cred)
f->f_wb_err = 0;
f->f_sb_err = 0;
+ f->f_allowed_upgrades = VALID_UPGRADE_FLAGS;
+
/*
* We're SLAB_TYPESAFE_BY_RCU so initialize f_ref last. While
* fget-rcu pattern users need to be able to handle spurious
diff --git a/fs/internal.h b/fs/internal.h
index cbc384a1aa09..0a37bb208184 100644
--- a/fs/internal.h
+++ b/fs/internal.h
@@ -189,6 +189,7 @@ struct open_flags {
int acc_mode;
int intent;
int lookup_flags;
+ unsigned int allowed_upgrades;
};
extern struct file *do_file_open(int dfd, struct filename *pathname,
const struct open_flags *op);
diff --git a/fs/namei.c b/fs/namei.c
index 58f715f7657e..c3d48709a73b 100644
--- a/fs/namei.c
+++ b/fs/namei.c
@@ -743,6 +743,7 @@ struct nameidata {
int dfd;
vfsuid_t dir_vfsuid;
umode_t dir_mode;
+ unsigned int allowed_upgrades;
} __randomize_layout;
#define ND_ROOT_PRESET 1
@@ -760,6 +761,7 @@ static void __set_nameidata(struct nameidata *p, int dfd, struct filename *name)
p->path.mnt = NULL;
p->path.dentry = NULL;
p->total_link_count = old ? old->total_link_count : 0;
+ p->allowed_upgrades = VALID_UPGRADE_FLAGS;
p->saved = old;
current->nameidata = p;
}
@@ -1156,11 +1158,15 @@ static int nd_jump_root(struct nameidata *nd)
return 0;
}
+const struct jump_how jump_how_unrestricted = {
+ .allowed_upgrades = VALID_UPGRADE_FLAGS
+};
+
/*
* Helper to directly jump to a known parsed path from ->get_link,
* caller must have taken a reference to path beforehand.
*/
-int nd_jump_link(const struct path *path)
+int nd_jump_link_how(const struct path *path, const struct jump_how *how)
{
int error = -ELOOP;
struct nameidata *nd = current->nameidata;
@@ -1181,6 +1187,7 @@ int nd_jump_link(const struct path *path)
nd->path = *path;
nd->inode = nd->path.dentry->d_inode;
nd->state |= ND_JUMPED;
+ nd->allowed_upgrades &= how->allowed_upgrades;
return 0;
err:
@@ -2738,6 +2745,8 @@ static const char *path_init(struct nameidata *nd, unsigned flags)
if (fd_empty(f))
return ERR_PTR(-EBADF);
+ nd->allowed_upgrades = fd_file(f)->f_allowed_upgrades;
+
if (flags & LOOKUP_LINKAT_EMPTY) {
if (fd_file(f)->f_cred != current_cred() &&
!ns_capable(fd_file(f)->f_cred->user_ns, CAP_DAC_READ_SEARCH))
@@ -4266,6 +4275,28 @@ static int may_open(struct mnt_idmap *idmap, const struct path *path,
return 0;
}
+static bool may_upgrade(const int flag, const unsigned int allowed_upgrades)
+{
+ int mode = flag & O_ACCMODE;
+ unsigned int allowed = allowed_upgrades & ~DENY_UPGRADES;
+
+ if (mode != O_WRONLY && !(allowed & READ_UPGRADABLE))
+ return false;
+ if (mode != O_RDONLY && !(allowed & WRITE_UPGRADABLE))
+ return false;
+ return true;
+}
+
+static int may_open_upgrade(struct mnt_idmap *idmap, const struct path *path,
+ int acc_mode, int flag,
+ const unsigned int allowed_upgrades)
+{
+ if (!may_upgrade(flag, allowed_upgrades))
+ return -EACCES;
+
+ return may_open(idmap, path, acc_mode, flag);
+}
+
static int handle_truncate(struct mnt_idmap *idmap, struct file *filp)
{
const struct path *path = &filp->f_path;
@@ -4666,7 +4697,8 @@ static int do_open(struct nameidata *nd,
return error;
do_truncate = true;
}
- error = may_open(idmap, &nd->path, acc_mode, open_flag);
+ error = may_open_upgrade(idmap, &nd->path, acc_mode, open_flag,
+ nd->allowed_upgrades);
if (!error && !(file->f_mode & FMODE_OPENED))
error = vfs_open(&nd->path, file);
if (!error)
@@ -4831,8 +4863,11 @@ static struct file *path_openat(struct nameidata *nd,
terminate_walk(nd);
}
if (likely(!error)) {
- if (likely(file->f_mode & FMODE_OPENED))
+ if (likely(file->f_mode & FMODE_OPENED)) {
+ file->f_allowed_upgrades =
+ op->allowed_upgrades & nd->allowed_upgrades;
return file;
+ }
WARN_ON(1);
error = -EINVAL;
}
diff --git a/fs/open.c b/fs/open.c
index e019ddecc73c..8b6ea5f90c6e 100644
--- a/fs/open.c
+++ b/fs/open.c
@@ -1167,6 +1167,7 @@ inline struct open_how build_open_how(int flags, umode_t mode)
struct open_how how = {
.flags = ((unsigned int) flags) & VALID_OPEN_FLAGS,
.mode = mode & S_IALLUGO,
+ .allowed_upgrades = VALID_UPGRADE_FLAGS
};
/* O_PATH beats everything else. */
@@ -1299,6 +1300,14 @@ inline int build_open_flags(const struct open_how *how, struct open_flags *op)
}
op->lookup_flags = lookup_flags;
+
+ if (how->allowed_upgrades == 0)
+ op->allowed_upgrades = VALID_UPGRADE_FLAGS;
+ else if (how->allowed_upgrades & ~VALID_UPGRADE_FLAGS)
+ return -EINVAL;
+ else
+ op->allowed_upgrades = how->allowed_upgrades;
+
return 0;
}
diff --git a/fs/proc/base.c b/fs/proc/base.c
index 4c863d17dfb4..3f3a471bbb75 100644
--- a/fs/proc/base.c
+++ b/fs/proc/base.c
@@ -218,7 +218,8 @@ static int get_task_root(struct task_struct *task, struct path *root)
return result;
}
-static int proc_cwd_link(struct dentry *dentry, struct path *path)
+static int proc_cwd_link(struct dentry *dentry, struct path *path,
+ struct jump_how *jump_how)
{
struct task_struct *task = get_proc_task(d_inode(dentry));
int result = -ENOENT;
@@ -227,6 +228,7 @@ static int proc_cwd_link(struct dentry *dentry, struct path *path)
task_lock(task);
if (task->fs) {
get_fs_pwd(task->fs, path);
+ *jump_how = jump_how_unrestricted;
result = 0;
}
task_unlock(task);
@@ -235,7 +237,8 @@ static int proc_cwd_link(struct dentry *dentry, struct path *path)
return result;
}
-static int proc_root_link(struct dentry *dentry, struct path *path)
+static int proc_root_link(struct dentry *dentry, struct path *path,
+ struct jump_how *jump_how)
{
struct task_struct *task = get_proc_task(d_inode(dentry));
int result = -ENOENT;
@@ -243,6 +246,7 @@ static int proc_root_link(struct dentry *dentry, struct path *path)
if (task) {
result = get_task_root(task, path);
put_task_struct(task);
+ *jump_how = jump_how_unrestricted;
}
return result;
}
@@ -1777,7 +1781,8 @@ static const struct file_operations proc_pid_set_comm_operations = {
.release = single_release,
};
-static int proc_exe_link(struct dentry *dentry, struct path *exe_path)
+static int proc_exe_link(struct dentry *dentry, struct path *exe_path,
+ struct jump_how *jump_how)
{
struct task_struct *task;
struct file *exe_file;
@@ -1789,6 +1794,7 @@ static int proc_exe_link(struct dentry *dentry, struct path *exe_path)
put_task_struct(task);
if (exe_file) {
*exe_path = exe_file->f_path;
+ *jump_how = jump_how_unrestricted;
path_get(&exe_file->f_path);
fput(exe_file);
return 0;
@@ -1801,6 +1807,7 @@ static const char *proc_pid_get_link(struct dentry *dentry,
struct delayed_call *done)
{
struct path path;
+ struct jump_how jump_how;
int error = -EACCES;
if (!dentry)
@@ -1810,11 +1817,11 @@ static const char *proc_pid_get_link(struct dentry *dentry,
if (!proc_fd_access_allowed(inode))
goto out;
- error = PROC_I(inode)->op.proc_get_link(dentry, &path);
+ error = PROC_I(inode)->op.proc_get_link(dentry, &path, &jump_how);
if (error)
goto out;
- error = nd_jump_link(&path);
+ error = nd_jump_link_how(&path, &jump_how);
out:
return ERR_PTR(error);
}
@@ -1848,12 +1855,13 @@ static int proc_pid_readlink(struct dentry * dentry, char __user * buffer, int b
int error = -EACCES;
struct inode *inode = d_inode(dentry);
struct path path;
+ struct jump_how jump_how;
/* Are we allowed to snoop on the tasks file descriptors? */
if (!proc_fd_access_allowed(inode))
goto out;
- error = PROC_I(inode)->op.proc_get_link(dentry, &path);
+ error = PROC_I(inode)->op.proc_get_link(dentry, &path, &jump_how);
if (error)
goto out;
@@ -2250,7 +2258,8 @@ static const struct dentry_operations tid_map_files_dentry_operations = {
.d_delete = pid_delete_dentry,
};
-static int map_files_get_link(struct dentry *dentry, struct path *path)
+static int map_files_get_link(struct dentry *dentry, struct path *path,
+ struct jump_how *jump_how)
{
unsigned long vm_start, vm_end;
struct vm_area_struct *vma;
@@ -2279,6 +2288,7 @@ static int map_files_get_link(struct dentry *dentry, struct path *path)
rc = -ENOENT;
vma = find_exact_vma(mm, vm_start, vm_end);
if (vma && vma->vm_file) {
+ *jump_how = jump_how_unrestricted;
*path = *file_user_path(vma->vm_file);
path_get(path);
rc = 0;
diff --git a/fs/proc/fd.c b/fs/proc/fd.c
index 9eeccff49b2a..344485e8cb6f 100644
--- a/fs/proc/fd.c
+++ b/fs/proc/fd.c
@@ -171,7 +171,8 @@ static const struct dentry_operations tid_fd_dentry_operations = {
.d_delete = pid_delete_dentry,
};
-static int proc_fd_link(struct dentry *dentry, struct path *path)
+static int proc_fd_link(struct dentry *dentry, struct path *path,
+ struct jump_how *jump_how)
{
struct task_struct *task;
int ret = -ENOENT;
@@ -183,6 +184,9 @@ static int proc_fd_link(struct dentry *dentry, struct path *path)
fd_file = fget_task(task, fd);
if (fd_file) {
+ *jump_how = (struct jump_how) {
+ .allowed_upgrades = fd_file->f_allowed_upgrades
+ };
*path = fd_file->f_path;
path_get(&fd_file->f_path);
ret = 0;
diff --git a/fs/proc/internal.h b/fs/proc/internal.h
index c1e8eb984da8..42f668059a30 100644
--- a/fs/proc/internal.h
+++ b/fs/proc/internal.h
@@ -14,6 +14,7 @@
#include <linux/sched/coredump.h>
#include <linux/sched/task.h>
#include <linux/mm.h>
+#include <linux/namei.h>
struct ctl_table_header;
struct mempolicy;
@@ -107,7 +108,8 @@ extern struct kmem_cache *proc_dir_entry_cache;
void pde_free(struct proc_dir_entry *pde);
union proc_op {
- int (*proc_get_link)(struct dentry *, struct path *);
+ int (*proc_get_link)(struct dentry *, struct path *,
+ struct jump_how *);
int (*proc_show)(struct seq_file *m,
struct pid_namespace *ns, struct pid *pid,
struct task_struct *task);
diff --git a/include/linux/fcntl.h b/include/linux/fcntl.h
index d1bb87ff70e3..6506c2c6eca5 100644
--- a/include/linux/fcntl.h
+++ b/include/linux/fcntl.h
@@ -15,6 +15,9 @@
/* upper 32-bit flags (openat2(2) only) */ \
OPENAT2_EMPTY_PATH)
+#define VALID_UPGRADE_FLAGS \
+ (DENY_UPGRADES | READ_UPGRADABLE | WRITE_UPGRADABLE)
+
/* List of all valid flags for the how->resolve argument: */
#define VALID_RESOLVE_FLAGS \
(RESOLVE_NO_XDEV | RESOLVE_NO_MAGICLINKS | RESOLVE_NO_SYMLINKS | \
@@ -22,7 +25,8 @@
/* List of all open_how "versions". */
#define OPEN_HOW_SIZE_VER0 24 /* sizeof first published struct */
-#define OPEN_HOW_SIZE_LATEST OPEN_HOW_SIZE_VER0
+#define OPEN_HOW_SIZE_VER1 32 /* added allowed_upgrades */
+#define OPEN_HOW_SIZE_LATEST OPEN_HOW_SIZE_VER1
#ifndef force_o_largefile
#define force_o_largefile() (!IS_ENABLED(CONFIG_ARCH_32BIT_OFF_T))
diff --git a/include/linux/fs.h b/include/linux/fs.h
index 8b3dd145b25e..697d2fc6322b 100644
--- a/include/linux/fs.h
+++ b/include/linux/fs.h
@@ -1296,6 +1296,7 @@ struct file {
};
file_ref_t f_ref;
/* --- cacheline 3 boundary (192 bytes) --- */
+ unsigned int f_allowed_upgrades;
} __randomize_layout
__attribute__((aligned(4))); /* lest something weird decides that 2 is OK */
diff --git a/include/linux/namei.h b/include/linux/namei.h
index 58600cf234bc..0c58ded7cd27 100644
--- a/include/linux/namei.h
+++ b/include/linux/namei.h
@@ -203,7 +203,20 @@ static inline umode_t __must_check mode_strip_umask(const struct inode *dir, umo
return mode;
}
-extern int __must_check nd_jump_link(const struct path *path);
+struct jump_how {
+ unsigned int allowed_upgrades;
+};
+
+extern const struct jump_how jump_how_unrestricted;
+#define JUMP_HOW_UNRESTRICTED &jump_how_unrestricted
+
+extern int __must_check nd_jump_link_how(const struct path *path,
+ const struct jump_how *how);
+
+static inline int nd_jump_link(const struct path *path)
+{
+ return nd_jump_link_how(path, JUMP_HOW_UNRESTRICTED);
+}
static inline void nd_terminate_link(void *name, size_t len, size_t maxlen)
{
diff --git a/include/uapi/linux/openat2.h b/include/uapi/linux/openat2.h
index c34f32e6fa96..fc1147e6ce41 100644
--- a/include/uapi/linux/openat2.h
+++ b/include/uapi/linux/openat2.h
@@ -20,8 +20,14 @@ struct open_how {
__u64 flags;
__u64 mode;
__u64 resolve;
+ __u64 allowed_upgrades;
};
+/* how->allowed_upgrades flags for openat2(2). */
+#define DENY_UPGRADES 0x01
+#define READ_UPGRADABLE (0x02 | DENY_UPGRADES)
+#define WRITE_UPGRADABLE (0x04 | DENY_UPGRADES)
+
/* how->resolve flags for openat2(2). */
#define RESOLVE_NO_XDEV 0x01 /* Block mount-point crossings
(includes bind-mounts). */
--
2.53.0
^ permalink raw reply [flat|nested] 13+ messages in thread
* [RFC PATCH v2 3/3] selftest: add tests for OPENAT2_EMPTY_PATH and allowed_upgrades
2026-03-26 18:20 [RFC PATCH v2 0/3] vfs: transitive upgrade restrictions for fds Jori Koolstra
2026-03-26 18:20 ` [RFC PATCH v2 1/3] vfs: add support for empty path to openat2(2) Jori Koolstra
2026-03-26 18:20 ` [RFC PATCH v2 2/3] vfs: transitive upgrade restrictions for fds Jori Koolstra
@ 2026-03-26 18:20 ` Jori Koolstra
2 siblings, 0 replies; 13+ messages in thread
From: Jori Koolstra @ 2026-03-26 18:20 UTC (permalink / raw)
To: Jeff Layton, Chuck Lever, Alexander Aring, Alexander Viro,
Christian Brauner, Jan Kara, Shuah Khan, Greg Kroah-Hartman,
Aleksa Sarai
Cc: Jori Koolstra, Andrew Morton, Mike Rapoport, Liam R . Howlett,
David Hildenbrand, Lorenzo Stoakes, Ethan Tidmore, NeilBrown,
Oleg Nesterov, Penglei Jiang, Kees Cook, Suren Baghdasaryan,
Vlastimil Babka, Amir Goldstein, Namjae Jeon, Mateusz Guzik,
Wei Yang, Bala-Vignesh-Reddy, linux-kernel, linux-fsdevel,
linux-kselftest
Add tests for new openat2 flag OPENAT2_EMPTY_PATH and new open_how
field: allowed_upgrades.
Also, the current openat2 tests include a helper header file that
defines the necessary structs and constants to use openat2(2), such as
struct open_how. This may result in conflicting definitions when the
system header openat2.h is present as well.
So also add openat2.h generated by 'make headers' to the uapi header
files in ./tools/include and remove the helper file definitions of
the current openat2 selftests.
Signed-off-by: Jori Koolstra <jkoolstra@xs4all.nl>
---
tools/include/uapi/linux/openat2.h | 53 ++++
tools/testing/selftests/openat2/Makefile | 4 +-
tools/testing/selftests/openat2/helpers.c | 2 +-
tools/testing/selftests/openat2/helpers.h | 40 +--
.../testing/selftests/openat2/upgrade_test.c | 242 ++++++++++++++++++
5 files changed, 301 insertions(+), 40 deletions(-)
create mode 100644 tools/include/uapi/linux/openat2.h
create mode 100644 tools/testing/selftests/openat2/upgrade_test.c
diff --git a/tools/include/uapi/linux/openat2.h b/tools/include/uapi/linux/openat2.h
new file mode 100644
index 000000000000..fbbf5483dc9d
--- /dev/null
+++ b/tools/include/uapi/linux/openat2.h
@@ -0,0 +1,53 @@
+/* SPDX-License-Identifier: GPL-2.0 WITH Linux-syscall-note */
+#ifndef _LINUX_OPENAT2_H
+#define _LINUX_OPENAT2_H
+
+#include <linux/types.h>
+
+/*
+ * Arguments for how openat2(2) should open the target path. If only @flags and
+ * @mode are non-zero, then openat2(2) operates very similarly to openat(2).
+ *
+ * However, unlike openat(2), unknown or invalid bits in @flags result in
+ * -EINVAL rather than being silently ignored. @mode must be zero unless one of
+ * {O_CREAT, O_TMPFILE} are set.
+ *
+ * @flags: O_* flags.
+ * @mode: O_CREAT/O_TMPFILE file mode.
+ * @resolve: RESOLVE_* flags.
+ */
+struct open_how {
+ __u64 flags;
+ __u64 mode;
+ __u64 resolve;
+ __u64 allowed_upgrades;
+};
+
+/* how->allowed_upgrades flags for openat2(2). */
+#define DENY_UPGRADES 0x01
+#define READ_UPGRADABLE (0x02 | DENY_UPGRADES)
+#define WRITE_UPGRADABLE (0x04 | DENY_UPGRADES)
+
+/* how->resolve flags for openat2(2). */
+#define RESOLVE_NO_XDEV 0x01 /* Block mount-point crossings
+ (includes bind-mounts). */
+#define RESOLVE_NO_MAGICLINKS 0x02 /* Block traversal through procfs-style
+ "magic-links". */
+#define RESOLVE_NO_SYMLINKS 0x04 /* Block traversal through all symlinks
+ (implies OEXT_NO_MAGICLINKS) */
+#define RESOLVE_BENEATH 0x08 /* Block "lexical" trickery like
+ "..", symlinks, and absolute
+ paths which escape the dirfd. */
+#define RESOLVE_IN_ROOT 0x10 /* Make all jumps to "/" and ".."
+ be scoped inside the dirfd
+ (similar to chroot(2)). */
+#define RESOLVE_CACHED 0x20 /* Only complete if resolution can be
+ completed through cached lookup. May
+ return -EAGAIN if that's not
+ possible. */
+
+/* openat2(2) exclusive flags are defined in the upper 32 bits of
+ open_how->flags */
+#define OPENAT2_EMPTY_PATH 0x100000000 /* (1ULL << 32) */
+
+#endif /* _LINUX_OPENAT2_H */
diff --git a/tools/testing/selftests/openat2/Makefile b/tools/testing/selftests/openat2/Makefile
index 185dc76ebb5f..cc6d4fad999c 100644
--- a/tools/testing/selftests/openat2/Makefile
+++ b/tools/testing/selftests/openat2/Makefile
@@ -1,7 +1,7 @@
# SPDX-License-Identifier: GPL-2.0-or-later
-CFLAGS += -Wall -O2 -g -fsanitize=address -fsanitize=undefined
-TEST_GEN_PROGS := openat2_test resolve_test rename_attack_test
+CFLAGS += -Wall -O2 -g -fsanitize=address -fsanitize=undefined $(TOOLS_INCLUDES)
+TEST_GEN_PROGS := openat2_test resolve_test rename_attack_test upgrade_test
# gcc requires -static-libasan in order to ensure that Address Sanitizer's
# library is the first one loaded. However, clang already statically links the
diff --git a/tools/testing/selftests/openat2/helpers.c b/tools/testing/selftests/openat2/helpers.c
index 5074681ffdc9..b6533f0b1124 100644
--- a/tools/testing/selftests/openat2/helpers.c
+++ b/tools/testing/selftests/openat2/helpers.c
@@ -98,7 +98,7 @@ void __attribute__((constructor)) init(void)
struct open_how how = {};
int fd;
- BUILD_BUG_ON(sizeof(struct open_how) != OPEN_HOW_SIZE_VER0);
+ BUILD_BUG_ON(sizeof(struct open_how) != OPEN_HOW_SIZE_VER1);
/* Check openat2(2) support. */
fd = sys_openat2(AT_FDCWD, ".", &how);
diff --git a/tools/testing/selftests/openat2/helpers.h b/tools/testing/selftests/openat2/helpers.h
index 510e60602511..af94d8211b9f 100644
--- a/tools/testing/selftests/openat2/helpers.h
+++ b/tools/testing/selftests/openat2/helpers.h
@@ -14,6 +14,9 @@
#include <linux/types.h>
#include "kselftest.h"
+#define OPEN_HOW_SIZE_VER0 24
+#define OPEN_HOW_SIZE_VER1 32
+
#define ARRAY_LEN(X) (sizeof (X) / sizeof (*(X)))
#define BUILD_BUG_ON(e) ((void)(sizeof(struct { int:(-!!(e)); })))
@@ -24,45 +27,8 @@
#define SYS_openat2 __NR_openat2
#endif /* SYS_openat2 */
-/*
- * Arguments for how openat2(2) should open the target path. If @resolve is
- * zero, then openat2(2) operates very similarly to openat(2).
- *
- * However, unlike openat(2), unknown bits in @flags result in -EINVAL rather
- * than being silently ignored. @mode must be zero unless one of {O_CREAT,
- * O_TMPFILE} are set.
- *
- * @flags: O_* flags.
- * @mode: O_CREAT/O_TMPFILE file mode.
- * @resolve: RESOLVE_* flags.
- */
-struct open_how {
- __u64 flags;
- __u64 mode;
- __u64 resolve;
-};
-
-#define OPEN_HOW_SIZE_VER0 24 /* sizeof first published struct */
-#define OPEN_HOW_SIZE_LATEST OPEN_HOW_SIZE_VER0
-
bool needs_openat2(const struct open_how *how);
-#ifndef RESOLVE_IN_ROOT
-/* how->resolve flags for openat2(2). */
-#define RESOLVE_NO_XDEV 0x01 /* Block mount-point crossings
- (includes bind-mounts). */
-#define RESOLVE_NO_MAGICLINKS 0x02 /* Block traversal through procfs-style
- "magic-links". */
-#define RESOLVE_NO_SYMLINKS 0x04 /* Block traversal through all symlinks
- (implies OEXT_NO_MAGICLINKS) */
-#define RESOLVE_BENEATH 0x08 /* Block "lexical" trickery like
- "..", symlinks, and absolute
- paths which escape the dirfd. */
-#define RESOLVE_IN_ROOT 0x10 /* Make all jumps to "/" and ".."
- be scoped inside the dirfd
- (similar to chroot(2)). */
-#endif /* RESOLVE_IN_ROOT */
-
#define E_func(func, ...) \
do { \
errno = 0; \
diff --git a/tools/testing/selftests/openat2/upgrade_test.c b/tools/testing/selftests/openat2/upgrade_test.c
new file mode 100644
index 000000000000..489d9088d7ce
--- /dev/null
+++ b/tools/testing/selftests/openat2/upgrade_test.c
@@ -0,0 +1,242 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#define __SANE_USERSPACE_TYPES__
+#include <fcntl.h>
+#include <unistd.h>
+#include <string.h>
+#include <errno.h>
+
+#include "helpers.h"
+
+static int open_opath(const char *path, __u64 allowed_upgrades)
+{
+ struct open_how how = {
+ .flags = O_PATH,
+ .allowed_upgrades = allowed_upgrades,
+ };
+ int ret = raw_openat2(AT_FDCWD, path, &how, OPEN_HOW_SIZE_VER1);
+
+ if (ret < 0)
+ ksft_exit_fail_msg("open O_PATH: %s\n", strerror(errno));
+
+ return ret;
+}
+
+static int reopen_empty(int dfd, __u64 flags, bool fatal)
+{
+ struct open_how how = {
+ .flags = flags | OPENAT2_EMPTY_PATH,
+ };
+ int ret = raw_openat2(dfd, "", &how, OPEN_HOW_SIZE_VER1);
+
+ if (ret < 0 && fatal)
+ ksft_exit_fail_msg("open with OPENAT2_EMPTY_PATH: %s\n",
+ strerror(errno));
+ return ret;
+}
+
+static int reopen_empty_opath(int dfd, __u64 allowed_upgrades)
+{
+ struct open_how how = {
+ .flags = O_PATH | OPENAT2_EMPTY_PATH,
+ .allowed_upgrades = allowed_upgrades,
+ };
+ int ret = raw_openat2(dfd, "", &how, OPEN_HOW_SIZE_VER1);
+
+ if (ret < 0)
+ ksft_exit_fail_msg("open O_PATH with OPENAT2_EMPTY_PATH: %s\n",
+ strerror(errno));
+ return ret;
+}
+
+static int reopen_proc(int dfd, int flags, bool fatal)
+{
+ char path[64];
+ snprintf(path, sizeof(path), "/proc/self/fd/%d", dfd);
+ int ret = open(path, flags);
+
+ if (ret < 0 && fatal)
+ ksft_exit_fail_msg("open via procfs: %s\n", strerror(errno));
+
+ return ret;
+}
+
+static void check_success(const char *desc, int ret)
+{
+ if (ret >= 0) {
+ ksft_test_result_pass("%s\n", desc);
+ close(ret);
+ } else {
+ ksft_test_result_fail("%s: expected success, got %s\n",
+ desc, strerror(errno));
+ }
+}
+
+static void check_failure(const char *desc, int ret, int expected_errno)
+{
+ if (ret < 0 && errno == expected_errno) {
+ ksft_test_result_pass("%s\n", desc);
+ } else if (ret >= 0) {
+ ksft_test_result_fail("%s: expected %s, got success\n",
+ desc, strerror(expected_errno));
+ close(ret);
+ } else {
+ ksft_test_result_fail("%s: expected %s, got %s\n",
+ desc, strerror(expected_errno),
+ strerror(errno));
+ }
+}
+
+static void check(const char *desc, int ret, int expected_errno)
+{
+ if (!expected_errno) {
+ check_success(desc, ret);
+ } else {
+ check_failure(desc, ret, expected_errno);
+ }
+}
+
+#define NUM_TESTS 42
+
+int main(void)
+{
+ const char *path = "/tmp/upgrade_mask_test";
+ int fd, src;
+
+ ksft_print_header();
+
+ if (!openat2_supported)
+ ksft_exit_skip("openat2(2) not supported\n");
+
+ /* Check allowed_upgrades support */
+ {
+ struct open_how how = { .flags = O_PATH,
+ .allowed_upgrades = DENY_UPGRADES };
+ fd = raw_openat2(AT_FDCWD, "/", &how, sizeof(how));
+ if (fd < 0 && -fd == EINVAL)
+ ksft_exit_skip("allowed_upgrades not supported by kernel\n");
+ if (fd >= 0)
+ close(fd);
+ }
+
+ ksft_set_plan(NUM_TESTS);
+
+ fd = open(path, O_CREAT | O_WRONLY, 0644);
+ if (fd < 0)
+ ksft_exit_fail_msg("failed to create test file: %s\n",
+ strerror(errno));
+ close(fd);
+
+ /* test 1: DENY_UPGRADES (deny all) */
+ src = open_opath(path, DENY_UPGRADES);
+ check("deny_all: use empty_path to reopen O_RDONLY", reopen_empty(src, O_RDONLY, false), EACCES);
+ check("deny_all: use empty_path to reopen O_WRONLY", reopen_empty(src, O_WRONLY, false), EACCES);
+ check("deny_all: use empty_path to reopen O_RDWR", reopen_empty(src, O_RDWR, false), EACCES);
+ check("deny_all: use procfs to reopen O_RDONLY", reopen_proc(src, O_RDONLY, false), EACCES);
+ check("deny_all: use procfs to reopen O_WRONLY", reopen_proc(src, O_WRONLY, false), EACCES);
+ check("deny_all: use procfs to reopen O_RDWR", reopen_proc(src, O_RDWR, false), EACCES);
+ close(src);
+
+ /* test 2: READ_UPGRADABLE */
+ src = open_opath(path, READ_UPGRADABLE);
+ check("read_only: use empty_path to reopen O_RDONLY", reopen_empty(src, O_RDONLY, false), 0);
+ check("read_only: use empty_path to reopen O_WRONLY", reopen_empty(src, O_WRONLY, false), EACCES);
+ check("read_only: use empty_path to reopen O_RDWR", reopen_empty(src, O_RDWR, false), EACCES);
+ check("read_only: use procfs to reopen O_RDONLY", reopen_proc(src, O_RDONLY, false), 0);
+ check("read_only: use procfs to reopen O_WRONLY", reopen_proc(src, O_WRONLY, false), EACCES);
+ check("read_only: use procfs to reopen O_RDWR", reopen_proc(src, O_RDWR, false), EACCES);
+ close(src);
+
+ /* test 3: WRITE_UPGRADABLE */
+ src = open_opath(path, WRITE_UPGRADABLE);
+ check("write_only: use empty_path to reopen O_RDONLY", reopen_empty(src, O_RDONLY, false), EACCES);
+ check("write_only: use empty_path to reopen O_WRONLY", reopen_empty(src, O_WRONLY, false), 0);
+ check("write_only: use empty_path to reopen O_RDWR", reopen_empty(src, O_RDWR, false), EACCES);
+ check("write_only: use procfs to reopen O_RDONLY", reopen_proc(src, O_RDONLY, false), EACCES);
+ check("write_only: use procfs to reopen O_WRONLY", reopen_proc(src, O_WRONLY, false), 0);
+ check("write_only: use procfs to reopen O_RDWR", reopen_proc(src, O_RDWR, false), EACCES);
+ close(src);
+
+ /* test 4: READ_UPGRADABLE | WRITE_UPGRADABLE */
+ src = open_opath(path, READ_UPGRADABLE | WRITE_UPGRADABLE);
+ check("allow_all: use empty_path to reopen O_RDONLY", reopen_empty(src, O_RDONLY, false), 0);
+ check("allow_all: use empty_path to reopen O_WRONLY", reopen_empty(src, O_WRONLY, false), 0);
+ check("allow_all: use empty_path to reopen O_RDWR", reopen_empty(src, O_RDWR, false), 0);
+ check("allow_all: use procfs to reopen O_RDONLY", reopen_proc(src, O_RDONLY, false), 0);
+ check("allow_all: use procfs to reopen O_WRONLY", reopen_proc(src, O_WRONLY, false), 0);
+ check("allow_all: use procfs to reopen O_RDWR", reopen_proc(src, O_RDWR, false), 0);
+ close(src);
+
+ /* test 5: VER0 open_how (allowed_upgrades absent, defaults to unrestricted) */
+ {
+ struct open_how how = { .flags = O_PATH };
+ src = raw_openat2(AT_FDCWD, path, &how, OPEN_HOW_SIZE_VER0);
+
+ check("ver0: use empty_path to reopen O_RDONLY", reopen_empty(src, O_RDONLY, false), 0);
+ check("ver0: use empty_path to reopen O_WRONLY", reopen_empty(src, O_WRONLY, false), 0);
+ check("ver0: use empty_path to reopen O_RDWR", reopen_empty(src, O_RDWR, false), 0);
+ check("ver0: use procfs to reopen O_RDONLY", reopen_proc(src, O_RDONLY, false), 0);
+ check("ver0: use procfs to reopen O_WRONLY", reopen_proc(src, O_WRONLY, false), 0);
+ check("ver0: use procfs to reopen O_RDWR", reopen_proc(src, O_RDWR, false), 0);
+ close(src);
+ }
+
+ /* test 6: invalid allowed_upgrades bit rejected */
+ {
+ struct open_how how = { .flags = O_PATH, .allowed_upgrades = (1ULL << 63) };
+ fd = raw_openat2(AT_FDCWD, path, &how, sizeof(how));
+ check("invalid: unknown bit in allowed_upgrades rejected with EINVAL", fd, EINVAL);
+ }
+
+ /* test 7: transitivity through OPENAT2_EMPTY_PATH reopen */
+ src = open_opath(path, READ_UPGRADABLE);
+ {
+ int mid = reopen_empty(src, O_RDONLY, true);
+ close(src);
+ check("transitive_empty: use procfs to reopen O_RDONLY", reopen_proc(mid, O_RDONLY, false), 0);
+ check("transitive_empty: use procfs to reopen O_WRONLY", reopen_proc(mid, O_WRONLY, false), EACCES);
+ check("transitive_empty: use procfs to reopen O_RDWR", reopen_proc(mid, O_RDWR, false), EACCES);
+ close(mid);
+ }
+
+ /* test 8: transitivity through procfs reopen */
+ src = open_opath(path, READ_UPGRADABLE);
+ {
+ int mid = reopen_proc(src, O_RDONLY, true);
+ close(src);
+ check("transitive_proc: use procfs to reopen O_RDONLY", reopen_empty(mid, O_RDONLY, false), 0);
+ check("transitive_proc: use procfs to reopen O_WRONLY", reopen_empty(mid, O_WRONLY, false), EACCES);
+ check("transitive_proc: use procfs to reopen O_RDWR", reopen_empty(mid, O_RDWR, false), EACCES);
+ close(mid);
+ }
+
+ /* test 9: narrowing via intermediate O_PATH reopen */
+ src = open_opath(path, READ_UPGRADABLE | WRITE_UPGRADABLE);
+ {
+ int mid = reopen_empty_opath(src, READ_UPGRADABLE);
+ close(src);
+ check("narrowing: use procfs to reopen O_RDONLY", reopen_proc(mid, O_RDONLY, false), 0);
+ check("narrowing: use procfs to reopen O_WRONLY", reopen_proc(mid, O_WRONLY, false), EACCES);
+ check("narrowing: use procfs to reopen O_RDWR", reopen_proc(mid, O_RDWR, false), EACCES);
+ close(mid);
+ }
+
+ /* test 10: three-level chain */
+ src = open_opath(path, READ_UPGRADABLE);
+ {
+ int mid = reopen_proc(src, O_RDONLY, true);
+ close(src);
+ int dst = reopen_proc(mid, O_RDONLY, true);
+ close(mid);
+ check("three_level: use procfs to reopen O_RDONLY", reopen_empty(dst, O_RDONLY, false), 0);
+ check("three_level: use procfs to reopen O_WRONLY", reopen_empty(dst, O_WRONLY, false), EACCES);
+ close(dst);
+ }
+
+ unlink(path);
+
+ if (ksft_get_fail_cnt() + ksft_get_error_cnt() > 0)
+ ksft_exit_fail();
+ else
+ ksft_exit_pass();
+}
--
2.53.0
^ permalink raw reply [flat|nested] 13+ messages in thread
* Re: [RFC PATCH v2 2/3] vfs: transitive upgrade restrictions for fds
2026-03-26 18:20 ` [RFC PATCH v2 2/3] vfs: transitive upgrade restrictions for fds Jori Koolstra
@ 2026-03-27 6:20 ` Aleksa Sarai
2026-03-29 21:54 ` Jori Koolstra
0 siblings, 1 reply; 13+ messages in thread
From: Aleksa Sarai @ 2026-03-27 6:20 UTC (permalink / raw)
To: Jori Koolstra
Cc: Jeff Layton, Chuck Lever, Alexander Aring, Alexander Viro,
Christian Brauner, Jan Kara, Shuah Khan, Greg Kroah-Hartman,
Andrew Morton, Mike Rapoport, Liam R . Howlett,
David Hildenbrand, Lorenzo Stoakes, Ethan Tidmore, NeilBrown,
Oleg Nesterov, Penglei Jiang, Kees Cook, Suren Baghdasaryan,
Vlastimil Babka, Amir Goldstein, Namjae Jeon, Mateusz Guzik,
Wei Yang, Bala-Vignesh-Reddy, linux-kernel, linux-fsdevel,
Andy Lutomirski, linux-kselftest, wangzijie
[-- Attachment #1: Type: text/plain, Size: 7373 bytes --]
On 2026-03-26, Jori Koolstra <jkoolstra@xs4all.nl> wrote:
> Add upgrade restrictions to openat2(). Extend struct open_how to allow
> setting transitive restrictions on using file descriptors to open other
> files. A use case for this feature is to block services or containers
> from re-opening/upgrading an O_PATH file descriptor through e.g.
> /proc/<pid>/fd/<nr> as O_WRONLY.
>
> The idea for this features comes form the UAPI group kernel feature idea
> list [1].
>
> [1] https://github.com/uapi-group/kernel-features?tab=readme-ov-file#upgrade-masks-in-openat2
I had a version of this in the original openat2(2) pull request many
years ago[1].
Unfortunately there is a pretty big issue with doing it this way (which
I mentioned in one of the changelogs back then[2]): There are lots of
VFS operations that imply operations on a file (through a magic-link)
that are not blocked. truncate(2) and mount(MS_BIND)/open_tree(2) are
the most problematic examples, but this applies to basically any syscall
that takes a path argument. If you don't block those then re-opening
restrictions are functionally useless.
It also would be really nice if you could block more than just trailing
component operations -- having a directory file descriptor that blocks
lookups could be quite handy for a bunch of reasons.
I think the only workable solution to block all of these issue entirely
and in a comprehensive way is to have something akin to capsicum
capabilities[3] tied to file descriptors and have all of the VFS
operations check them (though I think that the way this was attempted in
the past[4] was far from ideal).
I have tried my hand at a few lighter-weight prototypes over the years
(mainly trying to add the necessary checks to every generic_permission()
call, and adding some more generic_permission() calls as well...). My
last prototype was adding the restriction information to "struct path"
but that would bloat too many structures to be merge-able. I was
planning on looking at this again later this year, but if you can come
up with a nice way of getting a minimal version of capsicum working,
that'd be amazing. :D
That being said, while my view at the time of openat2(2) was that we
need to do this at the same time as O_EMPTYPATH (and my tests showed
that this was a backwards-compatible change -- on modern desktops at
least), at this point I think it'd be better to just merge O_EMPTYPATH
by itself and we can work on this hardening separately and make it an
opt-in sysctl (with individual file descriptors being opt-in-able as
well).
[1]: https://lore.kernel.org/lkml/20190930183316.10190-2-cyphar@cyphar.com/
[2]: https://lore.kernel.org/lkml/20191026185700.10708-1-cyphar@cyphar.com/
[3]: https://lwn.net/Articles/482858/
[4]: https://lore.kernel.org/lkml/1404124096-21445-1-git-send-email-drysdale@google.com/
> +const struct jump_how jump_how_unrestricted = {
> + .allowed_upgrades = VALID_UPGRADE_FLAGS
> +};
> +
> /*
> * Helper to directly jump to a known parsed path from ->get_link,
> * caller must have taken a reference to path beforehand.
> */
> -int nd_jump_link(const struct path *path)
> +int nd_jump_link_how(const struct path *path, const struct jump_how *how)
> {
> int error = -ELOOP;
> struct nameidata *nd = current->nameidata;
> @@ -1181,6 +1187,7 @@ int nd_jump_link(const struct path *path)
> nd->path = *path;
> nd->inode = nd->path.dentry->d_inode;
> nd->state |= ND_JUMPED;
> + nd->allowed_upgrades &= how->allowed_upgrades;
> return 0;
Way back then, Andy Lutomirski suggested that this be done via the
magic-link modes. While it is kind of ugly and in my patchset this
required adjusting some magic-link modes, it does provide a useful
indication to userspace of two things:
- What upgrade modes are available for a file (this is useful for
debugging but is also really necessary for the checkpoint-restore
folks' needs). I did this with fmode (and exposed fmode in fdinfo)
but I would not recommend that approach at all.
- It indicates whether the kernel supports this feature, which will
allow certain programs to loosen their hardening logic since the
kernel implements the hardening for them.
For instance, most container runtimes now either make a copy of
/proc/self/exe as a sealed memfd or create a read-only overlayfs
mount to re-exec /proc/self/exe so that containers cannot overwrite
the host binary by doing a /proc/$pid/exe re-open. See CVE-2019-5736
for more details.
Indicating that the kernel blocks this attack would let container
runtimes disable this hardening on such kernels.
Maybe we should at least change the modes even if they aren't used?
> -static int proc_exe_link(struct dentry *dentry, struct path *exe_path)
> +static int proc_exe_link(struct dentry *dentry, struct path *exe_path,
> + struct jump_how *jump_how)
> {
> struct task_struct *task;
> struct file *exe_file;
> @@ -1789,6 +1794,7 @@ static int proc_exe_link(struct dentry *dentry, struct path *exe_path)
> put_task_struct(task);
> if (exe_file) {
> *exe_path = exe_file->f_path;
> + *jump_how = jump_how_unrestricted;
> path_get(&exe_file->f_path);
> fput(exe_file);
> return 0;
This should restrict writes, for the reasons outlined above.
> -static int map_files_get_link(struct dentry *dentry, struct path *path)
> +static int map_files_get_link(struct dentry *dentry, struct path *path,
> + struct jump_how *jump_how)
> {
> unsigned long vm_start, vm_end;
> struct vm_area_struct *vma;
> @@ -2279,6 +2288,7 @@ static int map_files_get_link(struct dentry *dentry, struct path *path)
> rc = -ENOENT;
> vma = find_exact_vma(mm, vm_start, vm_end);
> if (vma && vma->vm_file) {
> + *jump_how = jump_how_unrestricted;
> *path = *file_user_path(vma->vm_file);
> path_get(path);
> rc = 0;
This should also restrict writes (this is a similar issue to
/proc/self/exe and is harder to harden against, the primary defense is
just ASLR...).
> diff --git a/include/uapi/linux/openat2.h b/include/uapi/linux/openat2.h
> index c34f32e6fa96..fc1147e6ce41 100644
> --- a/include/uapi/linux/openat2.h
> +++ b/include/uapi/linux/openat2.h
> @@ -20,8 +20,14 @@ struct open_how {
> __u64 flags;
> __u64 mode;
> __u64 resolve;
> + __u64 allowed_upgrades;
> };
>
> +/* how->allowed_upgrades flags for openat2(2). */
> +#define DENY_UPGRADES 0x01
> +#define READ_UPGRADABLE (0x02 | DENY_UPGRADES)
> +#define WRITE_UPGRADABLE (0x04 | DENY_UPGRADES)
I'm not a huge fan of how this bitmask is set up, to be honest. I get
that you did it this way to make it disable-by-default but given that we
probably will want to add restrictions in the future that would break
backward compatibility (imagine an execute restriction that blocks
execve(2) -- adding the feature would break all existing programs if you
follow this scheme).
It probably makes more sense to do something more like statx(2) -- you
pick the restrictions you want and get back information about which
restrictions were supported. This is similar to what I did in the old
patchset too, though it didn't give you information like statx(2) does.
--
Aleksa Sarai
https://www.cyphar.com/
[-- Attachment #2: signature.asc --]
[-- Type: application/pgp-signature, Size: 265 bytes --]
^ permalink raw reply [flat|nested] 13+ messages in thread
* Re: [RFC PATCH v2 1/3] vfs: add support for empty path to openat2(2)
2026-03-26 18:20 ` [RFC PATCH v2 1/3] vfs: add support for empty path to openat2(2) Jori Koolstra
@ 2026-03-27 6:26 ` Aleksa Sarai
2026-03-29 22:13 ` Jori Koolstra
2026-03-30 12:12 ` Jeff Layton
1 sibling, 1 reply; 13+ messages in thread
From: Aleksa Sarai @ 2026-03-27 6:26 UTC (permalink / raw)
To: Jori Koolstra
Cc: Jeff Layton, Chuck Lever, Alexander Aring, Alexander Viro,
Christian Brauner, Jan Kara, Shuah Khan, Greg Kroah-Hartman,
Andrew Morton, Mike Rapoport, Liam R . Howlett,
David Hildenbrand, Lorenzo Stoakes, Ethan Tidmore, NeilBrown,
Oleg Nesterov, Penglei Jiang, Kees Cook, Suren Baghdasaryan,
Vlastimil Babka, Amir Goldstein, Namjae Jeon, Mateusz Guzik,
Wei Yang, Bala-Vignesh-Reddy, linux-kernel, linux-fsdevel,
linux-kselftest
[-- Attachment #1: Type: text/plain, Size: 5298 bytes --]
On 2026-03-26, Jori Koolstra <jkoolstra@xs4all.nl> wrote:
> To get an operable version of an O_PATH file descriptor, it is possible
> to use openat(fd, ".", O_DIRECTORY) for directories, but other files
> currently require going through open("/proc/<pid>/fd/<nr>"), which
> depends on a functioning procfs.
>
> This patch adds the OPENAT2_EMPTY_PATH flag to openat2(2). If passed,
> LOOKUP_EMPTY is set at path resolve time.
>
> Note: This implies that you cannot rely anymore on disabling procfs from
> being mounted (e.g. inside a container without procfs mounted and with
> CAP_SYS_ADMIN dropped) to prevent O_PATH fds from being re-opened
> read-write.
Actually, this flag doesn't need to be openat2(2) only -- all existing
kernels will reject a pathname of "" with ENOENT. This means that
O_EMPTYPATH being set acting as a no-op is fine for older kernels (no
program will get an unexpected result from O_EMPTYPATH).
In my view, adding it to openat(2) is preferable because it means that
systemd et al. can use it (they currently block openat2(2) with
seccomp). This is what I did in the original openat2(2) patchset[1].
[1]: https://lore.kernel.org/lkml/20190930183316.10190-4-cyphar@cyphar.com/
> Signed-off-by: Jori Koolstra <jkoolstra@xs4all.nl>
> ---
> fs/fcntl.c | 4 ++--
> fs/open.c | 11 +++++------
> include/linux/fcntl.h | 5 ++++-
> include/uapi/linux/openat2.h | 4 ++++
> 4 files changed, 15 insertions(+), 9 deletions(-)
>
> diff --git a/fs/fcntl.c b/fs/fcntl.c
> index beab8080badf..d9ae3c71edfe 100644
> --- a/fs/fcntl.c
> +++ b/fs/fcntl.c
> @@ -1169,8 +1169,8 @@ static int __init fcntl_init(void)
> * Exceptions: O_NONBLOCK is a two bit define on parisc; O_NDELAY
> * is defined as O_NONBLOCK on some platforms and not on others.
> */
> - BUILD_BUG_ON(20 - 1 /* for O_RDONLY being 0 */ !=
> - HWEIGHT32(
> + BUILD_BUG_ON(21 - 1 /* for O_RDONLY being 0 */ !=
> + HWEIGHT64(
> (VALID_OPEN_FLAGS & ~(O_NONBLOCK | O_NDELAY)) |
> __FMODE_EXEC));
>
> diff --git a/fs/open.c b/fs/open.c
> index 91f1139591ab..e019ddecc73c 100644
> --- a/fs/open.c
> +++ b/fs/open.c
> @@ -1160,12 +1160,12 @@ struct file *kernel_file_open(const struct path *path, int flags,
> EXPORT_SYMBOL_GPL(kernel_file_open);
>
> #define WILL_CREATE(flags) (flags & (O_CREAT | __O_TMPFILE))
> -#define O_PATH_FLAGS (O_DIRECTORY | O_NOFOLLOW | O_PATH | O_CLOEXEC)
> +#define O_PATH_FLAGS (O_DIRECTORY | O_NOFOLLOW | O_PATH | O_CLOEXEC | OPENAT2_EMPTY_PATH)
>
> inline struct open_how build_open_how(int flags, umode_t mode)
> {
> struct open_how how = {
> - .flags = flags & VALID_OPEN_FLAGS,
> + .flags = ((unsigned int) flags) & VALID_OPEN_FLAGS,
> .mode = mode & S_IALLUGO,
> };
>
> @@ -1185,9 +1185,6 @@ inline int build_open_flags(const struct open_how *how, struct open_flags *op)
> int lookup_flags = 0;
> int acc_mode = ACC_MODE(flags);
>
> - BUILD_BUG_ON_MSG(upper_32_bits(VALID_OPEN_FLAGS),
> - "struct open_flags doesn't yet handle flags > 32 bits");
> -
> /*
> * Strip flags that aren't relevant in determining struct open_flags.
> */
> @@ -1281,6 +1278,8 @@ inline int build_open_flags(const struct open_how *how, struct open_flags *op)
> lookup_flags |= LOOKUP_DIRECTORY;
> if (!(flags & O_NOFOLLOW))
> lookup_flags |= LOOKUP_FOLLOW;
> + if (flags & OPENAT2_EMPTY_PATH)
> + lookup_flags |= LOOKUP_EMPTY;
>
> if (how->resolve & RESOLVE_NO_XDEV)
> lookup_flags |= LOOKUP_NO_XDEV;
> @@ -1362,7 +1361,7 @@ static int do_sys_openat2(int dfd, const char __user *filename,
> if (unlikely(err))
> return err;
>
> - CLASS(filename, name)(filename);
> + CLASS(filename_flags, name)(filename, op.lookup_flags);
> return FD_ADD(how->flags, do_file_open(dfd, name, &op));
> }
>
> diff --git a/include/linux/fcntl.h b/include/linux/fcntl.h
> index a332e79b3207..d1bb87ff70e3 100644
> --- a/include/linux/fcntl.h
> +++ b/include/linux/fcntl.h
> @@ -7,10 +7,13 @@
>
> /* List of all valid flags for the open/openat flags argument: */
> #define VALID_OPEN_FLAGS \
> + /* lower 32-bit flags */ \
> (O_RDONLY | O_WRONLY | O_RDWR | O_CREAT | O_EXCL | O_NOCTTY | O_TRUNC | \
> O_APPEND | O_NDELAY | O_NONBLOCK | __O_SYNC | O_DSYNC | \
> FASYNC | O_DIRECT | O_LARGEFILE | O_DIRECTORY | O_NOFOLLOW | \
> - O_NOATIME | O_CLOEXEC | O_PATH | __O_TMPFILE)
> + O_NOATIME | O_CLOEXEC | O_PATH | __O_TMPFILE | \
> + /* upper 32-bit flags (openat2(2) only) */ \
> + OPENAT2_EMPTY_PATH)
>
> /* List of all valid flags for the how->resolve argument: */
> #define VALID_RESOLVE_FLAGS \
> diff --git a/include/uapi/linux/openat2.h b/include/uapi/linux/openat2.h
> index a5feb7604948..c34f32e6fa96 100644
> --- a/include/uapi/linux/openat2.h
> +++ b/include/uapi/linux/openat2.h
> @@ -40,4 +40,8 @@ struct open_how {
> return -EAGAIN if that's not
> possible. */
>
> +/* openat2(2) exclusive flags are defined in the upper 32 bits of
> + open_how->flags */
> +#define OPENAT2_EMPTY_PATH 0x100000000 /* (1ULL << 32) */
> +
> #endif /* _UAPI_LINUX_OPENAT2_H */
> --
> 2.53.0
>
--
Aleksa Sarai
https://www.cyphar.com/
[-- Attachment #2: signature.asc --]
[-- Type: application/pgp-signature, Size: 265 bytes --]
^ permalink raw reply [flat|nested] 13+ messages in thread
* Re: [RFC PATCH v2 2/3] vfs: transitive upgrade restrictions for fds
2026-03-27 6:20 ` Aleksa Sarai
@ 2026-03-29 21:54 ` Jori Koolstra
2026-04-01 12:15 ` Aleksa Sarai
0 siblings, 1 reply; 13+ messages in thread
From: Jori Koolstra @ 2026-03-29 21:54 UTC (permalink / raw)
To: Aleksa Sarai
Cc: Jeff Layton, Chuck Lever, Alexander Aring, Alexander Viro,
Christian Brauner, Jan Kara, Shuah Khan, Greg Kroah-Hartman,
Andrew Morton, Mike Rapoport, Liam R . Howlett,
David Hildenbrand, Lorenzo Stoakes, Ethan Tidmore, NeilBrown,
Oleg Nesterov, Penglei Jiang, Kees Cook, Suren Baghdasaryan,
Vlastimil Babka, Amir Goldstein, Namjae Jeon, Mateusz Guzik,
Wei Yang, Bala-Vignesh-Reddy, linux-kernel, linux-fsdevel,
Andy Lutomirski, linux-kselftest
Hi Aleksa,
Thanks a lot for the detailed reply, it gives a lot of the background I was
missing. I really appreciate it, I have learned a lot as I wasn't aware of the
earlier work on this. Sorry for not getting back to you sooner, but I needed
a few days to absorb all the linked information.
> Op 27-03-2026 07:20 CET schreef Aleksa Sarai <cyphar@cyphar.com>:
>
>
> On 2026-03-26, Jori Koolstra <jkoolstra@xs4all.nl> wrote:
> > Add upgrade restrictions to openat2(). Extend struct open_how to allow
> > setting transitive restrictions on using file descriptors to open other
> > files. A use case for this feature is to block services or containers
> > from re-opening/upgrading an O_PATH file descriptor through e.g.
> > /proc/<pid>/fd/<nr> as O_WRONLY.
> >
> > The idea for this features comes form the UAPI group kernel feature idea
> > list [1].
> >
> > [1] https://github.com/uapi-group/kernel-features?tab=readme-ov-file#upgrade-masks-in-openat2
>
> I had a version of this in the original openat2(2) pull request many
> years ago[1].
>
> Unfortunately there is a pretty big issue with doing it this way (which
> I mentioned in one of the changelogs back then[2]): There are lots of
> VFS operations that imply operations on a file (through a magic-link)
> that are not blocked. truncate(2) and mount(MS_BIND)/open_tree(2) are
> the most problematic examples, but this applies to basically any syscall
> that takes a path argument. If you don't block those then re-opening
> restrictions are functionally useless.
Ah yes. If I am correct, it would block all the fXXX syscalls from doing
harm (at least w.r.t. read/write operations) because they use the fmode for
rights checking on the fd, and this cannot be changed without going through
an open() variant.
Hence the issue is the case when we pass a magic path to e.g. truncate()
as it does no upgrade restriction check right now on the struct file. So
we hade to do this for every relevant syscall. And the question is... where.
>
> It also would be really nice if you could block more than just trailing
> component operations -- having a directory file descriptor that blocks
> lookups could be quite handy for a bunch of reasons.
>
Yes, so (at the very least) we also want RESTRICT_LOOKUP for directory fds.
> I think the only workable solution to block all of these issue entirely
> and in a comprehensive way is to have something akin to capsicum
> capabilities[3] tied to file descriptors and have all of the VFS
> operations check them (though I think that the way this was attempted in
> the past[4] was far from ideal).
>
I went through Drysdale's implementation a bit. He links the capability check
to the translation of an fd to a struct file. I agree this is a bit invasive
(as he writes himself), and perhaps we can do better. Is this what you mean
by "far from ideal"?
> I have tried my hand at a few lighter-weight prototypes over the years
> (mainly trying to add the necessary checks to every generic_permission()
> call, and adding some more generic_permission() calls as well...). My
> last prototype was adding the restriction information to "struct path"
> but that would bloat too many structures to be merge-able. I was
> planning on looking at this again later this year, but if you can come
> up with a nice way of getting a minimal version of capsicum working,
> that'd be amazing. :D
I would really like to try; it is a very nice problem for me to tackle;
you need to gain experience somehow :)
I wonder how checking all this in generic_permission() would work. The
access to the fd that the procfs magic link provides is essentially an
issue of path traversal, and in generic_permission() you just have the
inode in question. Ah but of course, you can use the mode bits of the
magic link to encode the information, as you suggest. What downside did
you encounter using this idea?
One thing I can think of is that if we want more than rwx upgrade control
(more capsicum style control), this is not going to be sufficient. If you
want to restrict fchown on an fd, there is no way to encode this in the
magic link mode. Maybe we should determine first the minimum capability
support that we want to make the feature useful (and extendable)?
>
> That being said, while my view at the time of openat2(2) was that we
> need to do this at the same time as O_EMPTYPATH (and my tests showed
> that this was a backwards-compatible change -- on modern desktops at
> least), at this point I think it'd be better to just merge O_EMPTYPATH
> by itself and we can work on this hardening separately and make it an
> opt-in sysctl (with individual file descriptors being opt-in-able as
> well).
Not a version of the cap syscalls?
>
> > diff --git a/include/uapi/linux/openat2.h b/include/uapi/linux/openat2.h
> > index c34f32e6fa96..fc1147e6ce41 100644
> > --- a/include/uapi/linux/openat2.h
> > +++ b/include/uapi/linux/openat2.h
> > @@ -20,8 +20,14 @@ struct open_how {
> > __u64 flags;
> > __u64 mode;
> > __u64 resolve;
> > + __u64 allowed_upgrades;
> > };
> >
> > +/* how->allowed_upgrades flags for openat2(2). */
> > +#define DENY_UPGRADES 0x01
> > +#define READ_UPGRADABLE (0x02 | DENY_UPGRADES)
> > +#define WRITE_UPGRADABLE (0x04 | DENY_UPGRADES)
>
> I'm not a huge fan of how this bitmask is set up, to be honest. I get
> that you did it this way to make it disable-by-default but given that we
> probably will want to add restrictions in the future that would break
> backward compatibility (imagine an execute restriction that blocks
> execve(2) -- adding the feature would break all existing programs if you
> follow this scheme).
Ah, I wanted to have the upgradable options as white list, because with a
blacklist approach you have the issue that if there is ever a restriction
added that has overlap with an existing restriction, it would disable part
of a restriction you thought you had set. But maybe we just need to prevent
such a scenario, and I agree the whitelist option is even worse.
>
> --
> Aleksa Sarai
> https://www.cyphar.com/
Btw, I just saw you gave a cool talk at FOSDEM this year, and I missed it,
even though I was there! Thanks for linking the cve-2019-5736, was really
interesting to read.
Thanks,
Jori.
^ permalink raw reply [flat|nested] 13+ messages in thread
* Re: [RFC PATCH v2 1/3] vfs: add support for empty path to openat2(2)
2026-03-27 6:26 ` Aleksa Sarai
@ 2026-03-29 22:13 ` Jori Koolstra
0 siblings, 0 replies; 13+ messages in thread
From: Jori Koolstra @ 2026-03-29 22:13 UTC (permalink / raw)
To: Aleksa Sarai, Christian Brauner
Cc: Jeff Layton, Chuck Lever, Alexander Aring, Alexander Viro,
Jan Kara, Shuah Khan, Greg Kroah-Hartman, Andrew Morton,
Mike Rapoport, Liam R . Howlett, David Hildenbrand,
Lorenzo Stoakes, Ethan Tidmore, NeilBrown, Oleg Nesterov,
Penglei Jiang, Kees Cook, Suren Baghdasaryan, Vlastimil Babka,
Amir Goldstein, Namjae Jeon, Mateusz Guzik, Wei Yang,
Bala-Vignesh-Reddy, linux-kernel, linux-fsdevel, linux-kselftest
> Op 27-03-2026 07:26 CET schreef Aleksa Sarai <cyphar@cyphar.com>:
>
>
> On 2026-03-26, Jori Koolstra <jkoolstra@xs4all.nl> wrote:
> > To get an operable version of an O_PATH file descriptor, it is possible
> > to use openat(fd, ".", O_DIRECTORY) for directories, but other files
> > currently require going through open("/proc/<pid>/fd/<nr>"), which
> > depends on a functioning procfs.
> >
> > This patch adds the OPENAT2_EMPTY_PATH flag to openat2(2). If passed,
> > LOOKUP_EMPTY is set at path resolve time.
> >
> > Note: This implies that you cannot rely anymore on disabling procfs from
> > being mounted (e.g. inside a container without procfs mounted and with
> > CAP_SYS_ADMIN dropped) to prevent O_PATH fds from being re-opened
> > read-write.
>
> Actually, this flag doesn't need to be openat2(2) only -- all existing
> kernels will reject a pathname of "" with ENOENT. This means that
> O_EMPTYPATH being set acting as a no-op is fine for older kernels (no
> program will get an unexpected result from O_EMPTYPATH).
>
> In my view, adding it to openat(2) is preferable because it means that
> systemd et al. can use it (they currently block openat2(2) with
> seccomp). This is what I did in the original openat2(2) patchset[1].
I changed this in response to feedback from Christian [1]. He did mention
that if someone really wants to add it to openat(), we should wait for their
reasons :)
But if systemd could use it, I think it is worth considering. I am not sure
why Christian was against it in the first place. Maybe to save flag space for
things that really really need to be also in openat().
[1]: https://lore.kernel.org/linux-fsdevel/20260224-karotten-wegnimmt-79410ef99aeb@brauner/.
Thanks,
Jori.
^ permalink raw reply [flat|nested] 13+ messages in thread
* Re: [RFC PATCH v2 1/3] vfs: add support for empty path to openat2(2)
2026-03-26 18:20 ` [RFC PATCH v2 1/3] vfs: add support for empty path to openat2(2) Jori Koolstra
2026-03-27 6:26 ` Aleksa Sarai
@ 2026-03-30 12:12 ` Jeff Layton
2026-03-30 14:17 ` Jori Koolstra
2026-04-01 12:23 ` Aleksa Sarai
1 sibling, 2 replies; 13+ messages in thread
From: Jeff Layton @ 2026-03-30 12:12 UTC (permalink / raw)
To: Jori Koolstra, Chuck Lever, Alexander Aring, Alexander Viro,
Christian Brauner, Jan Kara, Shuah Khan, Greg Kroah-Hartman,
Aleksa Sarai
Cc: Andrew Morton, Mike Rapoport, Liam R . Howlett,
David Hildenbrand, Lorenzo Stoakes, Ethan Tidmore, NeilBrown,
Oleg Nesterov, Penglei Jiang, Kees Cook, Suren Baghdasaryan,
Vlastimil Babka, Amir Goldstein, Namjae Jeon, Mateusz Guzik,
Wei Yang, Bala-Vignesh-Reddy, linux-kernel, linux-fsdevel,
linux-kselftest
On Thu, 2026-03-26 at 19:20 +0100, Jori Koolstra wrote:
> To get an operable version of an O_PATH file descriptor, it is possible
> to use openat(fd, ".", O_DIRECTORY) for directories, but other files
> currently require going through open("/proc/<pid>/fd/<nr>"), which
> depends on a functioning procfs.
>
> This patch adds the OPENAT2_EMPTY_PATH flag to openat2(2). If passed,
> LOOKUP_EMPTY is set at path resolve time.
>
> Note: This implies that you cannot rely anymore on disabling procfs from
> being mounted (e.g. inside a container without procfs mounted and with
> CAP_SYS_ADMIN dropped) to prevent O_PATH fds from being re-opened
> read-write.
>
> Signed-off-by: Jori Koolstra <jkoolstra@xs4all.nl>
> ---
> fs/fcntl.c | 4 ++--
> fs/open.c | 11 +++++------
> include/linux/fcntl.h | 5 ++++-
> include/uapi/linux/openat2.h | 4 ++++
> 4 files changed, 15 insertions(+), 9 deletions(-)
>
> diff --git a/fs/fcntl.c b/fs/fcntl.c
> index beab8080badf..d9ae3c71edfe 100644
> --- a/fs/fcntl.c
> +++ b/fs/fcntl.c
> @@ -1169,8 +1169,8 @@ static int __init fcntl_init(void)
> * Exceptions: O_NONBLOCK is a two bit define on parisc; O_NDELAY
> * is defined as O_NONBLOCK on some platforms and not on others.
> */
> - BUILD_BUG_ON(20 - 1 /* for O_RDONLY being 0 */ !=
> - HWEIGHT32(
> + BUILD_BUG_ON(21 - 1 /* for O_RDONLY being 0 */ !=
> + HWEIGHT64(
> (VALID_OPEN_FLAGS & ~(O_NONBLOCK | O_NDELAY)) |
> __FMODE_EXEC));
>
> diff --git a/fs/open.c b/fs/open.c
> index 91f1139591ab..e019ddecc73c 100644
> --- a/fs/open.c
> +++ b/fs/open.c
> @@ -1160,12 +1160,12 @@ struct file *kernel_file_open(const struct path *path, int flags,
> EXPORT_SYMBOL_GPL(kernel_file_open);
>
> #define WILL_CREATE(flags) (flags & (O_CREAT | __O_TMPFILE))
> -#define O_PATH_FLAGS (O_DIRECTORY | O_NOFOLLOW | O_PATH | O_CLOEXEC)
> +#define O_PATH_FLAGS (O_DIRECTORY | O_NOFOLLOW | O_PATH | O_CLOEXEC | OPENAT2_EMPTY_PATH)
>
> inline struct open_how build_open_how(int flags, umode_t mode)
> {
> struct open_how how = {
> - .flags = flags & VALID_OPEN_FLAGS,
> + .flags = ((unsigned int) flags) & VALID_OPEN_FLAGS,
> .mode = mode & S_IALLUGO,
> };
>
> @@ -1185,9 +1185,6 @@ inline int build_open_flags(const struct open_how *how, struct open_flags *op)
> int lookup_flags = 0;
> int acc_mode = ACC_MODE(flags);
>
> - BUILD_BUG_ON_MSG(upper_32_bits(VALID_OPEN_FLAGS),
> - "struct open_flags doesn't yet handle flags > 32 bits");
> -
> /*
> * Strip flags that aren't relevant in determining struct open_flags.
> */
> @@ -1281,6 +1278,8 @@ inline int build_open_flags(const struct open_how *how, struct open_flags *op)
> lookup_flags |= LOOKUP_DIRECTORY;
> if (!(flags & O_NOFOLLOW))
> lookup_flags |= LOOKUP_FOLLOW;
> + if (flags & OPENAT2_EMPTY_PATH)
> + lookup_flags |= LOOKUP_EMPTY;
>
> if (how->resolve & RESOLVE_NO_XDEV)
> lookup_flags |= LOOKUP_NO_XDEV;
> @@ -1362,7 +1361,7 @@ static int do_sys_openat2(int dfd, const char __user *filename,
> if (unlikely(err))
> return err;
>
> - CLASS(filename, name)(filename);
> + CLASS(filename_flags, name)(filename, op.lookup_flags);
> return FD_ADD(how->flags, do_file_open(dfd, name, &op));
> }
>
> diff --git a/include/linux/fcntl.h b/include/linux/fcntl.h
> index a332e79b3207..d1bb87ff70e3 100644
> --- a/include/linux/fcntl.h
> +++ b/include/linux/fcntl.h
> @@ -7,10 +7,13 @@
>
> /* List of all valid flags for the open/openat flags argument: */
> #define VALID_OPEN_FLAGS \
> + /* lower 32-bit flags */ \
> (O_RDONLY | O_WRONLY | O_RDWR | O_CREAT | O_EXCL | O_NOCTTY | O_TRUNC | \
> O_APPEND | O_NDELAY | O_NONBLOCK | __O_SYNC | O_DSYNC | \
> FASYNC | O_DIRECT | O_LARGEFILE | O_DIRECTORY | O_NOFOLLOW | \
> - O_NOATIME | O_CLOEXEC | O_PATH | __O_TMPFILE)
> + O_NOATIME | O_CLOEXEC | O_PATH | __O_TMPFILE | \
> + /* upper 32-bit flags (openat2(2) only) */ \
> + OPENAT2_EMPTY_PATH)
>
> /* List of all valid flags for the how->resolve argument: */
> #define VALID_RESOLVE_FLAGS \
> diff --git a/include/uapi/linux/openat2.h b/include/uapi/linux/openat2.h
> index a5feb7604948..c34f32e6fa96 100644
> --- a/include/uapi/linux/openat2.h
> +++ b/include/uapi/linux/openat2.h
> @@ -40,4 +40,8 @@ struct open_how {
> return -EAGAIN if that's not
> possible. */
>
> +/* openat2(2) exclusive flags are defined in the upper 32 bits of
> + open_how->flags */
> +#define OPENAT2_EMPTY_PATH 0x100000000 /* (1ULL << 32) */
> +
> #endif /* _UAPI_LINUX_OPENAT2_H */
Looks sane to me. Can this be merged apart from the rest of the series?
It doesn't seem like the transitive stuff is dependent on this.
Reviewed-by: Jeff Layton <jlayton@kernel.org>
^ permalink raw reply [flat|nested] 13+ messages in thread
* Re: [RFC PATCH v2 1/3] vfs: add support for empty path to openat2(2)
2026-03-30 12:12 ` Jeff Layton
@ 2026-03-30 14:17 ` Jori Koolstra
2026-04-01 12:23 ` Aleksa Sarai
1 sibling, 0 replies; 13+ messages in thread
From: Jori Koolstra @ 2026-03-30 14:17 UTC (permalink / raw)
To: Jeff Layton, Chuck Lever, Alexander Aring, Alexander Viro,
Christian Brauner, Jan Kara, Shuah Khan, Greg Kroah-Hartman,
Aleksa Sarai
Cc: Andrew Morton, Mike Rapoport, Liam R . Howlett,
David Hildenbrand, Lorenzo Stoakes, Ethan Tidmore, NeilBrown,
Oleg Nesterov, Penglei Jiang, Kees Cook, Suren Baghdasaryan,
Vlastimil Babka, Amir Goldstein, Namjae Jeon, Mateusz Guzik,
Wei Yang, Bala-Vignesh-Reddy, linux-kernel, linux-fsdevel,
linux-kselftest
> Op 30-03-2026 14:12 CEST schreef Jeff Layton <jlayton@kernel.org>:
>
>
> On Thu, 2026-03-26 at 19:20 +0100, Jori Koolstra wrote:
> > To get an operable version of an O_PATH file descriptor, it is possible
> > to use openat(fd, ".", O_DIRECTORY) for directories, but other files
> > currently require going through open("/proc/<pid>/fd/<nr>"), which
> > depends on a functioning procfs.
> >
> > This patch adds the OPENAT2_EMPTY_PATH flag to openat2(2). If passed,
> > LOOKUP_EMPTY is set at path resolve time.
> >
> > Note: This implies that you cannot rely anymore on disabling procfs from
> > being mounted (e.g. inside a container without procfs mounted and with
> > CAP_SYS_ADMIN dropped) to prevent O_PATH fds from being re-opened
> > read-write.
> >
> > Signed-off-by: Jori Koolstra <jkoolstra@xs4all.nl>
>
> Looks sane to me. Can this be merged apart from the rest of the series?
> It doesn't seem like the transitive stuff is dependent on this.
>
> Reviewed-by: Jeff Layton <jlayton@kernel.org>
Yes, there is no dependence. However, Aleksa suggested that we DO add it
to openat() as well because then systemd folks can use it too. I don't
if there are any objections to that right now.
Thanks,
Jori.
^ permalink raw reply [flat|nested] 13+ messages in thread
* Re: [RFC PATCH v2 2/3] vfs: transitive upgrade restrictions for fds
2026-03-29 21:54 ` Jori Koolstra
@ 2026-04-01 12:15 ` Aleksa Sarai
0 siblings, 0 replies; 13+ messages in thread
From: Aleksa Sarai @ 2026-04-01 12:15 UTC (permalink / raw)
To: Jori Koolstra
Cc: Jeff Layton, Chuck Lever, Alexander Aring, Alexander Viro,
Christian Brauner, Jan Kara, Shuah Khan, Greg Kroah-Hartman,
Andrew Morton, Mike Rapoport, Liam R . Howlett,
David Hildenbrand, Lorenzo Stoakes, Ethan Tidmore, NeilBrown,
Oleg Nesterov, Penglei Jiang, Kees Cook, Suren Baghdasaryan,
Vlastimil Babka, Amir Goldstein, Namjae Jeon, Mateusz Guzik,
Wei Yang, Bala-Vignesh-Reddy, linux-kernel, linux-fsdevel,
Andy Lutomirski, linux-kselftest
[-- Attachment #1: Type: text/plain, Size: 5863 bytes --]
On 2026-03-29, Jori Koolstra <jkoolstra@xs4all.nl> wrote:
> > Unfortunately there is a pretty big issue with doing it this way (which
> > I mentioned in one of the changelogs back then[2]): There are lots of
> > VFS operations that imply operations on a file (through a magic-link)
> > that are not blocked. truncate(2) and mount(MS_BIND)/open_tree(2) are
> > the most problematic examples, but this applies to basically any syscall
> > that takes a path argument. If you don't block those then re-opening
> > restrictions are functionally useless.
>
> Ah yes. If I am correct, it would block all the fXXX syscalls from doing
> harm (at least w.r.t. read/write operations) because they use the fmode for
> rights checking on the fd, and this cannot be changed without going through
> an open() variant.
>
> Hence the issue is the case when we pass a magic path to e.g. truncate()
> as it does no upgrade restriction check right now on the struct file. So
> we hade to do this for every relevant syscall. And the question is... where.
They would also be bypassed by AT_EMPTY_PATH, so you don't even need
magic-links to cause the issue. This is why I considered doing it with
"struct path" and doing the blocking in *_permission() -- all of the
standard vfs operations already have .
> > It also would be really nice if you could block more than just trailing
> > component operations -- having a directory file descriptor that blocks
> > lookups could be quite handy for a bunch of reasons.
>
> Yes, so (at the very least) we also want RESTRICT_LOOKUP for directory fds.
Well, definitely not initially. You also don't really need to call it
RESTRICT_LOOKUP, blocking exec would be the "unixy" thing to do (and it
would be nice to have that to block fexecveat(2) too, but that is a
_VERY_ deep rabbithole).
> > I think the only workable solution to block all of these issue entirely
> > and in a comprehensive way is to have something akin to capsicum
> > capabilities[3] tied to file descriptors and have all of the VFS
> > operations check them (though I think that the way this was attempted in
> > the past[4] was far from ideal).
> >
>
> I went through Drysdale's implementation a bit. He links the capability check
> to the translation of an fd to a struct file. I agree this is a bit invasive
> (as he writes himself), and perhaps we can do better. Is this what you mean
> by "far from ideal"?
There were quite a few other issues with it, but that is one of them.
> > I have tried my hand at a few lighter-weight prototypes over the years
> > (mainly trying to add the necessary checks to every generic_permission()
> > call, and adding some more generic_permission() calls as well...). My
> > last prototype was adding the restriction information to "struct path"
> > but that would bloat too many structures to be merge-able. I was
> > planning on looking at this again later this year, but if you can come
> > up with a nice way of getting a minimal version of capsicum working,
> > that'd be amazing. :D
>
> I would really like to try; it is a very nice problem for me to tackle;
> you need to gain experience somehow :)
Sorry, I probably should've couched my reply with a bit more caution --
I really appreciate the enthusiasm, but this is quite a hairy topic for
quite a number of reasons. It will involve touching everything
fd-related in the kernel, it touches on an already-rejected patchset,
and is a topic that can get very heated easily. I would not rate the
chance of getting something merged very highly, and I think there are
more fruitful things for you to tackle and get your hands dirty.
Honestly, I think most people will be more than happy with just getting
the O_EMPTYPATH part merged -- looking back, I really should've just
done that back in 2019. :/
> I wonder how checking all this in generic_permission() would work. The
> access to the fd that the procfs magic link provides is essentially an
> issue of path traversal, and in generic_permission() you just have the
> inode in question. Ah but of course, you can use the mode bits of the
> magic link to encode the information, as you suggest. What downside did
> you encounter using this idea?
It wasn't based on the magic-link mode, I added a capability set to
"struct path" and then went and adjusted all generic_permission()s to
take the new capability mask. For stuff that did file_permission() or
path_permission(), no code change was needed.
Unfortunately this ran into quite a few hairy issues that made it
basically unmergeable (bloating "struct path" is very bad as it is
embedded everywhere, the whole model is kind of questionable because it
makes "struct path" have state about the lookup that produced it, some
VFS APIs deal with inodes or dentries directly and thus would need more
tree-wide fixes, and most importantly it just felt really ugly).
The open_tree(2) / mount(MS_BIND) issue is particularly problematic --
ideally you would want to block a RW bind-mount for a read-only file,
but there isn't really an obvious mechanism for passing down those kinds
of restrictions. Actually, fsconfig() arguments like "upperdir" to
overlayfs (or even "source") are likely even worse -- now you need
per-filesystem-option handling.
I don't think these issues are insurmountable, it's just that the
problem is much harder than it looks at first glance and I would humbly
suggest that working on reviving a very dead patchset is not the best
use of your time. Container runtimes really *really* want this, which is
the main reason I keep coming back to it (and we ended up with it in the
UAPI group proposal page) but I think it's a very niche thing that has a
big risk of being a lot of wasted effort.
--
Aleksa Sarai
https://www.cyphar.com/
[-- Attachment #2: signature.asc --]
[-- Type: application/pgp-signature, Size: 265 bytes --]
^ permalink raw reply [flat|nested] 13+ messages in thread
* Re: [RFC PATCH v2 1/3] vfs: add support for empty path to openat2(2)
2026-03-30 12:12 ` Jeff Layton
2026-03-30 14:17 ` Jori Koolstra
@ 2026-04-01 12:23 ` Aleksa Sarai
2026-04-01 20:49 ` Jori Koolstra
1 sibling, 1 reply; 13+ messages in thread
From: Aleksa Sarai @ 2026-04-01 12:23 UTC (permalink / raw)
To: Jeff Layton
Cc: Jori Koolstra, Chuck Lever, Alexander Aring, Alexander Viro,
Christian Brauner, Jan Kara, Shuah Khan, Greg Kroah-Hartman,
Andrew Morton, Mike Rapoport, Liam R . Howlett,
David Hildenbrand, Lorenzo Stoakes, Ethan Tidmore, NeilBrown,
Oleg Nesterov, Penglei Jiang, Kees Cook, Suren Baghdasaryan,
Vlastimil Babka, Amir Goldstein, Namjae Jeon, Mateusz Guzik,
Wei Yang, Bala-Vignesh-Reddy, linux-kernel, linux-fsdevel,
linux-kselftest
[-- Attachment #1: Type: text/plain, Size: 933 bytes --]
On 2026-03-30, Jeff Layton <jlayton@kernel.org> wrote:
> Looks sane to me. Can this be merged apart from the rest of the series?
> It doesn't seem like the transitive stuff is dependent on this.
No it isn't. I mentioned this in another mail but my thinking from a
long time ago was that we should harden these re-open paths if we want
to fully support O_EMPTYPATH -- but we already allow all of this stuff
today so we might as well merge this thing and make everyone's lives
easier. The transitive stuff also needs a lot more work IMHO, and I
suspect the full version is going to be a hard sell.
My only comment is that I would like this to be usable in open() as it
is one of the very few cases of an O_* flag that is actually backwards
compatible with all of the brokenness of open() and the systemd folks
would probably want to use it (they can't use openat2() yet).
--
Aleksa Sarai
https://www.cyphar.com/
[-- Attachment #2: signature.asc --]
[-- Type: application/pgp-signature, Size: 265 bytes --]
^ permalink raw reply [flat|nested] 13+ messages in thread
* Re: [RFC PATCH v2 1/3] vfs: add support for empty path to openat2(2)
2026-04-01 12:23 ` Aleksa Sarai
@ 2026-04-01 20:49 ` Jori Koolstra
0 siblings, 0 replies; 13+ messages in thread
From: Jori Koolstra @ 2026-04-01 20:49 UTC (permalink / raw)
To: Aleksa Sarai, Christian Brauner
Cc: Chuck Lever, Alexander Aring, Alexander Viro, Jan Kara,
Shuah Khan, Greg Kroah-Hartman, Andrew Morton, Mike Rapoport,
Liam R . Howlett, David Hildenbrand, Lorenzo Stoakes,
Ethan Tidmore, NeilBrown, Oleg Nesterov, Penglei Jiang,
Kees Cook, Suren Baghdasaryan, Vlastimil Babka, Amir Goldstein,
Namjae Jeon, Mateusz Guzik, Wei Yang, Jeff Layton,
Bala-Vignesh-Reddy, linux-kernel, linux-fsdevel, linux-kselftest
> Op 01-04-2026 14:23 CEST schreef Aleksa Sarai <cyphar@cyphar.com>:
>
>
> On 2026-03-30, Jeff Layton <jlayton@kernel.org> wrote:
> > Looks sane to me. Can this be merged apart from the rest of the series?
> > It doesn't seem like the transitive stuff is dependent on this.
>
> My only comment is that I would like this to be usable in open() as it
> is one of the very few cases of an O_* flag that is actually backwards
> compatible with all of the brokenness of open() and the systemd folks
> would probably want to use it (they can't use openat2() yet).
OK. I'll just wait a bit on what Christian has to say about adding it to
openat(), since he initially rejected that, before I write up the new patch.
>
> --
> Aleksa Sarai
> https://www.cyphar.com/
Thanks,
Jori.
^ permalink raw reply [flat|nested] 13+ messages in thread
end of thread, other threads:[~2026-04-01 20:50 UTC | newest]
Thread overview: 13+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-03-26 18:20 [RFC PATCH v2 0/3] vfs: transitive upgrade restrictions for fds Jori Koolstra
2026-03-26 18:20 ` [RFC PATCH v2 1/3] vfs: add support for empty path to openat2(2) Jori Koolstra
2026-03-27 6:26 ` Aleksa Sarai
2026-03-29 22:13 ` Jori Koolstra
2026-03-30 12:12 ` Jeff Layton
2026-03-30 14:17 ` Jori Koolstra
2026-04-01 12:23 ` Aleksa Sarai
2026-04-01 20:49 ` Jori Koolstra
2026-03-26 18:20 ` [RFC PATCH v2 2/3] vfs: transitive upgrade restrictions for fds Jori Koolstra
2026-03-27 6:20 ` Aleksa Sarai
2026-03-29 21:54 ` Jori Koolstra
2026-04-01 12:15 ` Aleksa Sarai
2026-03-26 18:20 ` [RFC PATCH v2 3/3] selftest: add tests for OPENAT2_EMPTY_PATH and allowed_upgrades Jori Koolstra
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Powered by JetHome