Yuzhe's Blog

yuzhes

Sandlock:705 行 C 代码实现的无 root Linux 沙箱

Posted at # 系统 # 安全 # C # Linux
EN ·

经过三周为学生代码评测构建沙箱,我们把核心逻辑提取成了独立工具:Sandlock

705 行 C 代码。无需 root。约 1.5ms 开销。

它是什么

Sandlock 是一个命令行包装器,把不可信代码运行在多层隔离环境中:

# 阻断网络、限制资源、清洗环境变量
sandlock --no-network --no-fork --clean-env \
         --cpu 5 --mem 256 --timeout 30 \
         -- python3 student_code.py

三层安全机制依次叠加:

┌──────────────────────────────────────┐
│  seccomp-bpf(syscall 过滤)          │
│  • 60+ 危险 syscall 阻断              │
│  • 可选:阻断所有网络                  │
│  • 可选:阻断 fork                    │
├──────────────────────────────────────┤
│  rlimits(资源限制)                  │
│  • CPU 时间、内存、文件大小            │
│  • 文件描述符数量                      │
├──────────────────────────────────────┤
│  prctl(NO_NEW_PRIVS)                 │
│  • 永久阻断特权提升                    │
└──────────────────────────────────────┘

在 kernel ≥ 5.13 上,还有第四层可用:Landlock——Linux 的无特权文件系统访问控制。下面详说。

与现有方案对比

工具需要 root开销syscall 过滤文件系统隔离
sandlock~1.5ms⚠️(Landlock,需 kernel 5.13+)
Docker~100ms
Firejail⚠️ SUID~50ms
bubblewrap⚠️~10ms

定位:单一二进制文件,无 root,快速启动,可组合使用。不需要管理容器,也不需要安装 SUID 二进制。

CI 难题:如何安全测试炸弹

Sandlock 会阻断 fork 炸弹、内存炸弹和 CPU 炸弹。但如何在 CI 里测试沙箱能正确阻断炸弹,同时不真的触发炸弹?

答案是三层防护:

timeout 10 ./sandlock --mem 64 --timeout 5 -- python3 -c "
import itertools; list(itertools.repeat(None))  # 内存炸弹
"
│           │                │
│           │                └── sandlock 5s 超时
│           └── sandlock 64MB 内存限制
└── 外部 10s 强制终止

GitHub Actions job 还有自己的 timeout-minutes: 10

沙箱失效场景防护机制
内存炸弹漏过GitHub runner 内存限制 + job 超时
CPU 无限循环timeout 10 + job 10 分钟超时
fork 炸弹漏过GitHub 每个 runner 的进程限制
磁盘写满GitHub 存储限制

最坏情况:job 在 10 分钟后被 GitHub 强制终止,账号不受影响。

CI 工作流只在 sandlock.cMakefile 变更时触发,不是每次 push 都跑。炸弹测试是可选的:security-tests 工作流有 skip_bombs 输入,手动触发时自行决定是否跑。

Landlock:文件系统隔离层

Landlock(Linux 5.13+)允许无特权进程限制自己的文件系统访问。在 exec 前授权特定路径的读写能力:

# 只有 /tmp 可写;/usr 只读
sandlock --landlock --rw /tmp --ro /usr -- python3 script.py

我们在配置 CI 时踩了一个坑。失败的测试是:

./sandlock --landlock --rw /tmp -- touch /tmp/test
# sandlock: exec: Permission denied (exit 127)

报错不是关于 /tmp 的——而是 /usr/bin/touch。Landlock 启用后,execve() 本身就需要二进制文件可访问。而二进制文件需要加载它的共享库(/lib/lib64)。所以正确的写法是:

sandlock --landlock --rw /tmp --ro /usr --ro /lib --ro /lib64 \
         -- touch /tmp/test

Landlock 的能力模型真的很严格:没有显式授权的路径一律不可访问——包括启动被沙箱进程本身所需的路径。这是正确行为,但意味着你需要考虑沙箱启动需要什么,而不只是被沙箱代码运行需要什么。

未解决的问题

和 shimmy 研究阶段一样的几个缺口:

仓库

github.com/bkmashiro/Sandlock,MIT 协议。

Sandlock/
├── sandlock.c              # 705 行,单文件
├── Makefile
├── test.sh
├── README.md
└── .github/workflows/
    ├── ci.yml              # 编译 + 快速测试,sandlock.c 变更时触发
    └── security-tests.yml  # 完整炸弹测试,手动触发

集成到 shimmy 只需一行:

cmd := exec.Command("sandlock",
    "--no-network", "--no-fork", "--clean-env",
    "--cpu", "5", "--mem", "256",
    "--", "python3", studentCode)

由明石(CTO)构建,作为 Lambda Feedback MSc 毕业项目的一部分。