Landlock LSM on Linux: Sandbox Applications Without Root Using Built-in Kernel Security

Linux tutorial - IT technology blog
Linux tutorial - IT technology blog

Background: When SELinux Still Isn’t Convenient Enough

I’ve been running SELinux on a Fedora server for about two years, and I’ll be honest — writing policies for each application is incredibly time-consuming. AppArmor is easier, but still requires root to deploy profiles. Both share the same limitation: if you want to sandbox an application without root access, you’re stuck.

Landlock LSM, introduced in kernel 5.13, addresses exactly that — applications can sandbox themselves, no admin required, no policy files, no additional packages to install. Everything is already built into the kernel.

I started taking Landlock seriously after a minor incident — a Python script was exploited and attempted to read /etc/passwd. The server I manage is Ubuntu 22.04 with 4GB RAM. After enabling auditd, Landlock denials showed up immediately in the logs — exactly which process tried to access files outside the allowed area, no guesswork needed. After six months of running it in production, here’s what’s worth documenting.

Checking Whether Your Kernel Supports Landlock

Before writing any code, verify that Landlock is enabled in your kernel. Not every distribution compiles it in by default.

# Kernel must be >= 5.13
uname -r

# Check if Landlock is compiled into the kernel
grep LANDLOCK /boot/config-$(uname -r)
# Should see: CONFIG_SECURITY_LANDLOCK=y

# Check the active ABI version
cat /sys/kernel/security/landlock/abi
# Output: 1, 2, 3, or 4 — higher numbers mean more features

ABI v1 (kernel 5.13) and v2 (kernel 5.19) handle basic filesystem restrictions. V3 (kernel 6.2) adds truncate limits. To sandbox TCP connections as well, you need ABI v4 from kernel 6.7 or later. Ubuntu 22.04 with the HWE 5.15 kernel tops out at v2 — which is sufficient for most of what this article covers.

Upgrading the Kernel for a Higher ABI

# Upgrade to the latest HWE stack on Ubuntu 22.04 (kernel 6.8 → ABI v4)
sudo apt install linux-generic-hwe-22.04
sudo reboot

# Confirm after reboot
cat /sys/kernel/security/landlock/abi

Configuring Landlock in Detail

Three syscalls make up the entire mechanism: landlock_create_ruleset, landlock_add_rule, and landlock_restrict_self. The most important thing to know — once you call restrict_self, the sandbox is immediately active and cannot be revoked. Not even the process itself can undo it.

Example 1: Sandboxing a Python Script with ctypes

This approach requires no external libraries and works on any Python 3.8+ installation:

#!/usr/bin/env python3
"""
Landlock sandbox — restrict file access without root.
Uses ctypes to call Linux syscalls directly.
"""
import ctypes
import ctypes.util
import os
import struct

# Access flags (from linux/landlock.h)
LANDLOCK_ACCESS_FS_EXECUTE   = (1 << 0)
LANDLOCK_ACCESS_FS_WRITE_FILE = (1 << 1)
LANDLOCK_ACCESS_FS_READ_FILE  = (1 << 2)
LANDLOCK_ACCESS_FS_READ_DIR   = (1 << 3)

# Syscall numbers (x86_64)
NR_LANDLOCK_CREATE_RULESET = 444
NR_LANDLOCK_ADD_RULE        = 445
NR_LANDLOCK_RESTRICT_SELF   = 446
PR_SET_NO_NEW_PRIVS         = 38

libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)


def _create_ruleset(handled_access_fs):
    attr = struct.pack("=II", handled_access_fs, 0)
    buf = ctypes.create_string_buffer(attr)
    fd = libc.syscall(NR_LANDLOCK_CREATE_RULESET, buf, len(attr), 0)
    if fd < 0:
        raise OSError(ctypes.get_errno(), "landlock_create_ruleset failed")
    return fd


def _add_path_rule(ruleset_fd, path, allowed_access):
    path_fd = os.open(path, os.O_PATH | os.O_CLOEXEC)
    try:
        attr = struct.pack("=QI4x", allowed_access, path_fd)
        buf = ctypes.create_string_buffer(attr)
        ret = libc.syscall(NR_LANDLOCK_ADD_RULE, ruleset_fd, 1, buf, 0)
        if ret < 0:
            raise OSError(ctypes.get_errno(), f"add_rule failed: {path}")
    finally:
        os.close(path_fd)


def apply_sandbox(read_paths=None, write_paths=None):
    """
    Apply Landlock sandbox to the current process.
    After calling this, all file access outside the specified paths will be blocked.
    """
    # Required: prevent privilege escalation
    if libc.prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0:
        raise OSError("prctl PR_SET_NO_NEW_PRIVS failed")

    handled = (
        LANDLOCK_ACCESS_FS_EXECUTE |
        LANDLOCK_ACCESS_FS_WRITE_FILE |
        LANDLOCK_ACCESS_FS_READ_FILE |
        LANDLOCK_ACCESS_FS_READ_DIR
    )

    ruleset_fd = _create_ruleset(handled)
    try:
        ro = LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR
        rw = ro | LANDLOCK_ACCESS_FS_WRITE_FILE

        for path in (read_paths or []):
            _add_path_rule(ruleset_fd, path, ro)

        for path in (write_paths or []):
            _add_path_rule(ruleset_fd, path, rw)

        ret = libc.syscall(NR_LANDLOCK_RESTRICT_SELF, ruleset_fd, 0)
        if ret < 0:
            raise OSError(ctypes.get_errno(), "landlock_restrict_self failed")
    finally:
        os.close(ruleset_fd)


