$RodHat_
Console Tips

io_uring's attack surface and IORING_REGISTER_RESTRICTIONS

Published by · Updated

io_uring's attack surface and IORING_REGISTER_RESTRICTIONS
Photo: AI-generated — no human photographer / RodHat AI Cover

io_uring shipped in Linux 5.1, May 2019. The pitch: a shared-memory ring buffer between userspace and kernel so I/O operations can be submitted and completed without a syscall per operation once the ring is up. Axboe’s benchmark numbers were real. Throughput on I/O-bound workloads improved, and the approach is genuinely clever: two mmap’d rings (submission queue, completion queue), a single io_uring_enter to flush a batch, optional SQPOLL mode to eliminate even that syscall.

Then the CVEs started: CVE-2022-29582, CVE-2022-2639, CVE-2022-3910, CVE-2023-2598, CVE-2023-6817. Most are use-after-free or type confusion in the kernel paths that execute submitted operations. Several have public working exploits. Jann Horn at Google Project Zero documented multiple root-from-unprivileged escalations. Android 13 disabled io_uring. Google’s production fleet disables it on machines that have no operational need for it. ChromeOS disables it.

This is what a shared-memory async kernel interface looks like when the kernel-side implementation has complexity bugs: you get privilege escalation from userspace with no execve in the chain. That means seccomp filters that allow io_uring_enter but block obvious escalation syscalls miss the whole attack. The interface is technically interesting and the throughput numbers are real. The default configuration for a general-purpose server is still wrong.

The sysctl knob

Mainline grew a kernel.io_uring_disabled sysctl in Linux 6.6, and some distributions backported it to older kernels:

sysctl kernel.io_uring_disabled
# kernel.io_uring_disabled = 0

Values:

  • 0: everyone can create io_uring instances. Default.
  • 1: restricted. Only processes with CAP_SYS_ADMIN, or members of the group named in kernel.io_uring_group, can create instances.
  • 2: disabled entirely. io_uring_setup returns EPERM.

For servers that have no operational use for io_uring:

sysctl -w kernel.io_uring_disabled=2
echo 'kernel.io_uring_disabled=2' >> /etc/sysctl.d/99-io-uring.conf

If some of your own services use io_uring, set it to 1 and run those services under a dedicated account with CAP_SYS_ADMIN scoped through the capabilities model, rather than leaving the interface open to all UIDs on the box.

Older kernels won’t have it unless your distro backported it. Check whether yours carries it:

sysctl kernel.io_uring_disabled 2>/dev/null || echo "sysctl not present"

On systems where it is absent (vanilla kernels older than 6.6, distro kernels that never backported it), io_uring is open by default. A seccomp filter that blocks SYS_io_uring_setup in processes that do not need it is the fallback.

The GID restriction (Linux 6.8)

Linux 6.8 added kernel.io_uring_group upstream:

sysctl kernel.io_uring_group
# kernel.io_uring_group = -1

When set to a valid GID, only processes in that group (or with CAP_SYS_ADMIN) can call io_uring_setup. Value -1 disables the check. Pair it with a dedicated group for services that legitimately need io_uring:

groupadd io_uring_allowed
usermod -aG io_uring_allowed myservice
sysctl -w kernel.io_uring_group=$(getent group io_uring_allowed | cut -d: -f3)

On kernels before 6.8 this sysctl does not exist.

IORING_REGISTER_RESTRICTIONS

The sysctl knobs control who can create rings. Once a ring exists, IORING_REGISTER_RESTRICTIONS (Linux 5.10) controls what the ring can do. This is the interface you use when you create a ring in a trusted process and then hand the fd to something less trusted: a sandboxed worker, a plugin subprocess, code that arrived over the network.

#include <liburing.h>
/* or include <linux/io_uring.h> for the raw structs without liburing */

struct io_uring_restriction restrictions[] = {
    /* Allow only read and write ops */
    { .opcode = IORING_RESTRICTION_SQE_OP, .sqe_op = IORING_OP_READ },
    { .opcode = IORING_RESTRICTION_SQE_OP, .sqe_op = IORING_OP_WRITE },
    /* No IORING_RESTRICTION_REGISTER_OP entries = no register ops allowed */
};

int ret = io_uring_register(ring.ring_fd,
                            IORING_REGISTER_RESTRICTIONS,
                            restrictions,
                            sizeof(restrictions) / sizeof(restrictions[0]));
if (ret < 0) {
    errno = -ret;
    perror("IORING_REGISTER_RESTRICTIONS");
    exit(1);
}

/* From this point: SQEs with any opcode other than READ or WRITE
   are rejected with -EACCES. io_uring_register calls are rejected. */

