Repository navigation
Description
Problem
io_uring operations are submitted through a ring in shared memory, not as
syscalls, so seccomp can't filter them. That is why the default profiles of
Docker and containerd (moby/moby#46762, containerd/containerd#9320) and
Podman block io_uring_setup, io_uring_enter and io_uring_register
entirely. Users must choose between no io_uring at all and an unconfined
container.
Linux 7.0 added task-level io_uring restrictions with classic BPF filters per
opcode (pull request,
LWN). They are designed for exactly this
case:
- registered with
io_uring_register(-1, …); - inherited across fork and exec;
- can only be tightened;
- need
no_new_privsorCAP_SYS_ADMINin the user namespace, as seccomp does.
The kernel pull request names containers and systemd as the consumers. As far
as I can tell, no runtime and no spec uses them yet.
Proposal
A new optional linux.ioUring object:
"ioUring": {
"defaultAction": "deny",
"ops": ["IORING_OP_READ", "IORING_OP_WRITE", "IORING_OP_SEND",
"IORING_OP_RECV", "IORING_OP_ACCEPT", "IORING_OP_SOCKET"],
"socketFamilies": ["AF_UNIX", "AF_INET", "AF_INET6"]
}defaultAction(string, REQUIRED):denyorallow. It applies to
opcodes not listed inops.ops(array of strings, OPTIONAL): opcode names fromenum io_uring_op
in<linux/io_uring.h>. Withdeny, these are the only opcodes allowed.
Withallow, these are the opcodes denied. The runtime MUST generate an
error for a name it doesn't know.socketFamilies(array of strings, OPTIONAL): while
IORING_OP_SOCKETis allowed, only these address families may be created.
Names are as in<sys/socket.h>.deniedSqeFlags(array of strings, OPTIONAL):IOSQE_*flag names.
Any otherwise allowed operation submitted with one of these flags is denied.
This is mainly forIOSQE_BUFFER_SELECT(provided buffers, the largest
cluster of exploited io_uring bugs) andIOSQE_FIXED_FILE.
Semantics:
- The runtime MUST apply the restrictions before the container process
executes, so the process and all its descendants are restricted. A denied
operation completes with-EACCES. - If the kernel can't enforce the restrictions, the runtime MUST generate an
error. It MUST NOT ignore them. features.json:linux.ioUringwithenabled, plus the knownopsand
socketFamiliesnames, as for seccomp.
Implementation notes, from the prototype:
- Apply the restrictions before seccomp, because a profile may deny
io_uring_register. - Apply them before capabilities are dropped. Registration needs
CAP_SYS_ADMINin the user namespace orno_new_privs, the same as
seccomp. That also works for rootless runtimes. - Build the policy only from BPF filters: an allow filter per listed opcode,
thenIO_URING_BPF_FILTER_DENY_REST. Don't use the task opcode allowlist
(IORING_REGISTER_RESTRICTIONS). That allowlist also denies every SQE flag
not explicitly allowed, and a task can register it only once, so the
workload could no longer add its own tighter restrictions. BPF filters
stack.
This complements seccomp; it doesn't replace it. On kernels that support
ioUring, an engine could change its default from "block io_uring in
seccomp" to "allow the io_uring syscalls in seccomp, restrict the operations
with ioUring".
Prototype and evidence
- youki prototype:
dev.youki.io_uringannotation with the same shape,
applied in container init before seccomp:
main...gvozdetsky:io-uring-restrictions.
It has 11 unit tests,
and the end-to-end runs with real youki containers on 7.0 and 7.2.9 cover:- deny by default;
- allow by default;
- socket families;
- deny-all;
- SQE flags denied (
IOSQE_BUFFER_SELECT,IOSQE_FIXED_FILE); - an invalid policy (refused);
- inheritance by forked processes;
- refusal on a kernel without support (6.18).
- Lab: 11 kernel-API scenarios in a VM, plus the youki scenarios and
notes: https://github.com/gvozdetsky/iouring-lab (FINDINGS.md). - Candidate default profile with its rationale (opcode usage survey of 15
projects, io_uring CVE history): profile/. - liburing test: a regression test for the exec case below (PR to follow).
Caveats and open questions
- Restrictions are lost on exec on 7.0 and 7.1. If a restricted task has
already used io_uring, its restrictions are dropped when it execs, so
inside a container you can open a ring and exec anything.- CVE-2026-80713, fixed upstream in bc0e8faf90e7 (7.2), which is tagged for stable 7.1+.
Ubuntu 26.04 has it in 7.0.0-39. - A version check can't tell a fixed distro kernel from an unfixed one.
Should the spec require runtimes to self-test (restrict, use a ring,
exec, check), or should it state a minimum kernel?
- CVE-2026-80713, fixed upstream in bc0e8faf90e7 (7.2), which is tagged for stable 7.1+.
- Only rings created after registration are restricted. A ring fd
passed into the container (SCM_RIGHTS) is not restricted. This is
probably fine to document, but worth stating. - Few opcodes expose arguments to filters. Only
SOCKET,
OPENAT/OPENAT2and, since 7.2,CONNECTdo. Later fields could cover
theopenat2resolve flags and theconnectfamilies or ports. Is an
extensible shape preferred now, e.g."rules": [{"op", "args"}]?
If this direction is acceptable, I'd send a PR for:
config-linux.md;schema/config-linux.json;features-linux.md;specs-go.
Activity
gvozdetsky commentedon Oct 8, 2026
One more data point for the design, and a question I left out of the first post.
While testing I found that task-level register-opcode restrictions were not enforced on rings created with
IORING_SETUP_R_DISABLED(7.0 through 7.3-rc). The fix is on the io-uring list:https://lore.kernel.org/r/20261009-iouring-task-restrict-fix-v1-1-a49daf55a12c@gmail.com
The SQE-side restrictions are not affected, and neither are the BPF filters the proposal builds on. Notes: FINDINGS.md.
Open question: should
linux.ioUringrestrict register opcodes at all?IORING_REGISTER_RESTRICTIONS) can restrict them.My inclination is to leave register opcodes out of the first version and revisit once the kernel can filter them with BPF.
gvozdetsky commentedon Oct 9, 2026
A short progress update, so the proposal can be judged against working code:
I'm happy to turn this into a spec PR (config-linux.md, schema, features-linux.md, specs-go) once there's agreement on the shape. The open points above still stand: exec on 7.0/7.1, and whether register opcodes are in scope.
config-linux: add linux.ioUring
config-linux: add linux.ioUring
amend! config-linux: add linux.ioUring