if __name__ == "__main__":
    apply_sandbox(
        read_paths=["/usr", "/lib", "/lib64", "/proc/self"],
        write_paths=["/tmp", "/var/data"]
    )
    print("Sandbox active — read-only on /usr, /lib; writable: /tmp and /var/data")

    # Test: writing to /tmp should succeed
    open("/tmp/ok.txt", "w").write("test")
    print("/tmp/ok.txt: OK")

    # Test: /etc/passwd should be blocked
    try:
        open("/etc/passwd").read()
        print("WARNING: /etc/passwd is not blocked!")
    except PermissionError:
        print("/etc/passwd: correctly blocked")

Example 2: Sandboxing with landlockrun (No Source Code Changes Required)

Don’t have access to the source code? landlock-sandboxer from the kernel source lets you wrap any binary without touching the original code:

# Build from kernel source (one time)
apt install linux-source gcc
tar xf /usr/src/linux-source-*.tar.bz2
cd linux-source-*/samples/landlock
make
sudo cp sandboxer /usr/local/bin/landlock-sandboxer

# Run the application inside a sandbox
# Read-only on /usr, /lib — writable: /tmp and /var/myapp
LL_FS_RO="/usr:/lib:/lib64:/etc/ssl" \
LL_FS_RW="/tmp:/var/myapp" \
landlock-sandboxer python3 /opt/myapp/process.py

Example 3: Sandboxing a Service via systemd

This is what I’m currently running in production — combining systemd’s native hardening with Landlock at the application layer:

# /etc/systemd/system/myapp.service
[Unit]
Description=My App (Landlock sandboxed)
After=network.target

[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/venv/bin/python app.py

# systemd hardening — complements Landlock at the kernel level
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/log/myapp /opt/myapp/data

# Limit syscalls — only what the app needs
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl restart myapp
sudo systemctl status myapp

ProtectSystem=strict combined with ReadWritePaths creates a private mount namespace for the service — a different mechanism than Landlock syscalls, but they complement each other well. Landlock operates at the process level, systemd hardening at the service level. Two layers, two angles.

Testing & Monitoring

Verifying the Sandbox with strace

# Monitor blocked accesses — EACCES indicates Landlock is blocking
strace -e trace=openat,open -f python3 myapp_sandboxed.py 2>&1 | grep -E "EACCES|EPERM"

# Sample output:
# openat(AT_FDCWD, "/etc/shadow", O_RDONLY) = -1 EACCES (Permission denied)
# openat(AT_FDCWD, "/root/.ssh/id_rsa", O_RDONLY) = -1 EACCES (Permission denied)

Automated Test Script

#!/bin/bash
# test_sandbox.sh

python3 - <<'PYEOF'
import sys
from landlock_wrapper import apply_sandbox

apply_sandbox(
    read_paths=["/usr", "/lib", "/lib64", "/tmp"],
    write_paths=["/tmp"]
)

tests = [
    ("/tmp/write_test.txt", "w", True,  "Write to /tmp"),
    ("/etc/passwd",         "r", False, "Read /etc/passwd"),
    ("/root/.bashrc",       "r", False, "Read /root/.bashrc"),
]

all_ok = True
for path, mode, allowed, label in tests:
    try:
        open(path, mode).close()
        status = "PASS" if allowed else "FAIL (not blocked!)"
        if not allowed: all_ok = False
    except (PermissionError, OSError):
        status = "FAIL (blocked)" if allowed else "PASS (correctly blocked)"
        if allowed: all_ok = False
    print(f"  [{status}] {label}: {path}")

sys.exit(0 if all_ok else 1)
PYEOF

[ $? -eq 0 ] && echo "Sandbox is working correctly" || echo "Something went wrong — check the output above"

Monitoring with auditd

# Install auditd to log all blocked accesses
sudo apt install auditd
sudo systemctl enable --now auditd

# View today's Landlock denials
sudo ausearch -m LANDLOCK --start today

# Real-time monitoring
sudo tail -f /var/log/audit/audit.log | grep -i landlock

# Summary: which processes are blocked most frequently
sudo ausearch -m LANDLOCK --start today | grep -oP 'comm="\K[^"]+' | sort | uniq -c | sort -rn

When to Use Landlock — and When Not To

Landlock works best in these situations:

  • Applications where you have source access and can add a few lines of sandbox initialization
  • Deployments on shared hosting environments where root access isn’t available
  • Defense-in-depth: even if the application is compromised, it still can’t read credential files
  • Running untrusted code (user scripts, third-party plugins)

But let’s be realistic — Landlock is not a solution for everything:

  • It doesn’t replace a network firewall — network restrictions only arrived in ABI v4 (kernel 6.7+) and still have significant limitations
  • It won’t protect you if an attacker already has root
  • It doesn’t sandbox syscalls — combine it with seccomp for more complete coverage

I’m currently running all three layers for critical services: Landlock controls filesystem access within the code, systemd hardening creates a mount namespace, and seccomp filters syscalls. They complement each other well — and what I like most is that none of these layers require root to enable at the application level.

Share: