Linux Landlock - Sandboxing Applications Without Root
Learn how to use the Landlock API to secure Linux applications by restricting filesystem and network access
It’s 2 AM, you wake up to a notification - a hacker found a vulnerability in your application and can now steal access credentials to your clients’ systems. How can we prevent such a scenario?
Until now, we only had the option to secure applications from the system side, where we have solutions like SELinux or AppArmor. We can also set user and group permissions, or filter syscalls through seccomp. What if developers could manage application permissions themselves?
This is where Landlock comes in - a Linux kernel security mechanism that allows applications to voluntarily restrict their own privileges. Without root. Without complex configuration. And in a simple way: just three new syscalls.
What is Landlock?
Landlock is a Linux Security Module (LSM) that enables unprivileged processes to voluntarily restrict their own access rights.
— Source: Linux Kernel Documentation
Landlock is a Linux Security Module introduced in kernel 5.13, which allows regular processes to voluntarily restrict their own access to system resources. It uses path-based access control, meaning you define exactly which paths and what operations are allowed.
Why Landlock?
Main advantages:
- Works without root
- Only 3 syscalls to learn:
landlock_create_ruleset()- creates a rulesetlandlock_add_rule()- adds specific access ruleslandlock_restrict_self()- applies restrictions to the process
- Applications work on older kernels (with reduced protection or none)
- Used by systemd, Chromium, Pacman
Landlock works on a deny-by-default principle: everything is blocked by default. You must explicitly define what is allowed. This is the opposite of normal Unix system behavior where everything is allowed until you forbid it.
We have three steps to secure an application:
-
Create ruleset → Define which access types you want to control.
- Handled_access - which permissions you want to control at all (e.g., reading, writing).
-
Add rules → Specify concrete paths and allowed operations.
- Allowed_access - what exactly is allowed for a given path
-
Apply rules → Activate restrictions for the process.
- Established rules are enforced and restrictions carry over to child processes (fork, exec).
Pseudo code:
// Chcę kontrolować czytanie i pisanie
ruleset = create_ruleset(READ | WRITE);
// /usr może być czytane
add_rule(ruleset, "/usr", READ);
// /tmp może być czytane i pisane
add_rule(ruleset, "/tmp", READ | WRITE);
// Wszystko inne: zablokowane!
restrict_self(ruleset);
After restrict_self(), the process (and its children) can only read from /usr and read/write to /tmp. Any attempt to access /home or /etc will result in EACCES.
Let’s Build a Sandbox :)
The application allows running software via command line and setting permissions through a configuration file. The full code is available here: Landlock - Sandbox. In main, we load the configuration and parameters passed via command line. Then we create a ruleset, add permissions for paths and network, and activate landlock. Finally, we run the software for which we want to restrict access.
// sandbox.cpp
int main(int argc, char* argv[], char *const *const envp) {
...
landlock.create_ruleset(fs_restrictions, net_restrictions);
for (auto& it : path_perms) {
landlock.add_rule(it.first, it.second);
}
...
if (net_port >= 0) {
landlock.add_net_rule(static_cast<__u64>(net_port), net_permissions);
...
landlock.restrict_self(no_new_priv);
...
execvpe(cmd_args_c[0], cmd_args_c.data(), envp);
}
Now we can move to the landlock.cpp file and analyze the implementation. First, we need the landlock.h header.
#include <linux/landlock.h>
Below are all available permissions up to ABI v5. I recommend reading the comments in the landlock.h header - there’s a great description of all symbols :).
...
static const std::map<std::string, __u64> LANDLOCK_FS_MAP = {
{"execute", LANDLOCK_ACCESS_FS_EXECUTE},
{"read_file", LANDLOCK_ACCESS_FS_READ_FILE},
{"write_file", LANDLOCK_ACCESS_FS_WRITE_FILE},
{"read_dir", LANDLOCK_ACCESS_FS_READ_DIR},
{"remove_dir", LANDLOCK_ACCESS_FS_REMOVE_DIR},
{"remove_file", LANDLOCK_ACCESS_FS_REMOVE_FILE},
{"make_char", LANDLOCK_ACCESS_FS_MAKE_CHAR},
{"make_dir", LANDLOCK_ACCESS_FS_MAKE_DIR},
{"make_reg", LANDLOCK_ACCESS_FS_MAKE_REG},
{"make_sock", LANDLOCK_ACCESS_FS_MAKE_SOCK},
{"make_fifo", LANDLOCK_ACCESS_FS_MAKE_FIFO},
{"make_block", LANDLOCK_ACCESS_FS_MAKE_BLOCK},
{"make_sym", LANDLOCK_ACCESS_FS_MAKE_SYM},
/* ABI v2 */
{"refer", LANDLOCK_ACCESS_FS_REFER},
/* ABI v3 */
{"truncate", LANDLOCK_ACCESS_FS_TRUNCATE},
/* ABI v5 */
{"ioctl_dev", LANDLOCK_ACCESS_FS_IOCTL_DEV},
};
static const std::map<std::string, __u64> LANDLOCK_NET_MAP = {
/* ABI v4 */
{"bind_tcp", LANDLOCK_ACCESS_NET_BIND_TCP},
{"connect_tcp", LANDLOCK_ACCESS_NET_CONNECT_TCP},
};
Next, we have three new syscalls: landlock_create_ruleset, landlock_add_rule, landlock_restrict_self.
...
static inline int sys_create_ruleset(
const struct landlock_ruleset_attr *attr,
size_t attr_size,
__u32 flags
) {
return syscall(__NR_landlock_create_ruleset, attr, attr_size, flags);
}
static inline int sys_add_rule(
int ruleset_fd,
enum landlock_rule_type rule_type,
const void *rule_attr,
__u32 flags
) {
return syscall(__NR_landlock_add_rule, ruleset_fd, rule_type, rule_attr, flags);
}
static inline int sys_restrict_self(
int ruleset_fd,
__u32 flags
) {
return syscall(__NR_landlock_restrict_self, ruleset_fd, flags);
}
To create a new ruleset, we must provide a landlock_ruleset_attr structure containing permission bitmasks that should be blocked by default. Depending on the ABI version, we have fields for filesystem and network permissions. An important step is adapting permissions to the ABI. A syscall without parameters returns the available version.
...
int Landlock::create_ruleset(
const std::vector<std::string>& fs_restr,
const std::vector<std::string>& net_restr) {
...
struct landlock_ruleset_attr ruleset_attr = {
.handled_access_fs = fs_access,
.handled_access_net = net_access,
};
int abi = sys_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION);
if (abi < 0) {
/* Degrades gracefully if Landlock is not handled. */
std::cerr << "The running kernel doesn't support Landlock API\n";
return 0;
}
switch (abi) {
case 1:
/* Removes LANDLOCK_ACCESS_FS_REFER for ABI < 2 */
ruleset_attr.handled_access_fs &= ~LANDLOCK_ACCESS_FS_REFER;
__attribute__((fallthrough));
case 2:
/* Removes LANDLOCK_ACCESS_FS_TRUNCATE for ABI < 3 */
ruleset_attr.handled_access_fs &= ~LANDLOCK_ACCESS_FS_TRUNCATE;
__attribute__((fallthrough));
case 3:
/* Removes network support for ABI < 4 */
ruleset_attr.handled_access_net &=
~(LANDLOCK_ACCESS_NET_BIND_TCP |
LANDLOCK_ACCESS_NET_CONNECT_TCP);
__attribute__((fallthrough));
case 4:
/* Removes LANDLOCK_ACCESS_FS_IOCTL_DEV for ABI < 5 */
#ifdef LANDLOCK_ACCESS_FS_IOCTL_DEV
ruleset_attr.handled_access_fs &= ~LANDLOCK_ACCESS_FS_IOCTL_DEV;
#endif
break;
}
ruleset_fd = sys_create_ruleset(&ruleset_attr, sizeof(ruleset_attr), 0);
...
}
To add permissions to paths, you need to provide a landlock_path_beneath_attr structure containing the file descriptor of the path and a permission bitmask.
int Landlock::add_rule(
const std::string path,
std::vector<std::string>& fs_perms) {
int path_fd;
struct landlock_path_beneath_attr path_beneath = {0};
if (open_path(path, path_fd) < 0) {
return -1;
}
__u64 fs_access = make_allowed_mask(fs_perms);
path_beneath.parent_fd = path_fd;
path_beneath.allowed_access = fs_access;
if (sys_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, &path_beneath, 0) < 0)
...
}
Network permissions are added to the ruleset.
sys_add_rule(ruleset_fd, LANDLOCK_RULE_NET_PORT, &net_port, 0)
Finally, we call landlock_restrict_self. This syscall will return an error if the process doesn’t have the PR_SET_NO_NEW_PRIVS flag set. Without this flag, the process can escalate its privileges after applying restrictions, e.g., by calling a binary with the setuid flag.
...
int Landlock::restrict_self(bool no_new_privs) {
/* Set no_new_privs (required before landlock_restrict_self) */
...
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0)
...
/* Apply the ruleset */
if (sys_restrict_self(ruleset_fd, 0) < 0)
...
}
How to Integrate Landlock into Your Application
Permission selection strategy:
- Start with minimum: only
READ_FILE+READ_DIR - Add permissions gradually as the application needs them
- Test that everything works with the restrictions
- Document why each permission is needed
ABI Versions
Landlock has evolved through several kernel versions. Each ABI version adds new capabilities:
| ABI | Kernel | Key Feature |
|---|---|---|
| 1 | 5.13 | Basic filesystem restrictions |
| 2 | 5.19 | REFER - rename/link control |
| 3 | 6.2 | TRUNCATE - file truncation |
| 4 | 6.7 | Network - TCP bind/connect |
| 5 | 6.10 | IOCTL_DEV - IOCTL on devices |
| 6 | 6.12 | IPC - signals, abstract sockets |
File Descriptor Trap
A file descriptor opened before applying permissions remains accessible.
int fd = open("/etc/passwd", O_RDONLY); // Otwórz PRZED sandbox
apply_landlock_sandbox();
// To nadal działa! FD już otwarty
char buf[1024];
read(fd, buf, sizeof(buf));
Solution: Close all descriptors before the restrict_self syscall or use O_CLOEXEC with open* syscalls.
How Can I Test the Application?
It’s simple :). Compile the application available here: Landlock - Sandbox. Check how the settings affect the application’s behavior.
The vulnerable_server.py application allows executing commands on system files, which makes it easier to get familiar with the API’s behavior.
./sandbox -- python3 vulnerable_server.py
Does My Kernel Support Landlock?
Check on your system:
dmesg | grep landlock
uname -r
Summary
Landlock revolutionizes Linux application security by enabling developers to directly introduce access restrictions to paths and sockets. Development on Landlock is still ongoing and further improvements will be introduced.
I hope you enjoyed it. Thanks for reading!