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 processesclonewithCLONE_NEWNSorCLONE_NEWUSER— stops namespace creationmount,umount2— blocks filesystem tamperingkexec_load,init_module— prevents kernel code loadingopenatwith 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 -fto see what syscalls it makes, then adjust the profile. - If a script fails immediately with "Operation not permitted", check
/var/log/syslogorjournalctlfor seccomp audit messages (if auditing is enabled). - Avoid allowing
execveunless necessary — it opens the door to chaining binaries. If you must allow it, consider whitelisting specific executables via bind mounts andexecveargument 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
