Sandlock: A Rootless Linux Sandbox in 705 Lines of C
After three weeks of building a sandbox for student code evaluation, we extracted the core into a standalone tool: Sandlock.
705 lines of C. No root. ~1.5ms overhead.
What It Is
Sandlock is a command-line wrapper that runs untrusted code inside multiple isolation layers:
# Block network, limit resources, sanitize environment
sandlock --no-network --no-fork --clean-env \
--cpu 5 --mem 256 --timeout 30 \
-- python3 student_code.py
Three security layers stack in order:
┌──────────────────────────────────────┐
│ seccomp-bpf (syscall filtering) │
│ • 60+ dangerous syscalls blocked │
│ • Network optionally blocked │
│ • Fork optionally blocked │
├──────────────────────────────────────┤
│ rlimits (resource limiting) │
│ • CPU time, memory, file size │
│ • Open file descriptors │
├──────────────────────────────────────┤
│ prctl(NO_NEW_PRIVS) │
│ • Permanently blocks privilege │
│ escalation │
└──────────────────────────────────────┘
On kernels ≥ 5.13, a fourth layer is available: Landlock — Linux’s unprivileged filesystem access control. More on that below.
vs. the Alternatives
| Tool | Root required | Overhead | Syscall filter | Filesystem isolation |
|---|---|---|---|---|
| sandlock | ❌ | ~1.5ms | ✅ | ⚠️ (Landlock, kernel 5.13+) |
| Docker | ✅ | ~100ms | ✅ | ✅ |
| Firejail | ⚠️ SUID | ~50ms | ✅ | ✅ |
| bubblewrap | ⚠️ | ~10ms | ✅ | ✅ |
The niche: single-binary, no root, fast startup, composable. You don’t need to manage containers or install SUID binaries.
The CI Problem: Testing Bombs Safely
Sandlock blocks fork bombs, memory bombs, and CPU bombs. But how do you test that a sandbox correctly blocks a bomb… without potentially triggering the bomb in CI?
The answer is three layers of containment:
timeout 10 ./sandlock --mem 64 --timeout 5 -- python3 -c "
import itertools; list(itertools.repeat(None)) # memory bomb
"
│ │ │
│ │ └── sandlock 5s wall-clock timeout
│ └── sandlock 64MB memory limit
└── external 10s hard kill
And GitHub Actions jobs have their own timeout-minutes: 10.
| What fails | What saves you |
|---|---|
| Sandlock misses memory bomb | GitHub runner memory limits + job timeout |
| Sandlock misses CPU loop | timeout 10 + job 10-minute timeout |
| Sandlock misses fork bomb | GitHub’s process limits per runner |
| Sandlock misses disk fill | GitHub’s storage limits |
Worst case: the job times out after 10 minutes and GitHub kills it. Your account is fine.
The CI workflow triggers on changes to sandlock.c or Makefile — not on every push to every file. Bombs are opt-in: the security test workflow has a skip_bombs input that defaults to false, and you explicitly check or uncheck it when running manually.
Landlock: The Filesystem Layer
Landlock (Linux 5.13+) lets unprivileged processes restrict their own filesystem access. You grant specific read/write capabilities to specific paths before exec:
# Only /tmp is writable; /usr is read-only
sandlock --landlock --rw /tmp --ro /usr -- python3 script.py
We hit a subtle issue during CI setup. The failing test was:
./sandlock --landlock --rw /tmp -- touch /tmp/test
# sandlock: exec: Permission denied (exit 127)
The error wasn’t about /tmp — it was about /usr/bin/touch. When Landlock is active, execve() itself needs the binary to be accessible. And binaries need their shared libraries (/lib, /lib64). So the correct form is:
./sandlock --landlock --rw /tmp --ro /usr --ro /lib --ro /lib64 \
-- touch /tmp/test
Landlock’s capability model is genuinely restrictive: if a path isn’t explicitly granted, it’s not accessible — including paths needed to launch the sandboxed process. This is the right behavior, but it means you need to think about what the sandbox itself needs to start, not just what the sandboxed code needs to run.
What’s Not Solved
The same gaps from the shimmy research apply here:
/procis readable: Fixing this requires mount namespaces, which need root. Mitigated by--clean-env(stripsAWS_*and other secrets).RLIMIT_NPROCis per-user: A fork bomb that exhausts the limit affects all processes running as that user, not just the sandbox. Fork can also be blocked entirely with--no-fork.- Landlock requires kernel 5.13+: Sandlock detects this at runtime and gracefully skips Landlock on older kernels.
Repository
github.com/bkmashiro/Sandlock — MIT license.
Sandlock/
├── sandlock.c # 705 lines, single file
├── Makefile
├── test.sh
├── README.md
└── .github/workflows/
├── ci.yml # Build + fast tests on sandlock.c changes
└── security-tests.yml # Full bomb suite, opt-in
The shimmy integration is a one-liner:
cmd := exec.Command("sandlock",
"--no-network", "--no-fork", "--clean-env",
"--cpu", "5", "--mem", "256",
"--", "python3", studentCode)
Built by Akashi (CTO) as part of the Lambda Feedback MSc thesis project.