跳到主要内容

数据截至 (上游 commit 97c8097d51d0)

03 · 三道锁:chroot、seccomp、降权

这章讲什么: Dify Sandbox 全部的安全性都压在一个 40 行的 Go 函数上。这章把这 40 行拆开讲透——每一步在挡什么、为什么必须是这个顺序、白名单是怎么攒出来的。


1. 先建立直觉:锁是被关的人自己上的

绝大多数沙箱是外面的人上锁:容器由 runtime 配置好 namespace/cgroup 再启动进程,VM 由 hypervisor 圈定边界。

Dify Sandbox 反过来:父进程就是普通地 exec 了一个 python3,没做任何隔离配置。是这个 python 进程运行到第 29 行时,主动调了一个函数,把自己关死:

父进程(Go 服务) 子进程(python3)

│ exec python3 bootstrap.py
├──────────────────────────► 第 16 行:加载 python.so
│ 第 25 行:chdir 到未来的根
│ 第 29 行:DifySeccomp(uid,gid,net)
│ │
│ ├─ chroot(".")
│ ├─ no_new_privs
│ ├─ seccomp BPF 装载
│ └─ setgroups/setgid/setuid
│ 第 35 行:exec(用户代码) ← 此刻已在笼中

为什么能这么干?因为 python.so 是 Go 写的、用 -buildmode=c-shared 编出来的动态库(build/build_amd64.sh:6),Python 用 ctypes 就能调它。Go 里能方便地做 syscall.Chroot、调 libseccomp,这些在纯 Python 里做起来非常别扭。

导出的函数只有一个(cmd/lib/python/main.go:8-13):

//export DifySeccomp
func DifySeccomp(uid int, gid int, enable_network bool) {
if err := python.InitSeccomp(uid, gid, enable_network); err != nil {
panic(err)
}
}

失败就 panic ——上不了锁,宁可整个进程炸掉,绝不放行


2. 四步自锁,逐步拆解

先把两个说法对齐: 本章标题里的三道锁指 chroot、seccomp、setuid 降权这三种隔离手段;no_new_privs 不单独算一道锁,它是第三道(seccomp)在非特权进程里能装上的技术前提。所以「三道锁」落到代码里是四步调用——下文一律按四步讲。

真身是 InitSeccomp(internal/core/lib/python/add_seccomp.go:12-53)。Node 版本是逐行对称的一份(internal/core/lib/nodejs/add_seccomp.go:12-53),只是白名单换成 Node 的。

┌───────────────────────────────────────────┐
① │ chroot(".") + chdir("/") │ 换掉「根」的定义
└───────────────────┬───────────────────────┘

┌───────────────────────────────────────────┐
② │ prctl(PR_SET_NO_NEW_PRIVS, 1) │ 永久放弃提权可能
└───────────────────┬───────────────────────┘

┌───────────────────────────────────────────┐
③ │ seccomp(SET_MODE_FILTER, TSYNC, bpf) │ 装上系统调用白名单
└───────────────────┬───────────────────────┘

┌───────────────────────────────────────────┐
④ │ setgroups([]) → setgid(gid) → setuid(uid) │ 丢掉 root 身份
└───────────────────────────────────────────┘

① chroot:把「根目录」这个词的含义换掉

err := syscall.Chroot(".") // add_seccomp.go:13
err = syscall.Chdir("/") // add_seccomp.go:17

chroot(".") 把当前工作目录变成这个进程眼里的 /。之后代码写 open("/etc/passwd"),内核实际找的是 <原来的cwd>/etc/passwd

当前目录是谁设的?Python 侧由引导脚本 os.chdir(sys.argv[1]) 设成 /var/sandbox/sandbox-python(prescript.py:21-25,argv[1] 来自 python.go:70);Node 侧由 WithTempDiros.Chdir 设成那个临时目录(temp_dir.go:54)。

效果由集成测试直接验证(tests/integration_tests/python_malicious_test.go:73-89,TestReadEtcPasswd):沙箱里 open("/etc/passwd")No such file or directory——不是权限不足,是那个文件在这个世界里根本不存在

