Design and threat model. Built on rust-landlock 0.4.5 and seccompiler 0.5.x; the FS-tier and seccomp designs are derived from first principles (kernel Landlock/seccomp semantics and known LPE-primitive classes), see §4/§5. Resolved decisions are in the Decision Log (§14).
oring is an unprivileged, self-applied, pre-exec confinement wrapper. It restricts itself — Landlock (FS + TCP + IPC scope) then seccomp-bpf — sets NO_NEW_PRIVS, scrubs FDs and its own policy env, then execve()s the target. The child inherits every restriction irrevocably; there is no supervisor and no window where the child runs less-confined than intended.
oring [--policy F] [--profile balanced] [--workdir D] -- <cmd> [args...]
It is one static-musl binary — a Landlock FS/net ring plus an in-process seccomp ring — usable as a container ENTRYPOINT or a bare-metal wrapper. Both rings are self-applied pre-execve, so it needs no daemon, no root, and no supervisor: the value over a plain --cap-drop=ALL --security-opt no-new-privileges --read-only --user 1000:1000 container is that Landlock and a custom seccomp denylist are actually installed, rather than riding Docker's default profile.
Not an orchestrator. Not a MAC authoring tool. Not a network policy engine. Not a VM boundary.
Attacker: a fully-compromised agent (prompt-injection → arbitrary native code) running as uid 1000 after restrict_self+execve. All rings already in effect and irrevocable. The attacker is the CHILD; policy is chosen by the trusted PARENT before the first exec.
- FS access outside policy tiers — host creds (
~/.ssh,~/.aws), other projects'.git/config, host config. Strongest guarantee (Landlock V1–V3, hard floor). Read this narrowly: it is.git/configof repos outside the granted tiers. The workdir's own.git/is insidewrite_execand is fully writable by the child — see the in-tree residual in §2. - W^X: no exec from writable scratch (
write_noexectiers). - Privilege gain via SUID/setcap/file-caps (
NO_NEW_PRIVS) — neutralizes the CVE-2023-0386 OverlayFS-SUID payoff for the confined child (its mount/userns half is separately closed by the seccompmount/unshare(CLONE_NEWUSER)denials, §5). - Namespace creation — per-flag arg-masks on
clone/unshareCLONE_NEW*(7 rules each),clone3→ENOSYS,mount/setns/pivot_rootblocked. - LPE launchpads: io_uring, eBPF, ptrace, userfaultfd, keyctl, perf_event_open, memfd_create, module ops,
open_by_handle_at, Dirty-Pipe (splice/vmsplice), StackRot (MAP_GROWSDOWN), TTY-injection ioctls, and x32-ABI re-entry of all of the above (§5). - AF_UNIX MSG_OOB UAF (CVE-2025-38236) — arg-masked on send/recv
MSG_OOB+SIOCATMARK(fix, was wrongly deferred). - Inherited leaked FDs (FD-scrub via
close_range).
- Connect/bind on non-allowed TCP ports — Landlock net (ABI V4 / kernel 6.7), always present at the floor. Port-only, and dependent on the ring-2
MSG_FASTOPENblock for the wall to hold (§4/§5). - Signalling / abstract-socket peers outside domain — Landlock scope (ABI V6 / kernel 6.12), always present at the floor →
EPERM. In the container path this is also provided by the runtime's netns/pidns; oring's scope ring is defense-in-depth there.
- Full control inside granted FS tiers (the workdir is
write_execunderbalanced— an in-tree write-then-run payload is possible; documented, not fixed). - Poisoning the workdir so the OPERATOR executes it later, unconfined. The child can write
.git/hooks/*and.git/config(core.fsmonitor,core.pager,core.sshCommand,alias.*,diff.*.textconvall run commands), and equallyMakefile,build.rs,package.jsonscripts,conftest.py. Each fires on the nextgit/build/test the operator runs outside the cage, at their uid. This is not closeable at this layer and is not the same class as the~/.cargo/bin$PATHover-grant that was fixed on: that was write access the agent never needed, whereas this is the tree the agent exists to edit. Landlock has no deny rule — access is the union of every rule on the path — so a subpath cannot be carved out of a granted tier; withholding.git/would mean never granting the repo root, which blocks creating any new top-level file. The control is review-before-execute: treat a workdir an agent has touched as untrusted input, and run the first build/test after an agent session inside the cage, not outside it. - Outbound TCP to any IP on an allowed port (443 → any HTTPS endpoint → exfil). Landlock net is port-only, not domain/IP.
- DNS/UDP exfil — Landlock has no UDP control through V7.
- Read its own env → steal any credential injected into the ring. Narrowed: the child's env is now an allowlist by default (
BASE_ENV_KEEP— POSIX/locale basics only), so a credential reaches the child only when the operator names it with--env-keepor opts out wholesale with--env-inherit. The residual is now "steal any credential the operator explicitly admitted," which is a decision rather than an accident. - Reach the operator's
ssh-agent— unless built with the V9 wall.AccessFs::ResolveUnixwalls pathname-socketconnect(), but only at ABI 9 and only when compiled--features resolve-unix. Without that (the default build, or any kernel < 7.1), there is no right gating it: both profiles grant/tmp(src/profile.rs:44,:183), OpenSSH's socket sits at/tmp/ssh-XXXXXX/agent.NNNN, and a confined child could sign as the operator. The env allowlist removes the trivial path (SSH_AUTH_SOCKis not passed) but not the hole —/tmpcarriesReadDir, so the socket is findable by scan. Mitigation is not available at the Landlock layer (no deny rule; a granted/tmpcannot have a subpath carved out), so oring reports it loudly at seal andwarn_on_socketswarns (a two-level scan that DOES reach the ssh-agent depth). Build--features resolve-unixon kernel 7.1+ to make it structurally absent. - Peer procfs read:
/proc/<pid>/{environ,maps,mem}— a compromised child can read a peer uid-1000 process's environment/memory. These are VFSread()s gated only byptrace_may_access, not by a blocked syscall, and Landlock does not reliably wall them at the V7 floor (measured NotBlocked on kernel 6.15, containment-matrix cellproc-peer-env-full). A newer kernel'sptrace_access_checkhook was observed to deny them on 7.1.3, but that is a version-dependent bonus, not a floor guarantee — so the honest posture is that this is a residual. Metadata (cmdline/stat) is world-readable regardless. Controls:--proc self, or runtime PID-ns +maskedPaths(§9). - Connect to a pathname AF_UNIX socket — walled only with the V9 build. With
--features resolve-unixon kernel ≥ 7.1, LandlockRESOLVE_UNIXgatesconnect()to a filesystem-path socket (handled + granted in no tier →EACCES); abstract sockets are covered by scope (EPERM) at the V7 floor regardless. Without the feature (or below 7.1), pathname-socket connect is unmediated — the docker.sock/podman.sock/journald and ssh-agent classes are reachable.warn_on_socketsflags a granted tier holding a live socket (two-level scan), and oring prints the residual at seal; neither is a control, so the operator's real defense is: don't grant a tier holding a privileged socket, or build the V9 wall. - Unfilterable kernel bug classes:
futexUAF, the AF_UNIX SCM_RIGHTS GC race (genuinely no arg to mask — distinct from the now-filtered MSG_OOB path), Dirty-COW-via-/proc/self/mem. Required by threaded workloads. - Read unmasked
/proc//sysunless the runtime masked them.
- Not a VM/hypervisor boundary. Shared host kernel. Kernel-isolation-critical workloads (Xen fuzzing rig) need a VM underneath.
- Not a kernel-0-day defense beyond surface reduction.
- Not a network policy engine. Domain/IP egress = host nftables default-deny or a filtering proxy — required companion control (§9), stated loudly.
- Does not author/load host MAC. SELinux/AppArmor stay the host's job and stack underneath (intersection).
- Does not mask procfs/sysfs. Runtime
maskedPaths/mount-view job. (Pathname unix-socket connect IS now mediated at V9 viaRESOLVE_UNIX— see §4.) - No supervisor / seccomp-notify / OCI-hook validation / egress firewall / credential broker / cgroups / rlimits. Each needs a persistent or privileged component oring by definition isn't.
| Ring | Mechanism | Closes |
|---|---|---|
| 1 | Landlock: FS tiers + TCP connect/bind + IPC scope | a plain hardened container has zero Landlock |
| 2 | seccomp-bpf denylist (+ x32 guard) | a plain hardened container rides the runtime's default seccomp |
| pre-exec | NO_NEW_PRIVS, DUMPABLE=0, signal-mask reset, FD-scrub, env-scrub |
NNP required before seccomp; FD-scrub = CVE-2024-21626 |
Layering is set-intersection — an op must pass every active layer:
effective = host_MAC ∩ cap-drop=ALL ∩ no_new_privs ∩ userns(uid1000)
∩ read-only-rootfs+tmpfs ∩ runtime-default-seccomp
┌───────────────────────────────────────┐
∩ oring Landlock (ring 1) │ ORING
∩ oring seccomp (ring 2) │
└───────────────────────────────────────┘
Consequences: oring is additive and safe when the runtime already confines (intersection only tightens) and must not assume outer layers exist (standalone on the Xen rig = no SELinux, maybe no userns/pidns). Its core (Landlock FS + NNP + seccomp) stands alone and fails closed.
Intersection invariant (load-bearing): Landlock only ever tightens. It can never re-grant a right a stricter outer layer denies. A write_exec tier is only realizable on a mount that lacks noexec; Execute granted on a noexec mount (e.g. a container's noexec /tmp) is a silent no-op (§9).
Single ruleset, single restrict_self.
| Tier | dir set | use |
|---|---|---|
read_only |
ReadFile|ReadDir (+Refer) |
config, read-only mounts |
read_exec |
read_only|Execute (+Refer) |
/usr,/lib,/usr/lib,/usr/libexec, interpreters |
write_noexec |
read_only|WriteFile|MakeReg|MakeDir|RemoveFile|RemoveDir|MakeSock|MakeFifo|MakeSym|Truncate|IoctlDev (+Refer) |
/tmp, scratch — W^X |
write_exec |
write_noexec|Execute |
the workdir |
FS decisions:
- Build unions explicitly; do NOT use
AccessFs::from_write(abi)— that helper includesMakeChar\|MakeBlock. Device-node rights appear in no tier; combined withhandle_access(AccessFs::from_all(abi))for FS they are handled-but-never-granted → globally denied (defense-in-depth with seccompmknod/mknodat→ERRNO). - Dir-vs-file split (
RightScopeinlandlock.rs): the kernel rejects dir-only rights (ReadDir,Make*,Remove*,Refer) on a non-dir FD (EINVAL). EachAccessFsright is taggedBothorDirOnly; a file mask keeps onlyBothrights. Route each path bymetadata().is_dir()(follows symlinks, matchingPathFd). Missing paths → dir bucket. - Manual
IgnoreIfMissing:PathFd::new→Erron ENOENT → skip; any other error (EACCES) → fail closed. Because a silently-skipped path yields a downstream EACCES that looks like an oring bug, emit a startup diagnostic listing every skipped path (§8, §11). - Refer in dir sets only. Safe on
read_only: it is only the inert endpoint of a rename; unlinking the source still needsRemoveFile/RemoveDir. No write leaks. - List real usrmerge targets, not symlinks. Do not rely on
/lib→/usr/lib; list/usr/liband/usr/libexecdirectly (node bundle / libexec live there).
Query ABI::new_current(). Hard floor V7 / kernel 6.15 (FLOOR_ABI = 7, src/landlock.rs), hardcoded, refuse below it. V7 is the coherent-cage minimum: FS core + Refer/Truncate (V1–V3), TCP net (V4), IoctlDev (V5), IPC scope (V6), and audit logging (V7) are all guaranteed there, so there are no degraded/backcompat branches — running a partial cage on an old kernel is worse than refusing (D-4). Below V7 oring exits with one clear message; there is no --allow-net-degrade, no scope-skip-below-V6, no audit-skip-below-V7. Mask the FS core to detected ABI (from_all(abi)) so a healthy kernel returns FullyEnforced.
One right beyond the floor, RESOLVE_UNIX (V9 / kernel 7.1), walls connect() to a pathname AF_UNIX socket (docker.sock / ssh-agent). It is additive hardening applied best-effort: active only when built --features resolve-unix AND the kernel is ≥ V9 (resolve_unix_active), and granted in no tier so an out-of-domain pathname connect returns EACCES. When inactive, pathname-socket connect is a residual (§2), reported loudly at seal and by the warn_on_sockets lint. The degradation is fail-safe — the missing right only ever removes a restriction, never widens a grant, and its absence is never silent. abi_from_version clamps to the crate ceiling (V7 without the feature, V9 with it); abi_from_version_clamp (Kani, make verify) proves that clamp never over-claims. A hard V9 floor was rejected: it would make oring refuse on nearly every machine (7.1 is recent) and force a vendored fork, where the graceful design keeps the checked-in dependency the published crate (§10).
rust-landlock chooses per-direction via handle_access (go-landlock's connect+bind coupling trap does not apply — one ruleset).
egress_tcp/ingress_tcpeach modeledOption<Vec<u16>>.None⇒ don't handle that direction (unrestricted).Some(ports)⇒ handle + oneNetPort::new(port, access)per port.Some(vec![])⇒ handled, zero rules ⇒ explicit deny-all.- Do NOT mask net to detected ABI. For any requested net restriction (connect or bind, including the empty-vec deny-all), set
CompatLevel::HardRequirementbeforehandle_access— never let deny-all silently become allow-all, never launder a dropped net ring intoFullyEnforced. TCP net is V4 and always present at the V7 floor, so this never degrades; there is no opt-in escape. - Landlock net gates only the
connect(2)/bind(2)hook, and only by port — not IP, not protocol. Two consequences the operator must know: an allowed port reaches any IP (port-only, §2), andsendto(…, MSG_FASTOPEN, …)performs an implicit connect inside the send path that never reachessecurity_socket_connect. The TCP port wall therefore holds only because ring 2 blocksMSG_FASTOPEN(§5); Landlock alone does not close it. Domain/IP egress is a required host companion control (§9).
scope(Scope::from_all(abi)) ⇒ AbstractUnixSocket|Signal, always present at the V7 floor (scope is V6). An out-of-domain abstract-socket connect → EPERM, and signalling peers outside the domain is blocked. In the container path this containment also comes from netns/pidns; oring's scope ring is defense-in-depth there.
After create() and before restrict_self(), best-effort (gated ABI≥V7): log_new_exec(true) (LOG_NEW_EXEC_ON — log the confined child's denials) + log_same_exec(false) (suppress oring's own pre-exec noise). Single call covers FS+net+scope (one ruleset). Records surface to the HOST auditd/dmesg only — the confined uid-1000 child cannot and should not read them. Zero cost where the host has no audit path.
Enforcement is guaranteed by ABI-masking of the FS core (FullyEnforced by construction on any non-broken V3+ kernel), not by the status check. The status gate is a tripwire, not the security-meaningful control:
NotEnforced→ fatal, actionable: "Landlock present but NotEnforced; kernel likely booted withlsm=disabling it orCONFIG_SECURITY_LANDLOCKoff — refusing."PartiallyEnforced→ fatal tripwire for a broken rust-landlock/kernel invariant (unreachable under FS-only masking); message prints detected ABI + the handled-access set oring itself built.no_new_privs == false→ fatal (internal bug — oring set it in step 4).
Additionally emit a per-ring effective report (fs=enforced, net=enforced|refused, scope=on|dropped(needs V6, host Vn)) so oring never prints green when a requested ring was dropped.
let abi = ABI::new_current();
if abi < ABI::V7 { refuse(120, "Landlock ABI < V7 (need kernel 6.15+); detected {abi:?}"); }
// FS core: masked to detected ABI (>= V7 by the floor above).
let mut rs = Ruleset::default()
.set_compatibility(CompatLevel::BestEffort)
.handle_access(AccessFs::from_all(abi))?;
// Net: HARD requirement — TCP (V4) is always present at the V7 floor.
if policy.net_requested() {
rs = rs.set_compatibility(CompatLevel::HardRequirement);
if let Some(_) = policy.egress_tcp { rs = rs.handle_access(AccessNet::ConnectTcp)?; }
if let Some(_) = policy.ingress_tcp { rs = rs.handle_access(AccessNet::BindTcp)?; }
rs = rs.set_compatibility(CompatLevel::BestEffort);
}
if policy.scope_ipc { rs = rs.scope(Scope::from_all(abi))?; } // scope (V6) present at floor
let created = rs.create()?;
created.set_log_new_exec(true).set_log_same_exec(false); // best-effort V7
// ... add_rules(fs tiers, net ports) ...
let status = created.restrict_self()?;
gate(status); // tripwire + per-ring reportDenylist, decisively — default = Allow with targeted ERRNO blocks. oring wraps arbitrary dev toolchains (cargo/npm/pip/go/node/git/gcc/clang/python); an allowlist has to track the moving union of every runtime's syscalls — one miss = SIGSYS mid-build. This is why Docker's own default is a denylist. Job: delete the well-known LPE launchpads. A denylist can't stop a 0-day in an allowed syscall — Landlock is the containment backstop.
Errno, never blanket-kill (per-syscall action, chosen by what the denial should look like to the caller):
ENOSYS(38)where a userspace fallback exists:io_uring*,userfaultfd,clone3,splice/tee/vmsplice,memfd_create,execveat,pidfd_open,keyctl/add_key/request_key.EPERM(1)where there's no fallback:mount/ptrace/bpf/kexec/reboot/etc.EAFNOSUPPORT(97)for thesocketfamily filter.EOPNOTSUPP(95)for the MSG_OOB / SIOCATMARK filters.ENOTTY(25)for the file-ioctl defense-in-depth set (deferred tostrict/v2 — not in shippedbalanced).
KillProcess reserved for a future paranoid profile (turns capability probes into crashed builds). Notify/user-notif out of scope (reintroduces a supervisor).
seccompiler's auto arch-prologue compares only seccomp_data.arch == AUDIT_ARCH_X86_64. It kills i386/int-0x80 (arch differs) but does NOT close x32: x32 runs in long mode with arch == AUDIT_ARCH_X86_64, distinguished by __X32_SYSCALL_BIT (0x40000000) OR'd into seccomp_data.nr. An x32-numbered ptrace/bpf/io_uring/mount/unshare passes the arch guard, matches no number-keyed deny rule, and falls through to default=Allow — reopening every blocked launchpad on any CONFIG_X86_X32_ABI=y kernel (stock Ubuntu/Debian generic). seccompiler's exact-u32-nr rule model cannot express a masked-bit test.
Fix: install a hand-assembled 7-instruction cBPF guard as a separate SET_MODE_FILTER call installed FIRST (filters stack; a KILL in any filter wins):
LD seccomp_data.arch
JEQ AUDIT_ARCH_X86_64 -> next ; else RET KILL_PROCESS (kills i386 + foreign arch)
LD seccomp_data.nr
JSET 0x40000000 -> RET KILL_PROCESS (any x32-bit call)
RET ALLOW ; defer to the real denylist
Whole-ABI kill is safe (a cap-drop=ALL uid-1000 dev toolchain never issues x32). This is a small audited splice, not a hand-rolled filter — the insta rule-snapshot (§11) makes it reviewable. The x32 re-entry (nr | 0x40000000) shares AUDIT_ARCH_X86_64, so a number-keyed deny is bypassable without it; the guard KILLs foreign/compat arches outright.
The denylist is the known kernel escape surface, selected from first principles: each entry is an LPE launchpad, a kernel-attack-surface syscall, a named-CVE mitigation, or device-node creation — a functional set a cap-drop=ALL+NNP uid-1000 dev toolchain never legitimately needs. Grouped by errno (§ mechanism below); every row carries its rationale. This table is the representative core; the authoritative, exhaustive set is the compiled-in &[SyscallRule] in src/seccomp.rs (snapshot-tested, §11) — read it, not this table, when reasoning about coverage.
| Syscall(s) | Match | Action | Rationale |
|---|---|---|---|
io_uring_setup/enter/register |
all | ENOSYS | async-IO LPE surface; runtimes fall back |
userfaultfd |
all | ENOSYS | UAF-exploit primitive |
memfd_create |
all | ENOSYS | fileless-exec staging |
clone3 |
all | ENOSYS | struct args unreachable to cBPF → glibc/Go fall back to clone |
execveat |
all | ENOSYS | fexecve/memfd exec vector (execve stays ALLOWED) |
pidfd_open |
all | ENOSYS | pidfd LPE surface |
splice/tee/vmsplice |
all | ENOSYS | Dirty-Pipe (CVE-2022-0847) |
keyctl/add_key/request_key |
all | ENOSYS | keyring LPE |
bpf |
all | EPERM | eBPF LPE surface |
perf_event_open |
all | EPERM | perf LPE surface |
ptrace/process_vm_readv/process_vm_writev/kcmp/process_madvise/pidfd_getfd |
all | EPERM | cross-process (does NOT cover /proc/pid/mem — §2) |
mount/umount2/open_tree/move_mount/fsopen/fsconfig/fsmount/fspick/pivot_root/chroot/setns |
all | EPERM | mount/ns manipulation |
mknod/mknodat |
all | EPERM | device-node creation (belt w/ Landlock) |
unshare |
7 × MaskedEq, one per CLONE_NEW* bit |
EPERM | per-flag (§ below) |
clone |
7 × MaskedEq, one per CLONE_NEW* bit |
EPERM | per-flag; blanket block would break threads |
kexec_load/kexec_file_load/init_module/finit_module/delete_module |
all | EPERM | kernel image/module |
reboot/swapon/swapoff/acct/quotactl/vhangup/syslog |
all | EPERM | admin ops |
ioperm/iopl/pciconfig_* |
all | EPERM | port IO |
settimeofday/clock_settime/adjtimex/clock_adjtime/sethostname/setdomainname |
all | EPERM | global mutation |
fanotify_init/fanotify_mark |
all | EPERM | fs-wide notify |
move_pages/migrate_pages/modify_ldt |
all | EPERM | niche LPE surface |
seccomp |
all | EPERM | self-defense |
socket/socketpair |
Ge(family, 17) |
EAFNOSUPPORT | block PACKET/TIPC/ALG/VSOCK; allow UNIX/INET/INET6/NETLINK (socketpair walks the same family gate) |
send/sendto/recv/recvfrom |
MaskedEq(arg3 & MSG_OOB) | EOPNOTSUPP | CVE-2025-38236 AF_UNIX MSG_OOB UAF |
sendmsg/recvmsg |
MaskedEq(arg2 & MSG_OOB) | EOPNOTSUPP | same — flags is arg2 for the msg variants |
sendmmsg/recvmmsg/recvmmsg_time64 |
MaskedEq(arg3 & MSG_OOB) | EOPNOTSUPP | same — flags is arg3 for the mmsg variants |
ioctl |
Eq(arg1 == SIOCATMARK 0x8905) | EOPNOTSUPP | MSG_OOB pair |
sendto/sendmmsg |
MaskedEq(flags & MSG_FASTOPEN) | EOPNOTSUPP | implicit-connect egress that never reaches Landlock's connect() hook — the only reason the TCP port wall holds (ATTACKS §3a) |
open_by_handle_at/name_to_handle_at |
all | EPERM | fd-from-handle reopen that sidesteps path-based Landlock |
ioctl |
Eq(arg1 ∈ {TIOCSTI, TIOCLINUX, TIOCSETD}) | EPERM | TTY injection |
ioctl |
Eq(arg1 ∈ file-ioctl set below) | ENOTTY | file-fd defense-in-depth — deferred to strict/v2, not in shipped balanced |
mmap/mmap2 |
MaskedEq(arg3 & MAP_GROWSDOWN) | EPERM | StackRot (CVE-2023-3269) |
prctl |
MaskedEq(arg0 == PR_SET_DUMPABLE & arg1 > 0), (arg0 == PR_SET_PTRACER) | EPERM | re-dumpable / ptracer |
File-ioctl defense-in-depth set — deferred to strict/v2, NOT shipped in balanced (exact-cmd Eq→ENOTTY, same mechanism as TIOCSTI; encoding a wrong ioctl number breaks benign ioctls, so it waits for the tighter profile): FS_IOC_ENABLE_VERITY (irreversible in-place immutability), FS_IOC_FIEMAP (physical-layout leak), FS_IOC_ADD_ENCRYPTION_KEY/FS_IOC_REMOVE_ENCRYPTION_KEY, XFS_IOC_FSBULKSTAT/XFS_IOC_BULKSTAT/XFS_IOC_FSGEOMETRY (cross-tier inode metadata on ext4/xfs), EXT4_IOC_ALLOC_DA_BLKS. These are metadata-leak / own-tier DoS, not confinement escape — they tighten the already-conceded writable-tier residual. They matter mainly on a bare-metal host with real ext4/xfs; largely moot on a container tmpfs/overlay. Tools fall back cleanly on ENOTTY (verified: cp/rsync do).
Deliberately NOT in v1:
personality— cut frombalanced. Withmprotectunrestricted (mandatory for JIT/GC runtimes), blockingREAD_IMPLIES_EXECbuys no W^X margin an attacker with code-exec can't already get viamprotect(PROT_EXEC), andADDR_NO_RANDOMIZEself-disable is negligible post-compromise; blanket-blocking inbalancedonly risks breakingsetarch/ASAN-re-exec/emulators.strictDOES blockpersonality(READ_IMPLIES_EXEC)— theremprotect/pkey_mprotect(PROT_EXEC)are filtered for true W^X (§7), sopersonality(RIE)is a real escape route that arg-filtering must also close (seccomp.rs).- Block-device / loop / dm ioctl wall (
BLK*, loop 0x4c, dm 0xfd) — dead under cap-drop=ALL + Landlock device-node denial + no block-dev nodes. →strictlater. - btrfs 0x94 magic block — its only unprivileged member is the benign
FICLONE/FICLONERANGE(reflink copy); every destructive member is CAP_SYS_ADMIN-gated. An order-dependent "ALLOW FICLONE first, then ERRNO the 0x94 magic" carve-out is impossible with cumulative seccomp filters (ERRNO outranks ALLOW; seccompiler binds one action per filter). So oring does NOT block 0x94 at all — no protection lost. Invariant: oring only ever uses whole-syscall or whole-magic blocks with no benign-exception; any in-mask carve-out is a libseccomp-only capability oring does not carry.
cBPF/seccompiler cannot do "any bit in mask set" in one compare (no masked-not-equal op). A single MaskedEq(mask=OR-of-all-NEW*, value=OR-of-all) matches only when ALL bits are set — trivially bypassed by unshare(CLONE_NEWUSER) alone (the userns-LPE launchpad). Emit 7 rules per syscall, each MaskedEq(mask==value==single bit): NEWNS 0x20000, NEWCGROUP 0x2000000, NEWUTS 0x4000000, NEWIPC 0x8000000, NEWUSER 0x10000000, NEWPID 0x20000000, NEWNET 0x40000000. The security-load-bearing bit is CLONE_NEWUSER (the unprivileged-userns LPE launchpad); the other six namespace flags are cheap defence-in-depth on the same syscalls.
- seccompiler 0.5.x (Firecracker, pure Rust —
libcbindings + optional serde, no-sys, no C link). Only option that keeps the musl-static entrypoint AND givesSeccompCmpOp::MaskedEq. Reject libseccomp-rs (linkslibseccomp.so). Reject hand-rolled cBPF except the audited x32 guard splice. - Per-syscall errno mixing = N cumulative sub-filters, one per DISTINCT errno. Not "two." Partition the deny set so each blocked syscall matches in exactly one sub-filter (
mismatch_action=Allow,match_action=Errno(e)). v1balanced= 4 sub-filters (EPERM, ENOSYS, EAFNOSUPPORT, EOPNOTSUPP) + the x32 KILL guard installed first (the file-ioctlENOTTYset is deferred tostrict/v2, above). Filters stack; ERRNO outranks ALLOW; no ties. Cost is an N-filter stack of linear partition scans + N−1 arch prologues, not a blanket "double" — quantify against actual partition sizes. The errno partition is a property of each syscall's denial semantics (pretend-absent → ENOSYS, denied → EPERM/EACCES, unsupported family → EAFNOSUPPORT), so the filter count follows the profile automatically. - Arch: x86_64 only + kill-on-mismatch + the x32 guard. aarch64 is a compile-time target only, added when a native arm64 Linux host becomes a security target (landlock 0.4.5 + seccompiler 0.5 both support it).
- Representation: blocklist as a compiled-in Rust
&[SyscallRule]— no JSON-at-runtime on the security-critical path, type-checked, useslibc::SYS_*constants (no syscall-name table).
pipeline::seal() runs this exact sequence; main then execs. Every step is HARD (?) — there is no SOFT category in v1 (both former "soft" steps are effectively infallible for a valid unprivileged caller; promoting them makes "every step fail-closed" literally true). panic="abort" ⇒ no partial-seal-then-exec. Note panic="abort" is set on the release profile only (§12); debug/test builds are panic="unwind". The seal is structurally safe under either: seal() -> Result<Infallible>, its execve is the last statement, so a panic anywhere unwinds past (never reaching) the exec — a future dev must not add a catch_unwind around the seal assuming abort semantics hold in every profile.
1. parse argv split at "--"; require a target after it (else exit 2)
2. load policy --policy | ORING_POLICY env | --profile balanced (none → refuse, exit 120)
3. resolve target PATH lookup → absolute STRING; fstat: reject non-regular/non-exec;
assert target under a read_exec/write_exec rule (not found → exit 122)
4. NO_NEW_PRIVS prctl(PR_SET_NO_NEW_PRIVS,1) (HARD: before seccomp)
5. DUMPABLE=0 prctl(PR_SET_DUMPABLE,0) (HARD; oring-window only)
6. LANDLOCK ring 1; core hard-fail (HARD: before seccomp)
7. signal-mask reset sigprocmask(SIG_SETMASK, empty) (HARD)
8. FD scrub close_range(3, ~0, CLOSE_RANGE_CLOEXEC)
9. build argv/env strip ORING_POLICY (pure memory)
10. SECCOMP x32 guard filter, then N errno sub-filters (LAST restriction)
11. attest + execve emit seal line to stderr, then execve resolved path, clean env
Rationale:
- Resolve target (3) before Landlock (6). Resolve to an absolute string first → clean
exit(122)on missing binary, rather than a post-sealEACCESthat looks like a malfunction.fstatonce to reject non-regular/non-executable and enforce the read_exec-coverage check (§8), then close that fd. No O_PATH fd is held — TOCTOU closure would requirefexecve/execveat, which oring deliberately blocks (memfd fileless-exec; cBPF can't safely arg-filter it), and the race has no attacker in either deployment (read-only rootfs in-container; single-threaded, no child, no local writer standalone). - NNP (4) before seccomp (10). Unprivileged
SET_MODE_FILTERreturns EACCES without NNP. Set it explicitly to decouple from Landlock ordering. DUMPABLE=0(5) — one syscall; protects oring's own pre-exec window only (blocks same-uid/proc/self/mem/ptrace-attach while policy sits in memory; suppresses a policy-leaking core dump). NOT inherited across execve — the kernel resets the child to dumpable. Child-side ptrace/mem protection comes from the seccompptrace/process_vm_*denials, NOT this step (and does NOT cover peer/proc/<pid>/mem— §2).- Landlock (6) before seccomp (10) so
landlock_*setup syscalls aren't blocked. - Signal-mask reset (7) — execve inherits the mask unchanged; a parent with signals blocked silently hands the child a bad mask. Correctness fix a Go-managed sealer doesn't hit.
- Disposition normalization (11a) — execve resets caught handlers but preserves
SIG_IGN, a separate inheritance channel from the mask. Rust installsSIGPIPE=SIG_IGNat startup, so an un-normalized child seesEPIPEwhere it should die (producer | head -1hangs;$PIPESTATUSreads 0, not 141). Every ignored signal exceptSIGKILL/SIGSTOPis restored toSIG_DFLimmediately before execve. Placement is after the seal-line write for ordering hygiene only — not, as first documented, because oring's ignoredSIGPIPEprotects that write:eprintln!panics on failure andpanic=abortconverts that to an abort, so a closed stderr prevents the exec under either ordering. POSIX hygiene, not a confinement control. Known regression: this also clears a deliberately inheritedSIG_IGN, sonohup oring -- cmdno longer hands the child an ignoredSIGHUP. - FD scrub (8) =
close_range(3, RawFd::MAX, CLOSE_RANGE_CLOEXEC)— not the/proc/self/fdwalk (that walk needsREAD_DIRon a/procrule the lean profiles don't grant → EACCES → the pipeline would brick every invocation; granting/procto fix it reintroduces the peer-procfs residual §2 disclaims).close_rangeneeds no path, is indifferent to Landlock ordering, preserves CLOEXEC-not-close semantics, and (critically) does NOT close the exec path since we hold no target fd. Baseclose_rangeis 5.9+,CLOSE_RANGE_CLOEXEC5.11+ — both well below the V7/6.15 floor. No async anywhere (tokio would spawn the epoll/eventfd/pipe FDs this defeats). Sync-only, hard architectural constraint. - Env-build (9) — the child's env is an allowlist (
BASE_ENV_KEEP, §8), built fresh, not an inheritance minusORING_POLICY. The old claim that "env is not a security boundary here" was wrong in one specific way: the child being sandboxed does not stop it reading a credential it was handed, and a credential in the env is exactly the exfil half SECURITY.md owns.LD_PRELOADinto the child is not a bypass;LD_*into oring would be catastrophic but oring is static-musl with no dynamic linker →LD_*ignored (a security reason for the musl constraint). - Attest + Seccomp + execve (10–11). Cross-ring contract: the filter MUST allow
execve,exit_group, andwrite(for the seal line), or oring bricks itself.execveallowed; onlyexecveat→ENOSYS.
The policy is version + six FS/net fields (read_only, read_exec, write_noexec, write_exec, egress_tcp, ingress_tcp) + a minimal seccomp selector.
{
"version": 1,
"landlock": {
"read_only": ["/etc","/usr/share"],
"read_exec": ["/usr/bin","/lib","/lib64","/usr/lib","/usr/libexec"],
"write_noexec": ["/run","/var/log","/tmp"],
"write_exec": ["/work/repo"],
"egress_tcp": [443],
"ingress_tcp": [],
"scope_ipc": true
},
"seccomp": { "profile": "balanced" }
}#[derive(Deserialize)] #[serde(deny_unknown_fields)]
struct Policy { #[serde(default="one")] version: u32,
#[serde(default)] landlock: Landlock,
#[serde(default)] seccomp: Seccomp }
#[derive(Deserialize, Default)] #[serde(deny_unknown_fields)]
struct Landlock {
#[serde(default)] read_only: Vec<PathBuf>, #[serde(default)] read_exec: Vec<PathBuf>,
#[serde(default)] write_noexec: Vec<PathBuf>, #[serde(default)] write_exec: Vec<PathBuf>,
#[serde(default)] egress_tcp: Option<Vec<u16>>,// None=unrestricted, Some([])=deny-all
#[serde(default)] ingress_tcp: Option<Vec<u16>>,
#[serde(default="yes")] scope_ipc: bool }
#[derive(Deserialize, Default)] #[serde(deny_unknown_fields)]
struct Seccomp { #[serde(default)] profile: SeccompProfile } // v1: only Balanceddeny_extra/allow_extra are cut from v1 — no v1 consumer, and validating names would drag a syscall-name→number arch table onto the (sole) config-parse path. Deferred to §13-later.
Validation (parse-time, before any restriction): version==1; deny_unknown_fields (a typo'd read_execc errors, never silently grants nothing); absolute paths only; port ≤65535 (serde-free); target binary must be under some read_exec rule (else specific error, not a cryptic execve: EACCES); error on a missing --workdir (typo-confines-to-nothing footgun); warn on missing system dirs; loud warn on egress_tcp containing 53 (UDP DNS not covered) and on any granted tier containing/ancestoring a live socket inode (docker.sock/podman.sock/containerd//run — §2 escape). Every message prefixed oring:, stderr, non-zero exit.
Self-protection: Policy::check_self_protection (run after validate, once every tier is final) hard-refuses when oring's own binary or the policy file resolves under any write tier — the --policy ./oring.json --workdir . shape, where the child could rewrite the policy governing its own next run. An unresolvable current_exe() WARNs rather than refuses (an unreadable /proc/self/exe is an environment defect, not a policy input).
Deny-by-default — three states: no policy resolves → refuse (exit 120); policy grants nothing → refuse with the target-not-covered message; policy with grants → apply. Landlock < V7 floor → refuse (§4); seccomp load failure → refuse.
Compiled-in constant; auto-includes standard read-exec system dirs so the common case is "name your workdir." Agent edits a repo + HTTPS:
- read_exec:
/usr/bin /usr/local/bin /bin /lib /lib64 /usr/lib /usr/libexec /opt - read_only:
/etc /usr/share+/proc(read-only, sogit/node/pythoncan read/proc/self/*; peer secret reads stay walled by Landlock's ptrace hook, §2) - write_exec: workdir (build agent compiles+runs in-tree — the in-tree W^X residual is documented in §2)
- write_noexec:
/tmp /var/log /var/cache /var/tmp+ the standard character devices - HOME toolchain caches:
~/.rustupread_exec;~/.gitconfigread_only;~/.npm ~/.cachewrite_noexec; and the split below egress_tcp:[443];ingress_tcp:[](deny-all TCP bind — a fixed-port dev server or test listener, e.g.python -m http.server 8000, getsEACCES; widen with a--policynaming the port);scope_ipc:true; seccompbalanced.
~/.cargo and ~/go are split, and the split is load-bearing. bin/ under each is on the
operator's $PATH, so granting write there is an escape that needs no kernel bug: the child drops a
binary, the operator runs it later unconfined at their own uid. The tiers are therefore
| path | tier | why |
|---|---|---|
~/.cargo, ~/go |
read_only | cargo reads config.toml/env; these are parents of bin/ so they must not carry write |
~/.cargo/bin, ~/go/bin |
read_exec | run the shims; never writable |
~/.cargo/registry, ~/.cargo/git, ~/.cargo/.package-cache, ~/go/pkg |
write_noexec | where the toolchains actually write |
The shape is forced by Landlock's combination rule: "One policy layer grants access to a file path
if at least one of its rules encountered on the path grants the access"
(Documentation/userspace-api/landlock.rst). Rules union along the path walk — a narrower rule
does not override a wider one. A write grant on ~/.cargo would therefore confer write on
~/.cargo/bin regardless of any rule placed there, so the write grants go on sibling subtrees and
never on a parent of bin/. Nothing executes from these caches (cargo builds scripts and proc-macros
into target/; go build links into $TMPDIR), so no-exec costs nothing.
Compat, stated plainly: cargo install and go install do not work inside the cage — they write
bin/. ~/.cargo/.package-cache is cargo's top-level lock file, granted as a file rule so it
does not widen ~/.cargo; on a $HOME that has never run cargo it does not exist yet, is skipped
like any missing path, and cargo cannot create it under a read-only ~/.cargo. Run cargo once
unconfined first, or grant it via --policy.
strict ships alongside balanced (by_name resolves both; src/profile.rs): untrusted-binary posture — nothing granted, workdir write_noexec for true W^X, deny-all egress, and a seccomp-blocked pkey_mprotect(PROT_EXEC) so the W^X wall can't be reopened. --profile accepts balanced (default) or strict. A dev (interactive, wide) profile is still deferred until a wired consumer exists; anything between or beyond the two profiles is expressed via --policy.
--workdir <path> injects one path into balanced's write_exec. oring asserts the resolved target binary is already under a read_exec/write_exec tier and refuses (exit 122) if it is not — it does NOT auto-grant the target. No mount-scraping, no $PATH/$HOME globbing (predictable > clever).
oring [OPTIONS] -- <command> [args...]
--policy <FILE> policy JSON file (wins over env)
--profile <NAME> baked profile: balanced (default) | strict
--workdir <DIR> inject DIR into the profile's writable workdir tier
(write_exec for balanced; write_noexec for strict)
--exec-tmp <DIR> create DIR (0700), grant write_exec, point child's TMPDIR at it,
for tools that write-then-exec in scratch. /tmp stays no-exec.
--proc <MODE> /proc grant: full (default) | self | none (self closes peer-procfs)
--env-clear pass NO env to the child except --env-keep vars
--env-keep <NAME> allowlist one more env var (repeatable). Child gets POSIX basics by
default; credentials (SSH_AUTH_SOCK/*_TOKEN/*_API_KEY/AWS_*) must be named
--env-inherit pass the FULL parent env (legacy default; unsafe — child reads every credential)
--require-all-grants refuse to seal if any intended path could not be granted (CI)
--denials <PID> report what Landlock DENIED to a sealed run, from the kernel's V7 audit
--since <WHEN> journal window for --denials (default: -1 hour)
--explain print the effective plan and exit (no execve)
--check validate policy+target+ABI floor (no execve)
-v, --verbose emit the effective plan to stderr before execve
-- everything after = target argv (required)
Policy also via ORING_POLICY (JSON env). ORING_QUIET=1 suppresses the seal line.
DELIVERY PRECEDENCE (highest wins; refuse if none): --policy > ORING_POLICY > --profile.
(SANDBOX_POLICY and ORING_PROFILE are NOT accepted — no consumer; keeps one parser shape.)
--profile supplies baked defaults; --policy/ORING_POLICY whole-replace it (no merge).
EXIT: 120 confinement refused (no policy / Landlock < V7 floor / seccomp load failed)
121 policy parse/validation error
122 target not found
159 seccomp KILL (x32/arch guard) = 128+SIGSYS
* child's execve-preserved status
Exit codes are deliberately out of the 125/126/127 shell+docker band and the 128+N signal band so a fail-closed security event is never confused with a benign child sh -c exec failure. Every refusal emits one machine-parseable stderr line oring: REFUSE reason=<slug> (code=120); a successful seal emits (§6 step 11, §11) a positive seal line (§12).
Diagnosis path (self-applied cages give bare errnos with no attribution):
--explain— pre-exec string dump: resolved FS tiers + path lists, connect/bind port sets, IPC scope state, detected ABI vs floor and which rings actually engaged, seccomp profile + one-line block summary, target read_exec coverage. No deps, no execve.-v/ORING_VERBOSE=1— emit that same one-block summary to stderr right before execve, so a failed build's log carries policy context inline.- Documented errno→cause table in
--help/README: EACCES on a path = Landlock FS; EACCES onconnect()= port not inegress_tcp/ net ABI absent; ENOSYS on io_uring/memfd/clone3 = oring seccomp; EPERM on ptrace/mount/bpf = oring seccomp. - Ceiling stated plainly: in-band per-denial attribution is impossible without a supervisor (seccomp-notify is out of scope; ptrace/strace are blocked by oring's own filter).
--explain+ verbose-startup + the errno table are the intended in-band workflow;--denials <pid>reads the host-side V7 audit records after the fact (operator-side, not readable by the confined child).
--exec-tmp <DIR>grants one operator-namedwrite_execscratch dir and wires the child'sTMPDIRto it, for tools that write-then-execvein temp (Miri's sysroot build,cc/ld,-sys/bindgen build scripts,go test/go run) which/tmp(write_noexec) denies. It is created at 0700 — a world-writable scratch would let another local uid plant a binary the confined child then runs. A pre-existing path is re-validated, not trusted: oringlstats it and refuses a symlink (elsecanonicalizewould grant the link's targetwrite_exec— a widening) or a group/other-writable directory. The grant is folded in beforecheck_self_protection, so pointing--exec-tmpat the oring binary or policy still refuses./tmpitself stays no-exec — this opens exactly one dir. Residual: a TOCTOU swap by an attacker who controls a writable parent — name the scratch under sticky/tmpor an operator-owned dir. Pinned bytests/exec_tmp.rs.--proc <MODE>=full(default) |self|none.selfgrants own-pid procfs plus a curated global allowlist, closing even the peer-metadata leak (§2);nonegrants no/proc.- Env allowlist — the child's env is built fresh from
BASE_ENV_KEEP(POSIX/locale basics) plus any--env-keep NAME; credentials are withheld by construction, not by enumerating what to drop.--env-clearpasses only what is named;--env-inheritrestores the legacy pass-everything default and is documented as unsafe (a compromised child reads every credential in the shell). The rule is the §5 rubric applied to a second namespace: enumerate what gets in, never what to drop. Pinned bytests/deny_env.rs.
oring runs as a container ENTRYPOINT: the runtime sets the hardened container
(cap-drop, no-new-privileges, read-only rootfs, non-root uid), and oring adds
the Landlock + seccomp rings in-process before execveing the workload. Two
things the integrator owns: (a) the policy must name container paths, not
host paths — Landlock resolves each rule inside the container mount ns, so a
host path is silently ENOENT-skipped; (b) a write_exec tier only takes effect
on a mount without noexec (intersection: the mount wins), so exec-scratch
needs an exec-capable mount. Deliver the policy via ORING_POLICY (env) or
--policy (file), and keep a reaper as PID 1 (e.g. --init) since oring is
execve-only and does not reap.
- Egress: host nftables default-deny + a credential proxy/broker. oring cannot do domain/IP egress or hide the API key from the process that legitimately holds it (§2). Shipping oring without a paired egress control gives a false sense of containment for exfil.
- Peer procfs: runtime PID namespace +
maskedPathson/proc/<pid>/{mem,environ,maps,kcore}(default docker/podman provide both). Without a PID ns (standalone), broad/procread exposes every uid-1000 peer's secrets. - Privileged sockets: at V9 ring 1 now blocks
connect()to a pathname unix socket (RESOLVE_UNIX,EACCES) and to abstract sockets (scope,EPERM) — see §4. Bind-mountingdocker.sock/podman.sock/containerd is nonetheless discouraged (defense-in-depth; and it is still unmediated on a V7/V8 kernel). oring's extra contribution is the policy-apply-time socket lint (§7), now ABI-gated to fire only where the connect is actually unmediated.
Same static binary: oring --policy /etc/oring/rig.json -- ./fuzzer .... All rings unprivileged, no root/MAC/pre-installed profile. Hard floor well below a modern host (e.g. Alpine on 6.18). No IPC/signal boundary here (shared host netns+pidns, kernel likely <6.12): a compromised child can SIGKILL same-uid host processes (incl. a supervising orchestrator) and reach host abstract sockets (systemd/D-Bus). Companion control: launch the target inside a PID+net namespace (unshare/bwrap/systemd-run) or on ≥6.12. Do NOT grant /run (or grant only specific non-socket subpaths). Keep /tmp/scratch write_noexec so the default profile stays W^X-clean where no noexec mount sits above oring.
Single crate, bin+lib split (core is unit-testable and reusable in-process on the Xen rig).
src/ main.rs (arg split, load, call pipeline::seal, execve)
lib.rs error.rs
policy.rs (serde + validation; CONFIG edge — parent-supplied, pre-attacker)
landlock.rs seccomp.rs preexec.rs denials.rs
sys.rs (ALL raw FFI — each site an `unsafe {}` block with a SAFETY: note)
pipeline.rs (THE ORDER) profile.rs (balanced + strict constants)
tests/ deny_fs deny_net deny_syscall deny_x32 deny_env deny_strict exec_tmp
signal_dispositions policy_props seccomp_smt containment_matrix common/
[dependencies]
landlock = "0.4.5" # pure Rust, no C, musl-clean (published crate; tops at V7)
seccompiler = "0.5" # pure-Rust BPF emitter; MaskedEq; no TSYNC (single-threaded)
libc = "0.2" # raw syscalls (prctl/close_range/execve/sigprocmask/seccomp) + SYS_*/flag consts
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[dev-dependencies]
insta = "1"
proptest = "1" # property tests over the policy layer
libc = "0.2" # integration tests are separate crates; the containment jail needs it directly
[profile.release]
opt-level="z" lto=true codegen-units=1 strip=true panic="abort" # fail-closed: no partial-seal-then-exec
[build-dependencies]
# build.rs: capture git SHA for the seal line (§12), SOURCE_DATE_EPOCH-safelibc, notrustix/nix — seccompiler already pullslibcin andlibcis musl-static-clean, so using it for the raw syscalls avoids a second FFI dependency. (The original §10 sketch specifiedrustix(linux_raw); the shipped crate useslibc— all raw FFI is isolated insrc/sys.rs, each site in its ownunsafe {}block with aSAFETY:note;unsafe_codeis denied crate-wide outside that module.)- No clap — grammar is a
--scan + a few flags (~40 LOC hand-rolled). clap is the largest subtree in a binary whose point is being tiny. - thiserror in lib (callers pattern-match which ring failed), anyhow at the bin edge.
run() -> Result<Infallible>encodes fail-closed at the type level. - edition 2021;
rust-version = "1.80"(Cargo.toml), built--locked. - Single-threaded through the whole pipeline — Landlock
restrict_selfand seccompapply_filterare per-thread; a second thread = only the caller sealed. Hard architectural constraint, and enforced:seal()refuses if/proc/self/taskshows >1 thread, so an embedder calling it from a threaded process fails closed rather than leaving siblings unconfined while printing SEALED (the shipped CLI is single-threaded; this guards the in-process reuse path).
a. Unit (runs anywhere incl. macOS): serde round-trip, malformed JSON → PolicyParse, deny_unknown_fields rejection, ../symlink/non-absolute rejection, port bounds, Option net None-vs-Some([])-vs-Some(ports) distinction, tier→AccessFs bitmask correctness.
b. Rule-table snapshot (deterministic, host-agnostic): insta-snapshot oring's OWN logical &[SyscallRule] (syscall nr → arg conditions incl. MaskedEq masks → action → errno), serialized deterministically (sorted by nr). This is where a denylist change appears as a one-line, legible diff — do NOT snapshot the compiled sock_filter[] (opaque codegen that churns on every seccompiler bump, training reviewers to blind-accept). Keep ONE tiny BPF-bytes canary, commented // canary: churns on seccompiler bumps; NOT a policy signal, that asserts the x32 guard's JSET 0x40000000 branch is present so the splice can't silently regress.
c. Integration (cfg(target_os="linux")) — the real assertions via self-exec probe (the MVP proof-of-boundary): oring re-execs itself with a hidden arg; child performs the op, exits with errno; parent asserts the exact errno.
deny_fs→ EACCES(13) outside tiers + control write inwrite_exectemp.deny_net→ Landlock EACCES on bad port (ECONNREFUSED on allowed port = pass).deny_syscall→ EPERM ptrace, ENOSYS keyctl,unshare(CLONE_NEWUSER)alone → EPERM (authoritative per-flag assertion, not just all-bits-set), EOPNOTSUPP on MSG_OOB send.deny_x32→ issueptracevia inlinesyscallwithrax = 0x40000000|__NR_ptrace, assert SIGSYS/kill; gate on detectingCONFIG_X86_X32_ABI, log LOUD if x32 present and blocked (never silently Allowed).
d. CI + LOUD skips (never silent-green): the GitHub hosted ubuntu-24.04 runner boots kernel ~6.8 — below oring's V7 floor — so the ring self-exec probes cannot run on the bare runner. They run inside a virtme-ng / KVM guest booting a ≥6.15 kernel (matrix at ABI V7/V8/V9, KVM available on the free runner via a udev rule). Runtime-probe ABI::new_current(); below the V7 floor → eprintln!("SKIP: …") and count the skip. Gate hard runs behind ORING_REQUIRE_KERNEL=1 so a runner that should have the kernel can't silently skip green. Unit + rule-table snapshot + the z3 SMT gate + Kani proofs run on the bare runner; macOS dev: integration cfg-gated off, unit+snapshot compile.
e. Drift alarm (roadmap, non-blocking): track the kernel's evolving LPE-primitive surface (new syscalls in the RustSec/kernel-hardening advisory stream) against oring's &[SyscallRule] and a checked-in rationale for each intentional omission, so a genuinely new escape primitive forces a human decision instead of silent lag.
Fuzzing (deferred to §13-later): the policy parser sits on a parent/config trust boundary, NOT an attacker-controlled path (the attacker is the post-execve child; nested re-wrapping only tightens). panic="abort" + refuse-on-error makes any parse bug fail-closed. So cargo-fuzz on Policy::parse is not v1 scope. cargo-deny/cargo-audit stay in CI (tiny dep tree by design).
x86_64-unknown-linux-musl, fully static, no interpreter. Non-negotiable: (1) oring is first-exec — a dynamic oring fails on distroless/scratch/Chainguard-static; (2) single relocatable artifact drops into any image or bare host; (3)LD_*ignored → can't subvert oring pre-seal (security, not just packaging).- Deps are pure-Rust/rustix-linux_raw → no C toolchain.
cargo build --release --target x86_64-unknown-linux-musl; from macOS usecross. CI gates onldd→"not a dynamic executable" +file→"statically linked". Sub-1MB expected. - Distribution: one static artifact +
.sha256. A container consumer bundles it via multi-stageCOPY(arch-aware so a native arm64 build can't get a bricked x86_64 entrypoint). The seccomp profile is inside the binary; standalone gets/etc/oring/*.jsonper host. - Reproducibility: committed
Cargo.lock+--locked, pinnedrust-toolchain.toml,SOURCE_DATE_EPOCH,--remap-path-prefix,--build-id=none;build.rs-captured git SHA feeds the seal line; two-host diff in CI. SLSA/cosign later.
Seal line (v1) — a status REPORT, not an attestation: the last action before execve (step 11), after restrict_self + seccomp apply, emit one stderr line: oring <version>+<sha> sealed ring1_fs=… abi=V<n> net=on|off scope=… audit=… nnp=1. Sourced from the actual RestrictionStatus, ABI::new_current(), and which handle_access calls fired. It is an unsigned, forgeable eprintln! — the sealed child inherits stderr and write is deliberately allowed, so the child can print an identical line. Treat it as an informational marker, not a trust boundary or proof of provenance; a downstream consumer must not treat the token as authoritative. Default ON (untrusted-agent model); suppress via ORING_QUIET=1. (The earlier ring1=degraded/net=refused sketch is stale — those states are unreachable at the V7 hard floor.) Real signed provenance (SLSA/cosign) is roadmap (§13).
- Ring 1 Landlock: 4 FS tiers + dir/file split + manual IgnoreIfMissing +
handle_access(from_all)device-node denial; hard V7/6.15 floor (hardcoded, no--abi-floor); FS masked to detected ABI; per-direction TCP with HardRequirement (V4 present at floor); scope on (V6 at floor); V7 audit logging wired;RESOLVE_UNIX(V9) opt-in hardening; status gate as tripwire + per-ring effective report. - Ring 2 seccomp: one embedded
balanceddenylist via seccompiler; x32 KILL guard prepend; kill-on-arch-mismatch; per-flag clone/unshare CLONE_NEW* (7+7); MSG_OOB/SIOCATMARK block; MSG_FASTOPEN implicit-connect block;open_by_handle_at/name_to_handle_atblock; personality cut; errno mixing via N (=4) cumulative sub-filters (file-ioctlENOTTYset deferred tostrict/v2); compiled&[SyscallRule], no JSON/name-table. - Pre-exec: NNP-first, DUMPABLE=0, signal-mask reset,
close_rangeFD-scrub (not /proc walk), env-scrub, absolute-string target resolve (no O_PATH fd), execve. Uniform HARD fail-closed (no SOFT category). - Policy envelope (version+landlock+seccomp) via
--policy/ORING_POLICY; one profilebalanced;--workdir; full validation incl. socket-in-tier lint. - UX:
--explain,--check,-v/ORING_VERBOSE, errno→cause table; exit codes 120/121/122 (out of shell/docker band); machine-parseable REFUSE + SEALED stderr tokens. - Static musl x86_64 +
.sha256+ build.rs SHA; seal line. - container-consumer wiring (reference): arch-pinned entrypoint; a reaper as PID 1; policy in container coordinates via
ORING_POLICY; read_exec covering the image toolchain; a CI smoke test that the sealed entrypoint runs the workload. - Tests: unit + rule-table snapshot + self-exec integration (fs/net/syscall/x32) + LOUD-skip CI.
-
strictprofile: full FS-ioctl magic wall (block-device/loop/dm class; whole-magic MaskedEq, NO in-mask carve-outs — libseccomp-only capability oring does not carry) + stricter FS/net. -
devprofile (interactive/trusted). - Seccomp drift-alarm CI leg (§11e).
- LATER-scope Landlock hardening: scope procfs to own-pid subtree (
/proc/<self-pid>+ curated global allowlist) to close peer-procfs read within oring's own ring (brittle — tools reading other pids break; hence not v1).
-
seccomp.allow_extrahole-punch (the one field that can weaken security) + additive floor guard;deny_extra(numeric nr only, or lift seccompiler-bin's arch table — never hand-roll). -
paranoidallowlist profile — only after the agent syscall set is measured. - aarch64-unknown-linux-musl target + multi-arch OCI manifest — when a native arm64 Linux host is a real security target.
-
--abi-flooroverride (purely additive, safe to defer); TOML front-end;--dry-runfull dump;--argv0/--env-clear/--env-keep; auto-derive Landlock from/proc/self/mountinfo; write_exec-on-noexec-mount validator; over-broad-grant lint; cargo-fuzz on the config parser; SLSA provenance.
cgroups/rlimits; MAC authoring; user-supplied seccomp JSON/BPF from untrusted input; seccomp-notify supervisor; egress firewall; credential broker; procfs masking; OCI-spec validation; any fork/supervise loop.
| # | Decision | Chosen | Rationale |
|---|---|---|---|
| D-1 | Profile names / count | One profile balanced + --policy escape hatch; flag kept, only value |
Only wired consumer is --profile balanced; strict/dev drag in the fragile full ioctl wall + a dev root footgun; dissolves OPEN #1/#9, collapses #5/#8 [10] |
| D-2 | Policy format | JSON only (env + file) | One serde front-end; TOML later if standalone authoring demands [OPEN #2] |
| D-3 | PartiallyEnforced gate |
Fatal, but demoted to tripwire; enforcement guaranteed by FS ABI-masking; actionable messages | Under FS masking FullyEnforced is tautological, so the gate never fires on real downgrade; its real jobs are NotEnforced + no_new_privs==false [38, OPEN #3] |
| D-4 | Old-kernel floor | Hardcode floor at V7/6.15, refuse below; remove --abi-floor + all sub-floor degrade |
Every deployment target runs ≥6.15, so the full FS/net/scope/audit set is guaranteed at the floor; a degrade path is a whole second untested posture — refusing below the floor is the fail-closed answer (supersedes the earlier V3/6.2 floor) |
| D-5 | Seccomp v1 content | Trimmed balanced + small file-ioctl defense-in-depth set; block-device wall + btrfs 0x94 → strict/dropped |
Block-device/dm ioctls dead under cap-drop=ALL; btrfs 0x94's only unprivileged member is benign FICLONE and its carve-out is inexpressible in cumulative filters [19, 29, 35, OPEN #5] |
| D-6 | Errno mixing | N cumulative sub-filters, one per distinct errno (v1 = 4) | balanced needs EPERM+ENOSYS+EAFNOSUPPORT+EOPNOTSUPP — one per distinct denial semantics; the ENOTTY file-ioctl set is deferred to strict/v2 [30, OPEN #6] |
| D-7 | SANDBOX_POLICY compat |
Excluded from v1 | No consumer needs it; a second flat shape widens the sole parser surface; dissolves the deny_unknown_fields drift risk [25, 32] |
| D-8 | --workdir tier |
write_exec for balanced; in-tree W^X residual documented |
balanced's real consumer is a build agent that compiles+runs in-tree [10d, OPEN #8] |
| D-9 | x32 ABI | Mandatory 7-instruction cBPF KILL guard prepend | Arch guard closes only i386; x32 shares AUDIT_ARCH_X86_64 and reopens every denied syscall via nr|0x40000000 — the guard KILLs it [1, 7, 21] |
| D-10 | MSG_OOB | Blocked in v1 (send/recv flags + SIOCATMARK → EOPNOTSUPP) | CVE-2025-38236 AF_UNIX UAF is filterable via MaskedEq already committed; deferring it was a strict regression vs the ported reference [2] |
| D-11 | FD scrub | close_range(3,MAX,CLOSE_RANGE_CLOEXEC) |
The /proc/self/fd walk needs a /proc Landlock grant the lean profiles lack → bricks every run; granting /proc reintroduces peer-procfs residual [3] |
| D-12 | clone/unshare NEW* | 7 per-flag MaskedEq rules each | A single combined mask matches only all-bits-set; unshare(CLONE_NEWUSER) alone (userns LPE) would slip through [15] |
| D-13 | personality | Cut from balanced; blocked in strict |
in balanced mprotect stays open so an RIE-block buys nothing; in strict (true W^X — mprotect/pkey_mprotect(PROT_EXEC) filtered) personality(RIE) is a real escape and is blocked [20] |
| D-14 | Landlock net ABI | HardRequirement; no silent masking of net (TCP V4 always present at the V7 floor) | Masking would launder a dropped port-restriction into FullyEnforced; empty-vec deny-all must not become allow-all [5, 8, 9] |
| D-15 | Landlock scope ABI | Best-effort, warn-if-<V6; moved to conditional stop in §2 | Scope no-op on every <6.12 host; container boundary is netns/pidns, standalone has none [18] |
| D-16 | V7 audit logging | Wired best-effort (log_new_exec/log_same_exec) | denial visibility with no supervisor — dropping it ships blind [16] |
| D-17 | O_PATH target fd | Dropped; resolve to absolute string | fexecve/execveat (the only fd-exec) is deliberately blocked, so the fd can't close the TOCTOU; no attacker in either deployment [23] |
| D-18 | Delivery precedence | 3 sources: --policy > ORING_POLICY > --profile | SANDBOX_POLICY + ORING_PROFILE have no consumer; fewer parser shapes/branches [25] |
| D-19 | deny_extra/allow_extra | Cut from v1 | No consumer; would drag a syscall-name→number arch table onto the config-parse path [22] |
| D-20 | Fuzz + parser trust label | Demote cargo-fuzz to later; relabel policy.rs "config edge" | Policy is parent-supplied, pre-attacker; nested wrapping only tightens; panic=abort fails closed [24] |
| D-21 | Exit codes | 120/121/122 (out of 125-127 + 128+N bands) + REFUSE/SEALED stderr tokens | 126/127 collide with docker + the child sh -c; a fail-closed security event must be distinguishable [28, 36] |
| D-27 | Rule-table snapshot | Snapshot logical &[SyscallRule], not sock_filter[] + one BPF canary |
Raw-BPF churns on seccompiler bumps → blind-accept; the logical table is the reviewable artifact [34] |
| D-28 | Self-verification | v1 seal line (status report) + --check predictor |
Core is already fail-closed; the gap is a positive audit trail + preflight, not fail-open detection [37, 40] |
| D-29 | SOFT pipeline steps | Eliminated; all steps HARD | Both former soft steps are effectively infallible; uniform fail-closed removes a self-contradictory contract [39] |
| D-30 | Peer procfs / AF_UNIX socket | procfs = documented residual + companion controls; abstract AF_UNIX walled by scope; pathname AF_UNIX walled only with --features resolve-unix (V9) |
VFS-read of /proc/pid/{mem,environ,maps} unmediated (runtime PID-ns/maskedPaths are the real controls); abstract by scope (EPERM) at the V7 floor; pathname by RESOLVE_UNIX (V9) opt-in, else a loudly-reported residual + warn_on_sockets [4, 6] |
| D-31 | Credential exfil | Documented required companion control (nftables + broker); do NOT auto-refuse on secret-env | oring can't do domain/IP egress or hide a key from its holder; refusing on env-heuristics is brittle [OPEN #10] |
| D-32 | Seccomp drift | Non-blocking CI drift-alarm vs a divergence-allowlist; reject superset gate / codegen | blocking every unneeded syscall is not a goal (deliberate scalpel: escape primitives only); codegen breaks hermetic static build [31] |