Landlock
Landlock is a Linux Security Module (LSM) that lets an ordinary, unprivileged process irreversibly restrict itself — no root, no administrator-authored policy, no namespaces. It was merged in Linux 5.13 (June 2021) after more than five years and 34 revisions of the patch set, developed by Mickaël Salaün (Corbet 2021, Landlock (finally) sets sail). It fills a precise gap left by the rest of the access-control toolbox: SELinux and AppArmor enforce a system-wide policy that an administrator must author and load; seccomp filters the syscall surface but cannot reason about which file a syscall touches. Landlock is the missing third primitive — an application embeds its own sandbox, declaring “I should only ever read
/usr, write/tmp, and connect to TCP 443,” and the kernel makes that restriction permanent for the thread and all its descendants. The official upstream framing: Landlock “empowers any process, including unprivileged ones, to securely restrict themselves” (landlock.io).
Version pin
Everything below is pinned to the Linux 6.12 long-term-support kernel unless stated otherwise, read from the
v6.12tag of the mainline tree. On 6.12,landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION)returns 6 — verified directly atsecurity/landlock/syscalls.c:153, which reads#define LANDLOCK_ABI_VERSION 6. Later kernels are ahead: reading the same constant at each tag gives 7 from 6.15 through 6.19 (renamed toconst int landlock_abi_version) and 8 at 7.0. Where this note mentions a post-6.12 feature it says so and dates it. As of 2026-09-04, 6.12 is a maintained LTS while mainline is 7.x.
Mental Model — A One-Way Ratchet You Bolt Onto Yourself
The right way to think about Landlock is a one-way privilege ratchet. A process builds a ruleset — a description of which operations it will be allowed to perform — and then calls landlock_restrict_self() to clamp the ratchet shut. After that call, the process can only ever have fewer rights than before; there is no syscall to loosen the restriction, and the restriction is inherited by every child across fork() and execve(). The clamp can be applied repeatedly, and each application adds a new layer that intersects with the previous ones — so privilege can only monotonically shrink (see Landlock Rulesets and ABI Versions for the layer mechanics).
Crucially, Landlock describes the world in terms of allowed access on a file hierarchy, not allowed syscalls. A rule says “beneath this directory, these operations are permitted”; everything not explicitly allowed within a handled access class is denied by default. This is what distinguishes it from seccomp — Landlock understands paths, inodes, and TCP ports, where seccomp only sees syscall numbers and raw register arguments.
flowchart TB subgraph BUILD["1. Build a ruleset (in userspace, via an fd)"] CR["landlock_create_ruleset(attr, size, 0)<br/>declares HANDLED access classes<br/>→ returns ruleset_fd"] AR1["landlock_add_rule(fd, PATH_BENEATH, /usr → read+exec)"] AR2["landlock_add_rule(fd, PATH_BENEATH, /tmp → read+write)"] AR3["landlock_add_rule(fd, NET_PORT, 443 → connect_tcp)"] CR --> AR1 --> AR2 --> AR3 end subgraph LOCK["2. Lock yourself in (irreversible)"] NNP["prctl(PR_SET_NO_NEW_PRIVS, 1)<br/>(mandatory prerequisite)"] RS["landlock_restrict_self(fd, 0)<br/>→ adds ruleset as a new DOMAIN LAYER"] NNP --> RS end AR3 --> NNP RS --> ENF["3. Every later syscall is checked<br/>against the thread's Landlock domain<br/>(intersection of all layers)"] ENF -.->|"inherited unchanged"| CHILD["fork() / execve() children<br/>cannot escape the domain"]
The three-call Landlock lifecycle. What it shows: the process first assembles a ruleset behind a file descriptor (declaring which access classes it will handle, then adding the specific allowances), then sets no_new_privs and calls restrict_self to fold that ruleset into its enforcement domain as a new layer. The insight to take: the ruleset fd is just a recipe with no effect until restrict_self enacts it; once enacted the domain is permanent and is inherited by all descendants — there is deliberately no “un-restrict” call.
The ratchet metaphor is worth making precise, because “irreversible” and “inherited” are two separate guarantees and both are load-bearing. Irreversible means there is no syscall, prctl, or credential change that removes a layer once landlock_restrict_self has returned; the domain lives in the thread’s credentials and every subsequent credential update copies it forward. Inherited means fork, clone, and execve all carry the domain into the new task or the new program image unchanged. Put together, they describe a state machine with no edges pointing backwards.
stateDiagram-v2 direction LR [*] --> Unsandboxed: thread starts<br/>domain == NULL Unsandboxed --> L1: landlock_restrict_self(fd_A)<br/>domain = {A} L1 --> L2: landlock_restrict_self(fd_B)<br/>domain = {A, B} L2 --> L3: landlock_restrict_self(fd_C)<br/>domain = {A, B, C} L3 --> L16: ... up to 16 layers L16 --> L16: 17th restrict_self<br/>returns E2BIG L1 --> L1: fork() / execve()<br/>child inherits {A} L2 --> L2: fork() / execve()<br/>child inherits {A, B} note right of L2 No transition ever removes a layer. Effective policy is the INTERSECTION of all layers present. end note
A thread’s Landlock state as a monotone one-way machine. What it shows: each landlock_restrict_self call moves the thread one step to the right by appending a layer; fork and execve are self-loops that preserve the current state rather than resetting it; and the machine has a hard wall at 16 layers, after which restrict_self fails with E2BIG (LANDLOCK_MAX_NUM_LAYERS 16 in v6.12 limits.h). The insight to take: there is no arrow that points left. That is the entire safety argument for handing this syscall to unprivileged code — a program that calls it can only ever hurt itself, never gain anything, so the kernel does not need to ask who you are before letting you do it.
Why Landlock Exists — The Gap It Fills
Before Landlock, an unprivileged developer who wanted to sandbox their own program had poor options. The mandatory-access-control LSMs (SELinux, AppArmor, Smack, TOMOYO) all require an administrator to write and install a system-wide policy; an ordinary user cannot define one for their own application, and shipping an AppArmor profile means asking every distribution and every sysadmin to install it. seccomp-BPF is available to unprivileged processes (with no_new_privs), but a seccomp filter only sees the syscall number and the raw, un-dereferenced register arguments — it physically cannot follow a pointer to inspect the pathname being opened, because doing so would be a time-of-check-to-time-of-use (TOCTOU) race. So seccomp can say “deny open entirely” but never “allow open only under /tmp.” The classic workaround was a sandbox built from mount and user namespaces (the approach of bubblewrap/Flatpak), but unprivileged user namespaces are themselves a large kernel attack surface and are disabled or restricted on many systems.
Landlock closes exactly this hole: a pathname-aware, unprivileged, application-embedded sandbox. Four confinement primitives now coexist in Linux, and the reason Landlock had to be built is visible as an empty cell in the grid:
| Primitive | Who authors the policy | Needs privilege to apply? | What it can name | Survives execve? |
|---|---|---|---|---|
| SELinux / AppArmor / Smack / TOMOYO | System administrator, ahead of time, system-wide | Yes — loading policy needs CAP_MAC_ADMIN or equivalent | Labels or pathnames, plus most object classes | Yes, via profile transitions |
| seccomp-BPF | The application, at run time, about itself | No — needs no_new_privs instead | Syscall number and register arguments only; cannot dereference a pointer | Yes |
| mount + user namespaces | The application (or a helper like bubblewrap) | No, if unprivileged user namespaces are permitted — often they are not | Whole filesystem subtrees, by hiding them | Yes |
| Landlock | The application, at run time, about itself | No — needs no_new_privs instead | File hierarchies, TCP ports, and (ABI 6) some IPC | Yes |
The bottom row is the cell nothing else filled: unprivileged and object-aware. The LWN merge coverage puts it the same way — “Like seccomp(), Landlock is an unprivileged sandboxing mechanism; it allows a process to confine itself” (Corbet 2021) — and the kernel’s own FAQ explains why the namespace row is not an adequate substitute: “Namespaces can help create sandboxes but they are not designed for access-control and then miss useful features for such use case (e.g. no fine-grained restrictions). Moreover, their complexity can lead to security issues, especially when untrusted processes can manipulate them” (landlock.rst).
The design history explains the shape of the API and is worth getting right, because it is commonly compressed into a single wrong sentence. Landlock began around 2016 as an eBPF-based mechanism: a process attached BPF programs to LSM hooks, used BPF maps to associate programs with parts of the filesystem, and drove the whole thing through a special seccomp() mode. Reviewers rejected both halves — exposing BPF to unprivileged users “fell out of favor,” and “it was also felt that seccomp(), which controls access to system calls, was a poor fit for Landlock, which does not work at the system-call level.” The pivot happened in three distinct steps, not one: version 14 of the patch set (February 2020) dropped BPF in favour of a rule-definition mechanism plus a single multiplexing landlock() syscall; the 20th version split that multiplexer into four separate syscalls; and the next revision dropped one of the four, leaving the three that shipped in 5.13 (Corbet 2021). Saying “revision 14 introduced the three syscalls” collapses two of those steps and is wrong.
Mechanical Walk-through — The Three Syscalls
Landlock exposes exactly three system calls, all added in 5.13. The full lifecycle is: declare → populate → enforce.
1. landlock_create_ruleset() — declare what you will handle. The signature is int landlock_create_ruleset(const struct landlock_ruleset_attr *attr, size_t size, __u32 flags) (landlock_create_ruleset(2)). The attr is a struct landlock_ruleset_attr whose three __u64 fields — handled_access_fs, handled_access_net, and scoped — are bitmasks of the access classes this ruleset will govern (verified against the v6.12 UAPI header). A “handled” access is one that is denied by default when the ruleset is enforced — it is the set of operations the program promises to account for. Any handled access not subsequently granted by a rule becomes forbidden. On success the call returns a new ruleset file descriptor; the ruleset is a kernel object referenced by that fd, and it has no effect on the thread yet — it is a blueprint. (The same syscall, called with attr=NULL, size=0, flags=LANDLOCK_CREATE_RULESET_VERSION, instead returns the kernel’s highest supported ABI version — the central subject of Landlock Rulesets and ABI Versions.)
2. landlock_add_rule() — populate the ruleset. The signature is int landlock_add_rule(int ruleset_fd, enum landlock_rule_type rule_type, const void *rule_attr, __u32 flags) (landlock_add_rule(2)). Each call binds an allowance to an object. For rule_type = LANDLOCK_RULE_PATH_BENEATH (value 1), rule_attr is a struct landlock_path_beneath_attr { __u64 allowed_access; __s32 parent_fd; }: parent_fd is an open file descriptor — preferably opened with O_PATH | O_CLOEXEC so it confers no I/O ability — identifying the top of a file hierarchy, and allowed_access is the bitmask of operations permitted anywhere beneath that directory. For rule_type = LANDLOCK_RULE_NET_PORT (value 2, added in ABI 4), rule_attr is a struct landlock_net_port_attr { __u64 allowed_access; __u64 port; } granting bind/connect on a specific TCP port. A hard constraint enforced by the kernel: a rule’s allowed_access must be a subset of the ruleset’s handled accesses — you cannot grant a right the ruleset did not declare it would handle, and attempting to grant a right outside handled_access_* returns EINVAL (landlock_add_rule(2)).
3. landlock_restrict_self() — enforce, irreversibly. The signature is int landlock_restrict_self(int ruleset_fd, __u32 flags). This is the call that does something to the calling thread: it folds the ruleset into the thread’s Landlock domain as a new layer. Per landlock_restrict_self(2): “A thread can be restricted with multiple rulesets that are then composed together to form the thread’s Landlock domain,” and “A domain can only be updated in such a way that the constraints of each past and future composed rulesets will restrict the thread and its future children for their entire life.” There is no inverse operation. The new domain is copied into the thread’s credentials and inherited by all children across fork() and execve().
The kernel-side implementation of restrict_self in v6.12’s security/landlock/syscalls.c makes the privilege gate explicit and verbatim:
if (!task_no_new_privs(current) &&
!ns_capable_noaudit(current_user_ns(), CAP_SYS_ADMIN))
return -EPERM;This is the crux of why Landlock is safe to expose to unprivileged code: a thread may only restrict itself if it either already has [[no_new_privs and Privilege Escalation Control|no_new_privs]] set or holds CAP_SYS_ADMIN. The no_new_privs requirement is the load-bearing one for the unprivileged case, discussed next.
Two implementation details of restrict_self are worth pulling out of that function, because they explain behaviour people find surprising. First, the ruleset fd is fetched with get_ruleset_from_fd(ruleset_fd, FMODE_CAN_READ) and the code asserts WARN_ON_ONCE(ruleset->num_layers != 1) — an fd handed to restrict_self is always a single-layer ruleset; layering happens in the domain, never inside the ruleset object. Second, the merge is done against a copy of the credentials: prepare_creds(), then landlock_merge_ruleset(new_llcred->domain, ruleset), then commit_creds(new_cred). Because credentials are per-thread, the source comments note “there is no possible race condition while copying and manipulating the current credentials because they are dedicated per thread” — and that is also why, in v6.12, restrict_self restricts only the calling thread, not its siblings in the same process. Sandboxing a multithreaded program on 6.12 means calling it on every thread yourself.
Because all three syscalls fail with a handful of overlapping errno values, the fastest way to debug a sandbox that will not start is to read the failure as a decision tree:
flowchart TD A["landlock_create_ruleset(attr, size, flags)"] --> A1{"Landlock in the<br/>boot LSM list?"} A1 -->|no, but compiled in| E1["EOPNOTSUPP<br/>add landlock to lsm= or CONFIG_LSM"] A1 -->|"syscall absent entirely"| E0["ENOSYS<br/>kernel older than 5.13"] A1 -->|yes| A2{"handled_access_* bits<br/>all known to this kernel?"} A2 -->|"unknown bit set"| E2["EINVAL<br/>you skipped ABI negotiation"] A2 -->|"size too small / too big"| E3["EINVAL or E2BIG"] A2 -->|"handled_access_fs == 0"| E4["ENOMSG"] A2 -->|ok| B["returns ruleset_fd"] B --> C["landlock_add_rule(fd, type, attr, 0)"] C --> C1{"allowed_access ⊆<br/>ruleset handled mask?"} C1 -->|no| E5["EINVAL<br/>granting a right you did not handle"] C1 -->|"allowed_access == 0"| E6["ENOMSG"] C1 -->|yes| C2{"parent_fd names a real,<br/>user-visible inode?"} C2 -->|"pipefs / sockfs / nsfs / ruleset fd"| E7["EBADFD"] C2 -->|"not an fd at all"| E8["EBADF"] C2 -->|"port > 65535"| E9["EINVAL"] C2 -->|yes| D["rule stored in the ruleset's red-black tree"] D --> F["prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)"] F --> G["landlock_restrict_self(fd, 0)"] G --> G1{"no_new_privs set OR<br/>CAP_SYS_ADMIN in user ns?"} G1 -->|no| E10["EPERM<br/>the classic silent-sandbox bug"] G1 -->|yes| G2{"domain already has<br/>16 layers?"} G2 -->|yes| E11["E2BIG"] G2 -->|no| H["domain = merge(old domain, ruleset)<br/>committed into the thread's creds"]
The three-syscall error surface, drawn as the order the kernel actually checks things in (v6.12 syscalls.c). What it shows: each syscall has a small, ordered set of gates, and every distinct errno corresponds to exactly one gate — EOPNOTSUPP means built-but-not-booted, EINVAL at create time almost always means an access bit newer than the running kernel, EBADFD means you pointed a rule at something that is not a real file, and EPERM at restrict_self means the prctl was skipped or failed. The insight to take: you can diagnose a broken Landlock sandbox from the errno alone without a debugger, provided you check every return value — which is precisely the step most first attempts omit, producing a program that “runs fine” and enforces nothing.
The no_new_privs Prerequisite — Why It Is Mandatory
The mandatory prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) before restrict_self (for the unprivileged path) is not bureaucratic — it closes a privilege-escalation escape hatch that would otherwise make the whole sandbox meaningless. Without no_new_privs, a sandboxed process could simply execve() a setuid-root binary and gain rights its sandbox was supposed to deny. PR_SET_NO_NEW_PRIVS is a one-way per-thread flag (also inherited across fork/exec) guaranteeing that no future execve can ever grant the thread more privileges than it currently has — setuid/setgid bits and file capabilities are ignored. This invariant is shared with seccomp, which also demands no_new_privs for unprivileged use. The kernel doc states it plainly: after the prctl, “the current thread is now ready to sandbox itself with the ruleset” (landlock.rst). For the full reasoning, see no_new_privs and Privilege Escalation Control.
The Access Rights, Bit by Bit
A Landlock policy is expressed entirely in bitmasks, so knowing the bits is knowing the vocabulary. The table below is transcribed from v6.12’s include/uapi/linux/landlock.h, which is the authoritative list; the “ABI” column is the Landlock ABI level from which each bit exists, so a program that wants to run on older kernels must strip the newer ones (the mechanics of that are the subject of Landlock Rulesets and ABI Versions).
Filesystem rights (handled_access_fs, landlock_path_beneath_attr.allowed_access). Sixteen bits in v6.12; LANDLOCK_MASK_ACCESS_FS in limits.h is derived as ((LANDLOCK_ACCESS_FS_IOCTL_DEV << 1) - 1), i.e. bits 0–15 inclusive.
| Bit | Constant | ABI | Applies to | What it governs |
|---|---|---|---|---|
| 0 | LANDLOCK_ACCESS_FS_EXECUTE | 1 | files | Execute a file |
| 1 | LANDLOCK_ACCESS_FS_WRITE_FILE | 1 | files | Open a file with write access |
| 2 | LANDLOCK_ACCESS_FS_READ_FILE | 1 | files | Open a file with read access |
| 3 | LANDLOCK_ACCESS_FS_READ_DIR | 1 | the directory itself and those beneath | Open a directory or list its content |
| 4 | LANDLOCK_ACCESS_FS_REMOVE_DIR | 1 | contents of a directory | Remove an empty directory, or rename one |
| 5 | LANDLOCK_ACCESS_FS_REMOVE_FILE | 1 | contents of a directory | Unlink (or rename) a file |
| 6 | LANDLOCK_ACCESS_FS_MAKE_CHAR | 1 | contents of a directory | Create/rename/link a character device |
| 7 | LANDLOCK_ACCESS_FS_MAKE_DIR | 1 | contents of a directory | Create or rename a directory |
| 8 | LANDLOCK_ACCESS_FS_MAKE_REG | 1 | contents of a directory | Create/rename/link a regular file |
| 9 | LANDLOCK_ACCESS_FS_MAKE_SOCK | 1 | contents of a directory | Create/rename/link a UNIX domain socket |
| 10 | LANDLOCK_ACCESS_FS_MAKE_FIFO | 1 | contents of a directory | Create/rename/link a named pipe |
| 11 | LANDLOCK_ACCESS_FS_MAKE_BLOCK | 1 | contents of a directory | Create/rename/link a block device |
| 12 | LANDLOCK_ACCESS_FS_MAKE_SYM | 1 | contents of a directory | Create/rename/link a symbolic link |
| 13 | LANDLOCK_ACCESS_FS_REFER | 2 | contents of a directory | Link or rename a file across directories (reparenting) |
| 14 | LANDLOCK_ACCESS_FS_TRUNCATE | 3 | files | truncate(2), ftruncate(2), creat(2), open(2) with O_TRUNC |
| 15 | LANDLOCK_ACCESS_FS_IOCTL_DEV | 5 | files and directories | ioctl(2) on an opened character or block device |
Network rights (handled_access_net, landlock_net_port_attr.allowed_access), ABI 4 and later — two bits only, both TCP:
| Bit | Constant | What it governs |
|---|---|---|
| 0 | LANDLOCK_ACCESS_NET_BIND_TCP | Bind a TCP socket to a local port |
| 1 | LANDLOCK_ACCESS_NET_CONNECT_TCP | Connect an active TCP socket to a remote port |
Scopes (scoped), ABI 6 and later — these are not rights that a rule can re-grant; see the scoping section below:
| Bit | Constant | What it isolates |
|---|---|---|
| 0 | LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET | connect(2) to an abstract UNIX socket created outside the domain |
| 1 | LANDLOCK_SCOPE_SIGNAL | Sending a signal to a process outside the domain |
Three semantic traps hide in that table and each has produced real bug reports.
“Applies to the contents, not the directory” is a real distinction. READ_DIR is granted on a directory; every MAKE_* and REMOVE_* right is granted on a directory but governs operations performed inside it. So a rule granting MAKE_REG on /tmp lets the program create files in /tmp and in /tmp/sub, but says nothing about creating /tmp itself.
LANDLOCK_ACCESS_FS_REFER is denied by default whether or not you handle it. This is the single exception to the “not handled means not restricted” rule, and the UAPI header states it explicitly: “For historical reasons, the %LANDLOCK_ACCESS_FS_REFER right is always denied by default, even when its bit is not set in @handled_access_fs.” On an ABI-1 kernel, therefore, any Landlock domain unconditionally forbids renaming or linking a file between two different directories. The reason is the one thing a path-based unprivileged sandbox cannot tolerate: if you could move a file from a restricted hierarchy into a permissive one, you would have laundered your way to more access. The kernel’s rule when REFER is granted encodes the same constraint arithmetically — “the reparented file may not gain more access rights in the destination directory than it previously had in the source directory. If this is attempted, the operation results in an EXDEV error,” and if the required MAKE_*/REMOVE_* rights are absent you get EACCES instead, with EACCES taking precedence over EXDEV.
TRUNCATE and IOCTL_DEV are bound to the file description, not re-checked per call. The kernel docs are explicit: whether an opened file can be truncated or ioctl’d “is determined during open(2), in the same way as read and write permissions are checked during open(2).” One consequence is intended — the check is cheap and happens once. Two consequences are surprising. The first is that a file descriptor opened before restrict_self keeps whatever it had: “pre-existing file descriptors like stdin, stdout and stderr are unaffected,” which is why the manual advises closing or reopening inherited TTY descriptors on older systems where TIOCSTI could be used to inject into another process’s terminal. The second is that “it is possible that a process has multiple open file descriptors referring to the same file, but Landlock enforces different things when operating with these file descriptors,” and such descriptors keep their Landlock properties when passed over a UNIX socket to a process that has no Landlock domain at all. Landlock rights, for these two bits, are a property of the open file description, and open file descriptions are transferable.
A fourth trap is about creat(2), and it is the one the upstream documentation flags as “particularly surprising”: creat sounds like it needs create-and-write rights, “however, it also requires the truncate right if an existing file under the same name is already present,” and conversely truncation does not require WRITE_FILE because open(2) with O_RDONLY | O_TRUNC truncates too. The upstream advice is blunt and worth following literally: “It is recommended to always specify both of these together.”
How Stacked Layers Intersect — The Whole Security Model
Everything distinctive about Landlock follows from one property: restrictions compose by intersection and only ever accumulate. This is what makes it safe to hand to unprivileged code, and it is what makes nesting work — a shell that sandboxes itself, launching a build tool that sandboxes itself, launching a compiler that sandboxes itself, produces exactly the conjunction of all three policies with no coordination between them and no policy language to reconcile.
The upstream statement of the rule has two halves, and both matter (landlock.rst):
One policy layer grants access to a file path if at least one of its rules encountered on the path grants the access. A sandboxed thread can only access a file path if all its enforced policy layers grant the access as well as all the other system access controls.
Read carefully, that is OR within a layer, AND across layers. Within one ruleset, the rules encountered while walking from the target file up towards the root are unioned; across the stack of enforced rulesets, the results are intersected. And the final clause — “as well as all the other system access controls” — is the reminder that Landlock is restrictive, never authoritative: it can only subtract from what discretionary access control (DAC), the other LSMs, and the mount flags already permit. Granting LANDLOCK_ACCESS_FS_WRITE_FILE on /etc does not let an ordinary user write /etc/shadow; see The Linux Security Module Framework for why every LSM works this way.
flowchart TB subgraph DOM["Thread's Landlock domain = 3 stacked layers"] direction LR L0["Layer 0 (from the shell)<br/>handles: read, write, exec<br/>/home → read+write<br/>/usr → read+exec"] L1["Layer 1 (from the build tool)<br/>handles: read, write, exec<br/>/home/proj → read+write<br/>/usr → read+exec"] L2["Layer 2 (from the compiler)<br/>handles: read, write, exec<br/>/home/proj/src → read<br/>/usr → read+exec"] end Q["Request: WRITE_FILE on<br/>/home/proj/src/main.c"] --> DOM L0 --> R0["Layer 0: /home grants write ✓"] L1 --> R1["Layer 1: /home/proj grants write ✓"] L2 --> R2["Layer 2: /home/proj/src grants<br/>read only — no write ✗"] R0 --> AND{"ALL layers<br/>must grant"} R1 --> AND R2 --> AND AND -->|"one layer said no"| DENY["EACCES"] AND -.->|"had all three said yes"| DAC["then still subject to<br/>DAC, other LSMs, MS_RDONLY"]
Three nested sandboxes evaluating one write. What it shows: each layer is consulted independently — inside a layer the path walk stops as soon as some ancestor rule grants the right, so layers 0 and 1 both allow the write — but the verdict is the conjunction across layers, and layer 2 (added last, by the innermost program) vetoes it. The insight to take: a later layer can never re-grant what an earlier one denied, and an earlier one can never veto by omission what a later one needs; each program only has to describe its own needs correctly, and composition is automatic. That is why three mutually-unaware programs can nest without a shared policy file, and it is the property that distinguishes Landlock from an administrator-authored MAC.
The bitmap that implements it
The kernel does not literally re-walk the tree once per layer. It carries a per-access-right bitmap of layers still owing a grant down the path walk, and the request is allowed the moment every bitmap is empty. The type is declared in v6.12 security/landlock/ruleset.h:
typedef u16 layer_mask_t;
static_assert(BITS_PER_TYPE(layer_mask_t) >= LANDLOCK_MAX_NUM_LAYERS);That is where the famous 16-layer ceiling comes from: one bit per layer in a u16. The algorithm has two halves. landlock_init_layer_masks() in ruleset.c fills the array — “for each access right in @access_request, the bits for all the layers are set where this access right is handled” — so a right handled by layers 0, 1, and 2 starts life as 0b111, meaning three layers are still owed a grant. Then, as the path walk climbs from the file towards the mount root, landlock_unmask_layers() clears bits:
for (layer_level = 0; layer_level < rule->num_layers; layer_level++) {
const struct landlock_layer *const layer = &rule->layers[layer_level];
const layer_mask_t layer_bit = BIT_ULL(layer->level - 1);
...
for_each_set_bit(access_bit, &access_req, masks_array_size) {
if (layer->access & BIT_ULL(access_bit))
(*layer_masks)[access_bit] &= ~layer_bit;
is_empty = is_empty && !(*layer_masks)[access_bit];
}
if (is_empty)
return true;
}Walking it: for each layer that has a rule on this directory, if that layer’s rule grants the requested access bit, clear that layer’s bit from the mask for that access. is_empty tracks whether every requested access has now been granted by every layer; the moment that is true the function returns true and the walk stops early. The source comment states the composition rule in one sentence — “An access is granted if, for each policy layer, at least one rule encountered on the pathwalk grants the requested access, regardless of its position in the layer stack” — and adds the union-within-a-layer case explicitly: “for each policy layer, the full set of requested accesses may not be granted by only one rule, but by the union (binary OR) of multiple rules. E.g. /a/b <execute> + /a <read> ⇒ /a/b <execute + read>.”
If the walk reaches the root with any bit still set, the access is denied. Note what “denied” means for a right that no layer handles: init_layer_masks never set a bit for it, its mask starts empty, and it is therefore allowed — which is the mechanical statement of “unhandled access rights are not restricted.”
Enforcing the ceiling
landlock_merge_ruleset() in ruleset.c is the function restrict_self calls, and it is where the ceiling bites:
if (parent) {
if (parent->num_layers >= LANDLOCK_MAX_NUM_LAYERS)
return ERR_PTR(-E2BIG);
num_layers = parent->num_layers + 1;
} else {
num_layers = 1;
}A fresh domain is allocated at parent->num_layers + 1, the parent’s layers are copied in by inherit_ruleset(), and the new ruleset’s access masks are installed as the last layer. Note that this allocates a new domain object on every restrict_self rather than mutating the old one — necessary because the old domain may still be referenced by other tasks that forked earlier, and it is why the operation is credential-scoped and RCU-safe.
The upstream guidance that follows from the 16-layer cap is practical: “It is then strongly suggested to carefully build rulesets once in the life of a thread, especially for applications able to launch other applications that may also want to sandbox themselves (e.g. shells, container managers, etc.).” A shell that adds a layer per command would exhaust the budget in sixteen commands.
Uncertain
Verify: the layer limit as stated by the man page, which disagrees with the kernel source.
landlock_restrict_self(2)says ofE2BIG: “The maximum number of composed rulesets is reached for the calling thread. This limit is currently 64.” That contradicts the kernel, which says 16 at both ends of the range I checked:LANDLOCK_MAX_NUM_LAYERS 16in v6.12limits.hand in v7.0limits.h, with the in-tree documentation at both tags likewise saying “There is a limit of 16 layers of stacked rulesets” (v7.0 landlock.rst). Reason: the man page appears to be simply wrong, or to describe an out-of-tree/proposed change;layer_mask_tis au16with astatic_asserttying it to the limit, so 64 would require a type change that has not happened. To resolve: treat 16 as authoritative and re-checklimits.hat whatever tag you are targeting; a man-page fix would be the thing to watch. uncertain
Landlock Is an LSM — A Minor, Stackable One
Landlock is implemented as an LSM: it registers callbacks at the kernel’s LSM hook points and is consulted on every relevant access decision, exactly like SELinux or AppArmor. But it is a minor (stackable) LSM, not one of the exclusive label-based MACs — meaning it can coexist with whatever major LSM the distribution runs. From v6.12’s security/landlock/setup.c, the registration is:
struct lsm_blob_sizes landlock_blob_sizes __ro_after_init = {
.lbs_cred = sizeof(struct landlock_cred_security),
.lbs_file = sizeof(struct landlock_file_security),
.lbs_inode = sizeof(struct landlock_inode_security),
.lbs_superblock = sizeof(struct landlock_superblock_security),
};
DEFINE_LSM(LANDLOCK_NAME) = {
.name = LANDLOCK_NAME,
.init = landlock_init,
.blobs = &landlock_blob_sizes,
};Two things matter here. First, landlock_blob_sizes declares that Landlock reserves its own slice of storage inside several kernel security objects — the credential (cred), the open file, the inode, and the superblock. This is the modern LSM-stacking machinery: rather than owning these structures, a minor LSM asks the framework to allocate it a private blob within each, so many LSMs can attach state to the same object simultaneously (see LSM Stacking and Module Ordering). The landlock_cred_security blob is where a thread’s domain (its stack of enforced rulesets) actually lives — which is why the domain travels with the credentials across fork/exec. Second, the DEFINE_LSM block has no .order field, so Landlock registers with default ordering rather than asserting LSM_ORDER_FIRST/LAST; it is content to be consulted alongside the others.
The domain itself is a refcounted struct landlock_ruleset * inside that credential blob. From v6.12 security/landlock/cred.h:
struct landlock_cred_security {
struct landlock_ruleset *domain;
};
static inline struct landlock_cred_security *
landlock_cred(const struct cred *cred)
{
return cred->security + landlock_blob_sizes.lbs_cred;
}landlock_cred() is pure pointer arithmetic: the framework hands every LSM an offset into a shared cred->security allocation, and Landlock’s slice holds exactly one pointer. landlocked(task) is then just !!domain. This is why every property in the mental model is true without any extra bookkeeping — a domain is inherited across fork because copy_creds() copies the credential (and bumps the refcount), and survives execve because commit_creds() on the new image carries the same blob forward.
flowchart LR T["struct task_struct<br/>(the thread)"] --> C["struct cred<br/>(its credentials)"] C --> S["cred->security<br/>(one allocation, shared by all LSMs)"] S --> B1["SELinux blob"] S --> B2["AppArmor blob"] S --> B3["landlock_cred_security<br/>{ struct landlock_ruleset *domain; }"] B3 --> D["domain: layer 0 ... layer N<br/>access_masks[] per layer<br/>red-black tree of rules,<br/>keyed by inode / TCP port"] D --> H["landlock_hierarchy *<br/>parent pointer — used by<br/>ptrace and IPC scoping"] T -.->|"fork(): copy_creds()<br/>bumps the ruleset refcount"| T2["child task<br/>same domain pointer"] T -.->|"execve(): new creds,<br/>same blob carried forward"| T3["new program image<br/>same domain pointer"]
Where a Landlock domain physically lives. What it shows: the domain is not a property of the process or of the executable — it is one pointer inside the LSM blob attached to the thread’s struct cred, sitting alongside whatever SELinux or AppArmor keep for the same credential. The insight to take: because the credential is what fork copies and execve replaces-and-recomputes, “inherited and irreversible” falls out of the kernel’s ordinary credential lifecycle rather than needing special-case code — and because credentials are per-thread, restrict_self on 6.12 confines the calling thread only.
Because Landlock is an LSM that must be enabled, two configuration facts follow. The kernel must be built with CONFIG_SECURITY_LANDLOCK=y, and Landlock must appear in the active LSM list at boot. The v6.12 security/landlock/Kconfig spells out the second half in its help text: “you should also prepend landlock, to the content of CONFIG_LSM to enable Landlock at boot time.” On a kernel where Landlock is compiled in but not enabled, all three syscalls return EOPNOTSUPP — “Landlock is supported by the kernel but disabled at boot time” (landlock_create_ruleset(2)). Note the distinction the ABI query relies on: ENOSYS means the syscall does not exist (kernel older than 5.13), EOPNOTSUPP means it exists but the module was not booted. That Kconfig also selects SECURITY_NETWORK and SECURITY_PATH, the two hook families Landlock needs.
Three commands tell you the state of a running system, and they are worth memorising because “my sandbox silently does nothing” almost always resolves to one of them:
$ cat /sys/kernel/security/lsm # ordered list of active LSMs
capability,landlock,lockdown,yama,bpf,apparmor
$ dmesg | grep landlock || journalctl -kb -g landlock
[ 0.000000] landlock: Up and running.
$ zgrep -h "^CONFIG_LSM=" /boot/config-$(uname -r) /proc/config.gz 2>/dev/null
CONFIG_LSM="landlock,lockdown,yama,integrity,apparmor"The “Up and running” log line is the upstream-recommended check (landlock.rst); if it is missing but the syscall exists, add lsm=landlock,... to the kernel command line, keeping the rest of the existing list intact.
In practice this is now a non-issue on desktop and server distributions. The upstream project maintains a per-distribution table, and as of its 2026-09-04 state every major distribution ships Landlock enabled by default: Alpine, Amazon Linux, Arch (since 5.13.1.arch1-1), Azure Linux, CentOS Stream, ChromeOS, Debian (Sid since kernel 5.18.16-1), Fedora (since Fedora 35), Flatcar, GNOME OS, NixOS, openSUSE (since 5.13-rc1), Rocky Linux, Ubuntu (since 20.04 LTS), and Windows Subsystem for Linux 2; Gentoo enables it depending on the kernel variant (landlock.io integrations). Red Hat Enterprise Linux enabled it in 9.6.0 and, notably, backported Landlock features up to ABI 5 onto its 5.14-based kernel (kernel-5.14.0-568.el9) — a reminder that on enterprise distributions the ABI level is decided by backports, not by uname -r, which is the strongest possible argument for querying the ABI at run time instead of inferring it from the kernel version.
Domains Form a Hierarchy — ptrace, and IPC Scoping (ABI 6)
A sandbox that can be escaped with ptrace(2) is not a sandbox: a confined process could attach to an unconfined one and make it do the forbidden work. Landlock closes this without any policy syntax at all, by exploiting the fact that domains form a tree. Every domain created by restrict_self allocates a landlock_hierarchy node whose parent is the domain it was derived from, so “is A an ancestor of B” is answerable by walking parent pointers. From v6.12 security/landlock/task.c:
static bool domain_scope_le(const struct landlock_ruleset *const parent,
const struct landlock_ruleset *const child)
{
const struct landlock_hierarchy *walker;
if (!parent) /* unsandboxed tracer: allowed */
return true;
if (!child) /* sandboxed tracing unsandboxed: denied */
return false;
for (walker = child->hierarchy; walker; walker = walker->parent) {
if (walker == parent->hierarchy)
return true; /* parent is an ancestor of child */
}
return false;
}hook_ptrace_access_check() and hook_ptrace_traceme() both funnel into this, returning -EPERM when it is false. The upstream wording of the rule is the clearest summary: “To be allowed to use ptrace(2) and related syscalls on a target process, a sandboxed process should have a superset of the target process’s access rights, which means the tracee must be in a sub-domain of the tracer” (landlock.rst). Note that this is a structural test on the hierarchy, not a comparison of the rulesets’ contents — a domain is “less restricted” than another exactly when it is an ancestor of it, which is guaranteed because layers only ever accumulate.
The same hierarchy machinery was generalised in ABI 6 (Linux 6.12) into IPC scoping, the scoped field of struct landlock_ruleset_attr. Where filesystem and network rights are allowlists with exceptions, a scope is a blanket wall: “IPC scoping does not support exceptions, so if a domain is scoped, no rules can be added to allow access to resources or processes outside of the scope.” The two scopes in 6.12 cover the two IPC channels that path- and port-based rules could not reach — abstract UNIX sockets (which live in a flat namespace with no filesystem path to attach a rule to) and signals.
flowchart TB ROOT["Unsandboxed<br/>(domain == NULL)"] ROOT --> DA["Domain A<br/>scoped: SIGNAL"] DA --> DB["Domain B<br/>(child of A)"] DA --> DC["Domain C<br/>(child of A)"] ROOT --> DX["Domain X<br/>(unrelated branch)"] DB -->|"kill() → allowed<br/>(A is an ancestor of B and C)"| DC DB -->|"kill() → EPERM<br/>(X not in B's scope)"| DX DB -->|"kill() → EPERM<br/>(target outside the scoped domain)"| ROOT ROOT -->|"kill() → allowed<br/>(sender is unscoped)"| DB DB -->|"ptrace() → allowed<br/>(tracee is a sub-domain)"| DB2["Domain B'<br/>(child of B)"] DB2 -->|"ptrace() → EPERM<br/>(tracer more restricted than tracee)"| DB
The domain tree, and the two rules read off it. What it shows: ptrace is permitted only downwards — a tracer may attach to a tracee whose domain is a descendant of its own — while a signal scope draws a wall around a subtree: processes inside may signal each other and anything further in, but nothing outside, and outsiders may still signal in. The insight to take: both restrictions are decided by ancestry in this tree, with no policy language and nothing for the sandboxing program to configure; because layers only accumulate, “is a descendant of” and “is at least as restricted as” are the same relation, which is what lets a purely structural check enforce a security property.
Three details of scoping regularly surprise people, all stated in the upstream documentation. Scoping is directional: “A sandboxed process can connect to a non-sandboxed process when its domain is not scoped. If a process’s domain is scoped, it can only connect to sockets created by processes in the same scope” — inbound connections from outside are not blocked by the receiver’s scope. Connected datagram sockets are grandfathered: “A connected datagram socket behaves like a stream socket when its domain is scoped, meaning if the domain is scoped after the socket is connected, it can still send(2) data just like a stream socket,” whereas an unconnected datagram socket cannot sendto(2) outside its scope. And inherited sockets are inert: “A process with a scoped domain can inherit a socket created by a non-scoped process. The process cannot connect to this socket since it has a scoped domain.”
Post-6.12 erratum, dated
Signal scoping as shipped in 6.12 was too strict between threads of one process: a sandboxed thread could not signal a sibling thread in the same process if the two were in different domains. Upstream considers this a bug — “threads are not security boundaries,” and “consistent with
ptrace(2)behavior, direct interaction between threads of the same process should always be allowed” — and fixed it as one of the first entries in Landlock’s new errata mechanism, queryable at run time viaLANDLOCK_CREATE_RULESET_ERRATA, which appears in the UAPI header from v6.15 onwards (landlock.io news #5; v6.15landlock.h). If you are enforcingLANDLOCK_SCOPE_SIGNALin a multithreaded program on a 6.12-vintage kernel, test it. (A second erratum from the same batch narrowed the network rights so that non-TCP stream sockets such as SMC, MPTCP, and SCTP are no longer caught by the TCPbind/connectchecks.)
A Complete Worked Example
The kernel ships a reference sandboxer at samples/landlock/sandboxer.c. A minimal, self-contained version of the core flow — sandbox a process so it can only read-execute under /usr and read-write under /tmp — looks like this:
#include <linux/landlock.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <fcntl.h>
#include <unistd.h>
/* glibc has no wrappers yet for these three syscalls, so call them raw. */
static int landlock_create_ruleset(const struct landlock_ruleset_attr *attr,
size_t size, __u32 flags) {
return syscall(__NR_landlock_create_ruleset, attr, size, flags);
}
static int landlock_add_rule(int fd, enum landlock_rule_type t,
const void *attr, __u32 flags) {
return syscall(__NR_landlock_add_rule, fd, t, attr, flags);
}
static int landlock_restrict_self(int fd, __u32 flags) {
return syscall(__NR_landlock_restrict_self, fd, flags);
}
int main(void) {
/* 1. Declare the access classes this ruleset HANDLES (deny-by-default). */
struct landlock_ruleset_attr ruleset_attr = {
.handled_access_fs =
LANDLOCK_ACCESS_FS_EXECUTE | LANDLOCK_ACCESS_FS_READ_FILE |
LANDLOCK_ACCESS_FS_READ_DIR | LANDLOCK_ACCESS_FS_WRITE_FILE |
LANDLOCK_ACCESS_FS_REMOVE_FILE | LANDLOCK_ACCESS_FS_MAKE_REG,
};
int rfd = landlock_create_ruleset(&ruleset_attr, sizeof(ruleset_attr), 0);
if (rfd < 0) { perror("create_ruleset"); return 1; }
/* 2a. Allow read+exec beneath /usr. parent_fd is an O_PATH handle. */
struct landlock_path_beneath_attr usr = {
.allowed_access = LANDLOCK_ACCESS_FS_EXECUTE |
LANDLOCK_ACCESS_FS_READ_FILE |
LANDLOCK_ACCESS_FS_READ_DIR,
.parent_fd = open("/usr", O_PATH | O_CLOEXEC),
};
landlock_add_rule(rfd, LANDLOCK_RULE_PATH_BENEATH, &usr, 0);
close(usr.parent_fd);
/* 2b. Allow read+write+create+remove beneath /tmp. */
struct landlock_path_beneath_attr tmp = {
.allowed_access = LANDLOCK_ACCESS_FS_READ_FILE |
LANDLOCK_ACCESS_FS_WRITE_FILE |
LANDLOCK_ACCESS_FS_MAKE_REG |
LANDLOCK_ACCESS_FS_REMOVE_FILE,
.parent_fd = open("/tmp", O_PATH | O_CLOEXEC),
};
landlock_add_rule(rfd, LANDLOCK_RULE_PATH_BENEATH, &tmp, 0);
close(tmp.parent_fd);
/* 3. Mandatory: forbid privilege gain, then enforce irreversibly. */
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { perror("nnp"); return 1; }
if (landlock_restrict_self(rfd, 0)) { perror("restrict_self"); return 1; }
close(rfd);
/* From here on, this thread and all children are sandboxed. */
/* open("/etc/shadow", O_RDONLY) now fails with EACCES. */
execlp("/usr/bin/myapp", "myapp", (char *)NULL);
perror("exec");
return 1;
}Walking the example: the handled_access_fs mask declares every filesystem operation the ruleset takes responsibility for; once enforced, every one of those operations is denied everywhere except where a PATH_BENEATH rule re-grants it. Note that READ_DIR is handled but never re-granted for /tmp, so the sandboxed app can write files in /tmp but cannot list the directory. The O_PATH flag on parent_fd is a deliberate hardening detail — an O_PATH descriptor names a location for the rule but cannot itself be used to read or write the file, so passing it to add_rule confers no capability beyond identifying the hierarchy. The whole thing ends with the prctl + restrict_self pair; after that, an attempt to open /etc/shadow returns EACCES because READ_FILE is handled but no rule grants it under /etc.
That example is deliberately naive in one respect: it hard-codes the access rights it wants. On any kernel older than the one it was built against, landlock_create_ruleset will reject an unknown bit with EINVAL and the sandbox will fail to start. The portable form asks the kernel what it supports first, and strips what it cannot have. The full ladder lives in Landlock Rulesets and ABI Versions; the shape of it, mirroring the upstream sample, is:
struct landlock_ruleset_attr attr = {
.handled_access_fs = /* all 16 bits, as built */ ,
.handled_access_net = LANDLOCK_ACCESS_NET_BIND_TCP |
LANDLOCK_ACCESS_NET_CONNECT_TCP,
.scoped = LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET |
LANDLOCK_SCOPE_SIGNAL,
};
/* Ask the running kernel, do not infer from uname(2). */
int abi = landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION);
if (abi < 0) {
/* ENOSYS = no Landlock syscalls at all (kernel < 5.13)
* EOPNOTSUPP = compiled in but not in the boot LSM list */
return 0; /* degrade gracefully, do not abort */
}
switch (abi) {
case 1: attr.handled_access_fs &= ~LANDLOCK_ACCESS_FS_REFER; /* fallthrough */
case 2: attr.handled_access_fs &= ~LANDLOCK_ACCESS_FS_TRUNCATE; /* fallthrough */
case 3: attr.handled_access_net &= ~(LANDLOCK_ACCESS_NET_BIND_TCP |
LANDLOCK_ACCESS_NET_CONNECT_TCP); /* fallthrough */
case 4: attr.handled_access_fs &= ~LANDLOCK_ACCESS_FS_IOCTL_DEV; /* fallthrough */
case 5: attr.scoped &= ~(LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET |
LANDLOCK_SCOPE_SIGNAL);
}
int rfd = landlock_create_ruleset(&attr, sizeof(attr), 0);
...
/* An outbound HTTPS connection, and nothing else on the network. */
struct landlock_net_port_attr https = {
.allowed_access = LANDLOCK_ACCESS_NET_CONNECT_TCP,
.port = 443, /* host byte order, not network */
};
landlock_add_rule(rfd, LANDLOCK_RULE_NET_PORT, &https, 0);Three things in that snippet are easy to get wrong. The switch cases fall through downward from the kernel’s level, so a kernel reporting ABI 3 enters at case 3 and strips network, ioctl-dev, and scopes in one pass — the single returned integer drives the whole trim. port is in host byte order, not network byte order, which is a trap for anyone used to htons(); and the header notes the special case that “a Landlock rule with port 0 and the LANDLOCK_ACCESS_NET_BIND_TCP right means that requesting to bind on port 0 is allowed and it will automatically translate to binding on the related port range” from /proc/sys/net/ipv4/ip_local_port_range. Finally, if the kernel was built without CONFIG_INET, adding a network rule returns EAFNOSUPPORT, “which can safely be ignored because this kind of TCP operation is already not possible” (landlock.rst).
Good practice: keep hierarchies self-sufficient
The upstream documentation gives one piece of policy-design advice that is easy to skip and expensive to retrofit: “It is recommended to set access rights to file hierarchy leaves as much as possible. For instance, it is better to be able to have ~/doc/ as a read-only hierarchy and ~/tmp/ as a read-write hierarchy, compared to ~/ as a read-only hierarchy and ~/tmp/ as a read-write hierarchy.” The reason is REFER. A hierarchy whose rights are stated on itself rather than inherited from an ancestor is self-sufficient: it can be moved without its permissions changing, and a rename into or out of it is decidable without consulting the ancestry. Policies built the other way — broad grant at the top, narrow override underneath — create what the docs call sinkhole directories, “directories where data can be linked to but not linked from,” and make every reparenting operation a question about where the file happens to sit today.
What Landlock Deliberately Cannot Do
Being honest about the boundary is more useful than a feature list, and the v6.12 documentation is unusually candid about it. The limits fall into three groups: things Landlock blocks outright rather than mediating, things it cannot see, and things that are simply out of scope by design.
| Limit | v6.12 behaviour | Why, and what to use instead |
|---|---|---|
| Filesystem topology | A thread with filesystem restrictions “cannot modify filesystem topology, whether via mount(2) or pivot_root(2)” — blocked, not mediated. chroot(2) is not denied. | Remounting could relocate a hierarchy out from under a rule. Use Mount Namespaces if you need to change the view. |
| Special filesystems | Pipes, sockets, and nsfs (/proc/<pid>/ns/*) “cannot currently be explicitly restricted”, even though they are reachable via /proc/<pid>/fd/*. | They have no stable user-visible hierarchy to hang a rule on. Partly mitigated automatically by the ptrace/domain rules above. |
| Arbitrary syscalls | No mechanism at all. Landlock names objects, not syscalls. | That is seccomp’s job; the two are designed to compose. |
| A specific list of file syscalls | chdir(2), stat(2), flock(2), chmod(2), chown(2), setxattr(2), utime(2), fcntl(2), access(2) cannot currently be restricted — the UAPI header carries this as an explicit .. warning::. | Not yet implemented (“Future Landlock evolutions will enable to restrict them”). If metadata changes matter, add a seccomp filter. |
| Already-open file descriptors | “Files or directories opened before the sandboxing are not subject to these restrictions.” | Rights are checked at open(2); close or reopen anything sensitive before enforcing. |
| OverlayFS | “A policy restricting an OverlayFS layer will not restrict the resulted merged hierarchy, and vice versa.” Bind mounts, by contrast, do propagate rules, because they expose the same files. | Overlay layers and the merged view are distinct hierarchies to Landlock. Write rules against the paths the program will actually use. |
| UDP, ICMP, non-TCP | Only TCP bind/connect are mediated, and only by port number — no address matching, no outbound-address filtering. | Network support is deliberately minimal at ABI 4. Use netfilter/eBPF for address-level policy. |
| Relaxing a restriction | Impossible by construction; no syscall exists. | If you need to regain access, you needed a separate unsandboxed process. |
| Multithreaded enforcement | On v6.12 restrict_self affects only the calling thread. | Call it on each thread. (LANDLOCK_RESTRICT_SELF_TSYNC, ABI 8, added in mainline 7.0, applies a configuration atomically to all threads — verified in v7.0 landlock.h; not available on 6.12.) |
The kernel documentation also answers the obvious “why not do this in userspace?” question by pointing at the classic result on system-call interposition: using a userspace supervisor to enforce restrictions on kernel resources “can lead to race conditions or inconsistent evaluations (i.e. Incorrect mirroring of the OS code and state)”, citing the NDSS 2003 paper Traps and Pitfalls: Practical Problems in System Call Interposition Based Security Tools. Landlock is in the kernel because a mirror of kernel state maintained outside the kernel is always, in principle, wrong.
Failure Modes and Common Misunderstandings
“It silently does nothing.” The single most common Landlock surprise is a sandbox that appears to have no effect. The usual cause is that the program built a ruleset and added rules but never reached restrict_self, or restrict_self returned EPERM (because no_new_privs was not set and the thread lacks CAP_SYS_ADMIN) and the program ignored the error. A ruleset that is never enforced restricts nothing. Always check every return value.
“Landlock can’t restrict everything.” Landlock deliberately governs only userspace-visible filesystem and (since ABI 4) network operations. The v6.12 documentation enumerates what it cannot touch (landlock.rst): a Landlock-sandboxed thread “cannot modify filesystem topology, whether via mount(2) or pivot_root(2)” (these are simply blocked); files on special filesystems (pipes, sockets) “cannot currently be explicitly restricted”; and there are interactions with OverlayFS — “A policy restricting an OverlayFS layer will not restrict the resulted merged hierarchy, and vice versa.” Landlock is a filesystem-access sandbox, not a complete syscall firewall — for syscall-level control you still need seccomp (the two compose well; see Landlock vs seccomp vs Namespaces).
The IOCTL_DEV gotcha (ABI 5+). When the LANDLOCK_ACCESS_FS_IOCTL_DEV right is handled, it “only applies to newly opened device files. This means specifically that pre-existing file descriptors like stdin, stdout and stderr are unaffected” (landlock.rst) — already-open fds are grandfathered in.
Version mismatch crashes the sandbox. If a program unconditionally requests an access right the running kernel’s ABI predates, landlock_create_ruleset rejects it with EINVAL. Programs must query the ABI version and degrade gracefully — the best-effort pattern that is the whole subject of Landlock Rulesets and ABI Versions. RHEL 9.6’s backport of ABI 5 onto a 5.14 kernel is the clinching argument: the kernel version tells you nothing reliable about the Landlock ABI, so the query is not optional.
Handling a right you never grant, by accident. handled_access_fs is a promise to account for a class of operation; every bit set there is denied everywhere unless re-granted. Copying the upstream “handle everything” mask into a program that then adds two PATH_BENEATH rules for read and exec produces a sandbox that cannot create a temporary file, cannot truncate a log, and cannot ioctl its own terminal — usually diagnosed as “Landlock broke my program” rather than “I asked for that.” The discipline is to keep the handled mask and the granted rules in sync in the same place in the code.
E2BIG from restrict_self in nested tooling. Sixteen layers is not many when a container manager, a shell, a build system, and a compiler each add one, and every restrict_self costs a layer even if the ruleset is tiny. Symptom: the outer tools work and the innermost one fails to start. Fix: build one ruleset per process, not one per phase.
Writing works, listing does not (or vice versa). READ_DIR and the file-content rights are separate bits, and READ_DIR on a directory does not imply reading files inside it. A sandbox that grants WRITE_FILE | MAKE_REG on /tmp but not READ_DIR produces a program that can create /tmp/x and write to it but gets EACCES from opendir("/tmp") — which surfaces as a confusing failure deep inside libc or a temp-file library rather than at the obvious call site.
creat() fails with EACCES only when the file already exists. Because creat(2) truncates an existing file, it needs LANDLOCK_ACCESS_FS_TRUNCATE in exactly that case. The bug is intermittent by construction: the first run succeeds, the second fails. Always handle and grant TRUNCATE alongside WRITE_FILE.
Rename or link fails with EXDEV, not EACCES. EXDEV normally means “cross-device link”, so it is routinely misread as a filesystem-layout problem. Under Landlock it means the reparented file would gain access rights in the destination hierarchy that it did not have in the source — the operation is structurally refused, and no amount of adding rights to the destination fixes it (adding rights makes it more likely to fail). The fix is to make both hierarchies self-sufficient, per the good-practice note above.
stdin/stdout/stderr are not covered. TRUNCATE and IOCTL_DEV are bound at open(2), so inherited descriptors keep the rights they were opened with. A sandboxed program can still ioctl the TTY it inherited. Close or reopen inherited descriptors from /proc/self/fd/* before enforcing if that matters.
Expecting Landlock to confine the whole process. On v6.12 restrict_self is per-thread. A program that sandboxes itself from main() after spawning a worker pool has sandboxed one thread. This is stated upstream as “one process’s thread may apply Landlock rules to itself, but they will not be automatically applied to other sibling threads (unlike POSIX thread credential changes).”
Alternatives and When to Choose Them
- seccomp-BPF — restricts syscalls, not paths. Choose seccomp to shrink the kernel attack surface (block
keyctl,ptrace, exotic syscalls); choose Landlock to control which files and ports. They are complementary, not substitutes, and are routinely used together. - SELinux / AppArmor — system-wide MAC authored by an administrator. Choose these when a central security team must enforce policy on software they don’t control; choose Landlock when the developer wants to confine their own app with zero deployment friction.
- mount + user namespaces (bubblewrap/Flatpak) — build a private filesystem view. More powerful (can remap and hide whole trees) but heavier and dependent on unprivileged user namespaces being enabled. Landlock needs no namespaces and a smaller attack surface.
- OpenBSD
pledge/unveil— the conceptual cousins on BSD; Landlock is Linux’s answer tounveil(path restriction) more than topledge(syscall restriction).
See Landlock vs seccomp vs Namespaces for the head-to-head.
Composing Landlock with Its Neighbours
The three unprivileged self-confinement primitives are not alternatives so much as three orthogonal cuts, and the canonical hardened program applies all of them in a specific order:
flowchart LR P0["Process starts<br/>(may be privileged)"] --> P1["1. Do the privileged work<br/>bind ports < 1024, open<br/>config, drop to a normal uid"] P1 --> P2["2. Drop capabilities<br/>capset / bounding set"] P2 --> P3["3. prctl(PR_SET_NO_NEW_PRIVS, 1)<br/>close the execve escalation gate"] P3 --> P4["4. landlock_restrict_self()<br/>name the objects you may touch"] P4 --> P5["5. seccomp(SECCOMP_SET_MODE_FILTER)<br/>shrink the syscall surface"] P5 --> P6["6. Run untrusted input / exec the payload"] P3 -.->|"required by both 4 and 5<br/>for unprivileged callers"| P4 P3 -.-> P5
The canonical hardening order. What it shows: capabilities are dropped first because they are the only step that can still be undone by a later execve; no_new_privs then makes everything after it permanent; Landlock names what may be touched; seccomp names what may be called. The insight to take: the ordering is forced, not stylistic — no_new_privs must precede both sandboxes because both refuse to install without it for an unprivileged caller, and seccomp goes last because a tight syscall filter can block the very syscalls the earlier steps need. Each layer answers a question the others cannot: capabilities answer “what am I allowed to be”, Landlock answers “what may I touch”, seccomp answers “what may I ask for.”
A concrete division of labour, which is also the honest argument for why you usually want both sandboxes rather than either: seccomp can deny keyctl, ptrace, bpf, userfaultfd, and the whole tail of exotic syscalls that constitute kernel attack surface — and Landlock cannot touch any of them. Landlock can say “read only under /usr and /etc/ssl” — and seccomp cannot, because it may not dereference the pathname pointer without a time-of-check-to-time-of-use (TOCTOU) race. Run one and you have half a sandbox. The head-to-head comparison, including where namespaces fit, is in Landlock vs seccomp vs Namespaces.
Production Notes
Landlock’s adoption curve is the best evidence for the claim that “no policy file, no admin involvement” was the missing ingredient. The upstream project maintains a (self-described non-exhaustive, unaudited) integrations list; as of 2026-09-04 it names on the order of fifty shipping projects (landlock.io integrations), and the shape of that list is more interesting than its length:
- Long-tail application self-sandboxing — the category that did not exist before Landlock. XZ Utils (since v5.6.0), p7zip, Unblob (v24.12.4+), Zathura,
strace(which additionally gained decoding for the three syscalls in v5.13), Suricata (v7.0.0+),sslh(v2.1.0+),wireproxy(1.0.8+), and GNOME’stracker-extract(GNOME 46+) all confine themselves, with no packaging or administrator action required. XZ Utils is a pointed entry given the 2024xzbackdoor. - Package and service managers — Arch’s
pacman(v7.0.0+),snapd(v2.72+), andsetprivfromutil-linux(v2.40+), which exposes Landlock to shell scripts. - Sandboxing tools and libraries — the official
rust-landlockcrate andgo-landlock(now packaged in Debian), plus Haskell and C# bindings,exile.h,extrasafe(v0.4.0+), Firejail (v0.9.74+), ChromeOS’s Minijail, and the project’s ownIslandsandboxer. - Runtimes and orchestrators — Cloud Hypervisor, HashiCorp Nomad’s
exec2driver, the Ladybird browser, and open pull requests againstrunc, the OCI runtime specification, systemd, and PAM. Those last four are the ones to watch: Landlock reaching the OCI spec andruncwould make it a container-runtime primitive rather than an application-level one. - AI agent sandboxing — a category that barely existed when Landlock merged and is now visibly the fastest-growing consumer of it: OpenAI’s Codex CLI, a Gemini CLI pull request, and a cluster of purpose-built tools (
Fence,Greywall,Landstrip,nono, NVIDIA OpenShell,Podlock). Unprivileged, self-applied, no-daemon confinement is exactly the shape an agent runner needs.
The kernel itself ships the reference samples/landlock/sandboxer.c and a selftest suite under tools/testing/selftests/landlock/, and security/landlock carries KUnit tests behind CONFIG_SECURITY_LANDLOCK_KUNIT_TEST (v6.12 Kconfig).
The defining operational concern in every real deployment remains graceful degradation across kernel versions. A binary built against the newest UAPI header will run on older kernels that lack the newer access rights, so the universal pattern is to query the ABI version at startup and strip unsupported rights before enforcing — and, since RHEL 9.6 backported ABI 5 onto a 5.14 kernel, to never infer the ABI from the kernel version. Upstream’s own advice is to use a library that does this for you: “All these issues can be avoided by using a Landlock library with best-effort support (Rust or Go for now)” (landlock.io news #5). That pattern, and the per-ABI feature timeline it depends on, is the subject of Landlock Rulesets and ABI Versions.
One operational gap in the 6.12 era is worth naming because it shapes how these deployments are debugged: 6.12 has no denial logging. A Landlock denial is an ordinary EACCES with nothing in the audit log to say Landlock caused it, which is why so many of the failure modes above present as inscrutable application errors. This was the largest change to Landlock since it merged — audit support landed with ABI 7 in Linux 6.15 (upstream describes it as “+46% SLOC for the kernel”), together with three LANDLOCK_RESTRICT_SELF_LOG_* flags for tuning noise, verified present in the v6.15 UAPI header. On 6.12 the substitutes are strace and bisecting the ruleset.
See Also
- Landlock Rulesets and ABI Versions — the versioned ABI, per-level feature timeline, and the best-effort degradation pattern (the central practical fact about using Landlock)
- Landlock vs seccomp vs Namespaces — when to reach for each unprivileged-sandboxing primitive
- no_new_privs and Privilege Escalation Control — the mandatory prerequisite that makes self-sandboxing safe
- The Linux Security Module Framework — the hook infrastructure Landlock plugs into
- LSM Stacking and Module Ordering — why Landlock can run alongside SELinux/AppArmor
- Seccomp and seccomp-BPF — the syscall-filtering sibling that composes with Landlock
- seccomp and Syscall Filtering — the filter machinery in depth, including why a filter cannot dereference a pathname pointer
- POSIX Capabilities — the privilege model Landlock deliberately sits outside of; step 2 of the hardening order above
- AppArmor — the path-based MAC Landlock is most often compared to, and the contrast that defines it: administrator-authored and system-wide, versus application-authored and self-applied
- User Namespaces — the alternative unprivileged confinement route, and why the Landlock documentation argues against it for access control
- Mount Namespaces — changing the filesystem view rather than denying access to it
- Linux Security MOC — parent map (§F, Unprivileged Sandboxing)