重要的诚实提醒: 单独的 chroot 不是安全边界。历史上有大量 chroot 逃逸手法(经典的是再 chroot 一次然后一路 .. 爬上去)。它在这里之所以够用,完全依赖后面两步:逃逸手法需要的系统调用(chrootforkmount)全都不在白名单里,而且身份已经不是 root。三道锁是一个整体,拆开任何一道都不成立。

② no_new_privs:提权这条路直接封死

syscall.Syscall6(syscall.SYS_PRCTL, 0x26, 1, 0, 0, 0, 0) // set_no_new_privs.go:15

0x26 = 38 = PR_SET_NO_NEW_PRIVS。设上以后:

  • 这个进程及其所有后代,永远不能通过 execve 获得新权限(setuid 位的程序不再生效)。
  • 这个标志不可撤销
  • 它同时是非特权进程装 seccomp 过滤器的前提——没有 CAP_SYS_ADMIN 时,不设 no_new_privs 就装不上过滤器。

所以第 ② 步既是安全措施,也是第 ③ 步的技术前提。

③ seccomp:装一段 BPF 白名单

这是最核心的一段(internal/core/lib/seccomp.go:15-69)。

第一步,建一个默认「杀」的过滤器:

ctx, err := sg.NewFilter(sg.ActKillProcess) // seccomp.go:16

ActKillProcess 意思是:凡是没有明确规则的系统调用,一律直接杀死整个进程。这是白名单模型——不是「禁止某些危险调用」,而是「除了列出来的,全都不行」。

第二步,加两类规则:

for _, syscall := range allowed_syscalls {
ctx.AddRule(sg.ScmpSyscall(syscall), sg.ActAllow) // 放行
}
for _, syscall := range allowed_not_kill_syscalls {
ctx.AddRule(sg.ScmpSyscall(syscall), sg.ActErrno) // 不杀,直接返回
}

第三步,把过滤器导出成 BPF 字节码再手动装载(seccomp.go:21-63)。这里用了个略绕的写法:开一对管道,让 libseccomp 把 BPF 程序 ExportBPF 写进管道,再从管道读回 4096 字节的缓冲区,按每条 8 字节切成 SockFilter 数组,最后手写 syscall.Syscall 发起 seccomp(2):

syscall.Syscall(
SYS_SECCOMP, // amd64 上是 317,见 seccomp_syscall_amd64.go:6
uintptr(SeccompSetModeFilter), // 0x1
uintptr(SeccompFilterFlagTSYNC), // 0x1 —— 关键
uintptr(unsafe.Pointer(&bpf)),
)

TSYNC 这个 flag 是不能少的: 它要求把过滤器同步到进程里的所有线程。CPython 和 Node 都是多线程的(GC 线程、libuv 线程池),只锁当前线程等于没锁。

④ 降权:最后才丢掉 root

syscall.Setgroups([]int{}) // 清空附加组
syscall.Setgid(gid) // 先 gid
syscall.Setuid(uid) // 后 uid

顺序不能反:先 setuid 就没有权限再 setgid 了,这是经典的降权顺序陷阱。

这三个调用发生在 seccomp 之后,所以 setgroups/setgid/setuid 本身必须在白名单里(syscalls_amd64.go:28)。而且代码额外做了一次保险(add_seccomp.go:31):

allowed_syscalls = lib.MergeSyscalls(allowed_syscalls, []int{syscall.SYS_SETGROUPS})

强制把 setgroups 并进白名单——哪怕有人手贱把它从静态列表里删了,降权也不会自己把自己锁死。


3. 顺序为什么必须是这个顺序

把四步的依赖关系摊开看,就明白它不是随便排的:

步骤为什么不能更早为什么不能更晚
① chrootchroot(2) 需要 root,一旦 setuid 就再也调不动
② no_new_privs是 ③ 的前置条件
③ seccomp装了以后就不能再做白名单外的事,chroot 得先做完必须在跑用户代码之前
④ setuid早了就没权限 chroot必须在跑用户代码之前

一句话记忆:先用 root 权限布置好牢房(chroot),再把牢房锁上(seccomp),最后自己脱掉 root 这身衣服(setuid)。 衣服一脱就再也布置不动了。


4. 白名单里有什么、没有什么

以 amd64 的 Python 白名单为例(internal/static/python_syscall/syscalls_amd64.go:15-45),它按用途分了组:

