DEV Community

Cover image for Landlock: When a Program Jails Itself
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

Landlock: When a Program Jails Itself

When we want to limit what a program can do, our habit is to look in the same direction: at the administrator. We write an AppArmor profile, compile an SELinux policy, add ProtectSystem= to a systemd unit. All of it works. But all of it shares one weakness — the party that sets the limit and the party that writes the restricted code are different, so the two drift apart over time. The application starts writing to a new directory and the profile stays behind; or the profile is far too wide and nobody notices.

Landlock inverts that relationship. The process places the limit on itself: at startup it says "I will only read these directories and connect to this port", and from that moment on it cannot do more. No root privileges required, no policy file, and no way to undo it.

The thesis of this article: Landlock's real novelty isn't being "one more sandbox", it's changing who owns the policy. That change has a price — version negotiation is now the application's job, and the list of things it cannot restrict is too long to ignore.

Already installed on my server

Landlock has been in the kernel since 5.13 and most current distributions ship it enabled — but not all of them. On my server running Ubuntu 24.04, the active security modules look like this:

$ cat /sys/kernel/security/lsm
lockdown,capability,landlock,yama,apparmor
Enter fullscreen mode Exit fullscreen mode

AppArmor and Landlock run side by side on the same machine; neither replaces the other. The restrictions stack, and each layer can separately deny the access that falls to it.

If you don't see landlock in that list, you're not done yet. There are two separate switches: the module being compiled in (CONFIG_SECURITY_LANDLOCK=y) and being present in the list enabled at boot (CONFIG_LSM). If the second is missing — and on some distribution kernels the list ships as lockdown,yama,integrity,apparmor — you need to add lsm=landlock,... to the kernel command line. You can check from the kernel log, since Landlock announces itself when it comes up:

zgrep -h "^CONFIG_LSM=" "/boot/config-$(uname -r)" /proc/config.gz 2>/dev/null
dmesg | grep landlock || journalctl -kb -g landlock   # "landlock: Up and running"
Enter fullscreen mode Exit fullscreen mode

But which Landlock? Because there isn't a single "Landlock", there's a numbered series of capabilities. The way to ask for the version is, interestingly, a system call:

$ python3 -c "
import ctypes
libc = ctypes.CDLL(None, use_errno=True)
abi = libc.syscall(444, None, ctypes.c_size_t(0), ctypes.c_uint32(1))
print('ABI:', abi, '| errno:', ctypes.get_errno())"
ABI: 4 | errno: 0
Enter fullscreen mode Exit fullscreen mode

(444 is the landlock_create_ruleset syscall number on x86_64 and arm64; on failure the return value is -1 and errno tells the two cases apart.)

The landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION) call returns the highest supported ABI version. On my kernel the answer is 4. We'll get to what that means shortly; for now note this: the answer can be anything between 1 and 11, and the difference directly determines what your sandbox can and cannot restrict.

The mechanism: three steps and a one-way door

The flow is short. First you define a ruleset declaring which kinds of access it handles — filesystem rights, and since ABI 4, TCP port rights. Then you add the hierarchies you allow: "this directory is readable", "this one is writable". In the final step, the process restricts itself with that ruleset.

Diagram

The third step has two conditions. First: for unprivileged processes, the no_new_privs attribute must be set. Processes carrying CAP_SYS_ADMIN can skip it, but the documentation advises against skipping — a sandboxed process could still execute a SUID binary, and that binary would run with elevated privileges inside a Landlock domain it doesn't expect, turning it into a confused deputy.

Second, and more importantly: this door is one-way. The documentation is explicit — once a thread is landlocked there is no way to remove its policy, only to add more restrictions. Every new thread born via clone(2) inherits the restriction from its parent, and it survives execve. So if a shell landlocks itself and then invokes a compiler, the compiler is born into the same jail.

The nicest practical consequence: the sandbox no longer lives in a file shipped with the distribution, it lives in the application's own code. It can't be forgotten, it doesn't fall behind on updates, and there's no "why is the profile disabled" conversation.

