Yuzhe's Blog

yuzhes

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

ToolRoot requiredOverheadSyscall filterFilesystem 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 failsWhat saves you
Sandlock misses memory bombGitHub runner memory limits + job timeout
Sandlock misses CPU looptimeout 10 + job 10-minute timeout
Sandlock misses fork bombGitHub’s process limits per runner
Sandlock misses disk fillGitHub’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:

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.