代表 syscall为什么必须有
文件 IOopenatreadwritecloselseekgetdents64import 模块要读文件,print 要写 stdout
内存mmapbrkmprotectmunmapmadvise解释器分配内存
线程/同步futexsched_getaffinityset_tid_addressCPython 的 GIL 与线程
进程信息getpidgetppidgettidexit_group运行时自查与退出
身份setgroupssetgidsetuidgetuid第 ④ 步降权本身
时间clock_gettimenanosleepgettimeofdaytime/datetime 模块
杂项getrandomunamestatxgetcwdpipe2哈希随机化、平台探测

没有的才是重点:

缺席的 syscall后果
execve / execveat起不了任何外部程序——os.systemsubprocess 全废
chroot不能再 chroot,堵死经典逃逸
mount / umount碰不了挂载
ptrace不能调试/注入别的进程
kill(只留了 tgkill)不能随便给别的进程发信号
unlink / rename删不掉、改不了名
socket 一族(默认)关网时没有任何网络能力

网络是一整块可插拔的补丁

网络相关的 21 个 syscall 单独放在 ALLOW_NETWORK_SYSCALLS(syscalls_amd64.go:54-59),其中 unamefstatfcntl 与基础白名单重复,净增 18 个。这一整块只在 enable_network 为真时追加(add_seccomp.go:26-28):

if enable_network {
allowed_syscalls = append(allowed_syscalls, python_syscall.ALLOW_NETWORK_SYSCALLS...)
}

干净利落——关网不是靠防火墙,是靠这个进程连 socket() 这个系统调用都不会被内核接受。这比 iptables 彻底得多,也不可能被绕过。


5. 最妙的一招:ActErrno 让 fork「假装成功」

问题

fork() 必须禁掉——沙箱里能 fork 就意味着能起进程炸弹。但如果按默认动作直接杀进程,用户体验很糟:一段代码里有个不痛不痒的 os.fork(),整个脚本就被 SIGSYS 干掉,报「operation not permitted」,用户一头雾水。

做法

cloneclone3mkdiratmkdir 被放进 ALLOW_ERROR_SYSCALLS(syscalls_amd64.go:47-52),规则动作是 sg.ActErrno(seccomp.go:32-34)——不杀进程,让这个调用直接返回

效果由集成测试钉死(tests/integration_tests/python_malicious_test.go:12-29,TestSysFork):

import os
print(os.fork())
print(123)

断言 stdout 恰好是 "0\n123\n"。也就是说 os.fork() 返回了 0、脚本继续往下跑、但并没有真的多出一个进程 (inferred:测试断言的是单份输出,若真的 fork 成功会看到两份)。

mkdir 同理:目录创建「成功」了,但文件系统上什么都没发生。

这一招的价值: 把「安全上必须拒绝」和「体验上不该炸」分开处理。危险但常见的调用降级成静默空操作,真正的越狱尝试(execve)才动杀招。


6. 越权时到底发生了什么(端到端追一遍)

用户写了 subprocess.run(["ls", "-l"]):

Python 调 posix_spawn/fork+execve


内核 seccomp 过滤器:execve 不在白名单 → ActKillProcess


进程收到 SIGSYS,立即死亡


父进程 cmd.Process.Wait() 拿到状态字符串,含 "bad system call"
│ (output_capture.go:196-215)

WriteExecError("error: operation not permitted\n")


buildExecutionError:exit_code != 0 → 拼上 "process exited with code -1"
│ (service/run_code.go:65-76)

返回给用户:error = "process exited with code -1\nerror: operation not permitted\n"

这条链路由 TestRunCommandTestExec 两个测试共同守着(tests/integration_tests/python_malicious_test.go:31-71)。


7. 白名单是怎么攒出来的(以及怎么加)

加法:环境变量合并,不是替换

运维可以用环境变量 ALLOWED_SYSCALLS 补充放行的 syscall 号(internal/core/lib/syscalls.go:9-34)。关键在于它是合并语义(add_seccomp.go:30):

allowed_syscalls = lib.MergeSyscalls(allowed_syscalls, lib.SyscallsFromEnv("ALLOWED_SYSCALLS"))

