core: apply SystemCallFilter= to io_uring operations

Many io_uring operations can do the work of a system call:
IORING_OP_OPENAT opens a file like openat(), IORING_OP_SOCKET creates a
socket like socket(), and so on. SystemCallFilter= doesn't apply to
io_uring operations. A service that can use io_uring can thus do things
that its system call filter denies.

On kernels with io_uring BPF filters, let's map each operation to the
system calls that it can do the work of, and deny the operation if the
filter denies any of them. IORING_OP_URING_CMD maps to ioctl() and also to
setsockopt(), getsockopt(), and getsockname(), because it runs those
socket calls on sockets.

We don't deny operations that we don't know about, even for an allow list.
A denied request fails with EACCES, while the kernel keeps reporting the
operation as supported, so programs couldn't fall back. Our seccomp allow
lists return ENOSYS for unknown system calls for the same reason.

We skip system calls that are denied with an error number of 0. A denied
io_uring request fails with EACCES and can't pretend to succeed. We also
don't register any io_uring filters if the system call filter denies
io_uring_setup(), because the service can't create rings then.
This commit is contained in:
Daan De Meyer committed 2026-11-04 10:54:29 +01:00
1 parent d2fe659ff6
commit f80d935027
6 files changed
+251 -3

No files matched your search

+15
View File
@@ -2995,6 +2995,21 @@ RestrictNamespaces=~cgroup net</programlisting>
<para>This feature makes use of the Secure Computing Mode 2 interfaces of the kernel ('seccomp
filtering') and is useful for enforcing a minimal sandboxing environment.</para>
<para>On kernels that support io_uring BPF filters (Linux 7.0 and newer), this setting also applies
to the operations of
<citerefentry project='man-pages'><refentrytitle>io_uring</refentrytitle><manvolnum>7</manvolnum></citerefentry>,
if the filter allows <function>io_uring_setup()</function>. An operation that can do the work of
one or more system calls, such as <constant>IORING_OP_OPENAT</constant> or
<constant>IORING_OP_SOCKET</constant>, is denied if the filter denies any of its equivalent system
calls. Operations of newer kernels that systemd doesn't know are not affected. Denied requests
always fail with <constant>EACCES</constant>, regardless of
<varname>SystemCallErrorNumber=</varname> and of the action of the system call. System calls that
are denied with an error number of 0 do not affect io_uring operations, since a denied request
can't pretend to succeed. The restriction applies to all io_uring instances that the processes of
the unit create, but not to io_uring instances that they receive from other processes. On such
kernels, <constant>IORING_REGISTER_RESTRICTIONS</constant> without an io_uring instance fails with
<constant>EPERM</constant> for the processes of the unit.</para>
<para>Note that on systems supporting multiple ABIs (such as x86/x86-64) it is recommended to turn
off alternative ABIs for services, so that they cannot be used to circumvent the restrictions of this
option. Specifically, it is recommended to combine this option with
+116 -3
View File
@@ -1,6 +1,7 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#include <grp.h>
#include <linux/io_uring.h>
#include <linux/securebits.h>
#include <poll.h>
#include <sched.h>
@@ -63,6 +64,7 @@
#include "namespace-util.h"
#include "nsflags.h"
#include "nsresource.h"
#include "nulstr-util.h"
#include "open-file.h"
#include "osc-context.h"
#include "pam-util.h"
@@ -1784,9 +1786,56 @@ static bool context_has_no_new_privileges(const ExecContext *c) {
return context_has_seccomp(c);
}
static bool check_io_uring_bpf_filters(const char *setting) {
#if HAVE_SECCOMP
static bool syscall_filter_allows(const ExecContext *c, int id) {
int e;
assert(c);
if (!hashmap_contains(c->syscall_filter, INT_TO_PTR(id + 1)))
return !c->syscall_allow_list;
/* With an error number of 0, the system call does nothing and pretends to succeed. A denied io_uring
* request fails with EACCES instead. A service that suppresses fsync() with an error number of 0
* would then see its io_uring fsync requests fail. */
e = PTR_TO_INT(hashmap_get(c->syscall_filter, INT_TO_PTR(id + 1)));
if (e == 0)
return true;
/* An allow list contains the allowed system calls with the value -1. Its other entries come from
* assignments like "~uname:EILSEQ" and deny the system call. */
return c->syscall_allow_list && e < 0;
}
#endif
static bool syscall_filter_denies_io_uring(const ExecContext *c) {
assert(c);
#if HAVE_SECCOMP
int id;
if (!exec_context_has_syscall_filters(c) || !is_seccomp_available())
return false;
id = sym_seccomp_syscall_resolve_name("io_uring_setup");
return id >= 0 && !syscall_filter_allows(c, id);
#else
return false;
#endif
}
static bool check_io_uring_bpf_filters(const ExecContext *c, const ExecParameters *p, const char *setting) {
assert(c);
assert(p);
assert(setting);
/* If SystemCallFilter= denies io_uring_setup(), the processes of the unit can't create rings, and
* io_uring filters have no effect. */
if (syscall_filter_denies_io_uring(c)) {
log_debug("SystemCallFilter= denies io_uring_setup(), not applying %s to io_uring requests.", setting);
return false;
}
if (!p->io_uring_bpf_filter_supported)
log_debug("io_uring BPF filters are not supported, not applying %s to io_uring requests.", setting);
@@ -1800,7 +1849,7 @@ static int apply_io_uring_address_families(const ExecContext *c, const ExecParam
if (!exec_context_has_address_families(c))
return 0;
if (!check_io_uring_bpf_filters("RestrictAddressFamilies="))
if (!check_io_uring_bpf_filters(c, p, "RestrictAddressFamilies="))
return 0;
return io_uring_restrict_address_families(c->address_families, c->address_families_allow_list);
@@ -1813,7 +1862,7 @@ static int apply_io_uring_suid_sgid(const ExecContext *c, const ExecParameters *
if (!c->restrict_suid_sgid)
return 0;
if (!check_io_uring_bpf_filters("RestrictSUIDSGID="))
if (!check_io_uring_bpf_filters(c, p, "RestrictSUIDSGID="))
return 0;
return io_uring_restrict_suid_sgid();
@@ -2008,6 +2057,64 @@ static int apply_restrict_suid_sgid(const ExecContext *c, const ExecParameters *
return seccomp_restrict_suid_sgid();
}
static bool syscall_filter_allows_all(const ExecContext *c, const char *syscalls) {
assert(c);
assert(syscalls);
NULSTR_FOREACH(name, syscalls) {
int id;
/* libseccomp returns negative numbers for system calls that the native architecture does
* not have. */
id = sym_seccomp_syscall_resolve_name(name);
if (id < 0)
continue;
if (!syscall_filter_allows(c, id))
return false;
}
return true;
}
static int apply_io_uring_syscall_filter(const ExecContext *c, const ExecParameters *p) {
_cleanup_set_free_ Set *denied = NULL;
int r;
assert(c);
assert(p);
if (!exec_context_has_syscall_filters(c))
return 0;
r = dlopen_libseccomp(LOG_DEBUG);
if (r < 0) {
log_debug_errno(r, "libseccomp is not available, not applying SystemCallFilter= to io_uring requests: %m");
return 0;
}
if (!check_io_uring_bpf_filters(c, p, "SystemCallFilter="))
return 0;
/* An io_uring operation can do the work of each of its equivalent system calls, so we deny it if
* the filter denies any of them. */
for (int op = 0; op < IORING_OP_LAST; op++) {
const char *syscalls = io_uring_op_syscalls_to_string(op);
if (!syscalls || syscall_filter_allows_all(c, syscalls))
continue;
r = set_ensure_put(&denied, /* hash_ops= */ NULL, INT_TO_PTR(op + 1));
if (r < 0)
return r;
}
/* A denied io_uring request fails with EACCES, but the kernel still reports the operation as
* supported. Programs therefore can't fall back from an unknown operation that we deny. We only deny
* known operations, even for an allow list of system calls. */
return io_uring_restrict_ops(denied, /* allow_list= */ false);
}
static int apply_protect_sysctl(const ExecContext *c, const ExecParameters *p) {
assert(c);
assert(p);
@@ -6977,6 +7084,12 @@ int exec_invoke(
}
#if HAVE_SECCOMP
r = apply_io_uring_syscall_filter(context, params);
if (r < 0) {
*exit_status = EXIT_SECCOMP;
return log_error_errno(r, "Failed to apply system call filters to io_uring: %m");
}
/* This really should remain as close to the execve() as possible, to make sure our own code is affected
* by the filter as little as possible. */
r = apply_syscall_filter(context, params);
+61
View File
@@ -19,6 +19,67 @@
#include "process-util.h"
#include "set.h"
#include "signal-util.h"
#include "string-table.h"
/* Each opcode maps to the system calls that it can do the work of. An opcode that is missing here has no
* system call equivalent, because it only manages the ring itself. */
static const char* const io_uring_op_syscalls_table[IORING_OP_LAST] = {
[IORING_OP_READV] = "readv\0preadv\0preadv2\0",
[IORING_OP_WRITEV] = "writev\0pwritev\0pwritev2\0",
[IORING_OP_FSYNC] = "fsync\0fdatasync\0",
[IORING_OP_READ_FIXED] = "read\0pread64\0",
[IORING_OP_WRITE_FIXED] = "write\0pwrite64\0",
[IORING_OP_POLL_ADD] = "poll\0ppoll\0ppoll_time64\0",
[IORING_OP_SYNC_FILE_RANGE] = "sync_file_range\0sync_file_range2\0",
[IORING_OP_SENDMSG] = "sendmsg\0",
[IORING_OP_RECVMSG] = "recvmsg\0",
[IORING_OP_ACCEPT] = "accept\0accept4\0",
[IORING_OP_CONNECT] = "connect\0",
[IORING_OP_FALLOCATE] = "fallocate\0",
[IORING_OP_OPENAT] = "open\0openat\0",
[IORING_OP_CLOSE] = "close\0",
[IORING_OP_STATX] = "statx\0",
[IORING_OP_READ] = "read\0pread64\0",
[IORING_OP_WRITE] = "write\0pwrite64\0",
[IORING_OP_FADVISE] = "fadvise64\0fadvise64_64\0arm_fadvise64_64\0",
[IORING_OP_MADVISE] = "madvise\0",
[IORING_OP_SEND] = "sendto\0",
[IORING_OP_RECV] = "recvfrom\0",
[IORING_OP_OPENAT2] = "openat2\0",
[IORING_OP_EPOLL_CTL] = "epoll_ctl\0",
[IORING_OP_SPLICE] = "splice\0",
[IORING_OP_TEE] = "tee\0",
[IORING_OP_SHUTDOWN] = "shutdown\0",
[IORING_OP_RENAMEAT] = "rename\0renameat\0renameat2\0",
[IORING_OP_UNLINKAT] = "unlink\0unlinkat\0rmdir\0",
[IORING_OP_MKDIRAT] = "mkdir\0mkdirat\0",
[IORING_OP_SYMLINKAT] = "symlink\0symlinkat\0",
[IORING_OP_LINKAT] = "link\0linkat\0",
[IORING_OP_FSETXATTR] = "fsetxattr\0",
[IORING_OP_SETXATTR] = "setxattr\0setxattrat\0",
[IORING_OP_FGETXATTR] = "fgetxattr\0",
[IORING_OP_GETXATTR] = "getxattr\0getxattrat\0",
[IORING_OP_SOCKET] = "socket\0",
[IORING_OP_URING_CMD] = "ioctl\0setsockopt\0getsockopt\0getsockname\0",
[IORING_OP_SEND_ZC] = "sendto\0",
[IORING_OP_SENDMSG_ZC] = "sendmsg\0",
[IORING_OP_READ_MULTISHOT] = "read\0",
[IORING_OP_WAITID] = "waitid\0",
[IORING_OP_FUTEX_WAIT] = "futex\0futex_time64\0",
[IORING_OP_FUTEX_WAKE] = "futex\0futex_time64\0",
[IORING_OP_FUTEX_WAITV] = "futex_waitv\0",
[IORING_OP_FTRUNCATE] = "ftruncate\0ftruncate64\0",
[IORING_OP_BIND] = "bind\0",
[IORING_OP_LISTEN] = "listen\0",
[IORING_OP_RECV_ZC] = "recvfrom\0",
[IORING_OP_EPOLL_WAIT] = "epoll_wait\0epoll_pwait\0epoll_pwait2\0",
[IORING_OP_READV_FIXED] = "readv\0preadv\0preadv2\0",
[IORING_OP_WRITEV_FIXED] = "writev\0pwritev\0pwritev2\0",
[IORING_OP_PIPE] = "pipe\0pipe2\0",
[IORING_OP_URING_CMD128] = "ioctl\0setsockopt\0getsockopt\0getsockname\0",
};
DEFINE_STRING_TABLE_LOOKUP_TO_STRING(io_uring_op_syscalls, int);
/* Filters load 32-bit words from struct io_uring_bpf_ctx in native byte order. Flags and mode of an open
* request are 64-bit fields, and all bits that we check are in their lower half. */
+2
View File
@@ -3,6 +3,8 @@
#include "forward.h"
const char* io_uring_op_syscalls_to_string(int i) _const_;
int io_uring_bpf_filter_supported(void);
int io_uring_restrict_ops(Set *ops, bool allow_list);
+29
View File
@@ -1,6 +1,7 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#include <fcntl.h>
#include <linux/io_uring.h>
#include <poll.h>
#include <signal.h>
#include <stdlib.h>
@@ -230,6 +231,34 @@ TEST(filter_sets) {
}
}
TEST(io_uring_op_syscalls) {
_cleanup_hashmap_free_ Hashmap *system_service = NULL;
ASSERT_NOT_NULL(system_service = hashmap_new(/* hash_ops= */ NULL));
if (is_seccomp_available())
ASSERT_OK(seccomp_filter_set_add_by_name(system_service, /* add= */ true, "@system-service"));
for (int op = 0; op < IORING_OP_LAST; op++) {
const char *syscalls = io_uring_op_syscalls_to_string(op);
if (!syscalls)
continue;
NULSTR_FOREACH(name, syscalls) {
log_debug("io_uring opcode %i: %s", op, name);
ASSERT_TRUE(nulstr_contains(syscall_filter_sets[SYSCALL_FILTER_SET_KNOWN].value, name));
if (!is_seccomp_available())
continue;
/* SystemCallFilter=@system-service must not deny any io_uring operation. */
int id = sym_seccomp_syscall_resolve_name(name);
if (id >= 0)
ASSERT_TRUE(hashmap_contains(system_service, INT_TO_PTR(id + 1)));
}
}
}
TEST(filter_sets_ordered) {
/* Ensure "@default" always remains at the beginning of the list */
ASSERT_EQ(SYSCALL_FILTER_SET_DEFAULT, 0);
+28
View File
@@ -49,6 +49,8 @@ IORING_OFF_CQ_RING = 0x8000000
IORING_OFF_SQES = 0x10000000
IORING_OP_NOP = 0
IORING_OP_FSYNC = 3
IORING_OP_READV = 1
IORING_OP_OPENAT = 18
IORING_OP_OPENAT2 = 28
IORING_OP_SOCKET = 45
@@ -103,6 +105,15 @@ if command == "probe":
print("supported" if r < 0 and ctypes.get_errno() == 14 else "unsupported")
elif command == "nop":
submit(IORING_OP_NOP)
elif command == "fsync":
submit(IORING_OP_FSYNC, fd=os.open(sys.argv[2], os.O_RDONLY | os.O_CLOEXEC))
elif command == "readv":
fd = os.open(sys.argv[2], os.O_RDONLY | os.O_CLOEXEC)
buf = ctypes.create_string_buffer(1)
class IOVec(ctypes.Structure):
_fields_ = [("base", ctypes.c_void_p), ("length", ctypes.c_size_t)]
iov = IOVec(ctypes.addressof(buf), 1)
submit(IORING_OP_READV, fd=fd, addr=ctypes.addressof(iov), length=1)
elif command == "socket":
family = {"unix": socket.AF_UNIX, "inet": socket.AF_INET, "inet6": socket.AF_INET6}[sys.argv[2]]
submit(IORING_OP_SOCKET, fd=family, off=socket.SOCK_DGRAM | socket.SOCK_CLOEXEC)
@@ -152,5 +163,22 @@ for op in openat openat2; do
(( $(run python3 "$HELPER" "$op" "$WORKDIR/suid-$op" 4755) >= 0 ))
done
# SystemCallFilter= applies to the io_uring operations that can do the work of the denied system calls.
# The cases set SystemCallErrorNumber=, because python3 itself can make a system call that the filter denies.
# With the default action, the filter would kill python3 before the helper prints the result.
[[ "$(run -p SystemCallFilter=~socket -p SystemCallErrorNumber=EPERM python3 "$HELPER" socket unix)" == "$EACCES" ]]
[[ "$(run -p SystemCallFilter=~socket -p SystemCallErrorNumber=EPERM python3 "$HELPER" nop)" == 0 ]]
(( $(run -p SystemCallFilter=@system-service -p SystemCallErrorNumber=EPERM python3 "$HELPER" socket unix) >= 0 ))
[[ "$(run -p SystemCallFilter=@system-service -p SystemCallFilter=~@network-io -p SystemCallErrorNumber=EPERM python3 "$HELPER" socket unix)" == "$EACCES" ]]
# An allow list denies system calls that are listed with an error number.
[[ "$(run -p SystemCallFilter=@system-service -p SystemCallFilter=~socket:EPERM -p SystemCallErrorNumber=EPERM python3 "$HELPER" socket unix)" == "$EACCES" ]]
# The dynamic loader uses pread64(), so denying preadv() lets Python start.
[[ "$(run python3 "$HELPER" readv /dev/null)" == 0 ]]
[[ "$(run -p SystemCallFilter=~preadv -p SystemCallErrorNumber=EPERM python3 "$HELPER" readv /dev/null)" == "$EACCES" ]]
# A system call that is denied with an error number of 0 pretends to succeed, which io_uring can't do.
[[ "$(run -p SystemCallFilter=~fsync -p SystemCallErrorNumber=EPERM python3 "$HELPER" fsync "$HELPER")" == "$EACCES" ]]
[[ "$(run -p SystemCallFilter=~fsync:0 python3 "$HELPER" fsync "$HELPER")" == 0 ]]
[[ "$(run -p SystemCallFilter=~io_uring_setup -p SystemCallErrorNumber=ENOSYS python3 "$HELPER" nop)" == "setup -38" ]]
# Services that run as an unprivileged user get the filters as well.
[[ "$(run -p User=testuser -p RestrictAddressFamilies=AF_UNIX python3 "$HELPER" socket inet)" == "$EACCES" ]]