Back to posts
Post

Testing Risky Commands with systemd-nspawn and seccomp Profiles

Learn how to safely run untrusted scripts using systemd-nspawn with custom seccomp filters for command-line sandboxing on Linux.

Linuxsystemdseccompsandboxingsystemd-nspawn

I often need to test or debug scripts I don’t fully trust — maybe from a public repo, a colleague’s gist, or something I found while troubleshooting. Running them directly on a production or even a workstation is risky. That’s where systemd-nspawn combined with seccomp profiles comes in handy. It lets me create a lightweight, isolated environment where I can restrict what syscalls the process can make, effectively sandboxing untrusted code without spinning up a full VM.

Why Use systemd-nspawn for Sandboxing

systemd-nspawn is lightweight compared to containers or VMs. It uses existing kernel namespaces but gives me tight control via the host’s systemd manager. I can launch a shell or run a command in an isolated filesystem with minimal overhead. When paired with a seccomp (secure computing mode) filter, I can block dangerous syscalls like ptrace, mount, or fork/clone combinations that could lead to privilege escalation or container escape.

In my workflow, I keep a minimal Debian or Ubuntu base image under /var/lib/container/ and use it as the rootfs for ephemeral test environments. This way, I don’t pollute my main system, and I can reset everything by just deleting the directory.

Setting Up the Base Image

First, I create a minimal filesystem. I use debootstrap for Debian-based systems:

sudo debootstrap --arch=amd64 bookworm /var/lib/container/testbox http://deb.debian.org/debian

After it finishes, I can chroot in to set a root password or add a regular user:

sudo chroot /var/lib/container/testbox
passwd
adduser testuser
exit

Now I have a bootable-ish directory tree. I don’t need a full init system — just enough to run a shell and basic commands.

Building a Custom seccomp Profile

seccomp works by filtering syscalls at the kernel level. I define a policy in JSON format that specifies which calls are allowed, blocked, or trigger an error. For testing untrusted scripts, I start with a strict default: block everything, then allow only what’s needed.

Here’s a minimal profile I use for running simple Bash or Python scripts:

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "archMap": [
    {"architecture": "SCMP_ARCH_X86_64", "subArchitectures": ["SCMP_ARCH_X86", "SCMP_ARCH_X32"]}
  ],
  "syscalls": [
    {"name": "read", "action": "SCMP_ACT_ALLOW"},
    {"name": "write", "action": "SCMP_ACT_ALLOW"},
    {"name": "openat", "action": "SCMP_ACT_ALLOW"},
    {"name": "close", "action": "SCMP_ACT_ALLOW"},
    {"name": "fstat", "action": "SCMP_ACT_ALLOW"},
    {"name": "mmap", "action": "SCMP_ACT_ALLOW"},
    {"name": "mprotect", "action": "SCMP_ACT_ALLOW"},
    {"name": "munmap", "action": "SCMP_ACT_ALLOW"},
    {"name": "brk", "action": "SCMP_ACT_ALLOW"},
    {"name": "exit", "action": "SCMP_ACT_ALLOW"},
    {"name": "exit_group", "action": "SCMP_ACT_ALLOW"},
    {"name": "rt_sigaction", "action": "SCMP_ACT_ALLOW"},
    {"name": "rt_sigprocmask", "action": "SCMP_ACT_ALLOW"},
    {"name": "ioctl", "action": "SCMP_ACT_ALLOW"},
    {"name": "getdents64", "action": "SCMP_ACT_ALLOW"},
    {"name": "getrandom", "action": "SCMP_ACT_ALLOW"}
  ]
}

I save this as /etc/systemd/nspawn/seccomp-lockdown.json. The defaultAction set to errno means any syscall not explicitly allowed will return an error (typically EPERM), killing the process if it tries something unsafe.

Launching the Sandbox

Now I can run a command inside the namespace with the seccomp filter applied:

sudo systemd-nspawn \
  --directory=/var/lib/container/testbox \
  --bind=/home/furkan/test-scripts:/mnt/tests \
  --seccomp-policy=/etc/systemd/nspawn/seccomp-lockdown.json \
  /usr/bin/bash

This drops me into a shell inside the container, with my test scripts mounted at /mnt/tests. The --bind flag is read-write by default, but I often use --bind-ro if I only need to read the scripts.

If I want to run a single script and exit, I can do:

sudo systemd-nspawn \
  --directory=/var/lib/container/testbox \
  --bind-ro=/home/furkan/test-scripts/script.sh:/mnt/script.sh \
  --seccomp-policy=/etc/systemd/nspawn/seccomp-lockdown.json \
  /bin/bash /mnt/script.sh

What This Blocks (and What It Doesn’t)

With this profile, common escape vectors are blocked:

  • ptrace — prevents debugging or attaching to other processes
  • clone with CLONE_NEWNS or CLONE_NEWUSER — stops namespace creation
  • mount, umount2 — blocks filesystem tampering
  • kexec_load, init_module — prevents kernel code loading
  • openat with dangerous paths (if combined with path filtering, though seccomp alone doesn’t filter paths)

But seccomp doesn’t filter arguments by default (unless using newer SCMP_CMP rules), so I can’t, for example, allow openat only for /tmp. For stricter control, I’d combine this with namespaces, user namespaces, or even a container runtime like Firejail. But for quick testing of scripts that just need to read files, compute, and write output, this is plenty.

Practical Tips and Gotchas

  • Always test your seccomp profile with a known-safe program first. Use strace -f to see what syscalls it makes, then adjust the profile.
  • If a script fails immediately with "Operation not permitted", check /var/log/syslog or journalctl for seccomp audit messages (if auditing is enabled).
  • Avoid allowing execve unless necessary — it opens the door to chaining binaries. If you must allow it, consider whitelisting specific executables via bind mounts and execve argument filtering (available in newer kernels).
  • Don’t forget to clean up: after testing, I delete the container directory or snapshot it for reuse.

When to Reach for Something More

If I need deeper isolation — like network access control, user ID mapping, or multi-layered defense — I’ll use Firejail or a full OCI container with Podman or Docker. But for ad-hoc, command-line testing where I want speed and minimal setup, systemd-nspawn with seccomp is my go-to.

As I mentioned before in my post about [systemd Slice resource isolation](https://furkanikkan.com/urun/systemd-slice-ile-bellek-io-sinirlari-kaynak-izolasyon-kilavuzu-112), the same principles apply: use the right tool for the threat model. Here, seccomp isn’t about performance — it’s about reducing the attack surface to near zero for untrusted code.

If you’re regularly handling third-party scripts, this approach gives you a repeatable, auditable way to test them without exposing your system. It’s not a replacement for proper code review or sandboxing in CI, but it’s a solid ops-layer safety net.


Cover image: Unknown · CC0 (Openverse / kamu malı) · https://www.rawpixel.com/image/5904551/photo-image-background-public-domain-technology