$RodHat_
Console Tips

Landlock: per-process filesystem restriction without root

Published by

Photo by Gabriel Heinzer
Photo: Gabriel Heinzer / Unsplash

OpenBSD shipped unveil(2) in 2019. Three syscalls, unprivileged, per-process filesystem restriction. You call it, a path becomes accessible, everything else disappears. Works in four lines of C.

Linux programmers watched this happen and kept reaching for chroot. For years. chroot requires root, does not restrict writes within the jail, and every container runtime has a breakout tucked somewhere. It was always the wrong tool for this.

Landlock landed in Linux 5.13 in 2021. It is a proper LSM (Linux Security Module) that any process can apply to itself without privileges. The model is an allow list: you define exactly which paths and operations are permitted, then lock the process down. Nothing in or out that you did not explicitly permit. Irreversible. Inherited by child processes.

The catch is a silent no-op that bites everyone the first time.

ABI version or bust

Landlock has a versioned ABI. New access rights were added in later kernel versions. Version 1 is the base filesystem set (Linux 5.13). Version 2 adds LANDLOCK_ACCESS_FS_REFER (Linux 5.19). Version 3 adds LANDLOCK_ACCESS_FS_TRUNCATE (Linux 6.2). Version 4 adds TCP bind/connect restrictions (Linux 6.7).

If you build a ruleset with access rights the running kernel does not support, those rights are silently ignored. That silent ignore is intentional for forward compatibility. It still means your sandbox can have holes you cannot see.

The fix is mandatory: check the ABI version before building the ruleset, then limit your access rights to what the kernel supports.

There are no libc wrappers for the Landlock syscalls yet. You call them directly:

#include <linux/landlock.h>
#include <sys/syscall.h>

static inline int landlock_create_ruleset(
    const struct landlock_ruleset_attr *attr,
    size_t size, uint32_t flags)
{
    return syscall(SYS_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 syscall(SYS_landlock_add_rule, ruleset_fd, type, attr, flags);
}

static inline int landlock_restrict_self(int ruleset_fd, uint32_t flags)
{
    return syscall(SYS_landlock_restrict_self, ruleset_fd, flags);
}

int get_landlock_abi(void) {
    return landlock_create_ruleset(NULL, 0,
        LANDLOCK_CREATE_RULESET_VERSION);
}

get_landlock_abi() returns the supported ABI version or -1 with ENOSYS if Landlock is not available. On anything older than 5.13, or a kernel with the Landlock LSM disabled in config, ENOSYS is the expected return.

The three-call pattern

#include <fcntl.h>
#include <sys/prctl.h>

int sandbox_filesystem(void) {
    int abi = get_landlock_abi();
    if (abi < 0) {
        if (errno == ENOSYS || errno == EOPNOTSUPP)
            return 0;  /* kernel too old or LSM disabled — degrade gracefully */
        return -1;
    }

    /* All filesystem rights through ABI v3 */
    uint64_t all_fs = LANDLOCK_ACCESS_FS_READ_FILE   |
                      LANDLOCK_ACCESS_FS_WRITE_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    |
                      LANDLOCK_ACCESS_FS_EXECUTE;

    if (abi >= 2)
        all_fs |= LANDLOCK_ACCESS_FS_REFER;
    if (abi >= 3)
        all_fs |= LANDLOCK_ACCESS_FS_TRUNCATE;

    struct landlock_ruleset_attr rs_attr = {
        .handled_access_fs = all_fs,
    };

    int rs_fd = landlock_create_ruleset(&rs_attr, sizeof(rs_attr), 0);
    if (rs_fd < 0)
        return -1;

    struct landlock_path_beneath_attr path_attr;

    /* Allow read-only access to /etc */
    path_attr.allowed_access = LANDLOCK_ACCESS_FS_READ_FILE |
                                LANDLOCK_ACCESS_FS_READ_DIR;
    path_attr.parent_fd = open("/etc", O_PATH | O_CLOEXEC);
    landlock_add_rule(rs_fd, LANDLOCK_RULE_PATH_BENEATH, &path_attr, 0);
    close(path_attr.parent_fd);

    /* Allow read-write access to /var/run */
    path_attr.allowed_access = LANDLOCK_ACCESS_FS_READ_FILE  |
                                LANDLOCK_ACCESS_FS_WRITE_FILE |
                                LANDLOCK_ACCESS_FS_READ_DIR   |
                                LANDLOCK_ACCESS_FS_REMOVE_FILE|
                                LANDLOCK_ACCESS_FS_MAKE_REG   |
                                LANDLOCK_ACCESS_FS_MAKE_SOCK;
    path_attr.parent_fd = open("/var/run", O_PATH | O_CLOEXEC);
    landlock_add_rule(rs_fd, LANDLOCK_RULE_PATH_BENEATH, &path_attr, 0);
    close(path_attr.parent_fd);

    /* No return from here. */
    if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0) {
        close(rs_fd);
        return -1;
    }

    int ret = landlock_restrict_self(rs_fd, 0);
    close(rs_fd);
    return ret;
}