There is one boundary: at most 16 stacked rulesets, and once that limit is reached landlock_restrict_self() returns E2BIG. That's why the documentation recommends building the ruleset carefully, once in a thread's lifetime — especially for shells and container managers that launch other applications.

The code itself: three system calls

To make this concrete, the skeleton from the kernel documentation's tutorial is enough. First, the ruleset is defined by explicitly listing the access types to be handled. Landlock denies those types by default; an access type you don't declare isn't checked at all:

struct landlock_ruleset_attr ruleset_attr = {
    .handled_access_fs =
        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_DIR | LANDLOCK_ACCESS_FS_MAKE_REG |
        LANDLOCK_ACCESS_FS_REFER | LANDLOCK_ACCESS_FS_TRUNCATE,
    .handled_access_net =
        LANDLOCK_ACCESS_NET_BIND_TCP | LANDLOCK_ACCESS_NET_CONNECT_TCP,
};
Enter fullscreen mode Exit fullscreen mode

That attribute struct becomes a ruleset descriptor:

int ruleset_fd = landlock_create_ruleset(&ruleset_attr, sizeof(ruleset_attr), 0);
Enter fullscreen mode Exit fullscreen mode

Then the allowed hierarchy is added. The striking detail is that the directory is opened with O_PATH and handed over as a file descriptor — the rule is defined over an opened object, not a path string:

struct landlock_path_beneath_attr path_beneath = {
    .allowed_access = LANDLOCK_ACCESS_FS_EXECUTE |
                      LANDLOCK_ACCESS_FS_READ_FILE |
                      LANDLOCK_ACCESS_FS_READ_DIR,
};
path_beneath.allowed_access &= ruleset_attr.handled_access_fs;
path_beneath.parent_fd = open("/usr", O_PATH | O_CLOEXEC);
landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, &path_beneath, 0);
Enter fullscreen mode Exit fullscreen mode

The final step closes the door:

prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
landlock_restrict_self(ruleset_fd, 0);
Enter fullscreen mode Exit fullscreen mode

The masking line is no accident: allowed rights must be a subset of handled rights. When you trim handled_access_fs according to the ABI, that mask narrows the rule automatically. The documentation applies the same trimming to landlock_restrict_self() flags — logging flags are removed below ABI 7, the multithreaded enforcement flag below ABI 8.

The filesystem rights list being this granular is deliberate too. Reading and listing a directory, creating a file and creating a directory, truncating and writing all have separate rights. That level of detail looks tiring at first, but it's the only way to keep a restriction genuinely narrow.

ABI negotiation is now your job

This is Landlock's most misunderstood aspect. Your code doesn't know which kernel it will run on, and Landlock deliberately builds backward compatibility on "explicitly declared rights" rather than "silently getting stricter". Making the handled access rights explicit creates a clear contract between kernel and user space, so a system update can't tighten the sandbox unexpectedly and break the application.

The price is that the application itself has to climb the version ladder. ABI 2 brought control over renaming and linking across directories (LANDLOCK_ACCESS_FS_REFER), ABI 3 file truncation (LANDLOCK_ACCESS_FS_TRUNCATE), ABI 4 the TCP side (LANDLOCK_ACCESS_NET_BIND_TCP, LANDLOCK_ACCESS_NET_CONNECT_TCP), ABI 5 ioctl control on device files, ABI 6 scoping restrictions for abstract unix sockets and signals, ABI 7 the logging flags, ABI 8 multithreaded enforcement, ABI 9 restrictions on connecting to pathname unix sockets (LANDLOCK_ACCESS_FS_RESOLVE_UNIX), ABI 10 the UDP rights plus per-rule log suppression, and ABI 11 the flag that folds no_new_privs into the enforcement call itself.

Now back to my server: ABI 4. A sandbox I write there can restrict the filesystem and TCP connections, and can't touch UDP at all.