This is a one-way door. Restrictions cannot be widened or removed after they are applied. The ring fd can be passed to a sandboxed child via SCM_RIGHTS and that child can only submit READ and WRITE. Any attempt to submit a IORING_OP_SOCKET, register a buffer, or set up SQPOLL comes back as EACCES in the CQE.

The four restriction opcodes in struct io_uring_restriction:

  • IORING_RESTRICTION_REGISTER_OP: allow a specific IORING_REGISTER_* operation. Absent means blocked.
  • IORING_RESTRICTION_SQE_OP: allow a specific IORING_OP_* operation. Absent means blocked.
  • IORING_RESTRICTION_SQE_FLAGS_ALLOWED: bitmask of IOSQE_* flags SQEs are permitted to set. Bits outside the mask cause rejection.
  • IORING_RESTRICTION_SQE_FLAGS_REQUIRED: bitmask of IOSQE_* flags that must be set on every SQE.

That last one is the interesting bit. Requiring IOSQE_FIXED_FILE forces the child to use pre-registered fds only, which means it cannot open new arbitrary file descriptors by number. A sandboxed file processor that should only read and write pre-opened fds:

struct io_uring_restriction restricted_ops[] = {
    { .opcode = IORING_RESTRICTION_SQE_OP, .sqe_op = IORING_OP_READ_FIXED },
    { .opcode = IORING_RESTRICTION_SQE_OP, .sqe_op = IORING_OP_WRITE_FIXED },
    {
        .opcode = IORING_RESTRICTION_SQE_FLAGS_REQUIRED,
        .sqe_flags = IOSQE_FIXED_FILE,
    },
};

This allows only fixed-buffer reads and writes, requires the fixed-file flag on every SQE. The child cannot register additional buffers, cannot open files by descriptor number, cannot submit network operations, cannot do anything outside those two op types.

SQPOLL needs its own ring

IORING_SETUP_SQPOLL spawns a kernel thread that polls the SQ ring without the application calling io_uring_enter. Zero-syscall I/O at the cost of a core. It requires CAP_SYS_ADMIN on pre-5.11 kernels (or a non-user-namespaced context).

The SQPOLL thread runs in the kernel on behalf of the ring and inherits the ring’s submission context. A vulnerability in the SQPOLL path is a kernel thread executing attacker-controlled I/O asynchronously. Do not share a SQPOLL ring with untrusted code. If your service uses SQPOLL internally and also needs to pass a ring to an untrusted component, create two separate rings: one SQPOLL ring entirely internal to the trusted process, one non-SQPOLL ring with restrictions for the untrusted component.

IORING_SETUP_SINGLE_ISSUER (Linux 6.0) enforces that only one thread submits SQEs to a ring. The kernel returns EEXIST if a second thread tries to submit. Primarily a correctness flag, but it also tightens the attack surface of the submission path.

The seccomp gap

Seccomp-bpf filters at the syscall boundary. The problem with io_uring is that once a ring is set up, all I/O operations go through a single io_uring_enter call (or through the SQPOLL kernel thread, which generates no userspace syscall at all). A seccomp filter sees one syscall. It cannot see which operations are packed into the SQE ring.

You can block SYS_io_uring_setup entirely (correct for processes that should never touch io_uring). You can allow SYS_io_uring_enter and rely on IORING_REGISTER_RESTRICTIONS to gate what the ring does. What you cannot do is write a seccomp rule that distinguishes “io_uring read” from “io_uring socket” without restrictions set at the ring level. The two layers complement each other; neither replaces the other.

If you pass a restricted ring fd to an untrusted process via fd passing, that process needs no io_uring_setup at all: it only calls io_uring_enter on the fd you gave it. A seccomp policy that blocks io_uring_setup in the child does nothing to prevent use of a pre-created ring. Apply restrictions before the fd leaves the trusted process, then apply PR_SET_NO_NEW_PRIVS and a seccomp filter in the child that limits what syscalls it has access to beyond io_uring_enter.

Landlock is the right complement for filesystem scope: use it in the parent to constrain which paths the pre-registered fds were opened from before the fd set is handed off.

If you are not using io_uring

Set kernel.io_uring_disabled=2 in /etc/sysctl.d/ and add SYS_io_uring_setup to your seccomp blocklist for services that should not create rings. You lose the throughput gains and you lose five years of escalation CVEs. That trade is correct unless you have benchmarks showing io_uring is actually your bottleneck.

Brendan Gregg’s BPF Performance Tools (book) is where to start on profiling async I/O performance before wiring in an interface that has given the kernel security team recurring headaches. Measure first, then decide whether the risk/reward makes sense for your specific workload.