handled_access_fs in the ruleset tells the kernel which access rights this ruleset manages. Rights not in handled_access_fs are not restricted by this ruleset at all, even if no rule grants them. This is the rule to internalize: list every right you want to restrict in the ruleset, then grant back only what you need. Omit WRITE_FILE from handled_access_fs and writes are unrestricted, no matter what your rules say.

prctl(PR_SET_NO_NEW_PRIVS) is required before landlock_restrict_self. Same requirement as seccomp. Without it, restrict_self returns EPERM. The call is irreversible: once set, no exec in this process tree can gain more privileges.

Open any sockets or files you need before calling this. Landlock applies immediately on restrict_self, and the process cannot open anything outside the allow list after that point.

The REFER footgun

LANDLOCK_ACCESS_FS_REFER controls whether a file can be renamed or linked to a parent directory other than its current one. It was not in the original ABI because the semantics are subtle.

If your ruleset handles REFER and no rule grants it, cross-directory rename fails with EXDEV even when both source and destination directories are individually accessible. This breaks anything that does atomic writes via rename(2) from a temp file in one directory to a destination in another. rename("/tmp/config.tmp", "/etc/config") returns EXDEV. Write-then-rename within the same parent works fine.

Two options: grant REFER on both directories involved in the rename, or keep temp files and final destinations under the same parent. The second is usually simpler.

On ABI v1 (kernel 5.13 through 5.18), REFER does not exist and cross-directory rename works normally. The restriction only appears when the ruleset includes REFER in handled_access_fs, which only happens when you check abi >= 2 and include it. Skipping the version check and always including REFER on a 5.14 kernel gets you EINVAL from landlock_create_ruleset. That is another reason the version check is not optional.

Network rules in ABI v4

Linux 6.7 added TCP port control. The model is the same as filesystem rules: define which ports the process can bind or connect to, and everything else is refused.

if (abi >= 4) {
    /* Add to rs_attr before creating ruleset */
    rs_attr.handled_access_net = LANDLOCK_ACCESS_NET_BIND_TCP |
                                  LANDLOCK_ACCESS_NET_CONNECT_TCP;

    /* After ruleset creation */
    struct landlock_net_port_attr net_attr;

    net_attr.allowed_access = LANDLOCK_ACCESS_NET_BIND_TCP;
    net_attr.port = 8080;
    landlock_add_rule(rs_fd, LANDLOCK_RULE_NET_PORT, &net_attr, 0);

    net_attr.allowed_access = LANDLOCK_ACCESS_NET_CONNECT_TCP;
    net_attr.port = 443;
    landlock_add_rule(rs_fd, LANDLOCK_RULE_NET_PORT, &net_attr, 0);
}

This restricts bind(2) and connect(2) on TCP sockets to exactly the listed ports. A daemon that should only listen on 8080 and call out to 443 cannot open other ports. UDP is not covered. IPv4 and IPv6 are treated identically at the port level.

The landlock_ruleset_attr struct gets a handled_access_net field alongside handled_access_fs. Same logic applies: only rights listed in handled_access_net are restricted by the ruleset.

What Landlock does not cover

Landlock does not restrict syscalls. A Landlock-restricted process can still call ptrace, keyctl, or anything else the kernel allows. You want both layers: Landlock for path and network restrictions, seccomp-bpf for syscall restrictions. The seccomp-bpf post covers the filter setup. The two compose cleanly. Apply your seccomp filter before or after Landlock’s restrict_self in any order; they do not interfere with each other.

Landlock does not restrict raw sockets, UDP, or netlink (as of ABI v4). For a process that should have no network access at all, pair Landlock filesystem restrictions with a seccomp rule that kills on socket(2).

Memory hygiene for secrets is out of scope for both. The mlock and MADV_DONTDUMP post covers that layer.

For comparison with OpenBSD’s approach: unveil is three lines versus sixty lines of C. OpenBSD got the developer ergonomics right. Landlock got the kernel LSM architecture right, which means it composes with the rest of the security stack and works across all LSM-capable kernels. The pledge and unveil post has the full comparison. If you are writing BSD-portable code, both are worth understanding.