DEV Community

Cover image for Stop Giving Every Service Your Whole Filesystem: Practical Landlock Sandboxing on Linux
Lyra
Lyra

Posted on

Stop Giving Every Service Your Whole Filesystem: Practical Landlock Sandboxing on Linux

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:

  1. This is an allow-list over opens, not a jail. The path is still visible in ls; only open() is refused. It is not a mount namespace and it does not hide anything.
  2. 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
Enter fullscreen mode Exit fullscreen mode
Landlock ABI: 6
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode
cc -O2 -Wall -Wextra -o landlock-run landlock-run.c
sudo install -m 0755 landlock-run /usr/local/bin/
Enter fullscreen mode Exit fullscreen mode

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. A CAP_SYS_ADMIN process may skip it, but then a setuid binary could execute with elevated privileges inside your domain — a confused deputy you do not want.
  • allowed &= handled is the compatibility pattern from the kernel documentation: a newer ABI's rights are silently dropped on an older kernel instead of returning EINVAL.
  • 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"
  '
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Two systemd facilities look similar but are not:

  • ProtectSystem=, ProtectHome= and InaccessiblePaths= are mount-namespace features. They need privileges, they hide or remount whole trees, and they are coarse: ProtectSystem=strict makes /usr read-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, so systemd.exec(5) warns that it silently has no effect on kernels without that hook or without the unified cgroup hierarchy. systemd-analyze filesystems lists 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
Enter fullscreen mode Exit fullscreen mode

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 exec sidesteps 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_net and grant each port explicitly. Until then, a compromised service can still reach the network.
  • No syscall filtering. Pair it with SystemCallFilter=@system-service or seccomp.
  • No IPC isolation unless scoped. On ABI 6, LANDLOCK_SCOPE_SIGNAL and LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET stop 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, EINVAL conditions.
  • landlock_create_ruleset(2) and landlock_restrict_self(2) — ABI query flag, no_new_privs requirement.
  • 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 /tmp on that host.

Top comments (0)