Repository navigation
linux: add experimental io_uring restrictions - #2386
gvozdetsky wants to merge 2 commits into
Conversation
giuseppe
left a comment
There was a problem hiding this comment.
left some comments, we also need the check and libcrun_apply_io_uring_restrictions for the libcrun_container_restore case
| }; | ||
|
|
||
| /* The io_uring opcodes, in `enum io_uring_op` order. */ | ||
| static const char *io_uring_ops[] = { |
There was a problem hiding this comment.
this looks painful to maintain, but I can't see any alternative given what the uapi offers
There was a problem hiding this comment.
Agreed. enum io_uring_op has no macros, so there is no way to get the names, or even to check the table against the headers. New opcodes are appended, so it is one line per release, and an opcode the running kernel doesn't know is skipped (EINVAL), so the table may be ahead of the kernel.
| IO_URING_ANNOTATION); | ||
| goto exit; | ||
| } | ||
|
|
There was a problem hiding this comment.
Opus 5.5 suggests to not accept socketFamilies without IORING_OP_SOCKET_VALUE:
+ if (UNLIKELY (! policy->ops[IORING_OP_SOCKET_VALUE]))
+ {
+ ret = crun_make_error (err, 0, "%s: `socketFamilies` requires IORING_OP_SOCKET in `ops`", IO_URING_ANNOTATION);
+ goto exit;
+ }
+There was a problem hiding this comment.
Done, a policy with socketFamilies but without IORING_OP_SOCKET in ops is now refused. I changed the spec proposal (opencontainers/runtime-spec#1328) and youki (youki-dev/youki#3805) the same way, so they stay in sync.
| /* Before seccomp, which may deny io_uring_register, and before the | ||
| capabilities are dropped: like seccomp, the restrictions need | ||
| CAP_SYS_ADMIN or no_new_privs. */ |
There was a problem hiding this comment.
| /* Before seccomp, which may deny io_uring_register, and before the | |
| capabilities are dropped: like seccomp, the restrictions need | |
| CAP_SYS_ADMIN or no_new_privs. */ | |
| /* Before seccomp, which may deny io_uring_register, and before the | |
| capabilities are dropped: registering a filter needs CAP_SYS_ADMIN | |
| in the user namespace. */ |
no_new_privs is set later as part of libcrun_set_caps.
Should we do like seccomp that we try to apply it in two different places?
There was a problem hiding this comment.
Good catch, no_new_privs isn't set at that point, so it only worked thanks to CAP_SYS_ADMIN. It is now done like seccomp: without noNewPrivileges before the capabilities are dropped, otherwise once libcrun_set_caps has set no_new_privs, both for the init and for exec, and in both cases before seccomp. The tests now run with both values of noNewPrivileges.
|
|
||
| if (UNLIKELY (container_paused)) | ||
| return crun_make_error (err, 0, "cannot exec in a paused container `%s`", id); | ||
|
|
There was a problem hiding this comment.
do we need to add here a check for exec'ed processes?
ret = libcrun_check_io_uring_restrictions (container, err);
if (UNLIKELY (ret < 0))
return ret;
|
@giuseppe thanks for the review. About restore: added both. The check is done before anything is created, and crun applies the restrictions to itself before running CRIU, like the keyring: CRIU doesn't restore them, but the restored processes inherit them from CRIU. crun itself stays restricted for the rest of the restore, which is fine as it doesn't use io_uring. The new The fixes are fixup commits for now, I'll squash them once you are happy with them. |
giuseppe
left a comment
There was a problem hiding this comment.
thanks, LGTM
@kolyshkin PTAL
45fde5f to
216ab3b
Compare
216ab3b to
c078cb2
Compare
|
Rebased onto main, no changes to the patches; CI is green. @kolyshkin PTAL when you have time. |
kolyshkin
left a comment
There was a problem hiding this comment.
Thanks for working on this, the overall structure looks good, and the ordering with respect to caps / no_new_privs / seccomp mirrors seccomp nicely. I checked the semantics against the kernel source (io_uring/bpf_filter.c, io_uring/register.c, 7.3-rc7). Inline comments below; the first one (DENY_REST only fills empty slots) is the one I consider blocking, as it makes the policy fail open when the task already has filters.
A few things that don't map to a specific line:
- The commit message and the PR description say that, since only BPF filters are used, "the workload can still add its own filters". That's true for BPF filters, but
IORING_REGISTER_RESTRICTIONSfor the task (io_register_restrictions_task) returns-EPERMoncecurrent->io_uring_restrictexists, and registering a BPF filter creates it. So the workload can no longer register task-level classic restrictions. Please reword. - Old crun versions (and runc) silently ignore the annotation, i.e. no restrictions at all, and there's no way to detect support via
crun features(only therun.oci.prefix is listed). That's inherent to experimental annotations, but maybe worth a line in the man page. - Nit: the magic
37is duplicated intests/init.c; the policy is parsed twice (check + apply), which is fine but could be shared.
This comment was written by an AI (Claude Code).
| /* Register an allow filter for every allowed opcode, the lowest one last. | ||
| It carries DENY_REST, which attaches a deny filter to every opcode that | ||
| has no filter yet, so it must come last. */ | ||
| for (i = IO_URING_OPS_LEN - 1; i >= lowest; i--) |
There was a problem hiding this comment.
This relies on DENY_REST alone to deny every opcode not in ops, but DENY_REST only fills empty slots (io_register_bpf_filter: if (old_filter) continue;). If the task already has filters when crun runs, the policy fails open.
Example: crun runs inside a container whose (outer) policy allows {READ, WRITE}, so both slots hold an allow filter and the rest the dummy deny. The inner policy is {READ}: crun registers allow on READ with DENY_REST, but the WRITE slot is not empty, so it stays allowed, and WRITE is permitted inside the inner container contrary to its policy.
Suggestion: explicitly register a deny program (BPF_RET|BPF_K, 0) for every opcode not in ops, iterating opcodes from 0 until the kernel returns EINVAL (that covers also opcodes newer than crun's table), and keep DENY_REST on the last one as a safety net. Appending a filter to a slot holding the dummy filter is fine, the dummy stays last and still denies. A test with nested policies (apply a wider policy to the test runner/parent first, or run crun under an already restricted crun) would catch this.
This comment was written by an AI (Claude Code).
There was a problem hiding this comment.
Confirmed: under an outer policy that allows WRITE, the container could submit WRITE. Fixed in 7594442: every opcode the kernel knows gets its own allow or deny filter. I dropped DENY_REST, as it has nothing left to fill. The new io-uring-nested test runs crun under tests/init io_uring-restrict nop,read,write; it fails on the old code (WRITE allowed). youki had the same bug, fixed in youki-dev/youki#3805.
| { | ||
| /* An opcode newer than the kernel cannot be submitted anyway, but | ||
| DENY_REST must still be registered. */ | ||
| if (errno == EINVAL) |
There was a problem hiding this comment.
EINVAL is treated as "the kernel doesn't know this opcode", but io_bpf_filter_import / io_uring_check_cbpf_filter also return EINVAL for a rejected BPF program, non-zero reserved fields, bad flags, etc. With the current fixed programs it's fail-closed (e.g. IORING_OP_SOCKET would just be silently denied), but it would be impossible to diagnose. Maybe only skip EINVAL for i >= <number of opcodes the kernel supports> (determined once, e.g. by probing), and report other EINVALs as errors.
This comment was written by an AI (Claude Code).
There was a problem hiding this comment.
Fixed by the same change (7594442): the opcodes are registered in order with fixed one-instruction programs, so EINVAL can only mean that the kernel doesn't know the opcode. The socket family program is registered after the loop, where any error is reported.
| address families. */ | ||
| #define IO_URING_ANNOTATION "run.oci.io_uring" | ||
|
|
||
| /* From <linux/io_uring.h> and <linux/io_uring/bpf_filter.h>, Linux 7.0. */ |
There was a problem hiding this comment.
Re the earlier discussion that there's no way to check the table against the headers: the enum values can be used both in designated initializers and in static asserts, so when HAVE_LINUX_IO_URING_H (already checked in configure.ac) is defined, something like
#ifdef HAVE_LINUX_IO_URING_H
# include <linux/io_uring.h>
_Static_assert (IORING_OP_SOCKET == IORING_OP_SOCKET_VALUE, "...");
_Static_assert (IORING_OP_LAST <= IO_URING_OPS_LEN, "update io_uring_ops"); /* or == */
#endif(or the table itself as [IORING_OP_READ] = "IORING_OP_READ", ...) catches drift at build time. Same for these constants and the struct layout: <linux/io_uring/bpf_filter.h> is in the uapi headers, so the hand-rolled structs could be checked with sizeof / offsetof asserts, and CRUN_IO_URING_BPF_CTX_PDU_OFFSET could be offsetof (struct io_uring_bpf_ctx, socket.family).
This comment was written by an AI (Claude Code).
There was a problem hiding this comment.
Added in 98075ad for the constants and the struct layouts, when the headers have <linux/io_uring/bpf_filter.h> (new configure check). Not for the table: designated initializers break the build with older headers, and IORING_OP_LAST <= IO_URING_OPS_LEN breaks building a released crun against newer ones. Since every opcode now gets a filter by number, a stale table only means that a newer name is ignored, and stays denied.
| value = find_op (name); | ||
| if (UNLIKELY (value < 0)) | ||
| { | ||
| ret = crun_make_error (err, 0, "%s: unknown io_uring operation `%s`", IO_URING_ANNOTATION, name); |
There was a problem hiding this comment.
Since ops is an allowlist, ignoring an unknown name (with a warning) is still fail-closed, and it's forward compatible: a policy generated by a newer podman/CRI listing a newly added opcode would otherwise make the container fail to start on an older crun. This is how unknown syscall names in seccomp profiles are commonly handled. (Rejecting unknown keys in the object is right, though, as those may add restrictions.) Same applies to unknown socket families.
This comment was written by an AI (Claude Code).
There was a problem hiding this comment.
Agreed, done in 81f20a5: unknown names are ignored with a warning, like unknown seccomp syscalls. If none of the families is known, IORING_OP_SOCKET is denied. Unknown keys are still an error. Same in the spec PR and in youki.
| { | ||
| const char *name; | ||
| int value; | ||
| } io_uring_socket_families[] = { |
There was a problem hiding this comment.
Only 9 families can be named, so e.g. AF_CAN, AF_TIPC, AF_BLUETOOTH, AF_RDS, AF_KCM can't be allowed at all. Maybe either list all AF_* from <sys/socket.h>, or accept numeric values too (this is probably a question for the spec proposal as well).
This comment was written by an AI (Claude Code).
There was a problem hiding this comment.
81f20a5 names all the families of <linux/socket.h>, by number, as libc may lack the newer ones. I kept names only, like ops.
| return ret; | ||
|
|
||
| if (UNLIKELY (! io_uring_restrictions_supported ())) | ||
| return crun_make_error (err, 0, "%s: io_uring restrictions are not supported by the kernel (Linux 7.0 or newer is required)", |
There was a problem hiding this comment.
Nit: this message is misleading when crun itself runs under a seccomp profile denying io_uring_register (e.g. crun in a container with the default docker/podman profile): the probe gets EPERM/ENOSYS and the user is told the kernel is too old. Maybe include the errno, or say "not supported or not permitted".
This comment was written by an AI (Claude Code).
There was a problem hiding this comment.
Done in 81f20a5: "not supported or not permitted", with the errno.
| if (UNLIKELY (ret < 0)) | ||
| return ret; | ||
|
|
||
| /* CRIU does not restore the io_uring restrictions, but the restored |
There was a problem hiding this comment.
Note that the restrictions are per task (thread) and permanent, so crun stays restricted for the rest of the restore. Fine for the CLI, but for programs using libcrun as a library this is an irreversible side effect on the calling thread; maybe worth a comment. Also, filters the workload added itself are lost on restore (CRIU doesn't dump them), probably worth documenting.
This comment was written by an AI (Claude Code).
There was a problem hiding this comment.
Added the comment in a1821b8; the man page now says that filters the processes added themselves are lost on restore.
| `ops` lists the names of the allowed `IORING_OP_*` operations, every | ||
| other operation is denied. If `socketFamilies` is present, | ||
| `IORING_OP_SOCKET` can create sockets only for these address families. | ||
| It must not be empty, and `ops` must contain `IORING_OP_SOCKET`. |
There was a problem hiding this comment.
Worth documenting: the kernel copies the task restrictions into a ring when it is created (io_ctx_restriction_clone at io_uring_setup), and checks submissions against the ring's filters. So a ring created outside the container and passed in (SCM_RIGHTS, --preserve-fds, LISTEN_FDS) is not restricted by this policy.
Also, socketFamilies only looks at the family, not type/protocol (so e.g. SOCK_RAW is allowed for AF_INET if the caps permit), and sockets created via socket(2) (subject to seccomp only) can still be used with any allowed io_uring op.
This comment was written by an AI (Claude Code).
|
Thanks for the review! The fixes are fixup commits, I'll squash them once you're happy.
The io_uring, exec, seccomp, capabilities, features and checkpoint/restore tests pass on 7.0.0-42. |
|
Ephemeral COPR build failed. @containers/packit-build please check. |
|
Rebased onto main: Packit couldn't merge the PR because of a conflict in |
f31af62 to
78394eb
Compare
| /* In a constructor, as the process is /proc/self/exe: crun or any program | ||
| using libcrun. */ | ||
| __attribute__ ((constructor)) static void | ||
| io_uring_exec_check (void) | ||
| { | ||
| int ret; |
There was a problem hiding this comment.
This runs on every run/create/exec/restore and it is really crazy (especially for exec).
I think this should be done once per kernel version (uname.release) and stored into cache dir. See code using SYSTEMD_MISSING_PROPERTIES_DIR).
Maybe if the uname.release shows 7.2+, don't do anything at all.
OR, we can change the spec requirement that runtime must check that the properties are applied. I mean, this is definitely a kernel bug -- maybe it's better to not report io_uring limiting in crun features if the bug is present.
There was a problem hiding this comment.
Agreed, done in 691bb28:
crun execno longer runs it: the container passed it when it was created, on the same kernel. exec still validates the policy and probes for support, which is one syscall.- On 7.2 and newer it isn't run at all.
- Otherwise it runs once per kernel: a kernel that passed it is recorded in
<state root>/.cache/io-uring-exec-check/<release>, like the missing systemd properties. The file holdsuname -v, so a kernel rebuilt with the same release is checked again. A failure isn't cached, as it refuses the container anyway.
Counting the processes created per command on 7.0.0-42: with the annotation, only the first crun run creates the extra child; the following runs and every crun exec create as many processes as without the annotation. 78deddb tests the cache.
About moving it to crun features instead: I'd rather keep the check in the runtime. Reporting the annotation only on fixed kernels leaves it to every caller to check the features first, and a caller that doesn't would silently get restrictions that a process loses when it execs. Refusing fails closed, and with the cache it costs one fork per kernel.
kolyshkin
left a comment
There was a problem hiding this comment.
LGTM, but I'm not sure we should merge it before runtime-spec.
|
@gvozdetsky please autosquash your commits |
seccomp cannot filter io_uring operations, as they are submitted through the ring and not as syscalls, so today the only option is to block io_uring entirely. Linux 7.0 added task-level io_uring BPF filters, which are inherited across fork and exec and can only be tightened, like seccomp. Add the run.oci.io_uring annotation, in the shape proposed for linux.ioUring in opencontainers/runtime-spec#1328: {"ops": [...], "socketFamilies": [...]} ops is an allowlist of IORING_OP_* names, every other operation is denied. socketFamilies, when present, limits IORING_OP_SOCKET to the listed address families. It must not be empty, and ops must contain IORING_OP_SOCKET. Unknown names are ignored with a warning: what they would allow stays denied. Every opcode the kernel knows gets a filter, an allow or a deny one: with DENY_REST alone, an opcode that a filter inherited by crun allows would stay allowed. The policy is validated, and kernel support checked, before the container is created or restored, and before a process is exec'ed in it. Linux 7.0 and 7.1 without bc0e8faf90e7 drop the restrictions on exec for a task that has used io_uring, so crun also checks that on a child that restricts itself, creates a ring and re-executes itself. The result is cached per kernel under the state root, and the check is skipped on Linux 7.2 and newer and by crun exec. Registering a filter needs either no_new_privs or CAP_SYS_ADMIN, so, like seccomp, the restrictions are applied to the container init and to every exec'ed process before the capabilities are dropped, or with noNewPrivileges once no_new_privs is set. In both cases they are applied before seccomp, which may deny io_uring_register. CRIU does not restore them, so on restore crun applies them to itself before running CRIU, and the restored processes inherit them. Only BPF filters are used, never IORING_REGISTER_RESTRICTIONS, so the SQE flags are not restricted. The workload can add its own BPF filters and restrict its own rings, but it can't register task restrictions with IORING_REGISTER_RESTRICTIONS. crun features reports run.oci.crun.io_uring. The annotation will be replaced by linux.ioUring once it is in the runtime spec. Assisted-by: Claude Code (claude-opus-5-5) Signed-off-by: Eugene Gvozdetsky <gvozdet@gmail.com>
Add an "io_uring" command to tests/init that submits one SQE per argument on a new ring and reports whether it was denied, an "io_uring-once-restored" command that does the same once the container is restored, an "io_uring-restrict" command that runs a command under an io_uring policy, and a "check-feature io_uring-restrictions" probe. The tests check the policy in the container init and in an exec'ed process, with and without noNewPrivileges, in a restored container, and with crun itself running under a policy; that whether an operation is allowed does not depend on its SQE flags, that an empty ops denies everything, that unknown names stay denied, that invalid policies are refused, and that crun features reports run.oci.crun.io_uring. They are skipped on kernels without io_uring BPF filters. Assisted-by: Claude Code (claude-opus-5-5) Signed-off-by: Eugene Gvozdetsky <gvozdet@gmail.com>
78deddb to
7951128
Compare
|
Autosquashed and rebased onto main. One more fixup went in before the squash: make syntax-check rejects sscanf, so the kernel version is now parsed with strtol. The commit message now mentions that the exec check is cached and skipped on 7.2+ and by exec. The remaining runc integration failure, readonly rootfs keeps nosuid,nodev, comes from opencontainers/runc#5588 (merged Nov 1) and fails on other PRs too, e.g. #2441. |
seccomp can't filter io_uring operations: they are submitted through the ring, not as syscalls, so runtimes currently block io_uring entirely. Linux 7.0 added task-level io_uring BPF filters, which are inherited across fork and exec and can only be tightened, like seccomp.
This adds experimental support behind the
run.oci.io_uringannotation, in the shape proposed forlinux.ioUringin opencontainers/runtime-spec#1328 (proposal: opencontainers/runtime-spec#1323):{ "ops": ["IORING_OP_READ", "IORING_OP_WRITE", "IORING_OP_SOCKET"], "socketFamilies": ["AF_UNIX", "AF_INET", "AF_INET6"] }opsis an allowlist, every other operation is denied. It doesn't depend on the SQE flags.socketFamilies, when present, limitsIORING_OP_SOCKETto these families. It requiresIORING_OP_SOCKETinops, and an empty list is refused; to deny all sockets, leaveIORING_OP_SOCKETout ofops.crun exec. crun also checks that the kernel keeps the restrictions on exec: Linux 7.0 and 7.1 without bc0e8faf90e7 drop them for a task that has used io_uring. This check forks and execs crun, so it is not done forcrun execnor on 7.2+, and otherwise once per kernel: its result is cached under the state root.crun execprocess (they don't inherit it from init). Like seccomp, registering a filter needsno_new_privsorCAP_SYS_ADMIN, so it is applied before the capabilities are dropped, or, withnoNewPrivileges, onceno_new_privsis set; in both cases before seccomp.crun restorecrun applies them to itself before running CRIU, and the restored processes inherit them.IORING_REGISTER_RESTRICTIONS: the workload can add its own BPF filters and restrict its own rings, but not register task restrictions.crun featuresreportsrun.oci.crun.io_uring.youki has the same semantics in youki-dev/youki#3805, so the spec shape is now implemented by two runtimes. Once the spec has the field, the annotation will be replaced by
linux.ioUring.Tests:
tests/test_io_uring.pychecks the policy in init and in exec (with and withoutnoNewPrivileges), SQE-flag independence, deny-all, unknown names, crun running under an outer policy (fails without a filter on every opcode), invalid policies, and the cache of the exec check;checkpoint-restore-io-uringchecks the init of a restored container. They are skipped on kernels without io_uring BPF filters. On Ubuntu's 7.0.0-42, which has the exec fix, they pass, and each of them fails with the corresponding apply call removed; on 7.0 without the exec fix (mainline, or Ubuntu's 7.0.0-38), crun refuses the annotation and the tests are skipped. The exec, seccomp, capabilities and checkpoint/restore tests pass too.