$RodHat_
Console Tips

landlock: sandbox your process without root

Published by

landlock: sandbox your process without root
Photo: AI-generated — no human photographer / RodHat AI Cover

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.

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 process or its descendants attempt that is not explicitly allowed by the ruleset is denied with EACCES or ENOMEM (the kernel is deliberately vague to avoid leaking path information).

The one prerequisite: either CAP_SYS_ADMIN, or you have called prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) first. The PR_SET_NO_NEW_PRIVS call is not landlock-specific; 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. ABI version 1 (5.13) covers filesystem access. ABI version 4 (6.7) adds TCP network rules. 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 that is older than 5.13 will return ENOSYS; treat that as “landlock unavailable, decide whether to abort or run unconfined.”

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 not a filter on the calling thread only: it applies to the thread group and is inherited by fork() and exec().

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, add LANDLOCK_RULE_NET_PORT rules for each allowed port:

if (abi >= 4) {
    /* Add network restriction to the existing ruleset attr. */
    attr.handled_access_net =
        LANDLOCK_ACCESS_NET_BIND_TCP |
        LANDLOCK_ACCESS_NET_CONNECT_TCP;

    /* Allow binding on port 8080 only. */
    struct landlock_net_port_attr net_rule = {
        .allowed_access = LANDLOCK_ACCESS_NET_BIND_TCP,
        .port           = 8080,
    };
    landlock_add_rule(rfd, LANDLOCK_RULE_NET_PORT, &net_rule, 0);
}

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 does not restrict IPC: pipes, sockets, and shared memory already established before restrict_self continue to work. 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.

Finally, landlock does not work on kernels before 5.13. Handle ENOSYS 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.


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. 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.