DEV Community

Cover image for The Path seccomp Cannot See: Should You Disable io_uring?
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

The Path seccomp Cannot See: Should You Disable io_uring?

When you write a seccomp profile for a server, the equation in your head is simple: this process cannot call openat, therefore it cannot open that file. Clean. And for years it was true.

io_uring quietly erased the equals sign. The process isn't calling openat — it doesn't need to. It drops a structure into a queue, the kernel picks it up and opens the file, and your filter sees nothing at all. You cannot block a system call that is never made.

This piece is the story of that gap. And of what the kernel did in 2026 to close it — and, as a CVE published two weeks ago shows, did not quite manage to.

Google already paid the bill

The most concrete number in this debate is buried in a security blog post. Google's kCTF bug bounty pays researchers for kernel exploits. When they published the program's ledger in 2023, the picture was this: over the past year, 60% of submissions exploited the io_uring component, and the payout for io_uring alone was around one million dollars.

The decision they made is unusually harsh for a performance feature. They disabled io_uring on ChromeOS. On Android, a seccomp-bpf filter cut off app access. And it is disabled on their own production servers.

The reasoning behind the decision is not "io_uring is insecure." Google's own phrasing is more careful: they disabled it while exploring new ways to sandbox it. The issue was not the quality of the code but the fact that it could not be confined — and if you cannot cage something, how well it is written becomes a secondary question.

Why is the filter blind?

Once you see the mechanism, it stops being mysterious. In a normal system call the application arrives at the kernel gate, the seccomp filter stands there, inspects the number and the arguments, and lets it through or doesn't.

With io_uring the application comes to the gate once. The io_uring_enter call passes seccomp — because it is a system call. But the actual payload of that call is the SQEs waiting in a shared-memory queue, and each SQE carries its own opcode: IORING_OP_OPENAT, IORING_OP_CONNECT, IORING_OP_SENDMSG. The kernel reads those opcodes from the queue and dispatches them straight to the relevant handler. They never pass through the system call entry path. As far as your filter is concerned, the work never happened.

Diagram

There is a common misreading here, and I held it myself for a long time: io-wq workers are said to "skip" the filter. The source tells a subtler story. Blocking work is handed off to kernel-side worker threads, and copy_process() calls copy_seccomp() for those threads too — the process's seccomp state is copied, and the worker carries it. The filter does not disappear; it is simply never consulted, because seccomp only runs at the userspace system call entry and a worker doing its work inside the kernel never passes through that gate. Not a separate escape route, but a continuation of the same gap.

⚠️ This is not a bug, it is a consequence of the design

All of io_uring's speed comes from skipping the system call entry path. seccomp stands exactly at that entry. You cannot have both at once — the cost of one is the absence of the other. Until 2026 the kernel did not resolve this tension; it only carried it.

The ecosystem decided before you did

Even if you have never heard this debate, chances are good you are not using io_uring anyway.

Docker's default seccomp profile blocks all three of io_uring_setup, io_uring_enter and io_uring_register. The reason given in the documentation is a single, blunt sentence: blocked due to security vulnerabilities that can be exploited to break out of containers. So if the application inside your container tries to create a ring, it hits a wall on the very first step.

A common misconception follows from this: a team moves to a version with io_uring support, measures, sees no difference, decides it was overhyped and moves on. The feature never engaged; the default profile had already shut the door. Confirm that a thing is running before deciding it doesn't help.

Do not assume the same protection in Kubernetes

Most of the teams that relax because of Docker's default are running Kubernetes, and there the picture is different.

containerd's default profile is also an allowlist, and today there is not a single line mentioning io_uring in the profile file on the main branch. A call absent from an allowlist is blocked — so containerd's RuntimeDefault profile does shut io_uring out. So far, so good.

The profile's content is sound; the real question is whether it is applied at all. The Kubernetes documentation is explicit: when no seccomp profile is specified, containers run Unconfined. Unless you ask for RuntimeDefault, containerd's carefully built list never comes into play. To make the runtime's preferred profile the default you must run the kubelet with the --seccomp-default flag (or seccompDefault: true in the kubelet configuration). The feature has been stable since v1.27 — but stable does not mean self-enabling; you have to turn it on for each node where you want it.

There is a quiet exception as well: a container running with privileged: true cannot have a seccomp profile applied, and stays Unconfined under all circumstances.

Add it up. In a container you started with docker run, io_uring is closed; run the same image as a pod on an unconfigured cluster and it is open. Same image, same kernel, different outcome. If you want one sentence that holds for your whole fleet, build it on kernel.io_uring_disabled; the sysctl has no Unconfined exception.

The off switch, and the footnote nobody reads

Since kernel 6.6 you have had an explicit switch. kernel.io_uring_disabled takes three values, and the documentation is clear:

# See the current setting
$ sysctl kernel.io_uring_disabled kernel.io_uring_group
kernel.io_uring_disabled = 0
kernel.io_uring_group = -1
Enter fullscreen mode Exit fullscreen mode

0 is the default: any process can create rings. 1: io_uring_setup() returns -EPERM for unprivileged processes that are not in the io_uring_group group. 2: disabled for everyone, no exceptions. io_uring_group defaults to -1, so under mode 1 only holders of CAP_SYS_ADMIN get through; give it a group number and that group's members join the list.

Now the footnote. The documentation's one-line but critical warning: existing rings can still be used regardless of the setting. It only blocks new creation.

The practical meaning is irritating. If you type sysctl -w kernel.io_uring_disabled=2 in the middle of an incident and relax, you have fooled yourself. A process that already opened its ring carries on. If you want it permanently, write it under /etc/sysctl.d/sysctl --system applies it immediately, no machine reboot required; the only thing that needs restarting is the process still holding a ring.

What 1 does and does not stop is also narrower than usually written. The check in the kernel is capable(CAP_SYS_ADMIN) — not ns_capable(). That has two consequences: services running as real root keep creating rings freely under mode 1, while the "root" inside a rootless container's user namespace does not get through. So 1 cuts off unprivileged and multi-tenant code; to cover root services as well, your only option is 2.

If you want to see who has opened a ring, io_uring file descriptors live in the kernel as an anonymous inode named [io_uring]:

# List processes holding a ring, with PID and name
$ for p in /proc/[0-9]*; do \
    ls -l "$p/fd" 2>/dev/null | grep -q '\[io_uring\]' \
      && echo "$(basename "$p") $(tr -d '\0' < "$p/comm")"; \
  done
Enter fullscreen mode Exit fullscreen mode

What breaks if you turn it off?

That is the right question, and the answer is smaller than most teams assume.

The most frequently voiced worry is the database side. The asynchronous I/O infrastructure introduced in PostgreSQL 18 is controlled by the io_method parameter, which takes three values: worker, io_uring and sync. Two details here dissolve most of the anxiety. First, the default is worker — not io_uring. Second, the io_uring value only works in a build compiled with --with-liburing (or -Dliburing on the Meson side). So the stock PostgreSQL 18 install that came with your distribution packages does not touch io_uring unless you explicitly ask. I looked at what that infrastructure actually changes under load in an earlier piece on PostgreSQL 18 asynchronous I/O; that table still holds, but the question here is different: what does closing that path cost?

As a general rule, the set of things genuinely leaning on io_uring today is narrow: storage layers chasing high IOPS, some virtualization disk back ends, zero-copy receive paths on the network side, and the cases where you configured them explicitly. None of these engage by accident. On a corporate server fleet, starting with io_uring_disabled=1 genuinely narrows the attack surface in most environments without producing a measurable performance loss.

If it were me, I'd order it like this: measure first, then close. Start at 1, watch for a few days, then move to 2.

There is a trap here, and the advice only works if you avoid it: when io_uring_setup() returns -EPERM, the kernel prints nothing. The error lands in the application's own log, not in dmesg. So "watch for a few days" requires deciding up front what you will watch — either a URINGOP/uring_exit audit rule, or at the simplest, taking the list of processes holding an [io_uring] descriptor before you close anything. Building the inventory afterwards means hunting for whatever quietly broke.

Linux 7.0: locking the door from the inside

This is the actual news, and surprisingly little was said about it.

Linux 7.0, released on 12 April 2026, contains io_uring maintainer Jens Axboe's answer to this long-running criticism. (7.1 has since landed in June and 7.2 in August; the feature was born in 7.0 but now lives in newer kernels.) The new mechanism has two parts. IORING_REGISTER_RESTRICTIONS, when called with -1 instead of a ring file descriptor, attaches restrictions to the process itself rather than to a ring. And a brand-new registration operation arrives — number 37 in mainline: IORING_REGISTER_BPF_FILTER. As the name suggests, a classic BPF filter per opcode.

The behaviour of this pair is deliberately modelled on seccomp. The comment in the kernel source says so outright: similar to seccomp, disallow setting a filter if task_no_new_privs is not set and the caller is not CAP_SYS_ADMIN. Restrictions are inherited by children across fork. Once a restriction set is installed, trying to install a second one returns -EPERM — so a child process cannot loosen itself, it can only tighten further by adding a BPF filter.

