A service runs as its own user. Its unit has NoNewPrivileges=yes. Maybe it even has ProtectSystem=strict and ProtectHome=yes. And yet, if someone finds a path traversal bug in it, the process can still open /etc/hostname, /etc/passwd, your application's config in /srv, or a leftover credential file in /var/backups.
That is not a misconfiguration. It is how discretionary access control works: if a file is world-readable, every uid on the box may read it. Your service does not have a "these are my files" list — it has a uid, and the kernel checks that uid against inode permissions on every open().
Landlock closes exactly that gap. It is a Linux LSM that lets a process hand the kernel a list of path hierarchies it is allowed to touch, and have everything else denied at open() — with no root, no mount namespace, and no container runtime. This article builds a small wrapper around it, proves the denials, and wires it into a systemd unit.
What Landlock actually does
Landlock was introduced in Linux 5.13 as a stackable LSM (CONFIG_SECURITY_LANDLOCK). The model is deliberately narrow and easy to reason about:
- A ruleset declares the access rights it wants to handle. Any handled right that no rule grants is denied, everywhere.
- Rules grant a subset of those rights to a file hierarchy, identified by an opened path.
- Enforcing a ruleset creates a domain. Every child thread and every
execve()descendant stays inside it. - Domains stack. A sandboxed process can only add more restrictions; it can never relax one. Neither can root.
- A denied operation fails with
EACCES— an ordinary "Permission denied", not a crash.
Two consequences are worth internalising before writing any policy:
- This is an allow-list over opens, not a jail. The path is still visible in
ls; onlyopen()is refused. It is not a mount namespace and it does not hide anything. - File descriptors opened before enforcement are exempt.
landlock(7)is explicit: "Files or directories opened before the sandboxing are not subject to these restrictions." So a service that inherits an open config fd from its parent keeps using it, and your policy must be applied before the process opens anything it should not have.
It also does not replace seccomp (it restricts objects, not syscalls), and it does not restrict networking unless you handle network rights too.
Know your ABI before you write the policy
Landlock is versioned by ABI, not by kernel version. Query it instead of guessing, because each ABI adds rights that older kernels reject with EINVAL if you mention them:
| ABI | Mainline kernel | What it added |
|---|---|---|
| 1 | 5.13 | Filesystem rights (execute, read, write, create, remove, rename) |
| 2 | 5.19 | LANDLOCK_ACCESS_FS_REFER (cross-directory link/rename) |
| 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 scopes: LANDLOCK_SCOPE_SIGNAL, LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET |
| 7 | 6.15 | Restriction logging flags |
| 8 | 7.0 | LANDLOCK_RESTRICT_SELF_TSYNC |
| 9 | 7.1 | LANDLOCK_ACCESS_FS_RESOLVE_UNIX |
On the Debian 13 box used for this article (kernel 6.12.113) the syscall reports ABI 6. A five-line check on x86-64, no compiler needed:
python3 - <<'EOF' import ctypes libc = ctypes.CDLL(None, use_errno=True) # landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION) print("Landlock ABI:", libc.syscall(444, None, ctypes.c_size_t(0), ctypes.c_uint32(1))) EOF Landlock ABI: 6 Syscall 444 is the x86-64 number; on other architectures use SYS_landlock_create_ruleset from <sys/syscall.h>. If the call returns -1 with ENOSYS or EOPNOTSUPP, Landlock is missing or disabled — check that landlock appears in /sys/kernel/security/lsm, and that the kernel log says landlock: Up and running (journalctl -kb -g landlock).
A wrapper you can actually use
Most services have no Landlock support, and you should not patch every application. The practical pattern is a tiny launcher: build the ruleset, enforce it, then exec the real binary. The sandbox is inherited across execve(), so the application never knows it happened.
Save this as landlock-run.c. It grants read+execute on -r paths and full access on -w paths, and drops rights the running ABI does not know about instead of failing.
/* landlock-run.c - apply a path allow-list with Landlock, then exec a command. * Build: cc -O2 -Wall -o landlock-run landlock-run.c * Usage: landlock-run [-r PATH]... [-w PATH]... -- CMD [ARG]... * -r PATH allow reading and executing inside PATH * -w PATH allow reading, writing, creating and removing inside PATH */ #define _GNU_SOURCE #include <errno.h> #include <fcntl.h> #include <linux/landlock.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/prctl.h> #include <sys/stat.h> #include <sys/syscall.h> #include <unistd.h> static int ll_create_ruleset(const struct landlock_ruleset_attr *attr, size_t size, __u32 flags) { return syscall(SYS_landlock_create_ruleset, attr, size, flags); } static int ll_add_rule(int ruleset_fd, enum landlock_rule_type type, const void *attr) { return syscall(SYS_landlock_add_rule, ruleset_fd, type, attr, 0); } static int ll_restrict_self(int ruleset_fd, __u32 flags) { return syscall(SYS_landlock_restrict_self, ruleset_fd, flags); } /* Everything this kernel knows about, depending on its Landlock ABI. */ static __u64 fs_rights_for_abi(int abi) { __u64 r = 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; if (abi >= 2) r |= LANDLOCK_ACCESS_FS_REFER; if (abi >= 3) r |= LANDLOCK_ACCESS_FS_TRUNCATE; if (abi >= 5) r |= LANDLOCK_ACCESS_FS_IOCTL_DEV; return r; } static int add_path(int ruleset_fd, const char *path, int writable, __u64 handled) { struct stat st; __u64 allowed; int fd = open(path, O_PATH | O_CLOEXEC); if (fd < 0) { fprintf(stderr, "landlock-run: cannot open %s: %s\n", path, strerror(errno)); return -1; } if (fstat(fd, &st) < 0) { fprintf(stderr, "landlock-run: cannot stat %s: %s\n", path, strerror(errno)); close(fd); return -1; } if (S_ISDIR(st.st_mode)) { allowed = writable ? handled : (LANDLOCK_ACCESS_FS_EXECUTE | LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR); } else { /* Directory-only rights are rejected with EINVAL on a file. */ allowed = writable ? (LANDLOCK_ACCESS_FS_WRITE_FILE | LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_TRUNCATE | LANDLOCK_ACCESS_FS_IOCTL_DEV) : (LANDLOCK_ACCESS_FS_EXECUTE | LANDLOCK_ACCESS_FS_READ_FILE); } allowed &= handled; /* drop rights this ABI does not support */ if (allowed) { struct landlock_path_beneath_attr pb = { .allowed_access = allowed, .parent_fd = fd, }; if (ll_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, &pb) < 0) { fprintf(stderr, "landlock-run: rule for %s failed: %s\n", path, strerror(errno)); close(fd); return -1; } } close(fd); return 0; } int main(int argc, char **argv) { struct landlock_ruleset_attr attr = { 0 }; const char *read_paths[64], *write_paths[64]; int n_read = 0, n_write = 0, i, abi, ruleset_fd; for (i = 1; i < argc; i++) { if (!strcmp(argv[i], "--")) break; if ((!strcmp(argv[i], "-r") || !strcmp(argv[i], "-w")) && i + 1 < argc) { if (argv[i][1] == 'r') read_paths[n_read++] = argv[++i]; else write_paths[n_write++] = argv[++i]; } else { fprintf(stderr, "usage: %s [-r PATH]... [-w PATH]... -- CMD [ARG]...\n", argv[0]); return 2; } } if (i + 1 >= argc) { fprintf(stderr, "landlock-run: no command given after --\n"); return 2; } argv += i + 1; abi = ll_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION); if (abi < 0) { fprintf(stderr, "landlock-run: Landlock unavailable: %s\n", strerror(errno)); return 1; } attr.handled_access_fs = fs_rights_for_abi(abi); ruleset_fd = ll_create_ruleset(&attr, sizeof(attr), 0); if (ruleset_fd < 0) { fprintf(stderr, "landlock-run: ruleset creation failed: %s\n", strerror(errno)); return 1; } for (i = 0; i < n_read; i++) if (add_path(ruleset_fd, read_paths[i], 0, attr.handled_access_fs)) return 1; for (i = 0; i < n_write; i++) if (add_path(ruleset_fd, write_paths[i], 1, attr.handled_access_fs)) return 1; if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0) { perror("landlock-run: prctl"); return 1; } if (ll_restrict_self(ruleset_fd, 0) < 0) { fprintf(stderr, "landlock-run: restrict_self failed: %s\n", strerror(errno)); return 1; } close(ruleset_fd); execvp(argv[0], argv); perror("landlock-run: execvp"); return 127; } cc -O2 -Wall -Wextra -o landlock-run landlock-run.c sudo install -m 0755 landlock-run /usr/local/bin/ Three details in there matter more than they look:
-
prctl(PR_SET_NO_NEW_PRIVS, 1, ...)is required for an unprivileged process, and it also makes setuid/setgid binaries inside the sandbox run without elevation. ACAP_SYS_ADMINprocess may skip it, but then a setuid binary could execute with elevated privileges inside your domain — a confused deputy you do not want. -
allowed &= handledis the compatibility pattern from the kernel documentation: a newer ABI's rights are silently dropped on an older kernel instead of returningEINVAL. - The ruleset handles all filesystem rights. That is intentional: an allow-list should deny creation and deletion too, not just reads.
It deliberately leaves handled_access_net and scoped at zero, so networking and IPC remain unrestricted. We will come back to that.
Prove it denies something
Build a scratch directory, then run a shell inside a policy that allows only that directory plus the usual runtime trees:
mkdir -p /tmp/lltest && echo "inside the allowed dir" > /tmp/lltest/ok.txt ./landlock-run \ -r /usr -r /lib -r /lib64 -r /etc/ld.so.cache \ -w /dev/null -r /dev/urandom -w /tmp/lltest \ -- /bin/sh -c ' cat /tmp/lltest/ok.txt echo "written by the sandbox" > /tmp/lltest/out.txt && echo "write: ok" cat /tmp/lltest/out.txt cat /etc/hostname || echo "read /etc/hostname: denied" /tmp/lltest2/outside.sh || echo "exec outside allow-list: denied" ' inside the allowed dir write: ok written by the sandbox cat: /etc/hostname: Permission denied read /etc/hostname: denied /bin/sh: 6: /tmp/lltest2/outside.sh: Permission denied exec outside allow-list: denied Note what was denied: /etc/hostname is mode 644, owned by root, and readable by every process on the machine. Landlock did not change its mode, did not change the uid, and did not unmount anything. The open simply failed. That is the whole point.
The traps that will bite you
/dev/null and /dev/urandom are files too. A policy that grants nothing under /dev breaks the most mundane shell redirection: echo x > /dev/null fails with EACCES, and any program that seeds a PRNG from /dev/urandom dies. Grant them explicitly — the program above handles them because it applies only file-applicable rights when the path is not a directory.
READ_DIR and READ_FILE are separate. Granting READ_FILE on a directory lets you open files by exact path but not list it. Granting READ_DIR lets you list it but not read the files. The -r flag grants both, plus EXECUTE, which is what you usually want for /usr.
TRUNCATE is not implied by WRITE_FILE. Since ABI 3 they are separate rights, and creat() on an existing file needs truncate even though its name sounds like a write. landlock(7) recommends always granting the two together, which is what -w does.
Cross-directory rename needs REFER. It is the one right that is denied by default even when it is not listed in handled_access_fs, and a rename between two allowed directories only works if both grant it. If the destination would grant the file more rights than the source, the kernel returns EXDEV rather than EACCES.
Directory-only rights on a file are EINVAL. LANDLOCK_ACCESS_FS_READ_DIR, REMOVE_* and MAKE_* cannot be attached to a regular file descriptor. That is why the wrapper branches on S_ISDIR.
A ruleset only covers the calling thread on older ABIs. landlock_restrict_self() restricts the thread that calls it; children inherit, but sibling threads do not. For a single-threaded launcher that execs immediately this is irrelevant. For a multithreaded process, every thread must enforce the ruleset — or use LANDLOCK_RESTRICT_SELF_TSYNC on ABI 8+ (kernel 7.0), which applies it to all threads.
The domain is permanent for that process tree. There is no "unrestrict" call. Test policies on a throwaway command, not on your only SSH session.
Putting it in a systemd unit
The launcher composes with the hardening you already have. A renderer that needs Python, TLS certificates, a spool directory, and nothing else:
[Unit] Description=Report renderer (Landlocked) After=network-online.target Wants=network-online.target [Service] DynamicUser=yes StateDirectory=render NoNewPrivileges=yes ExecStart=/usr/local/bin/landlock-run \ -r /usr -r /lib -r /lib64 \ -r /etc/ld.so.cache -r /etc/ssl/certs \ -r /etc/resolv.conf -r /etc/hosts -r /etc/nsswitch.conf \ -r /dev/urandom -w /dev/null \ -w /var/lib/render \ -- /usr/bin/python3 /usr/local/lib/render/main.py Restart=on-failure [Install] WantedBy=multi-user.target Two systemd facilities look similar but are not:
-
ProtectSystem=,ProtectHome=andInaccessiblePaths=are mount-namespace features. They need privileges, they hide or remount whole trees, and they are coarse:ProtectSystem=strictmakes/usrread-only, it does not stop a read of/etc/hostname. -
RestrictFileSystems=(systemd 250+) filters by filesystem type, not by path. It is implemented with the eBPF LSM hook, sosystemd.exec(5)warns that it silently has no effect on kernels without that hook or without the unified cgroup hierarchy.systemd-analyze filesystemslists the names and set aliases (@basic-api,@temporary, …) it accepts.
Landlock is the path-level layer. Keep the mount namespace and the syscall filter; add path allow-listing on top. They are cumulative — every layer must grant access.
Since DynamicUser=yes creates the uid at start and StateDirectory=render creates /var/lib/render, the directories exist before ExecStart runs, which is exactly the ordering Landlock needs: the paths must be openable at rule-creation time.
Verify with a deliberate failure rather than by reading the unit:
systemctl start render.service systemctl status render.service --no-pager journalctl -u render.service -g 'Permission denied' -n 20 --no-pager If a legitimate path is missing, the failure mode is a clean EACCES in the log, with the path named by the application — add a -r or -w for it and restart. If the launcher cannot open a path you listed, it exits with landlock-run: cannot open … before the service ever starts, which is much easier to debug than a half-running daemon.
Where this stops
Be honest about the boundary, because an overstated sandbox is worse than none:
- Already-open descriptors remain usable. Enforcing in a launcher before
execsidesteps this; if you sandbox a long-running process instead, every descriptor it already holds keeps working. - No network confinement by default. TCP bind/connect rules exist from ABI 4 (kernel 6.7), but you must handle
handled_access_netand grant each port explicitly. Until then, a compromised service can still reach the network. - No syscall filtering. Pair it with
SystemCallFilter=@system-serviceor seccomp. - No IPC isolation unless scoped. On ABI 6,
LANDLOCK_SCOPE_SIGNALandLANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKETstop a sandboxed service from signalling processes or connecting to abstract sockets outside its domain — useful, and easy to forget. - Not a substitute for file permissions. Landlock removes access; it does not fix a secret that is world-readable and copied around by other tools.
The useful mental shift is small but real: a service should have a list of paths it needs, and the kernel should enforce that list — not a uid that happens to be able to read half of /etc. Landlock is the first mechanism that makes that list enforceable by an ordinary unprivileged process, in about 140 lines of C you can read in one sitting.
Sources and references
- Linux kernel documentation, Landlock: unprivileged access control — ruleset/rule model, ABI compatibility pattern, scopes, capability and namespace rules.
- landlock(7) — access rights per ABI, REFER semantics, truncate/write interaction, inheritance, ptrace and IPC scoping, ABI-to-kernel table.
- landlock_add_rule(2) — rule attribute layout, file vs directory rules,
EINVALconditions. - landlock_create_ruleset(2) and landlock_restrict_self(2) — ABI query flag,
no_new_privsrequirement. - systemd.exec(5) —
RestrictFileSystems=,ProtectSystem=,ProtectHome=,NoNewPrivileges=,StateDirectory=. - Local verification: Debian 13, kernel 6.12.113, systemd 257.13, Landlock ABI 6; transcripts above are from runs in
/tmpon that host.