But the real boundary isn't UDP, and it deserves to be said plainly: Landlock sees the network at port level, not at destination level. The rule structure (struct landlock_net_port_attr) carries exactly one field — the port. So when you say "you may connect to TCP 443", the process may connect to port 443 on every server in the world. If you want to close the exfiltration path, Landlock alone won't do it; destination-based egress control is still a job for nftables, an egress proxy, or a network namespace. The missing UDP is only a sub-heading of that picture — and ABI 10 doesn't solve it at the address level either, it merely makes UDP controllable by port.

The most painful rung of the ladder is threads. Up to ABI 8 — and even at ABI 8 when the relevant flag isn't used — landlock_restrict_self() enforces the policy only for the calling thread and its children; sibling and parent threads stay outside. The documentation's warning is exactly this: the call is equivalent to "I jailed the process" only if the process is single-threaded. Forget it in a program written with Go, Java or any pooled runtime and you'll have jailed one thread while believing you jailed yourself. The multithreaded enforcement flag (LANDLOCK_RESTRICT_SELF_TSYNC) arrived with ABI 8; on older kernels the only way out is to apply the restriction at the very start of the program, before any extra thread is born.

The recommended path here is "best effort": detect the ABI version at runtime, use only the supported subset of rights, and protect users as much as possible whatever kernel they run. The error returned when the query fails also guides you: ENOSYS means the kernel doesn't support Landlock at all, EOPNOTSUPP means it has been disabled.

The engineering decision is yours: what will you do on a missing ABI? Silently continuing unrestricted is what most desktop tools choose. On the server side I think the right move is at least to print a warning at startup saying which protections aren't active.

How will you see a denied access?

When writing a sandbox, the real time sink isn't setting up the policy, it's answering "why doesn't this work". This side of Landlock matured late and in two steps. First ABI 7: the LANDLOCK_RESTRICT_SELF_LOG_* flags that govern whether denied accesses produce audit records. Then ABI 10: the LANDLOCK_ADD_RULE_QUIET flag together with the ruleset's quiet_access_fs/quiet_access_net fields, which selectively suppress logs for denied accesses on specific objects. Since suppression only applies to the layer that denies the access, a sandboxed program can't use it to hide its own violations. Landlock tracepoints are unaffected by these flags either way, so they remain a separate observability channel for debugging.

None of that exists on my ABI 4. So on an older kernel, debugging falls back to the classic method: hunt EACCES with strace and loosen the restriction step by step. Measuring which paths the application actually touches before switching the sandbox on in production isn't just a good habit on older kernels, it's a requirement.

One more caveat from the documentation: there is a LANDLOCK_CREATE_RULESET_ERRATA query for checking which bugs are fixed at runtime, but the docs say the vast majority of applications should not look at it — it complicates the code and can reduce protection when misused. In security APIs, "more configuration knowledge" doesn't always mean better security.

What it cannot restrict

Landlock is not a container and doesn't try to be. The "current limitations" section of the documentation lists them honestly; these are the ones that will hurt in production.

A sandboxed thread cannot modify filesystem topology: mount(2) and pivot_root(2) are denied. But chroot(2) is not refused — changing the root is still possible.

Objects that don't come from a user-visible filesystem (pipes, sockets) can still be reached through /proc/<pid>/fd/* yet cannot be explicitly restricted; the same goes for special kernel filesystems such as nsfs. What steps in for those is the ptrace restrictions: access to such sensitive /proc files is automatically limited according to domain hierarchies.

The item that traps most people is already-open file descriptors. Landlock evaluates access at open time; a descriptor opened before the jail stays in your hands and can even be passed between processes, carrying its Landlock properties with it. LANDLOCK_ACCESS_FS_IOCTL_DEV says so explicitly: it applies only to newly opened device files, and pre-existing descriptors like stdin, stdout and stderr are unaffected.

