跳到主要内容

数据截至 (上游 commit 97c8097d51d0)

02 · 沙箱核心:一次执行的完整机械过程

这章讲什么: 父进程为了起一个沙箱子进程,做了哪些准备动作。上锁的原理在 03 章;这一章讲的是上锁之前的所有铺垫。


1. 先看全流程

以 Python 为例(internal/core/runner/python/python.go:29-127,PythonRunner.Run):

① 借 UID AcquireUID(ctx) ────► 拿到 10000~10999 中的一个

② 写 bootstrap 把 prescript.py 模板里的 {{uid}}/{{gid}}/
│ {{enable_network}}/{{preload}} 替换掉,
│ 写到 /var/sandbox/sandbox-python/tmp/<uuid>.py (0600)

③ 开一对管道 os.Pipe() → codeReader 挂到子进程 fd 3

④ 起进程 python3 <bootstrap.py> /var/sandbox/sandbox-python
│ cmd.Env 被整个替换(不继承父进程环境)

⑤ 灌代码 另起 goroutine 把 code 写进 codeWriter 后关闭

⑥ 收输出 CaptureOutput 起三个 goroutine:读 stdout / 读 stderr / 等退出

⑦ 收尾 after-exit hook:关管道、删 bootstrap、还 UID

每一步都有讲究,下面逐个拆。


2. UID 池:每次执行换一个身份

要解决的小问题

沙箱进程最后要 setuid 降权。如果所有沙箱都降到同一个 UID,那么同时在跑的两个用户代码就互为「同一个人」——A 能给 B 发信号、能读 B 的文件。

思路

预先划一段 UID 区间当号码池,一次执行借一个、用完还回去。借不到就等。

实现是一个带缓冲 channel(internal/core/runner/uidpool/uid_pool.go:14-46):

type UIDPool struct {
pool chan int // 容量 = max-min,初始塞满所有可用 uid
min int
max int
}

借号就是从 channel 里读一个,还号就是写回去。Acquire 用 select 同时等 channel 和 ctx.Done()(uid_pool.go:33-40),所以客户端断连,排队的请求会立刻放弃而不是傻等。

全局池写死为 [10000, 11000),共 1000 个号,由 sync.Once 惰性初始化(uid_pool.go:57-63)。

一个容易忽略的细节:往 /etc/passwd 里写 1000 行

池子初始化时,还会顺手给这 1000 个 UID 在 /etc/passwd 里各建一条记录(uid_pool.go:67-78):

fmt.Fprintf(f, "sandbox%d:x:%d:0::/nonexistent:/usr/sbin/nologin\n", i, i)

注释说明了原因:Python 退出清理时可能调 getpwuid() 查当前用户;查不到时 glibc 会去尝试别的解析路径,可能触发白名单之外的系统调用,导致进程在收尾阶段被 SIGSYS 打死。先把记录填好,这条路就走不到了。

这是典型的「沙箱调试血泪」——为了让被关起来的进程能优雅退场而做的补丁。

借不到号的两种表现

语言借不到时位置
Python返回 -429,消息 sandbox UID pool exhaustedinternal/service/python.go:32-34
Node落进通用 -500internal/service/nodejs.go:28-30

错误能被 errors.Is 认出来,是因为 runner 用 %w 包装了原错误(python.go:41)。


3. bootstrap 脚本:模板替换,但绝不塞用户代码

模板长什么样

完整的 internal/core/runner/python/prescript.py 只有 35 行,骨架是:

lib = ctypes.CDLL("./python.so") # 16 行:加载上锁库
running_path = sys.argv[1]
os.chdir(running_path) # 25 行:进到未来的 chroot 根

{{preload}} # 27 行:前置代码(默认为空)

lib.DifySeccomp({{uid}}, {{gid}}, {{enable_network}}) # 29 行:上锁
os.environ.pop("GODEBUG", None) # 30 行:抹掉内部环境变量

with os.fdopen(3, "rb") as code_fd: # 32 行:从 fd 3 读用户代码
code = code_fd.read().decode("utf-8")

exec(compile(code, "<fd3>", "exec")) # 35 行:执行

模板替换在 buildBootstrap(python.go:129-158):四个占位符 {{uid}}{{gid}}{{enable_network}}{{preload}} 各替换一次(strings.Replace(..., 1))。

关键约束:用户代码不进模板

