| Version | Supported |
|---|---|
| 1.4.x | Yes |
| 1.3.x | No |
If you discover a security vulnerability in Compartment, please report it responsibly:
- Email: nenad@micic.be
- Subject:
[SECURITY] compartment: <brief description> - Include: steps to reproduce, affected version, potential impact
Please do not open a public GitHub issue for security vulnerabilities.
- Acknowledgment within 72 hours
- Fix or mitigation within 30 days for confirmed vulnerabilities
- Credit in release notes (unless you prefer anonymity)
In scope:
- Sandbox escapes (Landlock, seccomp, namespace bypasses)
- Privilege escalation via compartment-user or compartment-root
- Environment variable injection past the sanitize filter
- Profile parsing bugs that weaken the intended policy
- Shell-replacement mode bypasses
Out of scope:
- Bugs in the Linux kernel itself (report to kernel security team)
- Attacks requiring pre-existing root access on the host
- Denial of service against the sandboxed process (not a goal)
See the README disclaimer and DESIGN.md for documented limitations, including:
- No formal verification or professional penetration testing
- Network egress bypass testing not yet automated for sandbox.sh HARD mode
- compartment-root is tested under real root by
tests/scripts/root.d/compartment-root.sh, run together with the other root-only suites bysudo make test-root. It covers container start-up,/devdevice nodes, the default seccomp deny-list, the capability and privilege drop,no-new-privs,/procand/sysmasking, namespace isolation and escape attempts (host mounts visible in the container,/proc/1/root, a pre-opened host directory fd, a setuid-root binary insiderootdir), the PID 1 reaper and signal handling, the network namespace, uid/gid mapping, cgroup path confinement and policy reporting — 101 assertions in 1.4.0, all passing on Ubuntu 24.04 (kernel 6.8.0, gcc 13.3) and Ubuntu 26.04 (kernel 7.0.0, gcc 15.2).tests/scripts/root.d/compartment-root-landlock.shadds 78 more for Landlock inside the container, theexecbinary allow-list, the mount hardening (including the recursion difference betweenrootdir-flags roandmount-ro),rootdirownership, the--netnsjoin and devpts//dev/shm;tests/scripts/root.d/limited-root.shadds 70 for the limited-root deployment over a real sshd login;tests/scripts/root.d/profile-trust-root.shadds 48 for profile trust under root;tests/scripts/root.d/sandbox-hard.shadds 10; 312 in total with the discovery smoke suite. Those counts move as suites are added: what is covered is whatevertests/scripts/root.d/contains, and the runner prints the totals it measured — run it rather than trusting this paragraph. The root suites are not part ofmake test, which stays rootless, and they are run by theroot-testsCI job rather than bymake test-integration - The uid/gid map defaults to the identity map, so the user namespace
provides a capability boundary but no uid isolation unless
uid-map/gid-mapare set (see HOWTO.md) - The limited-root deployment confines a session, not a uid. Everything
examples/limited-root.confinstalls is per-task kernel state on the login, so it says nothing about a uid-0 process that was never in the session — that is what the auth-path seal profile is for — and it creates no uid boundary at all. The complete list of what it does not protect against is HOWTO.md, "Limited root over SSH" §8, which is the authoritative copy; condensed, it is:- uid 0 is still uid 0. The Landlock allow-list is the only thing between
the account and the filesystem and DAC contributes nothing, so read policy
is as load-bearing as write policy:
ro /etchands over/etc/shadow - the
masklist is an enumeration of privileged unix sockets, and a distribution that adds one adds a hole. Landlock has no access right coveringconnect(2)to a pathname socket and seccomp cannot filter that call's address family (it is behind a pointer). The two real fixes — an allow-listmask(tmpfs over/run, needed paths bound back) and a BPF socket ACL — are both future work - a mask neutralises a write rather than refusing one: a non-directory
mask is a
/dev/nullbind that inherits the profile'srw /dev/null, and a read-only remount does not cover device nodes. Mask read and connect surfaces; use a Landlock rule for write surfaces - a path cannot be both sealed and masked — the mask fails, and a failed mask refuses the login
- below Landlock ABI v6 the session can signal processes outside its domain.
A denial-of-service surface, not an escape, and the reason the seal
profile must be loaded with
--pinrather than run as a killable daemon - sshd stream-local forwarding and
internal-sftproute around a login-shell confinement. Closed by aMatch Userblock insshd_configplus keeping the externalSubsystem sftp, and by the seal that stops the setting being edited back — that is, by configuration and a seal, not by enforcement - an
actor=identity is forgeable by an unconfinedCAP_SYS_RESOURCEroot (PR_SET_MM_EXE_FILE); it is closed inside the session by the capability drop. Outside it, close it with a no-actor seal where the path needs no writer, a future directive that arms thePR_SET_MMdenial on its own, oractor-strictplus a static launcher - fds received over
SCM_RIGHTSare not re-checked (no hook), andmemfd+fexecveruns unnamed code — bounded, because it runs inside the same domain, filter and capability set - service management is unavailable while systemd's private socket and the system bus are masked. That is the point of the masks, and it is a real cost; a broker or the BPF socket ACL are the directions that would give it back safely
- the account cannot run
compartment-root,sandbox.shHARD mode or this project's root suites, becauseCAP_SYS_ADMINis dropped. A CI runner must not be a limited root - anything holding
CAP_BPFoutside the session owns the seals unless the policy was pinned with--pin --self-protect. The two controls compose and neither replaces the other lockdown=integrityon the validation guests pre-closes/dev/mem, unsigned module loading andkexec; a stock host does not. Yama'sptrace_scopeis not one-way below 3. And/proc/1/environand/proc/1/ns/*are readable on 7.0 while denied on 6.8 — a kernel divergence, recorded so it is not mistaken for flake
- uid 0 is still uid 0. The Landlock allow-list is the only thing between
the account and the filesystem and DAC contributes nothing, so read policy
is as load-bearing as write policy:
compartment-root --netns NAMEjoins the target namespace in the parent, beforeclone(), and dropsCLONE_NEWNETso the container inherits it. One consequence: a network namespace owned by the initial user namespace cannot have a freshsysfsmounted over it from inside the container, so/sysfalls back to an empty read-only tmpfs.--verbosereports that- The audit log is a record, not a restriction, and its confidentiality
depends on where it is kept. The defaults are outside every path rule the
built-in profiles grant, so a sandboxed process can neither read nor
rewrite its own trail — except for the admin-provisioned
/var/lib/compartment/audit/<uid>location, which the built-inai-agentprofile covers withro /var/liband is therefore readable (not writable or removable) from inside the sandbox. An operator-chosen--audit-logdirectory inside a grantedrw/rwxpath (for example--audit-log /tmp/xunderrw /tmp) is fully reachable by the confined process. Both tools now print a warning when the audit directory resolves inside a grantedrw/rwxrule, but it is a warning and not a refusal — the run continues - Landlock is additive. The rights of every rule matching an ancestor of
the path being opened are unioned, so a narrower rule never restricts a
wider one. A
rorule inside arw/rwxrule is now refused outright rather than silently doing nothing, but the underlying limitation stands: there is no way to carve an exception out of a granted subtree. Split the writable rules instead - Landlock network rules are TCP-only and allow-list-only.
net-bind,net-connectandnet-default denycoverbind(2)andconnect(2)on TCP and nothing else. UDP, unix sockets, netlink and raw sockets are untouched — a DNS query over UDP/53 works, and so would exfiltration over UDP. There is nolisten/acceptgranularity and no per-address rule: a rule for port 443 allows port 443 on every address, IPv4 and IPv6 alike. There is no way to express "everything except port N"; a port deny-list, per-address rules and UDP all need a BPF LSM. Below Landlock ABI v4 (Linux 6.7) both tools warn loudly and the port policy is not active while the filesystem rules still are - Landlock in compartment-root is opt-in. It is off unless the policy
says
landlock on(or a--ro/--rw/--rwx/--execflag is given), so a profile written for an earlier release keeps its previous behaviour and gets no filesystem confinement inside the container. A policy that carries path rules without turning Landlock on produces a warning, not an error - An
execallow-list is a property of the sandbox, not of a caller.exec /usr/bin/psqlrestricts what may be executed anywhere in the sandbox, including by uid 0 inside it, but it cannot express "the supervisor may run psql and the request handler may not". It also keys on the file rather than the name, so a busybox-style multi-call binary cannot be split into applets, and it says nothing about shell builtins, which are notexecveat all. The dynamic loader must be listed alongside the binaries —execve(2)opens the ELF interpreter withFMODE_EXEC— while shared libraries need only read access, becauseld.soopens them read-only and Landlock has no mmap hook - An
execallow-list that lists the loader is a hardening layer, not an exec-target boundary. Listing the dynamic loader is required for any dynamically linked policy (above), and it is also the way out of the allow-list:/lib64/ld-linux-x86-64.so.2 /path/to/readable.elfstarts that file with noexecve(2)of it and therefore no execute check on it. The loader opens the targetO_RDONLYand maps itPROT_EXEC, and Landlock has no mmap hook — the same mechanism that lets a shared library load on read access alone. A compatible loadable ELF the sandbox can read is one it can run. Sincerwgrants read, any location that is both readable and writable — a data directory,$HOME,/tmp— is somewhere the sandbox can write an ELF and then start it, and any readable unlisted system binary can be started without writing anything. Measured on 6.8.0-139 (ABI v4) and 7.0.0-31 (ABI v8), rootless and inside acompartment-rootcontainer: the directexecvereturnsEACCESand the loader runs the same file. Two shapes close the direct-loader bypass — static-link the allowed binaries and do not list the loader (an unlisted loader cannot be executed either, and a dynamically linked payload then cannot start at all), or grant no location that is both readable and writable and leave no readable ELF you did not intend to allow. Anoexecmount over the writable areas (mount-noexecin a compartment-root profile) closes the writable-payload half of the bypass and only that half, measured on both kernels — the loader maps its targetPROT_EXECand the kernel refuses that onMNT_NOEXEC— while a readable unlisted ELF elsewhere is untouched by it. Closing the bypass buys an exec-target boundary and never a code-execution boundary: an allowed program that is compromised can still interpret a script, JIT,dlopenormmap(PROT_EXEC)code of its own, none of which is anexecveor anopenfor execute. A kernel-side exec confinement that checks the program actually being started, and so does not need the loader listed, is designed and not shipped.HOWTO.md, "execon a file: a binary allow-list", fact 5, is the full statement;rootless.d/landlock-rules.shandroot.d/compartment-root-landlock.shre-measure it on every run roon a directory grants execute on every file beneath it. A directory rule is not a data grant:ro /usr/libin anexecallow-list makes/usr/lib/klibc/bin/true— and every other executable a distribution leaves under/usr/lib— anexecvetarget that noexecrule names.examples/restricted-root.confgrants the library and read-only-data directories withrwfor exactly this reason:rwis read plus write and carries no execute right. What makes that write right inert is amount-rodirective on each of those directories:rootdir-flags rois applied non-recursively, on purpose, so that/proc,/devand/sysstay usable, and a writable submount under arwrule would otherwise stay writable.mount-roremounts the subtree read-only throughmount_setattr(2)withAT_RECURSIVEand covers the submounts too.HOWTO.mdfacts 2 and 3 state the rule; both Landlock suites witness it, and the root suite witnesses the recursion difference in both directions- A Landlock rule for a path that does not exist grants nothing. That is
now a fatal error rather than a silent no-op; a trailing
?on the path marks a rule optional.--verbosereports the number of rules actually installed, and--dry-runmarks a path that is absent - The seccomp allow-list default action is
ERRNO(EPERM). A denied syscall returns an error the program may mishandle rather than terminating it, and an attacker can probe the filter one call at a time because every denial returns cleanly.seccomp-default killchanges that toSECCOMP_RET_KILL_PROCESS; the default stayserrnofor compatibility compartment-bpf:bpf_map_freeze()is not map integrity, andCAP_BPFis the boundary. Corrected in v0.8.0 — the previous text claimed the frozen seal maps were immune to mutation, and that was wrong in the reassuring direction. Freezing stripsFMODE_CAN_WRITEon the syscall path only; it does not gate the program path. Measured on 6.8.0-139 and 7.0.0-31: a caller holdingCAP_BPFthat obtains any fd to a frozen map —BPF_MAP_GET_FD_BY_IDwithBPF_F_RDONLYis enough — can splice it into a BPF program of its own and update or delete entries from program context. Every compartment map is reachable that way, and wiping the seal entries removes policy with no unlink, no umount and no audit event at all. The load-bearing control is keepingCAP_BPF(andCAP_SYS_ADMIN) off every workload and every root login — systemdCapabilityBoundingSet=, or the limited-root deployment above — plus ingesting theaudit_eventringbuf and alerting on enforcement stopping.tests/bypass/26-frozen-map-honesty.shre-measures the gap on every run, so it cannot quietly return to prosecompartment-bpf --pin --self-protectcloses that at the kernel, opt-in. The flag gatesbpf_map_new_fd()and the pin tree so only an authorised loader image can obtain a map fd — read-only included, because a read-only fd is a complete attack — unlink or rename a pin, over-mount the pin tree, or unmount the bpffs holding it. Measured on 6.8.0-139 and 7.0.0-31. It is off by default for three costs: it changes the upgrade ceremony (a successor loader at a different inode cannot--unpinwhat this one pinned unless it was named with--authorize-loaderat pin time, and a stranded tree costs a reboot); a host-widebpftool map showaborts at the first compartment map while it is on; and the gate costs of order 100–250 ns per map-fd creation, where the default build costs nothing. Six residuals with the flag on — a reboot withlsm=changed or akexec; a map fd stolen from a running loader viapidfd_getfd(2)/SCM_RIGHTS, which never callsbpf_map_new_fd(); a stranded pin tree; thebpftool map showlisting; an operator in a mount namespace that cannot see the pin tree; andCAP_BPFitself, which is the limited-root profile's job. Two adjacent upstream gaps sit beside them:mount --moveof the pin bpffs has no LSM hook and orphans the pins while enforcement stays live, and the ED-11 unpin sentinel lives on/runrather than bpffs, so deleting it downgrades--unpinto the legacy path without removing enforcement. The two controls compose and neither replaces the other.compartment-bpf/HOWTO.md§3.6 is the operator path; the self-protection section ofcompartment-bpf/LIMITATIONS.mdcarries the full table