$RodHat_
MOTD

io_uring keeps producing the same class of CVE and it's time to call that structural

Published by

A modern server room featuring network equipment with blue illumination. Ideal for technology themes.
Photo: panumas nikhomkhai / Pexels

A security research team published a new set of findings this week identifying privilege-escalation paths in io_uring — specifically in how the kernel handles deferred completions when the process that submitted the operation exits before it finishes. The details are in the pipeline through the usual coordinated disclosure process. The distros are already patching. If you run containers on Linux and haven’t restricted io_uring via seccomp, now is a good time to fix that; if your container runtime has an option to disable it, the answer is yes.

This is not the first time. It will not be the last time. And I want to be precise about why, because “this API has bugs” is a different claim from “this API’s design generates a particular class of bugs at a non-random rate.”

What io_uring actually is, for the people in the back

io_uring was merged into Linux 5.1 in 2019. Jens Axboe designed it as a way to eliminate the syscall-per-operation overhead of the traditional read(2)/write(2)/send(2) interface by giving userspace and the kernel a shared ring buffer in memory: userspace writes submission queue entries (SQEs) into the ring, the kernel reads them, does the work, and writes completion queue entries (CQEs) back into the ring. When everything is in the fast path, the kernel doesn’t have to context-switch for every I/O operation. Benchmarks for io-heavy workloads — databases, network servers, anything where you’re issuing a lot of I/O and traditional polling is the bottleneck — showed real improvements. Not synthetic improvements, real ones.

The performance case is legitimate. I will come back to this.

Why the design generates this particular bug class

The attack surface in io_uring comes from what happens at the edges of the happy path. The ring buffer model works cleanly when the submitting process is alive, the operation completes, the kernel writes the CQE, and everyone goes home. The problems accumulate in the cases where that clean sequence doesn’t happen.

Three of the most productive bug families:

Deferred completions that outlive the submitting process. io_uring can keep an operation running in the kernel even after the process that submitted it exits — the kernel holds a reference and sees it through. This means the kernel is executing on behalf of a process that no longer exists, with credentials and capability context that have to be captured at submission time and held through completion. Getting that capture-and-release dance right across all the code paths that touch it has proven hard. Researchers keep finding cases where a privileged process can be used as the submission vehicle, exit, and leave the kernel executing with capabilities the attacker shouldn’t have inherited.

Fixed buffer registration and memory ownership. io_uring lets you register memory buffers directly with the kernel (io_uring_register) to avoid per-operation memory pinning overhead. The lifetime management of those registrations — who can unregister, what happens to in-flight operations referencing a buffer that gets unregistered, whether you can get the kernel to read or write memory it shouldn’t — is a rich source of use-after-free and type confusion bugs. Memory safety at the kernel level, without the guardrails that userspace gets.

Privilege escalation through multishot and linked operations. io_uring supports linked operations (one SQE fires when the previous CQE lands) and multishot receive (one submission keeps generating completions). These are useful features. They’re also async state machines with complex lifetime semantics, and every transition in that state machine is a place where a wrong assumption about what context you’re executing in produces a vuln.

None of these are implementation bugs in the “someone forgot a bounds check” sense. They’re bugs that arise from the fundamental design: an async interface where operations execute in the kernel asynchronously on behalf of a principal whose state can change between submission and completion. That tension doesn’t go away when you fix the current CVEs. It’s the surface from which the next set of CVEs will emerge.

The performance case is real and I’m not pretending it isn’t

io_uring is not a mistake. The old async I/O interfaces on Linux (aio_read, epoll-based event loops with non-blocking sockets) each had their own failure modes and complexity costs. Databases like PostgreSQL and storage engines like RocksDB have shipped io_uring backends with honest improvements in throughput and latency at high connection counts. The SQLite team tested it. ScyllaDB went all-in on it. These are not cargo-cult benchmarks.

For bare-metal production database workloads where you’re managing the process environment tightly and you control what runs in the same kernel, io_uring is a defensible choice. The attack surface is real but the threat model on bare metal is different — privilege escalation bugs matter most when untrusted code can submit to the ring.

What changes the calculus completely: containers

In a container environment, the threat model inverts. You’re running multiple tenants in the same kernel, and io_uring’s attack surface becomes shared attack surface. This is why Google restricted io_uring in Android in 2023 and why most container security profiles disable it by default. CVE-2022-2585, CVE-2023-2598, CVE-2024-0582 — each generation of io_uring vulns tends to produce at least one container escape with a working public PoC within weeks of disclosure. The people writing these exploits are not slowing down.

The concrete list of what you should do, in rough priority order:

  1. In container runtimes, use the runtime’s seccomp knob. Docker’s default seccomp profile blocks io_uring; if you’re running with --security-opt seccomp=unconfined for any container, you’ve opted back into the full surface. Check your compose files and your orchestrator configurations for that flag and be honest about whether you put it there because you needed it or because something was broken and it was the fast fix.

  2. Know whether your application stack is using io_uring. This is now a non-obvious question. liburing is a library, and things link against it transitively. Recent versions of PostgreSQL (when built with the io_uring backend), some runtimes, some network stacks. If your container is running something that uses liburing, disabling io_uring at the runtime level will either cause the application to fall back gracefully or surface a startup failure — you want to know which before a patch cycle forces the question.

  3. Patch on a short cycle. io_uring CVEs have a consistent pattern of going from disclosure to working public exploit in under a week. The usual “patch within 30 days” posture doesn’t match the exploit timeline for this subsystem. Treat io_uring CVEs the same way you’d treat a privilege-escalation CVE in a widely-deployed network daemon: patch out-of-band if you have to.

  4. Audit your seccomp profiles. If you maintain custom seccomp profiles for your containers, make sure io_uring_setup, io_uring_enter, and io_uring_register are blocked unless you have a specific reason they need to be open. The syscall numbers are 425, 426, and 427 on x86-64. If you’re writing seccomp by hand, add them. If you’re not writing seccomp by hand, make sure your runtime’s default profile is actually being applied and hasn’t been overridden somewhere in your orchestrator configuration.

The research community keeps finding these because the design surface is there to find bugs in. Every round of disclosure produces a round of patches, and the patches are good and should be applied. But this is a subsystem where the correct operational posture is “restrict unless justified,” not “enable unless proven unsafe.”

That’s not a criticism of Jens Axboe’s work. The interface he designed solved a real problem and the performance wins are real. It’s an acknowledgment that some design choices that are right for one deployment context are wrong for another, and container environments are a fundamentally different context than bare-metal processes you control end to end.


For the underlying kernel I/O model that makes all of this make sense — why the process/kernel privilege boundary works the way it does, what “kernel executing on behalf of a process” actually means — The Linux Programming Interface has you covered. Kerrisk’s treatment of the process model and signal handling in particular. For the async I/O chapter specifically, cross-reference with how APUE explains the original POSIX AIO design decisions; the contrast with what io_uring is trying to solve is instructive.

If this post is the first time you’ve thought about your container seccomp posture, also see the supply chain attack post from a few days ago — same principle: the default configuration of widely-used tooling is not your security posture. It’s a starting point that you either harden deliberately or leave at baseline and find out what baseline meant when something goes wrong.