Virtualization · Sandboxing

Landlock Rulesets: Path Hierarchies and Access Masks

How Landlock binds access rights to directory hierarchies instead of syscalls, how the handled-access mask determines what a ruleset can enforce, and where the model stops.

A seccomp filter is asked about a syscall number. Landlock is asked about an inode. The difference determines what each mechanism can express: a filter can say “no unlinkat”, Landlock can say “no removal of anything under this directory, by any syscall that would remove it”. For sandboxes whose policy is about which files may be touched rather than which calls may be made, the second shape is the one that matches the requirement, and it does not depend on the caller’s cooperation.

Rights attach to hierarchies, not to calls

A Landlock ruleset is built from two parts: a mask of access rights the ruleset will handle, and a list of rules, each binding one filesystem object to a mask of permitted rights under it. When a process restricts itself with the ruleset, any filesystem operation whose right is in the handled mask is checked against the rules; if no rule covers the object being operated on, the operation fails with EACCES.

The filesystem rights, as named in the uapi header, are:

RightCovers
LANDLOCK_ACCESS_FS_EXECUTEexecuting a file
LANDLOCK_ACCESS_FS_WRITE_FILEopening for write, or any write path
LANDLOCK_ACCESS_FS_READ_FILEopening for read
LANDLOCK_ACCESS_FS_READ_DIRlisting a directory
LANDLOCK_ACCESS_FS_REMOVE_DIRrmdir
LANDLOCK_ACCESS_FS_REMOVE_FILEunlink
LANDLOCK_ACCESS_FS_MAKE_CHARcreating a character device node
LANDLOCK_ACCESS_FS_MAKE_DIRmkdir
LANDLOCK_ACCESS_FS_MAKE_REGcreating a regular file
LANDLOCK_ACCESS_FS_MAKE_SOCKcreating a socket file
LANDLOCK_ACCESS_FS_MAKE_FIFOcreating a FIFO
LANDLOCK_ACCESS_FS_MAKE_BLOCKcreating a block device node
LANDLOCK_ACCESS_FS_MAKE_SYMcreating a symlink
LANDLOCK_ACCESS_FS_REFERrenaming or linking across hierarchies
LANDLOCK_ACCESS_FS_TRUNCATEtruncate and ftruncate

The granularity is deliberate. READ_FILE and READ_DIR are separate because listing a directory and reading the files in it are different disclosures, and a policy that permits discovery but not extraction is a real requirement. MAKE_REG and MAKE_DIR are separate because a sandbox that allows creating files but not directories prevents the construction of arbitrary trees. REFER is separate because a rename between two hierarchies is neither a removal in one nor a creation in the other, and treating it as either would let a permitted operation move data across a boundary.

A single right can gate many syscalls. WRITE_FILE is checked for open with O_WRONLY or O_RDWR, for truncate on an existing file (via the separate TRUNCATE right, which was added because truncate does not open the file at all), and for the write paths of mmap with PROT_WRITE on a shared mapping. The policy author does not enumerate those calls; the kernel does.

The handled mask is a promise about coverage

landlock_create_ruleset takes a struct landlock_ruleset_attr whose handled_access_fs field is the set of rights the ruleset will enforce. Rights not in the mask are not restricted by this ruleset at all, which is the mechanism’s most important subtlety: a ruleset that handles only WRITE_FILE leaves reads completely unconfined, and does so silently.

The mask must be a subset of what the running kernel supports. landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION) returns the ABI version the kernel implements, which lets a program decide at startup whether the rights it intends to use exist. Attempting to create a ruleset with a bit the kernel does not know fails with EINVAL; attempting to add a rule with a right not in the ruleset’s handled mask also fails with EINVAL. The version query is the supported way to probe, and it is why portable sandboxes either pin to an older ABI or branch on the returned version.

The ABI version is not the same as the kernel version and is the only thing a program should test. Rights have been added over successive versions, with REFER and TRUNCATE arriving later than the initial set, and a ruleset built against a newer header on an older kernel will fail at creation rather than silently under-enforce. That failure direction is the right one, and it is why the mask is a hard commitment rather than a best-effort hint.

Building a ruleset from file descriptors

Rules are added by opening the directory (or file) to be covered and passing the descriptor to landlock_add_rule:

int ruleset_fd = landlock_create_ruleset(&attr, sizeof attr, 0);