That behaviour is both a trap and a tool. A trap, because while you say "I restricted everything" an old descriptor may remain in your hands. A tool, because opening the file you need before landlocking and keeping the descriptor is one way to preserve access to it. Just know the limits: what you keep is that one open file, every later openat in the same directory is checked again, the rights that stick to a descriptor are limited to LANDLOCK_ACCESS_FS_TRUNCATE and LANDLOCK_ACCESS_FS_IOCTL_DEV, and when a log file is rotated your descriptor keeps writing to the old inode — you'll be logging to the wrong file without noticing.

There's also a list of operations that simply aren't covered: calls like chdir, stat, flock, chmod, chown, setxattr, utime, fcntl and access fall outside Landlock's scope. So if you think "it can't do anything in this directory", quite a lot of metadata work remains possible.

Where it helps and where it doesn't

What accelerated Landlock's rise over the past year is, I think, coding agents. You want a tool that runs shell commands on your machine, but you don't want it reading ~/.ssh. On the filesystem side Landlock gives you exactly that; on the network side, remember the port restriction above — you can say "it only reads these directories", you can't say "it only connects to this server". A container is heavy and full of friction here; Landlock works inside the process itself, without asking for extra privileges. The go-landlock library and command-line wrappers like landrun exist because of that need.

On the server side, put Landlock on top of admin-side hardening, not in place of it. Restrictions in the unit file are still your first line of defence; the ProtectSystem= family I wrote about in hardening systemd services with the service sandbox is the only practical route for third-party services whose code you can't change. Landlock enters where you do own the code: the application knows its own needs best.

To see the two working together, a glance at the LSM list on my server is enough: AppArmor and Landlock sit right next to each other.

There's also a trap for services running under systemd. In a unit that says SystemCallFilter=@system-service, Landlock's system calls get caught by the filter, because they live in systemd's @sandbox group and @system-service doesn't include it. If a service trying to jail itself fails unexpectedly, check the unit's syscall filter first — the fix is SystemCallFilter=@system-service @sandbox. The same logic applies to seccomp: Landlock and seccomp aren't alternatives, a typical sandbox uses both, and a narrow seccomp filter can accidentally block Landlock itself.

So where doesn't it help? It doesn't if you can't change the code — then you either write a wrapper or return to admin-side tooling. If you want filtering at the system call level you're holding the wrong tool; that's seccomp's job. And if you want an isolated root filesystem you need containers; Landlock doesn't give you a separate world, it gives you a narrower view of the same one.

Before you start

  • First check that it's enabled (/sys/kernel/security/lsm, dmesg | grep landlock), then measure the ABI version. The lowest version in your fleet is your real level of protection.
  • Declare rights explicitly; an access type you don't handle isn't restricted.
  • Set no_new_privs — even when you have the privileges to skip it.
  • Build the ruleset once; don't spend the 16-layer limit on other sandboxes.
  • If you have a multithreaded runtime, apply the restriction at the very start: below ABI 8, enforcement only covers the calling thread and its children.
  • Open the descriptors you need before the jail; path-based access closes afterwards — but think through file rotation.
  • Network control is port-level: if you need destination control, plan for nftables or an egress proxy; UDP isn't covered at all before ABI 10.
  • Restrictions can't be undone: on failure your only option is restarting the process, so design for it.

Who writes the policy?

Looking at Landlock, what caught my attention more than the technical detail was the answer to a question: who should place a restriction? The classic answer was the administrator, because security was operations' job. Landlock's proposed answer is different — let the restriction be placed by the party that knows best what the program does, which is the code.

That means responsibility shifts to the developer, and frankly not every team is ready for it. But the reward is large: the sandbox becomes a feature of the application rather than an add-on of the deployment. It travels with versions, goes through code review, and is protected by tests.

The question for your own setup follows from that: for how many of your services does the policy live in their own repository, and for how many does it live in a file on a server? If the second group is large, drift is accumulating there — and the first party to notice drift is usually an attacker.

Official Sources

Top comments (0)