Here’s a program restricting itself, then trying to read two files:

without landlock:
  read /etc/hostname            ok
  read /tmp/secret.txt          ok
with landlock, /etc allowed:
  read /etc/hostname            ok
  read /tmp/secret.txt          FAILED (Permission denied)

There’s no root, no container, no configuration file, and no daemon. The program asked the kernel to take away its own access to most of the filesystem, and the kernel obliged.

That’s Landlock, which has been in the kernel since 2021 without most people noticing. This article builds that program from nothing, runs it, and then walks into the four surprises that catch people the first time.

What You Need

To follow along, you’ll need a kernel of 5.13 or newer, the standard headers, and a C compiler. Nothing else, and notably not root.

grep landlock /sys/kernel/security/lsm
ls /usr/include/linux/landlock.h

The first command matters. Landlock can be compiled into a kernel and still be inactive, because Linux Security Modules have to be enabled at boot. On this machine, that file reads lockdown,capability,landlock,yama,apparmor.

If landlock is missing from yours, add lsm=landlock, to the front of the existing list in your kernel command line and reboot. Ubuntu has shipped it enabled since 22.04, and current Fedora and Arch kernels carry it too, but the grep above is the only answer that counts for your machine.

Everything below was run on kernel 5.15.0-190-generic under Ubuntu 22.04.5, compiled with gcc 11.4, as an ordinary user with no sudo anywhere.

Where Landlock Sits

Linux Security Modules are a framework, not a policy. The kernel calls out to LSM hooks at decision points — before opening a file, creating a process, or mapping executable memory — and whatever modules are loaded get to say yes or no.

SELinux and AppArmor are the two most people have heard of, and both are administrator tools: someone with root writes a policy, the system loads it, and your program lives inside whatever that policy says.

Landlock inverts that. It’s the first LSM a process can apply to itself, without privilege, at runtime. You don’t need to convince an administrator that your program deserves a policy. The program asks for less than it currently has, and the kernel narrows it.

That “asks for less” is the whole design. Landlock can only ever remove access. There’s no call that grants you something you didn’t already have, which is precisely why it’s safe to expose to unprivileged processes.

Three System Calls and No Library

Landlock is three syscalls and glibc wraps none of them, so you call them directly through syscall():

static int create_ruleset(const struct landlock_ruleset_attr *attr)
{ return syscall(__NR_landlock_create_ruleset, attr, sizeof(*attr), 0); }

static int add_rule(int fd, const struct landlock_path_beneath_attr *pb)
{ return syscall(__NR_landlock_add_rule, fd, LANDLOCK_RULE_PATH_BENEATH, pb, 0); }

static int restrict_self(int fd)
{ return syscall(__NR_landlock_restrict_self, fd, 0); }

landlock_create_ruleset declares which kinds of access you intend to govern and returns a file descriptor representing the ruleset. landlock_add_rule adds an exception in the form of a directory you want to keep. Finally, landlock_restrict_self applies the whole thing to the calling process, permanently.

The handled_access_fs field in the ruleset attribute is the part people get backwards. It doesn’t list what you’re allowing. It lists the access types this ruleset is responsible for, and anything in that list is denied everywhere except the paths you explicitly add. Handle read access and you lose read access to the entire filesystem until you add rules back.

A Program That Restricts Itself

Here is the whole thing:

#define _GNU_SOURCE
#include <linux/landlock.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>

#define READ_RIGHTS (LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR)

static int create_ruleset(const struct landlock_ruleset_attr *attr)
{ return syscall(__NR_landlock_create_ruleset, attr, sizeof(*attr), 0); }

static int add_rule(int fd, const struct landlock_path_beneath_attr *pb)
{ return syscall(__NR_landlock_add_rule, fd, LANDLOCK_RULE_PATH_BENEATH, pb, 0); }

static int restrict_self(int fd)
{ return syscall(__NR_landlock_restrict_self, fd, 0); }

static int allow_read(int ruleset_fd, const char *path)
{
    struct landlock_path_beneath_attr pb = { .allowed_access = READ_RIGHTS };
    int rc;

    pb.parent_fd = open(path, O_PATH | O_CLOEXEC);
    if (pb.parent_fd < 0) { perror(path); return -1; }
    rc = add_rule(ruleset_fd, &pb);
    close(pb.parent_fd);
    return rc;
}

static void try_read(const char *path)
{
    int fd = open(path, O_RDONLY);

    if (fd < 0)
        printf("  read %-24s FAILED (%s)\n", path, strerror(errno));
    else
        { printf("  read %-24s ok\n", path); close(fd); }
}

int main(void)
{
    struct landlock_ruleset_attr attr = { .handled_access_fs = READ_RIGHTS };
    int ruleset_fd = create_ruleset(&attr);

    if (ruleset_fd < 0) { perror("landlock_create_ruleset"); return 1; }
    if (allow_read(ruleset_fd, "/etc") < 0) return 1;

    if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { perror("prctl"); return 1; }
    if (restrict_self(ruleset_fd)) { perror("landlock_restrict_self"); return 1; }
    close(ruleset_fd);

    printf("with landlock, /etc allowed:\n");
    try_read("/etc/hostname");
    try_read("/tmp/secret.txt");
    return 0;
}

Build and run it:

gcc -Wall -o sandbox sandbox.c
echo "hunter2" > /tmp/secret.txt
./sandbox
with landlock, /etc allowed:
  read /etc/hostname            ok
  read /tmp/secret.txt          FAILED (Permission denied)

Two details in there matter. The rule refers to a directory by an open file descriptor rather than a path string, opened with O_PATH so you get a handle without needing read permission on the directory itself. And restrict_self takes effect immediately for the calling process, with no way to undo it.

