io_uring Reads Your Files and Opens Sockets Without Calling the Syscalls Your auditd Rules Watch
On a Linux host where io_uring is enabled, a process can open a file, read it, connect out to an external IP, and write the response back to disk without issuing a single one of the traditional file and network syscalls — openat, read, connect, write — that your auditd rules and most EDR hooks are built to watch. It still makes system calls; just not those. Creating the ring is itself a syscall, which is exactly the seam we’ll hang the detection on. That is io_uring working exactly as designed, and it is the blind spot ARMO turned into a working proof-of-concept rootkit called Curing. If your Linux detection strategy rests on watching those operation syscalls — auditd -S openat, an eBPF sensor hooking the syscall entry points — a program that routes its I/O through io_uring is transacting underneath all of it. Seccomp is the exception worth stating precisely: it can shut this down cold by denying the io_uring syscalls themselves, but a profile that blocks openat or connect while still permitting io_uring enforces a boundary the ring walks straight through — it doesn’t evaluate each queued operation as if it were the equivalent syscall. The kernel still does the work. Your syscall-entry telemetry just never sees the equivalent operation request.
TL;DR
- io_uring routes file and network I/O through shared ring-buffer opcodes (OPENAT, READ, CONNECT, SEND) that never dispatch the corresponding openat/read/connect/send syscalls auditd rules and syscall-hooking EDR watch, letting a process — ARMO’s Curing PoC — read files and communicate without appearing in telemetry tied to those traditional syscall entry points (deeper layers like fanotify/FIM, LSM hooks, and network-flow monitoring can still see it). io_uring has shipped since kernel 5.1, but the opcodes an attacker actually gets depend on kernel version and backports (the network ops like IORING_OP_SOCKET landed around 5.19); SQPOLL mode can eliminate most io_uring_enter calls after setup while the polling thread stays active, so a busy process stays nearly silent on the operation syscalls.
- The simplest, lowest-volume signal is io_uring_setup(2) — the one syscall that must run to create a ring — so audit it (-S io_uring_setup) and alert on any caller not on your allowlist. It’s a creation-time signal, not proof of what flowed through the ring, and it can’t see a ring created before your rules loaded; the legitimate baseline is small on many server classes but measure it rather than assuming near-zero (a call from /tmp, www-data, or an sshd session is a strong lead, not a verdict).
- Two blind spots to bake in: a ring FD is inherited across fork and can survive exec if its default close-on-exec flag is cleared, or be passed over a Unix socket — so correlate to process lineage rather than the calling PID; and the whole detection assumes the kernel audit subsystem is enabled, the relevant rules are loaded, the backlog doesn’t overflow, and auditd consumes and ships the records — a boot race, backlog overflow, or audit=0 silently defeats it.
- The real fix is least-functionality removal via sysctl kernel.io_uring_disabled=2 (upstream since kernel 6.6). RHEL 9.3 and later ship io_uring as a disabled-by-default Technology Preview with this set to 2, so on those systems the job is verifying nobody flipped it to 0/1; on distros that ship io_uring enabled (Ubuntu among them) setting it to 2 is the action. It blocks new rings but does not kill rings already open, so apply it at provisioning or early boot. Google reported io_uring as ~60% of submissions to its kernel exploit-reward program over the preceding year and disabled or restricted access across ChromeOS, Android app sandboxes, and its own production servers; turning it off takes a whole class of memory-corruption bugs off the table for anything that can’t reach a ring.
- Operation-level visibility needs a separate instrumentation path — an ordinary -S openat syscall rule does NOT see IORING_OP_OPENAT. Modern Linux Audit has a dedicated always,io_uring rule list for selected opcodes (openat/connect/etc., ~kernel 5.16+, deliberately NOT the read/write data path), and LSM/KRSI BPF hooks (Falco announced after the ARMO disclosure; Tetragon where BPF-LSM is enabled) cover the rest or add context. Docker’s default seccomp profile and containerd’s RuntimeDefault now block the syscalls — but only for workloads actually running under them, not unconfined/privileged/custom-profile containers or host processes — so the remaining exposure is bare-metal and VM hosts where the kernel default stays permissive.
This is not a CVE you patch. Upstream, io_uring ships enabled on mainline kernels since 5.1, and many distributions — Ubuntu among them — carry that default forward, so the mitigation is a configuration decision most shops have never consciously made. Stock RHEL 9 is the important exception: Red Hat ships io_uring as a Technology Preview with kernel.io_uring_disabled=2 (creation disabled for all processes) by default, so the technique lands on a RHEL 9 box only if an administrator or a product has deliberately enabled the interface. If you run RHEL, your first move is to confirm nobody has.
Why the syscalls go missing
io_uring is an asynchronous I/O interface built around two ring buffers shared between userspace and the kernel: a submission queue (SQ) and a completion queue (CQ). A process fills the submission queue with operations it wants performed, the kernel drains them and posts results to the completion queue. The whole point — the reason ScyllaDB and a growing number of high-throughput storage engines have adopted or evaluated it — is that you can submit hundreds of I/O operations without paying the syscall entry-and-exit overhead for every individual one.
The security consequence falls straight out of that design. Each entry in the submission queue carries an opcode, and the opcode set is enormous. ARMO counted 61 operations, covering the file and network primitives that matter to both a database and a piece of malware: IORING_OP_OPENAT, IORING_OP_READ, IORING_OP_WRITE, IORING_OP_CONNECT, IORING_OP_SEND, IORING_OP_RECV, IORING_OP_SOCKET. When a process reads a file through IORING_OP_READ, the kernel never dispatches through the read(2) syscall path. So a sensor that hooks sys_enter_read sees nothing. The read happened; the accounting did not.
Push that further with SQPOLL mode. Set IORING_SETUP_SQPOLL at ring creation and a dedicated kernel thread polls the submission queue on your behalf, which means that while the polling thread stays awake the process can drive continuous I/O with few or no io_uring_enter(2) calls to kick the kernel. At that point the syscall stream from a busy, actively-exfiltrating process can be nearly silent.
ARMO ran Curing against the usual suspects. Falco missed it outright at the time of the research. Microsoft Defender for Endpoint on Linux is built largely the same way — most of its eBPF hooks watch syscalls, precisely the wrong layer here — so Curing’s io_uring-routed operations slipped past its syscall telemetry. One thing did fire, and it’s worth stating so the point isn’t overclaimed: ARMO reported that Defender’s File Integrity Monitoring still flagged the file modifications — with and without io_uring, so likely through fanotify rather than syscall hooks — while the syscall-oriented detections stayed blind. Microsoft hasn’t publicly validated ARMO’s architectural read. Tetragon could catch it but only if you’d already gone and written kprobe or LSM policy for the io_uring paths, which nobody does by default. This is the loudest part of the finding: the gap is an architectural assumption baked into how Linux endpoint security got built, common to every vendor that leaned on syscall hooking.
The creation signal you can catch first
Here’s the part that keeps this tractable. Every io_uring instance has to be created, and creation goes through exactly one syscall: io_uring_setup(2). SQPOLL, all 61 opcodes, the whole submission machinery — no userspace-accessible ring exists until some process calls io_uring_setup. That makes it your simplest, lowest-volume signal, and the thing to instrument first — with two honest limits worth naming up front: it tells you a ring was created, not what later flowed through it or which process ultimately drove the I/O, and it can’t retroactively catch a ring that was set up before your audit rules loaded. (It’s not auditd’s only option — modern Linux Audit can instrument selected ring operations too, covered below — but the setup call is the cheapest place to start and the one signal that fires even for opcodes nothing else hooks.)
On an auditd host, the rule is boring and that is the point:
-a always,exit -F arch=b64 -S io_uring_setup -k io_uring_create
The arch=b64 filter selects the host’s 64-bit syscall ABI — auditd auto-detects the machine family, so the same b64 rule is correct on x86-64, aarch64, ppc64le, or s390x; it’s an ABI selector, not a CPU name. Add a separate arch=b32 rule only where a compatible 32-bit userspace ABI is supported and in scope, which on a 2026 fleet you probably don’t run — but check before you assume. Ship the io_uring_create key to your SIEM and the detection logic is almost embarrassingly simple: alert on any process calling io_uring_setup that isn’t on your allowlist.
The reason that works is volume — but measure it, don’t assume it. On many general-purpose server classes — a web front end, a jump host, a domain-joined RHEL box running some vendor agent — the legitimate io_uring_setup baseline is small; this isn’t a syscall that fires in the background noise the way openat or stat do. ARMO’s own caveat is worth heeding, though: in environments where new or updated software lands regularly, naive io_uring alerting can throw a lot of false positives, so baseline your fleet before you trust the “near-zero” assumption. In Splunk, if you’re ingesting the audit sourcetype, something in the shape of:
sourcetype=linux:audit key=io_uring_create
| stats count values(exe) by host, comm, auid
should return a short, nearly-static list per host once you’ve done the first pass. The field you care about is exe (the resolved binary path) and comm (the process name), and the tell is a value you don’t recognize — a setup call from /tmp, from a web server’s worker under www-data, from a shell, from anything that has no expected reason to use io_uring-mediated asynchronous I/O. A previously unseen setup call from a PHP-FPM child, an interactive shell, or a process launched over SSH deserves high severity unless that executable is explicitly expected to use io_uring — treat it as a strong anomaly to run down, not proof of compromise on its own.
One attribution caveat to bake into the rule: a ring’s file descriptor is inherited across fork. It normally does not survive an exec, because io_uring FDs are created with the close-on-exec flag (FD_CLOEXEC) set — but a process that clears that flag before execve can carry the ring through, and either way the descriptor can be handed to another process over a Unix socket. So the io_uring_setup event may land on a parent or a helper binary while a different process drives the actual I/O. Correlate the setup call to the process lineage rather than trusting the calling PID.
What the first week of tuning actually looks like
The allowlist is the whole game, and building it is where teams stumble.
Legitimate io_uring users exist and they are not exotic anymore. ScyllaDB leans on it hard. A storage engine configured to use liburing will normally call io_uring_setup, often during initialization. Node.js is the one that trips people up — libuv wired io_uring in for filesystem operations, then walked it back to off-by-default after the security fallout, so whether a given Node service triggers your rule depends on the runtime version and whether someone set UV_USE_IO_URING. (The behavior moved between libuv releases; verify against the version actually on the box rather than trusting a blog, this one included.) Some proxies and userspace network stacks use it too.
So the likely first-week pattern on a stable host class: you enable the rule, the first pass surfaces only a handful of hits, and most of them are known daemons starting up. You tag those exe paths as expected, and the alert goes quiet. Expect the allowlist per host class to be tiny — often a single entry, sometimes zero. If it’s growing past a handful, you have an inventory problem worth solving anyway, because you’ve got software using an alternate kernel I/O submission path that nobody catalogued.
In a stable server fleet, the false positives that survive tuning are mostly startup-time activity from known daemons. The genuinely nasty case is the inverse — the process that calls io_uring_setup and then goes quiet on every other syscall you’d expect from something doing real work. A binary that set up a ring, and whose subsequent openat/connect/write volume is near zero relative to its network or disk activity, is worth a closer look. That correlation is harder to express as a clean SIEM rule and you’ll probably run it as a hunt, not an alert, because the join across syscall-present and syscall-absent telemetry gets expensive at fleet scale and the retention cost of keeping full syscall streams hot is real.
One coverage gap to name plainly: this all assumes the audit path is actually intact end to end. On a host where the audit subsystem is disabled (audit=0 on the kernel command line), where your io_uring_setup rule simply hasn’t been loaded yet during early boot, where the kernel audit backlog overflows, or where auditd never comes up to consume and ship the records, your creation-time signal is missing and you won’t know. (Note the distinction: with auditing enabled, the kernel can queue records before auditd starts, subject to the backlog limit — a stopped daemon isn’t the same as an unarmed kernel.) Confirm io_uring_setup events are landing in the index before you trust their absence.
Detecting the operations, not just the setup
Watching setup tells you a ring was created. It does not tell you what flowed through it — but auditd is not limited to the setup call, and instrumenting the operations is the part most fleets have never configured. Modern Linux Audit has a dedicated io_uring rule list, separate from the ordinary exit syscall rules. This is the crux of the whole blind spot: an -a always,exit -S openat rule does not fire on IORING_OP_OPENAT — not because auditd can’t observe the operation, but because io_uring operations are matched by a different filter that most rule sets never populate. The matching rule lives in the io_uring list:
-a always,io_uring -S openat,openat2 -F key=io_uring_file_open
-a always,io_uring -S connect -F key=io_uring_connect
Note that io_uring rules take no arch field — the architecture is implicit in the filter. Two caveats decide whether this is available to you. First, kernel and userspace support: the audit io_uring hooks landed around Linux 5.16, so an older kernel (or an auditctl too old to know the io_uring list) can’t load these — verify which operation names your installed auditctl actually accepts before you rely on them. Second, and more important for coverage, the kernel deliberately hooks only a curated set of security-relevant opcodes — openat/openat2, connect, sendmsg/recvmsg, accept, rename*, unlink*, and a handful more — and pointedly not the high-volume IORING_OP_READ/IORING_OP_WRITE data path, because auditing every read would wreck the performance that is the whole point of io_uring. So native audit rules give you the file-open and network-connect events that matter most for detection, while the raw data movement through a ring still generates no audit record.
For the operations audit doesn’t cover, or when you need richer context than an audit record carries, the answer lives one layer down, in the LSM hooks. Kernel Runtime Security Instrumentation (KRSI) — BPF programs attached to LSM hooks — sees io_uring operations because io_uring’s opcodes still pass through the same security_* permission checks as their syscall equivalents. Falco’s syscall-hooking engine could already flag the io_uring_setup call itself; seeing the operations submitted through the ring is the KRSI piece, and Falco announced work on exactly that native io_uring visibility in direct response to the ARMO disclosure — confirm the state and version of that support on your own sensors rather than assuming it’s shipped and complete. Tetragon can do it with BPF-LSM policy, subject to the kernel actually having BPF-LSM built in and active. Between the native audit io_uring rules and the LSM/KRSI layer, operation-level detection genuinely exists — it just lives at a different layer than the syscall tracing most Linux EDR was built on, and neither replaces the io_uring_setup chokepoint, which stays valuable precisely because it’s low-volume and fires even for the opcodes nothing else hooks.
The actual fix is a sysctl
Detection is the consolation prize. If nothing on a host legitimately needs io_uring, the correct control is to remove the capability, and since kernel 6.6 there’s a clean knob for it:
sysctl -w kernel.io_uring_disabled=2
The three values, per the kernel documentation: 0 is unrestricted, 1 permits new io_uring creation only for processes with CAP_SYS_ADMIN or membership in the io_uring_group gid, and 2 disables io_uring_setup for everyone — every call returns -EPERM regardless of privilege. Two caveats before you lean on this. First, it is not retroactive: both 1 and 2 block new rings, but the kernel docs are explicit that existing io_uring instances keep working, so a ring a process already opened survives the change — apply it during baseline provisioning or early boot, and restart the affected workloads (or reboot) when you flip it on an already-running host. Second, the knob is an upstream 6.6-era addition and RHEL 9 rides a 5.14-derived kernel — but Red Hat backported kernel.io_uring_disabled well ahead of that: RHEL 9 has shipped io_uring as a Technology Preview with the control present and defaulting to 2 (creation disabled for all processes) since 9.3, and RHEL 9.4 additionally documents io_uring_group and the group-based behavior of mode 1. So on stock RHEL 9 the interface is already off, and the task is to confirm nobody set it to 0 or 1; on a distribution that ships io_uring enabled, setting 2 in /etc/sysctl.d/ and persisting it is the action. Where the control is genuinely absent you’re back to a seccomp filter or LSM policy to block the syscall. Setting 1 is the middle path when you have one or two known privileged consumers, but be honest about it: CAP_SYS_ADMIN is close to root anyway, so the security delta of 1 over 0 is thinner than it looks.
Google reached this conclusion early and publicly. In its own oss-security post the team reported that io_uring accounted for roughly 60% of the submissions to its kernel exploit-reward program over the preceding year — a bounded, program-specific figure, not a lifetime or all-of-Linux number — and disabled it across ChromeOS and its own production servers, with Android using a seccomp-bpf filter to keep apps away from it. That the interface is also a memory-corruption magnet, not merely a detection gap, keeps getting reinforced; CVE-2026-43174, disclosed in May 2026, fixes unsafe lifetime handling in the io_uring zero-copy-receive error path that can result in use-after-free or memory corruption. If you weren’t using the subsystem, preventing untrusted processes from creating or receiving a ring removes their direct exposure to a whole class of io_uring memory-safety flaws — the code still ships in the kernel, but nothing untrusted can reach it.
Containers, at least, moved in the right direction on their own. Docker’s built-in default seccomp profile now blocks io_uring_setup, io_uring_enter, and io_uring_register, and in containerd/Kubernetes environments the RuntimeDefault profile was updated to deny the same syscalls; the changes landed through moby and containerd (containerd removed them from its default allowlist in 2.0, backported to some earlier branches) after a long stretch where the syscalls were allowed by default — so whether a given install actually denies them tracks the runtime version, not just that the upstream change exists. A workload running under one of those default profiles is fenced off — with the usual asterisks: seccomp=unconfined, --privileged, a custom profile that re-permits io_uring, or an orchestrator that never applies the default profile all put the ring back within reach, and host processes aren’t covered at all. The exposure that remains lives on the bare-metal and VM hosts running distributions that ship io_uring enabled by default — Ubuntu among them — plus any RHEL systems where an administrator or product has explicitly turned on the Technology Preview. Those are the boxes where nobody thought to change a permissive default.
Where this maps
The control story is mostly SI and CM. The core problem is an audit and monitoring blind spot, which is SI-4 — your continuous monitoring is structurally unable to observe a class of I/O, and AU-12 is implicated because traditional syscall-exit audit rules aren’t triggered by the corresponding io_uring operations unless you configure dedicated io_uring-filter audit rules and the running kernel supports them. The remediation is textbook CM-7 least functionality: an unused kernel interface with a bad vulnerability history, disabled by config. CM-6 covers making kernel.io_uring_disabled=2 a baseline setting rather than a one-off. The io_uring_disabled=1 capability gating touches AC-6, least privilege, since it ties the feature to CAP_SYS_ADMIN. And the recurring memory-corruption CVEs are ordinary RA-5 / SI-2 flaw-remediation work — the difference being that turning the interface off retires the whole branch instead of chasing each bug.
Decide io_uring on purpose. Inventory what needs it, allowlist those binaries in your io_uring_setup rule, and set the sysctl to 2 everywhere else. A capability you’ve explicitly disabled is worth more than a detection you’re hoping fires.
Sources
- io_uring Rootkit Bypasses Linux Security Tools (ARMO)
- Detecting and Mitigating io_uring Abuse for Malware Evasion (Sysdig)
- Re: Our learnings from 42 Linux kernel exploits, we are limiting io_uring (openwall oss-security / Google)
- Documentation for /proc/sys/kernel/ (The Linux Kernel documentation)
- audit.rules(7) — the io_uring rule list (man7.org)
- Auditing io_uring (LWN.net)
- Linux 6.6 Will Make It Easy To Disable IO_uring System-Wide (Phoronix)
- Add a sysctl to disable io_uring system-wide (LWN.net)
- Important changes to external kernel parameters — RHEL 9.4 Release Notes (Red Hat)
- CVE-2026-43174: Linux Kernel io_uring Vulnerability (SentinelOne)
- io_uring is blocked by the default seccomp profile, issue #47532 (moby/moby)
- Update RuntimeDefault seccomp profile to disallow io_uring related syscalls, PR #9320 (containerd)
This post was engineered and validated through a multi-agent AI workflow — drafted, adversarially reviewed by several independent models, checked against primary sources, and given a human review before publishing. See an inaccuracy, or found this useful? Leave a comment below — corrections and feedback are read and shape what comes next.