用户代码不参与任何字符串拼接,它只走 fd 3。这条约束有单元测试守着(internal/core/runner/python/python_test.go:10-25,TestBuildBootstrapInjectsPreloadButNotUserCode):断言 bootstrap 里有 os.fdopen(3、有 preload、但没有用户代码。Node 侧有对称的一份(internal/core/runner/nodejs/nodejs_test.go:12-26)。

为什么重要: 如果把用户代码 %s 进模板,那么代码里出现 {{uid}} 之类的字面量、或者引号/缩进的意外,都可能破坏引导逻辑,甚至绕过 DifySeccomp 那一行。走 fd 就彻底没有这类注入面。

脚本文件的权限

bootstrap 写到 <LIB_PATH>/tmp/<uuid>.py,权限 0600,再 chown 给这次借到的 UID(python.go:170-182):

err = os.WriteFile(bootstrapPath, []byte(script), 0600)
if err = syscall.Chown(bootstrapPath, uid, static.SANDBOX_GROUP_ID); err != nil { ... }

由于所有 Python 沙箱共用同一个 chroot 根,同时在跑的几个沙箱能互相看见对方的 tmp/ 目录;0600 + 各自 UID 让它们读不到彼此的脚本 (inferred:源码只写了权限动作,没写这句意图)。

文件名用去掉横线的 UUID(python.go:165-166),避免并发撞名。


4. fd 3:代码怎么进到子进程里

直觉

父子进程之间有一根匿名水管。父进程往一头灌代码,子进程从另一头读。这根管子在子进程里的编号是 3——因为 0/1/2 已经被 stdin/stdout/stderr 占了。

三行代码

codeReader, codeWriter, err := os.Pipe() // python.go:50
cmd.ExtraFiles = []*os.File{codeReader} // python.go:80 → 子进程里就是 fd 3
go func() { // python.go:112-115
_, _ = io.WriteString(codeWriter, code)
codeWriter.Close()
}()

cmd.ExtraFiles 是 Go 标准库的约定:第 i 个文件在子进程里是 fd 3+i

写入必须放在单独的 goroutine 里:管道有容量上限,代码超过这个上限时写操作会阻塞,直到子进程开始读。如果在主流程里同步写,大代码会死锁。

时序上的巧合很关键

看时间线:子进程是先上锁,后读 fd 3(prescript.py:29 在 32 之前)。这说明读 fd 的 read 系统调用必须在白名单里——确实在(syscalls_amd64.go:18,syscall.SYS_READ)。而这也意味着:代码是在锁上了以后才被读进来的,连「读代码」这一步都发生在笼子里。

集成测试 TestPythonFd3Transport / TestNodejsFd3Transport 就是在守这条通路(tests/integration_tests/python_feature_test.go:135-153)。


5. 子进程的环境变量:白名单制

cmd.Env整个替换而不是追加(python.go:72-78),所以子进程默认什么环境变量都没有——没有 PATH、没有 HOME、没有你容器里的任何密钥。

然后按需加回四类:

类别内容位置
GODEBUGdecoratemappings=0,containermaxprocs=0,updatemaxprocs=0python.go:72-78
代理socks5 优先,否则 http/https;再加 NO_PROXYpython.go:82-96
显式放行allowed_env_vars 里列名的,才从父进程复制过去python.go:98-102
额外系统调用ALLOWED_SYSCALLS=1,2,3...python.go:104-110

放行名单的行为有成对的测试守着:列了名的能传进去(TestNodejsAllowedEnvVarsPropagation),没列名的传不进去(TestNodejsUnlistedEnvVarNotPropagated),见 tests/integration_tests/nodejs_feature_test.go:181-215

GODEBUG 那一坨是干嘛的

源码注释解释得很清楚(python.go:73-76):子进程会加载一个 Go 编译的 c-shared 库,这个库自带一个 Go runtime。Go runtime 会在后台做一些「家务」系统调用(内存映射打标签、探测 CPU 配额),而这些调用在 seccomp 生效后会直接把进程打死。这三个 GODEBUG 开关就是把这些家务关掉。

然后引导脚本在上锁之后立刻 os.environ.pop("GODEBUG")(prescript.py:30),Node 侧是 delete process.env.GODEBUG(prescript.js:14)——别让用户代码看见我们的内部实现细节


6. 收尾:after-exit hook

清理挂在输出捕获器上(python.go:59-64):

outputHandler.SetAfterExitHook(func() {
codeReader.Close()
codeWriter.Close()
os.Remove(bootstrapPath)
ReleaseUID(uid)
})

这个 hook 在「进程 wait 返回之后、往 done channel 发信号之前」被调用(output_capture.go:218-222),所以顺序是保证的:先清理干净,再让 service 层去收结果

启动阶段的每一处失败也都逐个手工回滚(python.go:46-55117-124)——写脚本失败还 UID、开管道失败删脚本 + 还 UID。写得啰嗦,但没有泄漏。


7. Node 路径:同样的骨架,不同的根

Node 的编排(internal/core/runner/nodejs/nodejs.go:40-153)和 Python 是一个模子,但有三处实打实的差别。

差别一:每次请求现搭一个根目录

Python 用固定/var/sandbox/sandbox-python 当 chroot 根(python/setup.go:26-29)。

Node 则调 WithTempDir 现搭(nodejs.go:60):新建 /tmp/sandbox-<uuid>/,把一份清单里的文件 cp -r 复制进去,然后 os.Chdir 进去(internal/core/runner/temp_dir.go:13-64)。

清单 REQUIRED_FS 有 7 项(nodejs.go:29-38):Node 项目目录、nodejs.so、CA 证书、nsswitch.confresolv.conf、systemd 的 stub-resolv、hosts

/tmp/sandbox-<uuid>/ ← 这次执行的 chroot 根
├── var/sandbox/sandbox-nodejs/
│ ├── nodejs.so ← 上锁库
│ └── nodejs-project/node_temp/ ← koffi 依赖 + test.js(bootstrap)
└── etc/{hosts,resolv.conf,...} ← 联网要用的解析配置

代价: 每次执行都要 cp -r 一个含 node_modules 的目录树。这是 Node 路径明显更重的原因。

差别二:参数传递方式不同

PythonNode
uid/gid 怎么给模板替换进脚本文本命令行 argv[2]、argv[3]
选项怎么给模板替换 {{enable_network}}argv[4] 是 {"enable_network":true} JSON
依据python.go:129-157nodejs.go:155-162,buildCommandArgs

差别三:加载 .so 的方式不同

加载器调用点
Python标准库 ctypes.CDLL("./python.so")prescript.py:16-18
Node第三方 FFI 库 koffi.load(...)prescript.js:4-6

koffi 是预先 vendored 进仓库的(internal/core/runner/nodejs/dependens/node_temp/node_modules/koffi/),因为沙箱里不可能联网 npm install。

两边最终调的是同一个导出符号 DifySeccomp,签名一致(cmd/lib/python/main.go:8-13cmd/lib/nodejs/main.go:6-11)。

还有一个小差别:执行用户代码的方式

Python 用 exec(compile(code, "<fd3>", "exec")),Node 用 eval(code)(prescript.js:17)。用 eval 会让用户代码和引导脚本共享同一个作用域——所以才有那个恶意测试 TestNodejsRunRedeclareFunctionCommand(tests/integration_tests/nodejs_malicious_test.go:43-58):用户重定义 parseInt 试图劫持引导逻辑。测试断言它照样被 seccomp 挡住,即上层作用域被污染不影响安全性,因为锁在更下面


8. 代码地图

主题文件路径符号名
Python 编排主流程internal/core/runner/python/python.goPythonRunner.Run
bootstrap 模板替换internal/core/runner/python/python.gobuildBootstrapInitializeEnvironment
Python 引导脚本模板internal/core/runner/python/prescript.pyDifySeccompos.fdopen(3, "rb")
Node 编排主流程internal/core/runner/nodejs/nodejs.goNodeJsRunner.RunREQUIRED_FS
Node 参数与引导internal/core/runner/nodejs/nodejs.gobuildCommandArgsbuildBootstrap
Node 引导脚本internal/core/runner/nodejs/prescript.jskoffi.loaddifySeccomp
临时根目录internal/core/runner/temp_dir.goTempDirRunner.WithTempDir
UID 池internal/core/runner/uidpool/uid_pool.goUIDPool.AcquireAcquireUIDensurePasswdEntries
沙箱用户与组internal/static/user.gointernal/core/runner/init.goSANDBOX_USER_UIDSANDBOX_GROUP_IDinit
.so 释放到磁盘internal/core/runner/python/setup.goreleaseLibBinaryLIB_PATH
不塞用户代码的测试internal/core/runner/python/python_test.goTestBuildBootstrapInjectsPreloadButNotUserCode