int dir_fd = open("/srv/scratch", O_PATH | O_CLOEXEC);
struct landlock_path_beneath_attr path = {
    .allowed_access = LANDLOCK_ACCESS_FS_READ_FILE |
                      LANDLOCK_ACCESS_FS_WRITE_FILE |
                      LANDLOCK_ACCESS_FS_MAKE_REG,
    .parent_fd = dir_fd,
};
landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, &path, 0);
close(dir_fd);

prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
landlock_restrict_self(ruleset_fd, 0);
close(ruleset_fd);

The O_PATH descriptor is the standard idiom: it names the object without granting read or write access to it, so building a ruleset does not require the rights the ruleset is about to grant. The parent_fd is not consumed by the call; the kernel takes a reference to the object and the descriptor can be closed immediately afterwards, as the example does. The allowed_access must be a subset of handled_access_fs, and rights that only apply to directories are rejected when the descriptor names a regular file.

PR_SET_NO_NEW_PRIVS is required unless the caller holds CAP_SYS_ADMIN, for the same reason it is required for seccomp: a set-user-ID transition would execute new code with more authority than the ruleset was built to bound.

Rules nest, and the more specific rule wins for objects under it. A rule covering /srv with READ_FILE and a rule covering /srv/scratch with READ_FILE | WRITE_FILE gives write access only under the subdirectory. Overlapping rules are resolved by the deepest matching ancestor, not by union across the whole tree, so ordering of landlock_add_rule calls does not matter.

Enforcement happens during path walk

The check is inside the kernel’s path resolution, after each component has been resolved, and it applies to the inode being operated on rather than to the string that named it. That placement is what makes Landlock immune to the race described in Why a Seccomp Filter Cannot Inspect Syscall Arguments in Memory: there is no interval between a check on a name and a use of a different name, because the check and the use are the same resolved object.

It also means symlinks are handled by resolution rather than by string matching. A symlink inside a permitted hierarchy that points outside it resolves to the outside target, and the operation on that target is checked against the target’s own coverage, which is usually a denial. The corollary is that a permitted hierarchy containing a symlink to / does not become a permission to touch /; it becomes a path that fails when followed. A sandbox author who expects string-prefix behaviour will find this either reassuring or surprising depending on which way they guessed.

Renames and hard links cross hierarchies, which is why REFER exists. Moving a file from a permitted hierarchy into another permitted hierarchy requires REFER on both the source and the destination, plus the rights the operation implies (REMOVE_FILE at the source, MAKE_REG at the destination). Without REFER in the handled mask, cross-hierarchy renames are denied outright, which is a safe default and a frequent cause of “my build fails on mv” reports.

Reparenting and the immutability guarantee

landlock_restrict_self stacks: a second call adds the new ruleset to the existing set, and the effective policy is the intersection, exactly as with seccomp filter stacking. There is no call that loosens or removes a ruleset from a thread that has one.

Restrictions are inherited by children created after the call, and survive execve. A process that restricts itself and then executes an untrusted binary has confined that binary before it ran, which is the property that makes Landlock usable for sandboxing a payload the sandbox does not control.

The domain distinction matters here. A process in a new user namespace gets a fresh Landlock domain, and restrictions from the parent domain still apply on top, but a process that creates a user namespace can build a ruleset for objects it owns within that namespace. The hierarchy of domains composes by intersection, and the kernel documentation is explicit that a ruleset cannot grant access denied by an ancestor domain.

What Landlock does not cover

Filesystem rights are one domain. Landlock also has a network domain, added in ABI version 4, which restricts bind and connect to TCP ports through LANDLOCK_RULE_NET_PORT rules carrying a 64-bit port number; it does not restrict UDP, raw sockets, or the addresses a connection may target beyond the port. A rule for port 0 combined with LANDLOCK_ACCESS_NET_BIND_TCP permits binding to an ephemeral port. That narrowness is a limitation to state plainly rather than a gap to paper over.

There is no coverage of ioctl, of ptrace, of mount, of device access, or of syscalls that do not resolve a path. A Landlock ruleset is not a syscall filter, and a sandbox that needs both installs both. Nor does it restrict access through an already-open file descriptor: a process that inherits an open descriptor to a file outside every permitted hierarchy can read and write it, because the open succeeded before the restriction and Landlock does not retroactively close descriptors. The inheritance path is the same one that makes a chroot escape work, and it is why a sandbox that restricts itself must also close or avoid inheriting descriptors it does not intend to grant, and why the ordering of “drop descriptors, restrict, exec” is not interchangeable with “restrict, exec”.

References

  1. Linux kernel documentation — Landlock — Linux kernel project
  2. landlock_create_ruleset(2) — Linux manual page — man-pages project
  3. landlock_restrict_self(2) — Linux manual page — man-pages project