Why no_new_privs is Mandatory

Take the prctl call out and the program stops working:

landlock_restrict_self -> Operation not permitted

That’s EPERM, and it’s deliberate. PR_SET_NO_NEW_PRIVS tells the kernel that this process and its descendants can never gain privileges through execve, which is what stops a sandboxed process from escaping by running a setuid binary.

Without that guarantee, a restricted process could exec sudo or any setuid program and step outside the restrictions you just applied. Landlock refuses to apply itself at all rather than offer a sandbox with that hole in it. Set no_new_privs first, every time.

Rulesets Intersect, They Never Widen

This is the property to get right. Apply a ruleset allowing /etc, then apply a second allowing /tmp, and ask what you can reach:

after first ruleset:  /etc=ok               /tmp=Permission denied
after second ruleset: /etc=Permission denied  /tmp=Permission denied

The second ruleset didn’t add /tmp. It took away /etc, and left you with nothing.

Diagram showing a Landlock sandbox narrowing in three steps: with no ruleset all five directories are readable, after a ruleset allowing /etc only /etc is readable, and after a second ruleset allowing /tmp nothing is readable at all, because the two rulesets intersect and their overlap is empty

Each restrict_self intersects with everything already applied. The first ruleset permitted /etc and denied the rest. The second permitted /tmp and denied the rest. What survives is the overlap of those two, which is empty.

So a Landlock sandbox is a ratchet. Every application can only tighten, never loosen, and there’s no operation anywhere in the API that widens what a restricted process may do. If you need a process to have access to two directories, both rules go into one ruleset before you apply it.

That also means you can’t change your mind. A long-running process that restricts itself early can’t be granted more access later, by itself or by anyone else, short of starting a new process.

What Your Children Inherit

Restrictions follow fork without asking:

parent:       /etc=ok   /tmp/secret.txt=Permission denied
forked child: /etc=ok   /tmp/secret.txt=Permission denied

The child inherits the parent’s Landlock domain exactly and there’s no flag to opt out. The same holds across exec: a restricted process that replaces itself with another program passes its restrictions along. The new program can add more rulesets to tighten further, but it cannot remove the ones it inherited.

This inheritance model is what makes Landlock useful for wrapping programs you didn’t write. A launcher can set up the sandbox and then exec the target, and the target runs inside the restrictions without any cooperation or modification required on its part.

The exec Trap

There is one subtlety with exec worth calling out separately. When a process calls execve, the new program image starts fresh in most respects — open file descriptors marked O_CLOEXEC are closed, signal handlers are reset, and so on. Landlock restrictions are not reset. They survive exec intact.

The trap is the ruleset file descriptor itself. You open it with create_ruleset, add rules to it, call restrict_self, and then you should close it. If you forget and the descriptor is still open when you exec a child, the child inherits an open file descriptor to a ruleset object. That descriptor is harmless on its own, but it is an unnecessary leak. Using O_CLOEXEC on the ruleset fd or closing it explicitly before exec keeps things clean.

Wrapping a Program You Didn’t Write

The inheritance behavior makes a small wrapper straightforward:

// set up ruleset and rules here
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { perror("prctl"); return 1; }
if (restrict_self(ruleset_fd)) { perror("restrict_self"); return 1; }
close(ruleset_fd);

execvp(argv[1], &argv[1]);
perror("execvp");
return 1;

The wrapper builds the sandbox, applies it to itself, then hands control to the target program via execvp. The target inherits the restrictions and runs inside them. It doesn’t need to know Landlock exists.

Finding the Paths a Program Needs

The hard part of wrapping an unfamiliar program is knowing which paths to allow. The right tool for this is strace:

strace -e trace=openat,open -o trace.txt the-program its-args
grep -E 'openat|open\(' trace.txt | grep -v ENOENT

That gives you every path the program actually opened during a run. Add those paths as rules, then tighten from there by removing paths that aren’t needed for the specific task you’re sandboxing. A few test runs under strace with representative inputs usually covers the realistic access pattern.

Which ABI Version You Have

Landlock has added features across kernel versions. ABI version 1 (kernel 5.13) covers filesystem access. Version 2 (5.19) adds LANDLOCK_ACCESS_FS_REFER for cross-directory renames. Version 3 (6.2) adds LANDLOCK_ACCESS_FS_TRUNCATE. Version 4 (6.7) adds network controls.

You can query the running kernel’s ABI version at runtime:

int abi = syscall(__NR_landlock_create_ruleset, NULL, 0,
                  LANDLOCK_CREATE_RULESET_VERSION);

A positive return value is the ABI version. Use this to decide which access rights to include in handled_access_fs: only set bits that the running kernel understands, or create_ruleset will return EINVAL. The usual pattern is to start with the full set of rights you want, then mask off any that require a higher ABI version than the kernel reports.

Conclusion

Landlock gives any unprivileged process a way to enforce least-privilege filesystem access on itself, using three syscalls available in every Linux kernel since 5.13. The key rules to keep in mind are: handled_access_fs defines what you’re restricting, not what you’re allowing; rules within a single ruleset combine with OR, but multiple rulesets applied in sequence combine with AND, so the intersection can easily be empty; no_new_privs must be set before restrict_self; and restrictions are inherited across both fork and exec with no way to opt out or reverse them. For programs that handle untrusted input, parse files, or run third-party code, applying a Landlock ruleset early in startup is one of the lowest-effort security improvements available on modern Linux.

Epilogue

The program at the top of this article — the one that demonstrates restriction with two file reads — is complete and self-contained. Everything in this article was verified running as an ordinary user on Ubuntu 22.04.5 with kernel 5.15, with no elevated privileges at any step. Landlock has been stable and available for long enough that there’s little reason not to use it.