Sandbox a Node process with Landlock and seccomp (no root, no agent)
Short answer: Linux lets an unprivileged process restrict itself, and everything it later starts, using Landlock (which programs it may execute, where it may write, which TCP ports it may use) and seccomp (which system calls fail). You need no root, container or daemon. Below is a working wrapper, tested against a stand-in for a compromised dependency, and an honest list of what such a sandbox does not stop.
This is a teaching tool. Dynv’s process firewall applies the same kernel mechanisms to your services with learning mode, signed policies and reporting, and we show what it does in a tested run. Here we show the mechanism itself, so you can see exactly what is and isn’t covered.
What the kernel gives you
- Landlock restricts a process’s file and network actions. Per the kernel documentation, you create a ruleset of rights you want to handle, add rules that allow specific paths or ports, and enforce it on yourself. Network rules for TCP exist since Landlock ABI v4 (Linux 6.7); file execute and write rules are older (5.13). For unprivileged processes, setting
no_new_privsfirst is required. - seccomp-bpf runs a small filter on each system call. A deny-list of rarely needed, high-risk calls (
ptrace,bpf,io_uring_setup) is cheap defence in depth. - Both are inherited by children and cannot be loosened later.
Check your kernel: cat /sys/kernel/security/lsm should list landlock, and the wrapper below prints the ABI version it found (ours was 7 on kernel 6.19).
The wrapper
#!/usr/bin/env python3
"""landlock-run: start a command under a Landlock ruleset and a small seccomp deny-list.
No root, no daemon. Linux only (Landlock exec/write need 5.13+, TCP port rules need 6.7+).
landlock-run.py [--exec PATH]... [--write DIR]... [--connect PORT]... [--bind PORT]... -- cmd args...
"""
import argparse, ctypes, os, platform, struct, sys
libc = ctypes.CDLL(None, use_errno=True)
CREATE, ADD_RULE, RESTRICT = 444, 445, 446 # landlock syscalls (x86_64 and aarch64)
FS_EXECUTE, FS_WRITE_FILE, FS_READ_FILE, FS_READ_DIR = 1 << 0, 1 << 1, 1 << 2, 1 << 3
FS_REMOVE_DIR, FS_REMOVE_FILE = 1 << 4, 1 << 5
FS_MAKE = sum(1 << b for b in range(6, 13)) # create char, dir, reg, sock, fifo, block, sym
FS_REFER, FS_TRUNCATE = 1 << 13, 1 << 14
NET_BIND_TCP, NET_CONNECT_TCP = 1 << 0, 1 << 1
RULE_PATH_BENEATH, RULE_NET_PORT = 1, 2
def sc(nr, *args):
r = libc.syscall(nr, *args)
if r < 0:
e = ctypes.get_errno(); raise OSError(e, os.strerror(e))
return r
def landlock(exec_paths, write_dirs, connect_ports, bind_ports):
abi = sc(CREATE, None, 0, 1) # LANDLOCK_CREATE_RULESET_VERSION
fs_handled = FS_EXECUTE | FS_WRITE_FILE | FS_REMOVE_DIR | FS_REMOVE_FILE | FS_MAKE
if abi >= 2: fs_handled |= FS_REFER
if abi >= 3: fs_handled |= FS_TRUNCATE
net_handled = (NET_BIND_TCP | NET_CONNECT_TCP) if abi >= 4 else 0
attr = struct.pack('QQ', fs_handled, net_handled) if abi >= 4 else struct.pack('Q', fs_handled)
fd = sc(CREATE, ctypes.create_string_buffer(attr), len(attr), 0)
for paths, rights in ((exec_paths, FS_EXECUTE | FS_READ_FILE | FS_READ_DIR), (write_dirs, fs_handled & ~FS_EXECUTE)):
for p in paths:
pfd = os.open(p, os.O_PATH | os.O_CLOEXEC)
rule = struct.pack('=Qi', rights & fs_handled, pfd)
sc(ADD_RULE, fd, RULE_PATH_BENEATH, ctypes.create_string_buffer(rule), 0)
os.close(pfd)
if net_handled:
for ports, right in ((connect_ports, NET_CONNECT_TCP), (bind_ports, NET_BIND_TCP)):
for port in ports:
sc(ADD_RULE, fd, RULE_NET_PORT, ctypes.create_string_buffer(struct.pack('=QQ', right, port)), 0)
elif connect_ports or bind_ports:
sys.exit(f'this kernel (Landlock ABI {abi}) cannot filter TCP ports (needs ABI 4, Linux 6.7+)')
sc(RESTRICT, fd, 0)
os.close(fd)
return abi
def seccomp_deny(names):
"""Refuse a few syscalls with EPERM (x86_64 numbers); everything else is allowed."""
nums = {'ptrace': 101, 'process_vm_writev': 311, 'io_uring_setup': 425, 'bpf': 321,
'userfaultfd': 323, 'perf_event_open': 298, 'kexec_load': 246}
AUDIT_ARCH_X86_64 = 0xC000003E
prog = [(0x20, 0, 0, 4), (0x15, 1, 0, AUDIT_ARCH_X86_64), (0x06, 0, 0, 0), (0x20, 0, 0, 0)] # other arch: kill
for n in (nums[x] for x in names):
prog += [(0x15, 0, 1, n), (0x06, 0, 0, 0x00050000 | 1)] # SECCOMP_RET_ERRNO | EPERM
prog += [(0x06, 0, 0, 0x7FFF0000)] # SECCOMP_RET_ALLOW
arr = ctypes.create_string_buffer(b''.join(struct.pack('HBBI', *i) for i in prog))
fprog = struct.pack('HxxxxxxP', len(prog), ctypes.addressof(arr))
if libc.prctl(22, 2, ctypes.c_char_p(fprog)) != 0: # PR_SET_SECCOMP, FILTER
e = ctypes.get_errno(); raise OSError(e, os.strerror(e))
return arr
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--exec', action='append', default=[], dest='exec_paths')
ap.add_argument('--write', action='append', default=[], dest='write_dirs')
ap.add_argument('--connect', action='append', default=[], type=int)
ap.add_argument('--bind', action='append', default=[], type=int)
ap.add_argument('--no-seccomp', action='store_true')
ap.add_argument('cmd', nargs=argparse.REMAINDER)
a = ap.parse_args()
cmd = a.cmd[1:] if a.cmd[:1] == ['--'] else a.cmd
if not cmd: ap.error('give a command after --')
libc.prctl(38, 1, 0, 0, 0) # PR_SET_NO_NEW_PRIVS: required without root
keep = None
if not a.no_seccomp and platform.machine() == 'x86_64':
keep = seccomp_deny(['ptrace', 'process_vm_writev', 'io_uring_setup', 'bpf', 'userfaultfd', 'perf_event_open', 'kexec_load'])
abi = landlock(a.exec_paths, a.write_dirs, a.connect, a.bind)
print(f'landlock-run: Landlock ABI {abi}', file=sys.stderr)
os.execv(cmd[0], cmd)
main()
Design notes: file reads are deliberately left unrestricted (a Node app reads many files), so the rules cover only what executes, what is written and which TCP ports are used. With no --connect flag, all outgoing TCP is refused once the kernel supports port rules. A dynamic binary also needs its ELF interpreter to be executable, so we allow /lib64/ld-linux-x86-64.so.2. The seccomp half is x86_64-only as written.
Test it
We used the same stand-in compromised dependency as the firewall run: it tries a shell, curl, a dropped binary, a call to an attacker port and a write outside the allowed directory, while the app itself talks to a database on port 9501 and writes to an uploads directory. Without the wrapper:
[app] connect to database :9501 connected
[app] write to uploads ok
[analytics-helper] run /bin/sh -c id ran: zalkanorr
[analytics-helper] run curl ran: curl 8.11.1 (x86_64-redhat-linux-gnu) ...
[analytics-helper] run a dropped binary ran: payload-ran
[analytics-helper] connect to attacker :6667 connected
[analytics-helper] write outside the allowed path wrote it
With the wrapper:
$ python3 landlock-run.py --exec "$NODE" --exec "$LD" --exec /tmp/fw/app \
--write /tmp/fw/uploads --connect 9501 -- "$NODE" app/index.js
landlock-run: Landlock ABI 7
[app] connect to database :9501 connected
[app] write to uploads ok
[analytics-helper] run /bin/sh -c id refused (EACCES)
[analytics-helper] run curl refused (EACCES)
[analytics-helper] run a dropped binary refused (EACCES)
[analytics-helper] connect to attacker :6667 refused (EACCES)
[analytics-helper] write outside the allowed path refused (EACCES)
($NODE is the resolved path of the node binary and $LD the resolved ELF interpreter; /tmp/fw/app is the app directory; uploads sit outside the exec-allowed directories so a dropped file cannot run.) And the seccomp deny-list: a ptrace call from a child returned -1 with errno 1 (EPERM) with the filter on, and succeeded with --no-seccomp and without the wrapper.
What this does not stop
Treat the sandbox as friction against common direct actions, not a security boundary around code that is already running in your runtime:
- Allowed loaders and interpreters. With the ELF interpreter allowed,
ld-linux /usr/bin/idruns a program that was not listed (we ran it). Any allowed interpreter (node -e,python -c) runs attacker code. memfd, native libraries, JIT. Executable memory that isn’t a path rule is outside Landlock’s execute check.- Ports are not hosts. Allowing port 443 allows any host on 443.
- UDP, DNS, ICMP and inherited sockets aren’t covered by TCP port rules. Newer kernels add UDP rules (the kernel docs list network rules for UDP since ABI v10); our kernel was ABI 7, so we did not test them.
- The process can read its own environment, and any file you did not restrict, and send it over any allowed channel.
- It protects nothing if you ask for too much, such as allowing a writable directory to also be executable.
For policies you can actually maintain (learning what a service does, proposing a policy, reviewing it, enforcing it, and seeing what was refused), that is the job of tooling around these primitives. Dynv’s firewall is one; its documented limits match the list above.
Facts last verified . Spotted an error? Tell us.