io_uring and sandbox security: the fast path that walks around your syscall filter
io_uring is one of the most impressive pieces of engineering to land in the Linux kernel in the last decade. It is also, for anyone running untrusted code on a shared kernel, a recurring headache — the kind that has led several very large operators to simply turn it off for workloads they don't trust. The reason is almost funny when you say it out loud: we invented an interface whose entire purpose is to let a program do I/O without making a syscall per operation, and then were surprised that the syscall filter stopped seeing the I/O. This post covers the mechanism in detail: what io_uring is, why an opcode-in-a-ring architecture defeats seccomp-style filtering, why it has been such fertile ground for kernel exploitation, how to detect and disable it, what performance you give up, and why a guest-kernel boundary makes the whole question much less interesting.
What io_uring actually is
The traditional Linux I/O model is one syscall per operation: read a file, pay a user-to-kernel transition, an argument copy and a return. Do it a million times and you've paid that a million times. The cost used to be small enough to ignore; after speculative-execution mitigations made kernel entry meaningfully more expensive, and after NVMe made storage fast enough that software overhead became the bottleneck, it stopped being ignorable. io_uring, introduced in Linux 5.1, is the answer: an asynchronous I/O interface built on two ring buffers shared between the application and the kernel.
The application mmaps a submission queue (SQ) and a completion queue (CQ) that the kernel can also see. To ask for work, the application writes a submission queue entry — an SQE, a fixed-size struct describing one operation: an opcode, a file descriptor, an offset, a buffer pointer, a length, some flags, and a user-supplied token — into the submission ring and advances a tail index. The kernel picks entries off the ring, executes them (often on kernel-side worker threads, so the calling task doesn't block), and posts completion queue entries — CQEs, carrying the result and the token — into the completion ring, which the application reaps without a syscall per result. That is the whole idea, and it is a very good idea: a batch of a hundred operations can cost one syscall instead of a hundred.
The ring model: opcodes, not syscalls
The entire io_uring interface is three syscalls. io_uring_setup creates a ring and returns a file descriptor for it. io_uring_register attaches resources to that ring in advance — buffers, file descriptors, eventfds — so later operations can reference them by index instead of by pointer or fd number. io_uring_enter submits queued SQEs and optionally waits for completions. Three doors, and behind them a large and growing menu of opcodes.
That menu is the crux. io_uring started as a storage interface — read and write — and then grew, because the ring model is genuinely useful for anything that blocks. Today the opcode set covers file operations (openat, close, statx, fallocate, renameat, unlinkat, mkdirat), network operations (socket, connect, accept, send, recv, sendmsg, recvmsg), polling, timeouts, splice and tee, and a passthrough command opcode that hands driver-specific commands to a device. In other words: most of the interesting things a process can do to a system are now expressible as an entry in a ring buffer.
- Chaining — SQEs can be linked, so one operation's completion feeds the next. An open can be chained to a read chained to a send, all queued in one batch, with the kernel walking the chain on your behalf.
- Registered files and buffers — io_uring_register pins resources to the ring up front. Later operations refer to slot indices, so the operation's arguments don't necessarily look like the fd numbers or pointers a naive inspector expects.
- Kernel-side execution — many operations are executed by kernel worker threads on behalf of the submitting task rather than inline on the submitting thread. The work happens in kernel context, driven by data the task wrote into shared memory.
- SQPOLL mode — with IORING_SETUP_SQPOLL, a dedicated kernel thread polls the submission ring on its own. The application writes an SQE, advances the tail, and the operation runs with essentially no syscall at all. Not even io_uring_enter. (SQPOLL has carried privilege requirements that shifted across kernel versions — treat it as a mode you should check for, not a mode you can assume is gated.)
Here is the shape of it, deliberately as pseudo-code rather than working code — the point is the structure, not a recipe. Notice that after setup, the file gets opened and read with no openat and no read syscall anywhere in the flow:
# ILLUSTRATIVE PSEUDO-CODE. Not a working program, and deliberately not one.
# The point is the SHAPE of the interface, not a recipe.
# --- Setup: the only syscalls in the whole flow happen here. ---
ring_fd = io_uring_setup(entries=64, params) # syscall #1
sq, cq = mmap_the_two_rings(ring_fd) # shared memory with the kernel
# --- Submission: writing structs into memory. No syscall involved. ---
sqe = sq.next_free_entry()
sqe.opcode = IORING_OP_OPENAT # "open a file"
sqe.fd = AT_FDCWD
sqe.addr = ptr("/etc/some/path")
sqe.open_flags= O_RDONLY
sqe.user_data = 1 # token echoed back on completion
sqe.flags |= IOSQE_IO_LINK # ...and chain the next one to it
sqe2 = sq.next_free_entry()
sqe2.opcode = IORING_OP_READ # "read from what the open returned"
sqe2.buf = my_buffer
sqe2.len = 4096
sqe2.user_data = 2
sq.tail += 2 # publish both entries to the kernel
# --- Submit. In default mode this is one syscall for the whole batch: ---
io_uring_enter(ring_fd, to_submit=2, min_complete=2) # syscall #2
# --- Or, with IORING_SETUP_SQPOLL, a kernel thread is already polling sq.tail,
# so the two operations run with NO syscall here at all. ---
# --- Reap: read CQEs out of shared memory. Again, no syscall. ---
for cqe in cq.drain():
handle(cqe.user_data, cqe.res)
# Net effect: a file was opened and read.
# Syscalls actually issued by this task: io_uring_setup, io_uring_enter.
# Syscalls a filter would need to have seen to stop it: openat, read.
# It never saw them. They were opcodes in a ring.Why syscall filtering loses
seccomp is a filter on the syscall boundary. You install a BPF program, the kernel runs it on every syscall the task makes, and it returns allow, errno, or kill based on the syscall number and (with limits) its raw arguments. It's an excellent design, covered in detail in /blog/seccomp-explained, and it rests on one assumption: that the syscall boundary is where the interesting decisions happen. io_uring quietly retires that assumption.
Work through what a policy actually sees. Your sandbox profile denies openat outside a whitelist, denies connect and socket entirely, denies unlinkat. Good policy. Now the confined process calls io_uring_setup — which your profile allows, because some library in the runtime wanted it, or because the default profile you inherited allows it, or because you never thought about it. From that moment the process can submit IORING_OP_OPENAT, IORING_OP_CONNECT, IORING_OP_SEND and IORING_OP_UNLINKAT as ring entries. Your seccomp filter is never consulted for any of them, because no openat, connect, send or unlinkat syscall is ever issued. The filter isn't being evaded by a clever trick; it is simply not on the path any more.
Be precise about what is and isn't bypassed here, because the sloppy version of this claim is wrong and a kernel-literate reader will call it. io_uring does not bypass ordinary permission checks. An IORING_OP_OPENAT still goes through the VFS and still fails if the file's DAC permissions say no. LSM hooks still run on the underlying operations, and the kernel gained io_uring-specific LSM hooks so SELinux and AppArmor can reason about ring creation, credential override and passthrough commands. What io_uring bypasses is the syscall-level layer: seccomp filtering, syscall auditing, and syscall-based observability. strace shows you a task making io_uring_enter over and over and tells you nothing about what it did. Syscall-hooking EDR goes similarly quiet.
That's the honest framing: io_uring is not a privilege-escalation primitive by itself, it's a policy-and-visibility bypass. If your entire sandbox story was DAC plus a strong LSM policy, io_uring dents your monitoring but not your boundary. If your sandbox story was "we run untrusted code as a confined process behind a seccomp allowlist" — which is the story most container sandboxes tell — then io_uring goes straight through the middle of it.
Why it became an exploit favorite
The filtering problem is only half of it. The other half is that io_uring has had a long run of high-severity kernel bugs, and the reasons are structural rather than accidental.
- It is a lot of new code in a very hot part of the kernel, added quickly and extended continuously. Every new opcode is new attack surface, and the opcode set has grown steadily since 5.1.
- Its core model is asynchronous and reference-counted: operations outlive the submitting call, resources are registered and pinned, requests are chained, cancelled and retried. That combination — async lifetimes plus shared state — is a textbook breeding ground for use-after-free and refcount bugs, which are exactly the bug class that turns into a reliable kernel exploit.
- It is reachable from unprivileged userspace by design. An interface only root can touch is a much less interesting target than one any process can open.
- It sits at a junction: the same subsystem reaches into the block layer, the network stack, the VFS and the credential machinery. Bugs there tend to be leverage-rich rather than dead ends.
- Automated bug-finding took to it enthusiastically. A ring-based ABI with a structured entry format is very pleasant to fuzz, and the results showed up steadily in the CVE stream.
I'm deliberately not quoting a CVE count or naming specific bugs — the number moves, and citing a stale one is worse than citing none. The pattern is what matters, and it was consistent enough that several operators of large multi-tenant fleets publicly restricted or disabled io_uring for untrusted workloads rather than keep pace with it, while some container platforms and hardened distributions default to blocking it. Maintainers have hardened the subsystem substantially in response — but "the exploit surface got better" and "the exploit surface is not worth defending against hostile code" are compatible statements. Check your distribution's current kernel security documentation rather than trusting any blog post's snapshot, this one included.
How to detect and disable io_uring
Three levers, in decreasing order of strength: the kernel isn't built with it, a sysctl kill switch, or a seccomp policy that denies the three syscalls. Start by finding out what you actually have — the answer varies by distribution and kernel, and assuming is how you end up with a policy that doesn't apply.
# 1) Is io_uring even compiled into this kernel?
grep -E '^CONFIG_IO_URING' "/boot/config-$(uname -r)" 2>/dev/null \
|| zgrep -E '^CONFIG_IO_URING' /proc/config.gz 2>/dev/null \
|| echo 'kernel config not readable -- check another way'
# 2) Does this kernel have the kill-switch sysctl?
# It landed relatively recently upstream and has been backported unevenly,
# so TEST FOR THE FILE rather than assuming your kernel has it.
if [ -e /proc/sys/kernel/io_uring_disabled ]; then
cat /proc/sys/kernel/io_uring_disabled
# 0 = io_uring available to everyone (the usual default)
# 1 = restricted: only privileged tasks (CAP_SYS_ADMIN), or members of
# a group named by the companion kernel.io_uring_group sysctl
# where that exists; everyone else gets -EPERM from io_uring_setup
# 2 = disabled entirely: io_uring_setup always fails with -EPERM
else
echo 'no io_uring_disabled sysctl on this kernel -- use seccomp instead'
fi
# 3) Turn it off, now and across reboots.
sudo sysctl -w kernel.io_uring_disabled=2
echo 'kernel.io_uring_disabled = 2' | sudo tee /etc/sysctl.d/99-disable-io-uring.conf
# 4) Who is holding an io_uring instance right now? Ring fds show up as
# anon_inode:[io_uring] in a process's fd table.
sudo ls -l /proc/*/fd/* 2>/dev/null | grep 'anon_inode:\[io_uring\]'
# Do (4) BEFORE (3) on anything you care about. Modern databases, proxies and
# runtimes use io_uring when it's there, and they do not all degrade politely.If the sysctl isn't available on your kernel — or you want the control to live with the workload rather than with the host — deny the three syscalls in seccomp. This is the portable answer, it works on any kernel that supports seccomp-bpf, and it's how container runtimes express the same policy. Note the ordering trap: a profile whose defaultAction is a deny already blocks io_uring unless something explicitly allowlisted it, so the real work is usually auditing what your existing profile permits, not adding a rule.
# A seccomp profile fragment that denies the three io_uring syscalls.
# EPERM (rather than kill) so well-behaved software falls back to epoll/AIO
# instead of dying -- most libraries probe io_uring and degrade gracefully.
cat > no-io-uring.json <<'JSON'
{
"defaultAction": "SCMP_ACT_ALLOW",
"architectures": ["SCMP_ARCH_X86_64", "SCMP_ARCH_AARCH64"],
"syscalls": [
{
"names": ["io_uring_setup", "io_uring_enter", "io_uring_register"],
"action": "SCMP_ACT_ERRNO",
"errnoRet": 1
}
]
}
JSON
docker run --security-opt seccomp=./no-io-uring.json myimage
# NOTE: defaultAction ALLOW above is a DENYLIST, shown this way only to isolate
# the io_uring rule. A real sandbox profile is deny-by-default:
# "defaultAction": "SCMP_ACT_ERRNO" (or SCMP_ACT_KILL_PROCESS)
# ...and then you simply never add io_uring_* to the allowlist. Blocking
# io_uring_setup alone is enough to stop a ring being created -- but block all
# three anyway, so an inherited or otherwise-obtained ring fd is useless too.
# Check what your runtime already does before writing anything: default
# profiles differ between runtimes and versions, and several have changed
# their stance on these three syscalls over time.
docker info --format '{{ .SecurityOptions }}'What you actually give up
Turning io_uring off is not free, and pretending otherwise is how security advice gets ignored. For the workloads that care — high-IOPS storage, high-connection-count network servers, anything doing many small operations — batched submission and completion is a substantial improvement on the epoll-plus-syscalls model. Databases, proxies, object stores and modern async runtimes all reach for it when it's there, and Firecracker itself can use io_uring on the host side to service guest disk I/O more efficiently, which I wrote about in /blog/firecracker-io-uring-block-io.
So the honest cost model has three parts. For I/O-bound server workloads you lose a genuine throughput and latency improvement — the size depends entirely on your I/O pattern, storage and kernel, and anyone quoting you a single multiplier is selling something. For ordinary application code — a web app, a build, a script, most of what a coding agent writes — you lose approximately nothing, because the libraries probe for io_uring, get EPERM, and quietly fall back to the interfaces they used two years ago. And for anything running arbitrary untrusted code on a kernel you share with other tenants, you're trading a performance ceiling you were unlikely to hit for an exploit surface you'd rather not defend. That third case is easy. The first one is a real decision.
Where the boundary sits changes the whole question
Everything above assumes a shared-kernel sandbox: the untrusted code runs as a process on the same kernel as everything else, and syscall filtering is load-bearing. Move the boundary and every line of the analysis changes. Compare the two models directly:
- What io_uring exposes — Seccomp-filtered container: a large opcode menu (file, network, splice, passthrough) reaching the host kernel that all tenants share, from unprivileged code. microVM guest kernel: the same opcode menu, reaching the guest's own kernel — a kernel with one tenant in it.
- What filtering can see — Seccomp-filtered container: io_uring_setup and io_uring_enter, and nothing about the operations inside them; syscall auditing and strace go dark on the actual work. microVM guest kernel: irrelevant to the host boundary, because the host doesn't filter guest syscalls at all — the guest can't make one. The host boundary is VM-exits through KVM, plus a seccomp-confined VMM.
- Performance you keep — Seccomp-filtered container: none, if you disable it — that's the price of the mitigation. microVM guest kernel: all of it. The guest uses io_uring freely because there is no reason to stop it.
- Blast radius of an io_uring kernel bug — Seccomp-filtered container: the host kernel, and therefore every co-tenant on the box. microVM guest kernel: the guest kernel, and therefore that one sandbox. The attacker now owns a VM they were already given.
- Operational effort — Seccomp-filtered container: audit every runtime's seccomp profile, keep the sysctl set fleet-wide, re-audit when profiles or kernels change, and track the kernel's io_uring CVE stream forever. microVM guest kernel: patch the host kernel on a normal cadence, like you already do.
- Who makes the call — Seccomp-filtered container: the platform operator, for everyone, uniformly — and someone's legitimate high-IOPS workload eats the cost. microVM guest kernel: the workload, per sandbox, because its choice can only hurt itself.
That last row is the one I care about most. On a shared-kernel platform, io_uring is a question you must answer once, globally, and badly: disable it and penalize the tenants who'd benefit, or allow it and accept a hole in your syscall filter shaped like the entire opcode table. Neither answer is good, which is why the mitigation guidance for shared-kernel sandboxes is so blunt — turn it off.
Why a guest kernel changes the calculus
When untrusted code runs inside a KVM-backed microVM, it isn't confined by a filter on the host's syscall table — it's confined by the CPU. Guest code cannot make a syscall into the host kernel at all; there is no such path. Its syscalls go to its own guest kernel, and the only way out of the VM is a hardware VM-exit into a small, audited device model. A guest that exploits an io_uring bug in its guest kernel gets root in a kernel that already belonged to it, in a VM that is about to be destroyed. It has escalated from "can run code in this sandbox" to "can run code in this sandbox."
This is why the layering matters more than any individual mitigation. seccomp is still in the picture in a microVM stack — but it's applied where it's strong, confining the VMM process on the host, backstopping the thin host-side surface underneath KVM rather than trying to be the whole boundary. That's the difference between using a syscall filter as defense in depth and staking multi-tenant safety on it. And it's why the io_uring question, which is genuinely hard for a shared-kernel sandbox, is close to a non-question for a microVM one.
This is the model PandaStack runs on: every sandbox, managed database and hosted app is its own Firecracker microVM with its own guest kernel, restored from a snapshot in about 179ms at p50 (203ms p99; a first cold boot with no snapshot yet is around 3s). Guest workloads use io_uring or don't, entirely at their own discretion, because the consequences are contained to the VM that made the choice. The host side is where the discipline lives — a minimal VMM under a strict seccomp filter and a privilege-dropping jailer. I'd rather spend my paranoia on a small, stable host surface than on chasing a fast-moving kernel subsystem's CVE stream across a fleet.
The honest summary
io_uring is not evil, and treating it as such does the kernel developers a disservice — it's a well-designed answer to a real problem, hardened considerably since the worst of its bug run. For trusted, first-party, I/O-bound workloads it's a legitimate performance win and you should use it.
For untrusted code — user submissions, AI-generated programs, anything you didn't write — on a kernel you share with other tenants, the calculus flips. A syscall filter is your boundary, io_uring is specifically an interface that stopped using syscalls, and the honest options are to turn it off (sysctl where you have it, seccomp everywhere else, and audit what your runtime's default profile actually permits) or to move the boundary so that the kernel being attacked is a guest kernel that only that workload owns. Do one of those two things. Doing neither, and assuming a seccomp allowlist that permits io_uring_setup is still enforcing your file and network policy, is the failure mode this whole post exists to describe. For the filtering primitive itself, read /blog/seccomp-explained; for why the shared-kernel model has this class of problem generally, /blog/why-docker-is-not-a-sandbox. The PandaStack core is open source under Apache-2.0, so you can read the host-side filters and run the whole stack on your own KVM hosts.
Frequently asked questions
Does io_uring bypass seccomp?
In the sense that matters for sandboxing, yes. seccomp filters syscalls, but io_uring operations aren't syscalls — they're opcodes written into a shared memory ring that the kernel reads and executes. Once a process has created a ring via io_uring_setup, it can submit file opens, reads, writes, socket creation, connects and sends as ring entries, and the seccomp filter is never consulted for any of them because no corresponding syscall is issued. What is not bypassed is ordinary permission checking: DAC file permissions and LSM hooks still apply to the underlying operations, and the kernel gained io_uring-specific LSM hooks. So io_uring is a syscall-filtering and syscall-visibility bypass, not a permission bypass.
How do I disable io_uring on Linux?
There are three levers. Strongest: run a kernel built without io_uring support at all. Next: the kernel.io_uring_disabled sysctl, where your kernel has it — 0 allows it for everyone, 1 restricts it to privileged tasks (or a group named by a companion sysctl), and 2 disables it entirely so io_uring_setup always returns EPERM. It landed relatively recently upstream and has been backported unevenly, so test for /proc/sys/kernel/io_uring_disabled rather than assuming. Most portable: a seccomp policy that denies io_uring_setup, io_uring_enter and io_uring_register — blocking setup alone stops new rings, and blocking all three also neutralizes a ring file descriptor obtained some other way. Check which processes currently hold ring fds (they appear as anon_inode:[io_uring]) before flipping anything in production.
Why did large operators disable io_uring for untrusted workloads?
Two reasons that compound. First, io_uring has had a long run of high-severity kernel bugs — it's a large, fast-growing body of new code in a hot path, its asynchronous reference-counted design is fertile ground for use-after-free and refcount bugs, and it's reachable from unprivileged userspace by design. Second, it defeats the syscall-filtering sandboxes that shared-kernel platforms rely on, so the usual mitigation of narrowing the allowlist doesn't help. Facing that combination across a large multi-tenant fleet, several major operators restricted or disabled it for code they don't trust rather than trying to keep pace with the bug stream. The subsystem has been hardened significantly since; check your distribution's current kernel security documentation rather than any blog post's snapshot.
What performance do I lose by disabling io_uring?
It depends entirely on the workload. For I/O-bound servers doing many small operations — high-IOPS storage, high-connection-count network services, databases, proxies — you give up genuine throughput and latency gains from batched submission and completion, and the magnitude depends on your I/O pattern, storage and kernel; be suspicious of anyone quoting a fixed multiplier. For ordinary application code — web apps, builds, scripts, most AI-generated programs — you lose approximately nothing, because libraries probe for io_uring, receive EPERM, and fall back to epoll or ordinary blocking I/O. That asymmetry is why disabling it for untrusted workloads is usually an easy trade and disabling it fleet-wide for trusted first-party services is a real decision.
Is io_uring safe inside a microVM?
It's a much smaller problem, because the kernel a guest attacks is its own. In a KVM-backed microVM the guest cannot make a syscall into the host kernel at all — its syscalls go to the guest kernel, and the only exit is a hardware VM-exit into a small, audited device model. A guest that exploits an io_uring bug in its guest kernel gains root in a kernel that only that sandbox owns, inside a VM that gets destroyed anyway, so the blast radius doesn't grow. That's why a microVM platform can let guests use io_uring freely for the performance while a shared-kernel platform has to make a global disable-or-accept decision. The host side is where the discipline belongs: a minimal VMM under a strict seccomp filter and a privilege-dropping jailer.
Keep reading
- seccomp explained: filtering syscalls to shrink the kernel attack surface — The filtering primitive io_uring walks around — and where it's still strong.
- Firecracker's io_uring block backend explained — The same interface used well: host-side batching of guest disk I/O.
- Why Docker is not a sandbox — Why the shared-kernel model has this whole class of problem.
- gVisor syscall interception explained — Another answer to the syscall boundary — and what it costs.
49ms p50 cold start. Fork, snapshot, and scale to zero.