$RodHat_
MOTD

io_uring shipped in 2019 to eliminate syscall overhead. It also became the most reliably exploited surface in the kernel. Production fleets are disabling it.

Published by

io_uring shipped in 2019 to eliminate syscall overhead. It also became the most reliably exploited surface in the kernel. Production fleets are disabling it.
Photo: AI-generated — no human photographer / RodHat AI Cover

Jens Axboe sent the first io_uring patches in 2018. Linux 5.1 merged them in May 2019. The problem it solved is real: for high-throughput I/O workloads, one syscall per operation is not free. read(2), write(2), sendmsg(2) all cross the user/kernel boundary individually. On a storage backend doing a million operations per second, that crossing shows up in profiling. epoll with non-blocking descriptors handles concurrency but does not remove the per-operation syscall cost. Linux’s aio(7) existed but had enough rough edges and incomplete async coverage that serious workloads mostly avoided it.

io_uring’s answer was two ring buffers shared between kernel and userspace: a submission queue where userspace posts operation descriptors, and a completion queue where the kernel posts results. One io_uring_enter(2) call submits a hundred operations and harvests completions without one syscall per item. IORING_SETUP_SQPOLL goes further and spins a dedicated kernel polling thread on the submission queue, eliminating the enter syscall entirely for latency-sensitive workloads at the cost of a CPU core spinning. Registered fixed buffers and fixed file sets reduce copy overhead further. The benchmarks Axboe published were not fabricated.

It also became one of the more productive kernel attack surfaces in recent memory.

Why the attack surface is large

The design puts the kernel in a position of interpreting an operation queue written by userspace and executing those operations at kernel privilege. That interpretation layer touches a large fraction of the kernel’s internals. File I/O, network I/O, splice, timeouts, cancellation, fixed-interval polls, async process operations: io_uring was explicitly designed as a unified async interface across all of them. Every subsystem it integrates with is a potential path for a malformed operation descriptor to confuse the kernel.

The cancellation path has been particularly productive for researchers. When an operation is in-flight and the caller cancels it, the kernel has to find the in-progress request, pull it out of whatever queue it is sitting in across whatever subsystem it was executing against, and clean up correctly. Do that wrong and you get use-after-free, reference count corruption, or credential confusion, depending on where the operation was when the cancel arrived. io_uring has had bugs in all three categories.

Credential handling across async operations is a separate class. An io_uring operation submitted by a privileged process can outlive the process’s privilege change. Getting the credential snapshot right at submission time versus execution time, across fork and exec, turns out to be harder than it looks. The kernel has patched this area multiple times.

This is not unusual for a kernel subsystem that touches everything. It is unusual for a subsystem five years old.

What the operational response has been

Google disabled io_uring in Android’s seccomp filter profiles. Chrome’s renderer processes block io_uring_setup(2). Google’s internal Linux production fleet moved to deny it by default for the bulk of their workloads. This is not a reaction from people who misunderstand the interface. Google’s kernel team contributed to io_uring. They ran the cost-benefit calculation on the security track record and made a deliberate call.

Ubuntu followed with a sysctl: kernel.io_uring_disabled. Value 0 is the original behavior. Value 1 restricts io_uring_setup(2) to processes with elevated privilege. Value 2 disables it entirely. Ubuntu started shipping with 1 as the default.

The Docker default seccomp profile and the Kubernetes default seccomp profile both block io_uring_setup(2). A container running with the default seccomp configuration gets EPERM when it calls io_uring_setup. Applications that treat io_uring as an optional fast path fall back silently. Applications that require it fail at startup. Those are the interesting ones to find in your environment.

The performance case is still real for specific workloads

SQLite added an io_uring VFS backend. PostgreSQL has an experimental io_uring path for direct I/O against tablespace files. The Tokio async runtime in Rust has a tokio-uring crate for workloads that can commit to the interface. High-frequency network servers where epoll overhead is measurable use it. Storage backends doing hundreds of thousands of IOPS per core see the difference.

The question is whether your workload is one of them. For most services running in containers on cloud infrastructure, the bottleneck is not per-syscall overhead. It is disk throughput, network bandwidth, or application logic. On those systems, restricting io_uring costs nothing measurable and removes a demonstrated attack surface. That is a straightforward trade.

What to do on an existing system

Check what you have running. grep -r io_uring_setup /proc/*/syscall will not get you far, but strace -e trace=io_uring_setup -p <pid> will tell you whether a process is actively calling it. For a broader survey across a fleet, the seccomp-bpf audit approach gives you per-process syscall telemetry without blocking anything first.

If you are not on Ubuntu or a distribution that has shipped kernel.io_uring_disabled already, you can set it manually:

sysctl -w kernel.io_uring_disabled=1

Add it to /etc/sysctl.d/99-io-uring.conf to survive reboots. Value 1 means unprivileged processes cannot call io_uring_setup. Anything running as root or with the appropriate capability can still use it. For a general-purpose server running containerized services owned by non-root uids, that is the right default.

For services that genuinely need io_uring, the right architecture is to run them with an explicit seccomp profile that allows io_uring_setup(2) for that specific process, drop all other unnecessary syscalls, and set the minimum required capabilities via the Linux capability model rather than running as root. That is more work than leaving the default permissive, but it is what principle of least privilege means in practice.

The broader pattern

io_uring is the same story as a lot of powerful kernel interfaces: the feature is real, the performance is real, and the security review did not keep up with the design ambition. The kernel community has patched the individual bugs. The operational response of “restrict by default, require explicit opt-in” is the correct answer when the bug count in a subsystem reaches a certain level.

The OpenSSH DSA removal and the GnuPG RFC 9580 deployment are both examples of the same forcing function from the crypto side: things that accumulated enough operational debt eventually get restricted or removed, not because the original design was stupid, but because the maintenance cost and the attack surface stopped being worth it.

io_uring is not being removed. It is being gated. That is the appropriate response for a subsystem that is genuinely useful for a specific class of workload and genuinely dangerous as a default-on interface for everything else.

Check your kernel.io_uring_disabled setting. If it is 0 and you cannot name a specific workload that benefits, set it to 1. You will not notice the difference and neither will your users.

Sources

  1. Efficient IO with io_uring (Jens Axboe / kernel.dk)
  2. io_uring_setup(2) Linux man page (Michael Kerrisk / man7.org)