landlock: sandbox your process without root
Published by RodHat · Updated

Every sane sandboxing mechanism before this one required either root, a kernel module, or a mandatory-access-control framework that someone else configured for you. SELinux policies are written by a different team in a different language in a different repository and you will never touch them. AppArmor profiles live in /etc/apparmor.d/ and need apparmor_parser and you still need root to load them. seccomp-BPF (see the seccomp post) lets you filter syscalls from userspace without privileges, which is great, but it does not model filesystem paths at all.
Landlock fills that gap. Since Linux 5.13 (2021), an unprivileged process can construct a filesystem ruleset, add allowed paths to it, then clamp itself to those rules permanently. The restrictions inherit into forked children and exec’d programs. The ruleset is additive-only: once you restrict yourself, nothing inside the sandbox can widen those permissions back. It stacks with whatever MAC policy the admin has installed. Chrome has used it for renderer process isolation on Linux since Chrome 108.
Three syscalls. No root. No config files.
OpenBSD people will point out, correctly and smugly, that unveil(2) shipped in 2019 and does the filesystem half of this in about four lines. Linux spent those years with people reaching for chroot, which needs root, does nothing about writes inside the jail, and was never a security boundary to begin with. Landlock is the late, verbose, but architecturally sound answer.
The model
You create a ruleset that declares which filesystem access rights you want to constrain. Then you add allowed-path rules to it (each rule says: “this file descriptor is allowed these specific rights”). Then you call landlock_restrict_self(). After that point, any access the thread or its descendants attempt that is not explicitly allowed by the ruleset is denied with EACCES (or EXDEV for the cross-directory rename case covered below).
The one prerequisite: either CAP_SYS_ADMIN in your user namespace, or you have called prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) first. Skip it and landlock_restrict_self() fails with EPERM. The PR_SET_NO_NEW_PRIVS call is not landlock-specific (seccomp has the same requirement), it is irreversible, and it also prevents execve’d programs from gaining privileges via setuid bits. It is a reasonable default for any daemon that does not need to exec privileged helpers.
ABI negotiation
Landlock has been extended across kernel releases, and every extension is a new ABI version:
| ABI | Kernel | Adds |
|---|---|---|
| 1 | 5.13 | base filesystem rights (read/write/exec/readdir, make/remove) |
| 2 | 5.19 | LANDLOCK_ACCESS_FS_REFER (cross-directory rename/link) |
| 3 | 6.2 | LANDLOCK_ACCESS_FS_TRUNCATE |
| 4 | 6.7 | TCP bind/connect rules |
| 5 | 6.10 | LANDLOCK_ACCESS_FS_IOCTL_DEV |
| 6 | 6.12 | IPC scoping: abstract UNIX sockets and signals |
| 7 | 6.15 | logging flags for enforcement |
| 8 | 7.0 | LANDLOCK_RESTRICT_SELF_TSYNC (apply to every thread at once) |
| 9 | 7.1 | LANDLOCK_ACCESS_FS_RESOLVE_UNIX |
You negotiate the highest version the kernel supports with a special flag on the first syscall:
#define _GNU_SOURCE
#include <linux/landlock.h>
#include <sys/syscall.h>
#include <sys/prctl.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
/* These are not in glibc as of this writing; call them via syscall(). */
static inline int landlock_create_ruleset(
const struct landlock_ruleset_attr *attr,
size_t size, uint32_t flags)
{
return (int)syscall(__NR_landlock_create_ruleset, attr, size, flags);
}
static inline int landlock_add_rule(int ruleset_fd,
enum landlock_rule_type type,
const void *attr, uint32_t flags)
{
return (int)syscall(__NR_landlock_add_rule,
ruleset_fd, type, attr, flags);
}
static inline int landlock_restrict_self(int ruleset_fd, uint32_t flags)
{
return (int)syscall(__NR_landlock_restrict_self, ruleset_fd, flags);
}
/* Query the highest ABI version the running kernel supports. */
static int landlock_abi_version(void)
{
int v = landlock_create_ruleset(NULL, 0,
LANDLOCK_CREATE_RULESET_VERSION);
return (v < 0) ? -1 : v;
}
Always negotiate. Do not hardcode abi = 1 and call it done. A kernel older than 5.13 (or built without landlock) returns ENOSYS; a kernel that has it compiled in but disabled at boot returns EOPNOTSUPP. Treat both as “landlock unavailable, decide whether to abort or run unconfined.”
The silent no-op
Here is the trap that gets everyone the first time. The kernel is not lenient about rights it does not know: hand landlock_create_ruleset() an access bit from a newer ABI and you get EINVAL, loudly. So people “fix” that by hardcoding the ABI v1 set and moving on. That compiles, runs, returns zero everywhere, and leaves holes you cannot see.
The holes come from the rule you need to tattoo somewhere: a right that is not in handled_access_fs is not restricted at all, no matter what your path rules say. Your v1-only ruleset on a 6.2 kernel does not handle LANDLOCK_ACCESS_FS_TRUNCATE, so truncation is wide open. And truncation does not require WRITE_FILE: truncate(2), ftruncate(2), creat(2), and open(2) with O_TRUNC are all governed by the TRUNCATE right alone. Your “read-only” /etc can be zeroed out by the sandboxed process you were so proud of. Nothing errors. Nothing logs. The sandbox is just quietly less of a sandbox.
The correct pattern: build the full mask of everything you want to restrict, then strip it down to what the negotiated ABI understands. Handle everything the kernel knows about; grant back only what you need.
static uint64_t handled_fs_for_abi(int abi)
{
uint64_t fs = LANDLOCK_ACCESS_FS_EXECUTE |
LANDLOCK_ACCESS_FS_WRITE_FILE |
LANDLOCK_ACCESS_FS_READ_FILE |
LANDLOCK_ACCESS_FS_READ_DIR |
LANDLOCK_ACCESS_FS_REMOVE_DIR |
LANDLOCK_ACCESS_FS_REMOVE_FILE |
LANDLOCK_ACCESS_FS_MAKE_CHAR |
LANDLOCK_ACCESS_FS_MAKE_DIR |
LANDLOCK_ACCESS_FS_MAKE_REG |
LANDLOCK_ACCESS_FS_MAKE_SOCK |
LANDLOCK_ACCESS_FS_MAKE_FIFO |
LANDLOCK_ACCESS_FS_MAKE_BLOCK |
LANDLOCK_ACCESS_FS_MAKE_SYM; /* ABI 1 */
if (abi >= 2) fs |= LANDLOCK_ACCESS_FS_REFER;
if (abi >= 3) fs |= LANDLOCK_ACCESS_FS_TRUNCATE;
if (abi >= 5) fs |= LANDLOCK_ACCESS_FS_IOCTL_DEV;
return fs;
}
Feed that into handled_access_fs instead of a hand-picked list. The example below uses a shorter list to keep the walkthrough readable; in production, use the full mask.
Locking down filesystem access
The landlock_ruleset_attr struct has a handled_access_fs bitmask. Every right you list there is “handled by this ruleset,” meaning the kernel will deny accesses to that right unless you explicitly allow them with a rule. Rights you omit are not restricted at all.
static int create_fs_ruleset(void)
{
/*
* Declare everything we want to restrict. If a right is not listed
* here, the ruleset does not touch it and the normal DAC rules apply.
*/
struct landlock_ruleset_attr attr = {
.handled_access_fs =
LANDLOCK_ACCESS_FS_READ_FILE |
LANDLOCK_ACCESS_FS_READ_DIR |
LANDLOCK_ACCESS_FS_WRITE_FILE |
LANDLOCK_ACCESS_FS_EXECUTE |
LANDLOCK_ACCESS_FS_REMOVE_FILE|
LANDLOCK_ACCESS_FS_MAKE_REG |
LANDLOCK_ACCESS_FS_MAKE_DIR,
};
int rfd = landlock_create_ruleset(&attr, sizeof(attr), 0);
if (rfd < 0) {
perror("landlock_create_ruleset");
return -1;
}
return rfd;
}
/* Allow read-only access to a specific path. */
static int allow_read(int ruleset_fd, const char *path)
{
int fd = open(path, O_PATH | O_CLOEXEC);
if (fd < 0) return -1;
struct landlock_path_beneath_attr rule = {
.allowed_access = LANDLOCK_ACCESS_FS_READ_FILE |
LANDLOCK_ACCESS_FS_READ_DIR,
.parent_fd = fd,
};
int ret = landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH,
&rule, 0);
close(fd);
return ret;
}
/* Allow read+write access to a specific path. */
static int allow_readwrite(int ruleset_fd, const char *path)
{
int fd = open(path, O_PATH | O_CLOEXEC);
if (fd < 0) return -1;
struct landlock_path_beneath_attr rule = {
.allowed_access =
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,
.parent_fd = fd,
};
int ret = landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH,
&rule, 0);
close(fd);
return ret;
}
The O_PATH flag is important: it opens a file descriptor that refers to the path without opening the file’s content, so you can pass it as the parent_fd for any file or directory, including directories you do not have read access to. The kernel resolves the ruleset relative to the path the fd points at.
Applying the sandbox
int main(void)
{
/* Check kernel support first. */
int abi = landlock_abi_version();
if (abi < 0) {
fprintf(stderr, "landlock not available (kernel < 5.13?)\n");
/* Decide: abort, or run without sandbox? */
return 1;
}
printf("landlock ABI version: %d\n", abi);
/* Required before restrict_self when not CAP_SYS_ADMIN. */
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0) {
perror("prctl PR_SET_NO_NEW_PRIVS");
return 1;
}
int rfd = create_fs_ruleset();
if (rfd < 0) return 1;
/* Allow read access to /usr, /lib, /lib64 (shared libraries). */
allow_read(rfd, "/usr");
allow_read(rfd, "/lib");
allow_read(rfd, "/lib64");
/* Allow read+write to the working directory only. */
allow_readwrite(rfd, ".");
/* Lock it in. After this point the rules are active and permanent. */
if (landlock_restrict_self(rfd, 0) < 0) {
perror("landlock_restrict_self");
close(rfd);
return 1;
}
close(rfd);
/* Now try to open something we didn't allow. */
FILE *f = fopen("/etc/passwd", "r");
if (!f) {
perror("/etc/passwd"); /* EACCES — expected */
} else {
printf("opened /etc/passwd, sandbox did not work\n");
fclose(f);
}
return 0;
}
After landlock_restrict_self() returns, the ruleset fd can be closed; the kernel holds its own reference. The restriction is inherited by fork(), clone(), and exec(). It is per-thread, though, not per-process: sibling threads that already exist stay unconfined. Restrict in main() before you spawn worker threads, or on 7.0+ (ABI 8) pass LANDLOCK_RESTRICT_SELF_TSYNC to apply it to every thread at once. A sandbox with one unconfined thread is a sandbox with a door in it.
You can call restrict_self again later with a tighter ruleset; the layers stack and an access must pass every one. The kernel caps that at 64 composed rulesets per thread, after which you get E2BIG. If you are hitting 64 you have a design problem, not a kernel problem.
Open any files or sockets you need before you restrict. After restrict_self the process cannot open anything outside the allow list.
The REFER footgun
LANDLOCK_ACCESS_FS_REFER (ABI 2) governs reparenting: renaming or hard-linking a file into a different directory than the one it lives in. It is the one right that is denied by default by every ruleset, whether or not you listed it in handled_access_fs. On ABI 1 there is no REFER right to grant, so a landlocked process can never move a file between directories, full stop.
This breaks the most common safe-write idiom in Unix: write to a temp file, then rename(2) it over the real one. rename("/tmp/config.tmp", "/etc/config") fails with EXDEV even when both directories are individually writable under your rules. Same-directory write-then-rename is fine. (You get EACCES instead if you are missing the MAKE_* or REMOVE_* rights the operation needs on either end.)
Two fixes. On ABI 2+, grant LANDLOCK_ACCESS_FS_REFER on both directories involved; reparenting is still refused with EXDEV if the file would end up with more rights at the destination than it had at the source. Or, simpler and portable back to 5.13: keep temp files in the same directory as their final destination. That has been the correct way to do atomic replace since before Linux existed anyway, because rename across filesystems was never atomic either.
Network rules (ABI v4, Linux 6.7)
Starting with ABI version 4, you can restrict which TCP ports the process can bind or connect to. The mechanism is the same: declare handled_access_net in the ruleset attr before you create the ruleset, then add LANDLOCK_RULE_NET_PORT rules for each allowed port. Setting handled_access_net on the attr after landlock_create_ruleset() has already run does exactly nothing.
struct landlock_ruleset_attr attr = {
.handled_access_fs = handled_fs_for_abi(abi),
};
if (abi >= 4)
attr.handled_access_net = LANDLOCK_ACCESS_NET_BIND_TCP |
LANDLOCK_ACCESS_NET_CONNECT_TCP;
int rfd = landlock_create_ruleset(&attr, sizeof(attr), 0);
/* ... filesystem rules ... */
if (abi >= 4) {
struct landlock_net_port_attr net_rule = {
.allowed_access = LANDLOCK_ACCESS_NET_BIND_TCP,
.port = 8080, /* listen here */
};
landlock_add_rule(rfd, LANDLOCK_RULE_NET_PORT, &net_rule, 0);
net_rule.allowed_access = LANDLOCK_ACCESS_NET_CONNECT_TCP;
net_rule.port = 443; /* call out here */
landlock_add_rule(rfd, LANDLOCK_RULE_NET_PORT, &net_rule, 0);
}
That daemon can listen on 8080 and dial out to 443 and nothing else over TCP. Note what is not in that sentence: UDP. The network rights are TCP bind(2) and connect(2) only. UDP, raw sockets, and netlink are untouched by landlock; if the process should have no network at all, pair landlock with a seccomp rule that kills on socket(2).
If you create the ruleset with an older attr size (no handled_access_net field) on a kernel that supports ABI v4, the kernel accepts it gracefully. Future rights in fields you don’t include are not restricted. This is why the ABI negotiation matters: compute the right struct size for the ABI version you negotiated.
What this does not cover
Landlock does not restrict syscalls. If you want to block ptrace, perf_event_open, or other dangerous syscalls, combine landlock with seccomp-BPF. The two compose cleanly: both are applied independently, and both must allow an operation for it to proceed.
Landlock mostly does not restrict IPC: pipes, sockets, and shared memory already established before restrict_self continue to work. The exception is ABI 6 (6.12), which added a scoped field to the ruleset attr for cutting the sandbox off from abstract UNIX sockets and signals outside its own domain. It also does not restrict access to file descriptors already open when the ruleset is applied. Open your fd, apply the ruleset, and nothing about the already-open fd changes.
Rules attach to file hierarchies, so they follow bind mounts but not OverlayFS: a rule on an overlay layer does not restrict the merged view, and vice versa. Container people, take note.
Finally, landlock does not work on kernels before 5.13. Handle ENOSYS (and EOPNOTSUPP) from the first syscall and decide whether your application can run usefully without the sandbox or should refuse to start. A daemon that silently skips its own confinement on old kernels is not a hardened daemon.
Verifying the kernel supports it
# Check config
zcat /proc/config.gz | grep LANDLOCK
# CONFIG_SECURITY_LANDLOCK=y
# CONFIG_LSM="...,landlock"
# Check ABI version (Python one-liner)
python3 -c "
import ctypes, ctypes.util
libc = ctypes.CDLL(None)
# landlock_create_ruleset with LANDLOCK_CREATE_RULESET_VERSION = 1 << 0
v = libc.syscall(444, None, 0, 1)
print('landlock ABI version:', v if v > 0 else 'unavailable (errno check needed)')
"
# The three syscall numbers on x86-64:
# landlock_create_ruleset: 444
# landlock_add_rule: 445
# landlock_restrict_self: 446
Your distro kernel needs CONFIG_SECURITY_LANDLOCK=y and "landlock" in the CONFIG_LSM list. Ubuntu 22.04 and later ship with it enabled. Debian 12 has it. Arch has it. Older LTS kernels do not, so check before deploying.
Versus unveil
Count the lines. unveil(2) is a handful; the landlock equivalent above is sixty-odd lines of C plus a version table you have to keep in your head. OpenBSD got the developer ergonomics right. Landlock got the kernel architecture right: it is a real LSM, it stacks with whatever else the box is running, and it extends to network and IPC without inventing a new mechanism each time. If you write code that has to run on both, learn both; the pledge and unveil post has the BSD side.
The seccomp-BPF post covers syscall-level filtering, which stacks directly with landlock for defense in depth. The pidfd post is relevant if you are sandboxing child processes and need race-free handles to them. The nsenter post covers namespace-based isolation, which is a heavier approach to the same problem. Keeping secrets out of core dumps and swap is a different layer entirely; the mlock and MADV_DONTDUMP post covers it. Kerrisk’s The Linux Programming Interface has the capabilities and privilege model chapters that give the mental context for why PR_SET_NO_NEW_PRIVS exists in the first place.