It is tempting to call this the kernel reversing course, but that isn't accurate. Processes narrowing their own rights is not a new idea — seccomp-bpf has done exactly this for over a decade, and Landlock since 2021. What is happening here is io_uring catching up to that model, late. The cuffs go on from the inside rather than from an administrator outside, and the key is not handed to the children.

And the first crack

The most honest way to convey how new this mechanism is, is to show its first serious bug.

CVE-2026-80713, published on 28 August 2026, targets exactly this feature. Its description is short and instructive: per-task restrictions were being dropped after exec. For a task that had used io_uring, the exec cancellation path freed the task context and the restriction record along with it, so a ring created after exec was unrestricted. The CVSS 3.1 base score is 8.4 — but that score comes from the Linux kernel CNA, not NVD; as I write this the record is still in Received state, meaning NVD has not run its own analysis.

Picture it: you lock yourself down, then run a binary, and the cuffs fall off by themselves. The fix splits task context cleanup in two and frees only the context on the exec path; the restriction record survives until the task truly ends.

The number that actually matters to you rarely makes the headlines: the io_uring_free_tctx() function carrying the fix is absent from the 7.0 and 7.1.7 trees and present in 7.1.8 and 7.2. So the affected range is 7.0 – 7.1.7, and the fix arrived in 7.1.8 (stable) and 7.2 (mainline). If you are going to production trusting task restrictions, your floor is 7.1.8.

💡 The practical lesson here

A new security mechanism is never as mature as the thing it claims to protect. Linux 7.0's task restrictions are a serious step in the right direction — but it is early to design them as your single line of defence in production. A blunt, old switch like io_uring_disabled breaks less often precisely because it is blunt.

So what does audit see?

There is a consolation I hear often: "if seccomp can't see it, auditd will." Half true.

The kernel does have audit hooks for io_uring operations: audit_uring_entry and audit_uring_exit are called as each operation is processed. So io_uring is not a completely dark tunnel.

But there are two conditions.

The first is scope. The hooks only fire for opcodes that are not marked audit_skip in their operation definition. As I write this, looking at io_uring/opdef.c in the mainline tree, 34 of the 65 defined opcodes have audit_skip set; in the released 7.0 and 7.1 trees the number is 33. A reasonable choice for cutting noise, but the scope of your audit trail is set by that kernel-side decision before your own rules ever apply.

The second hurts more in practice: io_uring operations do not produce SYSCALL records. They have their own record type — AUDIT_URINGOP, number 1336 — and want rules on their own filter list, AUDIT_FILTER_URING_EXIT. The syscall rules you accumulated in auditctl over the years never see these operations at all. Before you say "we log everything," check whether you have a single URINGOP record; most likely you do not.

A decision framework

For your own fleet the answer comes down to three questions.

Is there anything on this machine that needs io_uring? If the answer is "not sure," it is practically "no." io_uring does not engage by accident; everything that uses it wants explicit configuration. Count the [io_uring] descriptors first, then decide.

Is unprivileged, untrusted code running on this system? A multi-tenant environment, a CI runner executing customer jobs, any service running user code — these are where io_uring_disabled=1 wins with the least argument. The logic behind Google's ChromeOS and Android decision transfers here directly.

If you close it, is your way back clear? One sysctl line and a restart of the affected process; sysctl -w kernel.io_uring_disabled=0 reverses it instantly. Security decisions this cheap to undo are rare.

There is also a scenario the sysctl cannot answer: "three services in the fleet need io_uring, I want to cut the rest." A binary switch will not do it. Two real levers exist: io_uring_group, if you can collect the permitted processes into a single group; and the LSM side — io_uring_allowed() ends in a call to security_uring_allowed(), and SELinux backs this with an io_uring class carrying the override_creds, sqpoll, cmd and allowed permissions. Google's own post describes exactly this route when it says future Android releases will use SELinux to limit io_uring access to a select few system processes.

For the specialized workloads outside that set which genuinely need io_uring's speed, 7.0's task restrictions are now on the table. But if you take that route, follow your kernel version closely — August's CVE says this area is still in motion.

Closing

The real lesson of this story is not confined to io_uring. When you break one layer's assumption in order to speed up another, the assumption you broke is often the one your security rests on. We believed seccomp worked because every system call passed through that gate; when io_uring opened a second one, your filter did not become wrong, it merely became incomplete.

The kernel is now fitting a lock to the second gate too, and that is good news. Still, as someone who owns a system running in production, my preference is this: I do not open a door I don't need.

Official Sources

Top comments (0)