MergeSyscalls 去重且保序(syscalls.go:36-51),测试 TestMergeSyscallsKeepsDefaultsWhenExtending 明确锁定了「加 = 加,不会顶掉默认」这个语义(internal/core/lib/syscalls_test.go:42)。FAQ 也用 ALLOWED_SYSCALLS=204 举了例(FAQ.md)。

解析很宽容:非数字项直接跳过,不报错(syscalls.go:22-26)。

减法工具:一个暴力的二分挖掘器

仓库里带了个小工具 cmd/test/syscall_dig/main.go:31-60,思路极其朴素:

  1. 先假设 0-499 号 syscall 全放行。
  2. 每轮拿掉队首那个,跑一遍目标脚本。
  3. 如果报 bad system call,说明这个是必需的,放回队尾;否则丢弃。
  4. 500 轮之后,剩下的就是最小必需集

跑 500 次进程只为了求一个集合——粗暴,但对「给 numpy 这类库找出它到底要哪些 syscall」这种一次性苦力活非常好使。FAQ 里给了完整用法和 strace 备选方案。

白名单按语言 × 架构分四份

amd64arm64
Pythoninternal/static/python_syscall/syscalls_amd64.go.../syscalls_arm64.go
Nodeinternal/static/nodejs_syscall/syscalls_amd64.go.../syscalls_arm64.go

必须分开,因为同一个 syscall 在不同架构上号码不同:rseq 在 amd64 是 334、在 arm64 是 293(python_syscall/syscalls_amd64.go:9 vs syscalls_arm64.go:10)。用 //go:build 标签选择编译哪份。

两个语言的列表差别也不小:Node 需要 dup3readlinkepoll_pwait(libuv 事件循环),Python 需要 getdents64readv/writevmbind(numpy 的内存策略)。


8. 关键细节与坑

BPF 缓冲区写死 4096 字节。 data := make([]byte, 4096)(seccomp.go:40),一条 SockFilter 8 字节,即最多约 512 条 BPF 指令。白名单继续膨胀下去,这里会静默截断——代码没有检查读回的字节数是否等于导出长度。这是个真实存在的隐患。

过滤器只匹配 syscall 号,不看参数。 AddRule 没有传任何参数条件(seccomp.go:29),所以放行 openat 就是放行所有openat——路径限制完全靠 chroot,不靠 seccomp。

.so 必须先编译才能编译主程序。 //go:embed python.so(python/setup.go:23-24)把 .so 嵌进二进制,而这个文件在 .gitignore 里。所以构建顺序是死的:先编两个 c-shared 库,再编 main(build/build_amd64.sh:6-10)。

开发调试口子。 cmd/test/permission/main.go:9-12InitSeccomp(0, 0, true) 然后立刻试 exec.Command("/bin/sh", ...),专门用来验证「上锁后 exec 确实起不来」。


9. 代码地图

主题文件路径符号名
四步自锁(Python)internal/core/lib/python/add_seccomp.goInitSeccomp
四步自锁(Node)internal/core/lib/nodejs/add_seccomp.goInitSeccomp
BPF 构建与装载internal/core/lib/seccomp.goSeccomp
no_new_privs 与常量internal/core/lib/set_no_new_privs.goSetNoNewPrivsSeccompFilterFlagTSYNC
seccomp syscall 号internal/core/lib/seccomp_syscall_amd64.go / _arm64.goSYS_SECCOMP
环境变量补充白名单internal/core/lib/syscalls.goParseSyscallNumbersSyscallsFromEnvMergeSyscalls
Python 白名单internal/static/python_syscall/syscalls_amd64.goALLOW_SYSCALLSALLOW_ERROR_SYSCALLSALLOW_NETWORK_SYSCALLS
Node 白名单internal/static/nodejs_syscall/syscalls_amd64.goALLOW_SYSCALLSALLOW_NETWORK_SYSCALLS
c-shared 导出入口cmd/lib/python/main.gocmd/lib/nodejs/main.goDifySeccomp
最小 syscall 集挖掘器cmd/test/syscall_dig/main.gomainrun
越狱行为测试tests/integration_tests/python_malicious_test.goTestSysForkTestExecTestRunCommandTestReadEtcPasswd