跳到主要内容

沙箱核心:一段不可信代码如何被关进笼子

30 秒导读: 这一章讲 Dify Sandbox 的心脏——一段用户提交的 Python 代码,从"被 fork 成一个解释器子进程",到"被 seccomp 系统调用过滤器彻底锁死",完整的一条时序线。最关键的一个设计:先把笼子锁上,再把代码放进去——子进程先装好过滤器,再从一个文件描述符里读代码来执行。

本章聚焦"单个子进程的生与死"。它的上游(HTTP 请求怎么进来、怎么排队)见 01-request-lifecycle;两道锁本身的机制细节(seccomp 白名单里到底放了哪些系统调用、chroot 怎么换根)见 03-seccomp-chroot;沙箱里的文件系统怎么搭、多语言怎么支持见 04-fs-and-multilang


1. 这是什么(先建直觉)

1.1 要解决的问题

Dify Sandbox 的工作是:替别人跑他不信任的代码。用户在 Dify 工作流里写一段 Python,服务端得真的把它跑起来、拿到结果——但绝不能让这段代码读走别的用户的数据、删服务器的文件、或者往外网乱发请求。

这是个经典难题:既要执行它,又要不相信它

1.2 核心思路:关进笼子,而不是审查

Dify Sandbox 不去"审查代码有没有恶意"(那永远审不干净),而是换一个思路:

让代码在一个什么坏事都做不成的环境里跑。

具体来说,跑用户代码的那个进程被剥夺了几乎所有能力:

  • 只能调用一份白名单里的系统调用(system call,进程向内核请求服务的唯一入口),白名单外的一律当场杀掉进程——这是 seccomp
  • chroot 换了根目录、降成一个没有权限的普通用户,看不到真实文件系统。
  • 默认连不了网(除非显式开启)。

一句话类比:普通进程像住在有门有窗的房子里,能出门、能打电话;沙箱进程像被关进一间只有一个投食口的密室——大部分与外界打交道的动作(system call)都被堵死了。

1.3 最巧的一步:先锁笼再喂食

天真的做法是:把用户代码写成一个 .py 文件,然后 python 用户代码.py。问题是——解释器一启动就在跑用户代码了,笼子还没锁上。

Dify 的做法反过来:

  1. 启动的不是用户代码,而是一段引导脚本(bootstrap)。
  2. 引导脚本先把 seccomp 笼子锁死。
  3. 锁死之后,才从一个文件描述符里把用户代码读进来 exec 执行。

用户代码从被执行的第一条指令起,就已经在笼子里了。这条时序是本章的主角,后面反复出现。


2. 顶层全景(一个子进程的一生)

主角是两个进程:

  • 父进程 = runner(Go 写的 PythonRunner),负责搭台子、fork 子进程、喂代码、收输出。
  • 子进程 = 沙箱(一个 Python 解释器),它先跑引导脚本给自己上锁,再跑用户代码。

2.1 部件一句话职责

部件干什么在哪
PythonRunner.Run父进程主流程:领 UID、写引导脚本、开管道、起子进程internal/core/runner/python/python.go:29
buildBootstrap{{uid}} 等占位符填进引导脚本模板internal/core/runner/python/python.go:129
prescript.py引导脚本模板本身:子进程里给自己上锁、读代码执行internal/core/runner/python/prescript.py
DifySeccompc-shared 库导出的函数,子进程调它来装笼子cmd/lib/python/main.go:9
releaseLibBinary把编译进二进制的 python.so 落盘到磁盘internal/core/runner/python/setup.go:35

2.2 主线走一遍(高层,不进代码)

怎么读这张图:从上到下是时间顺序;左列是父进程 runner 的动作,右列是子进程沙箱的动作;中间的横线 fork 是分叉点。注意 seccomp 上锁在读用户代码之前。

父进程 (runner / Go) 子进程 (sandbox / Python)
───────────────────────── ──────────────────────────
① 领一个 UID (10000~10999)
② 写引导脚本到磁盘 (填模板)
③ os.Pipe():建一根管道
把 codeReader 挂到 fd3
④ exec.Command(python, 引导脚本)

│ fork + exec
└──────────────────────────▶ ⑤ 启动,跑引导脚本 prescript
⑥ ctypes 加载 python.so
⑦ os.chdir(沙箱根目录)
⑧ 执行 preload (预置代码)
⑨ 从管道另一端写入 ─────┐ ⑨ lib.DifySeccomp(...) 上锁 🔒
用户代码 │ (chroot + seccomp + 降权)
│ ⑩ pop 掉 GODEBUG 环境变量
└──────────▶ ⑪ 从 fd3 读用户代码
⑫ exec(compile(code)) ← 跑用户代码
⑬ 收集 stdout/stderr
⑭ 子进程退出 → 归还 UID、删脚本

关键在 ⑨:上锁(DifySeccomp)排在读代码(⑪)之前。父进程虽然在 ④ 之后就往管道写代码了,但子进程要到把自己锁死之后才去读——写和读被管道解耦,时序由子进程掌握。


3. 核心原理(逐个拆,由浅入深)

3.1 父进程:runner 如何 fork 出一个解释器

先看父进程 PythonRunner.Run(internal/core/runner/python/python.go:29)。它做的事按顺序是:

① 领一个沙箱 UID。

uid, err := AcquireUID(ctx)

python.go:39。UID 从一个容量固定的池子里取(范围 10000~11000,见 internal/core/runner/uidpool/uid_pool.go:59)。池子用一个带缓冲的 channel 实现,取不到就阻塞等待——这天然给并发执行数量封了顶。每个并发子进程用一个独立的低权限 UID 跑,彼此之间也隔离。用完在退出钩子里归还(python.go:63ReleaseUID(uid))。

② 写引导脚本到磁盘。

bootstrapPath, err := p.InitializeEnvironment(preload, options, uid)

python.go:44InitializeEnvironment(python.go:160)先确认 python.so 在位(不在就落盘,见 §3.5),再调 buildBootstrap 把模板填好,写到 /var/sandbox/sandbox-python/tmp/<uuid>.py,并 chown 给刚领的那个 UID(python.go:179)——这样低权限子进程才读得了自己的引导脚本。

③ 用一根管道把用户代码送进去,而不是写文件。

codeReader, codeWriter, err := os.Pipe()

python.go:50。这是一个关键设计:用户代码不落盘。父进程持 codeWriter,把 codeReader 通过 cmd.ExtraFiles 交给子进程:

cmd.ExtraFiles = []*os.File{codeReader}

python.go:80ExtraFiles 里的第一个文件,在子进程里就是文件描述符 3(fd3)——fd0/1/2 是标准输入/输出/错误,额外文件从 3 开始编号。所以子进程读 fd3 就是读用户代码。

代码在一个独立 goroutine 里写入,写完即关:

go func() {
_, _ = io.WriteString(codeWriter, code)
codeWriter.Close()
}()

python.go:112。为什么要用 goroutine?因为管道有缓冲上限,代码大时 WriteString 会阻塞直到子进程开始读;放进 goroutine 就不会卡住父进程的主流程。

为什么不写成文件? 落盘的用户代码是一份需要清理、可能被别的进程窥见、也可能被恶意代码自我改写的资产。走管道 + fd3,代码只存在于内核管道缓冲区里,进程一死就没了,更干净也更安全。

④ 起子进程,并精心构造它的环境。

cmd := exec.Command(
configuration.PythonPath,
bootstrapPath,
LIB_PATH,
)

python.go:67。注意子进程执行的是 bootstrapPath(引导脚本),不是用户代码;LIB_PATH(/var/sandbox/sandbox-python)作为 argv[1] 传进去,引导脚本会 chdir 过去(见 §3.3)。同时 cmd.Dir = LIB_PATH(python.go:79),让子进程能用相对路径 ./python.so 找到那个 c-shared 库。

环境变量是从一张白纸开始,只塞进明确允许的几样:

cmd.Env = []string{
"GODEBUG=decoratemappings=0,containermaxprocs=0,updatemaxprocs=0",
}

python.go:72。默认清空所有环境变量,只加这一条 GODEBUG(作用见 §3.2)。之后按需追加:

追加的环境变量来源代码位置
HTTPS_PROXY / HTTP_PROXY / NO_PROXY配置里的代理设置python.go:82-96
配置白名单里的透传变量AllowedEnvVars,只透传真实存在的python.go:98-102
ALLOWED_SYSCALLS配置里自定义的 syscall 白名单,逗号拼接python.go:104-110

ALLOWED_SYSCALLS 这条尤其要留意:它把"允许哪些系统调用"这个决定,从 Go 侧通过环境变量传给子进程,子进程装 seccomp 时会读它(internal/core/lib/python/add_seccomp.go:33)。白名单默认值和它的含义留给 03

最后收集输出、等子进程退出,退出钩子里关管道、删引导脚本、归还 UID(python.go:59-64)。

3.2 GODEBUG:关掉 Go 运行时的"善后动作"

这条 GODEBUG 值得单独说,因为它不显然。

子进程是个 Python 解释器,但它加载了一个 Go 写的 c-shared 库(python.so)来装 seccomp。Go 有自己的运行时(runtime),会在后台做一些内存映射装饰、探测 CPU 核数之类的"善后"系统调用。这些 syscall 一旦在 seccomp 锁死之后发生,就会撞上白名单被杀。

"GODEBUG=decoratemappings=0,containermaxprocs=0,updatemaxprocs=0",

python.go:77。这三个开关分别关掉映射装饰、容器最大进程数探测、动态调整最大进程数——都是可能触发额外 syscall 的 Go runtime 行为。代码注释(python.go:73-76)也点明了这个意图:装 seccomp 的是 Go c-shared 库,得压住 Go runtime 在过滤器生效后自己乱发 syscall。

而且这个 GODEBUG 只在上锁那一刻之前需要;上锁之后引导脚本会主动把它从环境里删掉(见 §3.3 的第 ⑩ 步),避免它泄漏给用户代码或影响后续行为。

3.3 子进程:prescript.py 里的上锁时序

现在进到子进程。它跑的引导脚本模板是 prescript.py(internal/core/runner/python/prescript.py),整段只有 30 多行,却是全项目最该逐行读懂的地方。按执行顺序:

加载 c-shared 库。

lib = ctypes.CDLL("./python.so")
lib.DifySeccomp.argtypes = [ctypes.c_uint32, ctypes.c_uint32, ctypes.c_bool]

prescript.py:16。用 ctypes 加载同目录下的 python.so(所以父进程要把 cmd.Dir 设成 LIB_PATH),并声明导出函数 DifySeccomp 的签名:接 (uid, gid, enable_network)。这个 python.so 是 Go 编出来的,由来见 §3.4/§3.5。

切到沙箱根目录。

running_path = sys.argv[1]
if not running_path:
exit(-1)
os.chdir(running_path)

prescript.py:21-25sys.argv[1] 就是父进程传进来的 LIB_PATH。之所以要先 chdir 到这里,是因为随后 seccomp 步骤里会执行 chroot(".")(把当前目录变成新根,见 03);当前目录得先站对位置。

执行 preload(预置代码)。

{{preload}}

prescript.py:27。这是模板占位符,buildBootstrap 会把调用方传入的 preload 字符串填进来(python.go:152)。preload 在上锁之前运行,常用来提前 import 好用户可能用到的库——因为很多 import 背后要做的 syscall(打开 .so、mmap)在上锁后就被禁了,必须赶在锁上之前完成。

上锁——本章的核心一行。

lib.DifySeccomp({{uid}}, {{gid}}, {{enable_network}})

prescript.py:29。调进 Go 的 DifySeccomp,它内部依次做:chroot 换根 → SetNoNewPrivs → 装 seccomp 过滤器 → setgroups/setgid/setuid 降权(全在 internal/core/lib/python/add_seccomp.go:17,机制细节见 03)。{{uid}}/{{gid}}/{{enable_network}} 三个占位符由 buildBootstrap 填(python.go:130-150);{{gid}} 取自 SANDBOX_GROUP_ID,当前是 0(internal/static/user.go:6)。这一行返回之后,当前进程就被关进笼子了。

扔掉 GODEBUG。

os.environ.pop("GODEBUG", None)

prescript.py:30。上锁完成,§3.2 说的那个环境变量使命结束,主动删掉,不让它影响接下来的用户代码。

最后,才读用户代码并执行。

with os.fdopen(3, "rb") as code_fd:
code = code_fd.read().decode("utf-8")

exec(compile(code, "<fd3>", "exec"))

prescript.py:32-35os.fdopen(3, ...) 打开的正是父进程挂上来的 fd3——里面是通过管道流进来的用户代码。读出来、compileexec

为什么读代码这一步非得放在 DifySeccomp 之后? 这是整条时序的题眼:

如果先读、先跑用户代码,再上锁,那么"读代码到上锁"之间存在一个没有笼子的窗口——恶意代码可能在这个窗口里就已经把坏事做完了。把 exec 放在上锁之后,意味着用户代码执行的每一条指令都发生在 seccomp 生效之后,窗口为零。先锁笼,后喂食。

3.4 为什么用 Go 的 c-shared 库(python.so 的由来)

有个自然的疑问:装 seccomp 的逻辑,为什么不直接用 Python 写,非要绕一个 Go 编的 .so?

因为 seccomp 过滤器、chrootsetuid 这些是底层系统调用,Dify Sandbox 的这套逻辑用 Go 实现(internal/core/lib/)。为了让 Python 解释器能调用这段 Go 代码,项目把它编译成一个 C 兼容的共享库,再用 Python 的 ctypes 加载。

Go 侧的入口极简:

import "C"

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

cmd/lib/python/main.go:6-13。两个关键点:

  • import "C" + //export DifySeccomp 是 cgo 的导出语法。它让 DifySeccomp 这个 Go 函数在编出的共享库里,以一个 C ABI 可调用的符号存在——ctypes.CDLL(...).DifySeccomp 找的就是它。
  • func main() {} 是空的(main.go:15):这个包不是要跑成程序,而是要被编成库。

编译命令用 -buildmode=c-shared 把它产出成 python.so:

CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build \
-o internal/core/runner/python/python.so \
-buildmode=c-shared -ldflags="-s -w" cmd/lib/python/main.go

build/build_amd64.sh:6(arm64 版在 build/build_arm64.sh:6)。这就是 python.so 的出生地。

3.5 python.so 怎么到子进程手上(embed + 落盘)

python.so 编出来后,并不是运行时去磁盘找现成文件,而是被编译进 runner 的 Go 二进制里:

//go:embed python.so
var python_lib []byte

internal/core/runner/python/setup.go:23//go:embed 指令把编译期的 python.so 内容嵌进 python_lib 这个字节切片。这样发布时只有一个二进制,库跟着走。

进程启动时(setup.go:31init()),再把这段字节写回磁盘到固定路径,供子进程加载:

err = os.WriteFile(path.Join(LIB_PATH, LIB_NAME), python_lib, 0755)

setup.go:64(和 :52),LIB_PATH = /var/sandbox/sandbox-pythonLIB_NAME = python.so(setup.go:27-28)。落盘逻辑在 releaseLibBinary(setup.go:35)里:老库在就按需删掉重写,不在就建目录写入。

于是闭环合上了:Go 源码 → 编成 python.so → embed 进主二进制 → 启动时 releaseLibBinary 落盘到 /var/sandbox/sandbox-python/python.so → 子进程 prescript.py 用 ctypes 从 ./python.so 加载它。


4. 一段示意伪流程(帮助建立直觉)

下面这段是示意,非源码,把父子两个进程的核心时序抽成最简形式,帮你在读真实代码前先有一张心智图。

# 示意,非源码 —— 父进程 runner 在做的事
def run(user_code, preload):
uid = acquire_uid() # ① 领一个低权限 UID
bootstrap = fill_template(prescript, # ② 填模板:uid/gid/网络开关/preload
uid=uid, preload=preload)
write_file(bootstrap_path, bootstrap) # 引导脚本落盘,chown 给 uid

reader, writer = os.pipe() # ③ 开一根管道
child = spawn( # ④ 起子进程:跑的是引导脚本,不是用户代码
cmd=[python, bootstrap_path, LIB_PATH],
env={"GODEBUG": "...关掉善后syscall..."}, # 环境从空白开始,只加允许的
extra_files=[reader], # reader 在子进程里 = fd3
)
spawn_thread(lambda: writer.write(user_code)) # ⑨ 另开线程把代码写进管道
return collect_output(child) # ⑬ 收 stdout/stderr,等退出
# 示意,非源码 —— 子进程引导脚本在做的事 (对应 prescript.py)
lib = load("./python.so") # ⑥ 加载 Go 编的 c-shared 库
os.chdir(LIB_PATH) # ⑦ 站到沙箱根目录

run(preload) # ⑧ 上锁前:预置 import 等

lib.DifySeccomp(uid, gid, net) # ⑨ 🔒 上锁:chroot + seccomp + 降权
del os.environ["GODEBUG"] # ⑩ 使命完成,扔掉

code = os.fdopen(3).read() # ⑪ 上锁后:才从 fd3 读用户代码
exec(compile(code)) # ⑫ 在笼子里跑用户代码

重点看两处:一是用户代码走 fd3(管道)进来、从不落盘;二是第 ⑨ 行的上锁严格排在第 ⑪ 行读代码之前——这是"先锁笼后喂食"的全部秘密。


5. 巧妙之处(带走的精华)

  • 先上锁再读代码,把无保护窗口压到零。 exec(compile(code))(prescript.py:35)排在 lib.DifySeccomp(...)(prescript.py:29)之后,用户代码没有任何一条指令能在笼子外执行。

  • 用户代码走 fd3 管道,不落盘。 os.Pipe() + cmd.ExtraFiles(python.go:50python.go:80)让代码只存在于内核管道里,进程一死即消失,免去清理与自我改写风险。

  • 环境从空白起,GODEBUG 只为压住 Go runtime 的善后 syscall。 cmd.Env = []string{...}(python.go:72)默认清空;那条 GODEBUG(python.go:77)专治 c-shared 库带来的 Go runtime 在过滤器生效后乱发 syscall,用完即由子进程 pop 掉(prescript.py:30)。

  • preload 抢在上锁前 import。 占位符 {{preload}}(prescript.py:27)让需要触发 open/mmapimport 提前完成,绕开"上锁后禁这些 syscall"的限制。

  • seccomp 逻辑用 Go 写、编成 c-shared 库给 Python 用。 //export DifySeccomp + -buildmode=c-shared(cmd/lib/python/main.go:8build/build_amd64.sh:6),再 //go:embed 进主二进制、启动时落盘(setup.go:23setup.go:64),一个二进制自带全部依赖。

  • UID 池给并发封顶且互相隔离。 带缓冲 channel 的 UID 池(uid_pool.go:20-40)领不到就阻塞,天然限流;每个子进程用独立低权限 UID 跑。


6. 边界与局限

  • 强依赖 Linux。 chrootseccompsetuid、fd 传递都是 Linux 语义;add_seccomp.gosetup.go 都带 //go:build linux。非 Linux 上这套锁根本装不起来。

  • preload 与上锁前代码不在笼子里。 {{preload}}(prescript.py:27)在 DifySeccomp 之前执行,它是受信任的、由平台注入的代码,不是用户代码。如果 preload 内容被污染,那是笼子外的执行——所以 preload 的来源必须可信。

  • DifySeccomp 内部失败会 panic。 Go 侧 DifySeccomp 出错直接 panic(cmd/lib/python/main.go:11),意味着上锁失败时进程崩溃而非"降级放行"——这是故意的(宁可不跑,也不在没锁上的情况下跑用户代码)。

  • 白名单/换根的具体机制不在本章。 seccomp 到底放行哪些 syscall、chroot 后的文件系统长什么样,本章只讲到"调用 DifySeccomp 这一步";细节见 03-seccomp-chroot04-fs-and-multilang


7. 代码地图(导航索引)

主题文件路径符号名
父进程主流程internal/core/runner/python/python.goPythonRunner.Run
填引导脚本模板internal/core/runner/python/python.gobuildBootstrap
写脚本 / chown / 落库internal/core/runner/python/python.goPythonRunner.InitializeEnvironment
子进程引导脚本(上锁时序)internal/core/runner/python/prescript.pyDifySeccomp 调用点 / os.fdopen(3)
c-shared 库导出入口cmd/lib/python/main.goDifySeccomp
seccomp/chroot/降权实现internal/core/lib/python/add_seccomp.goInitSeccomp
python.so embed 与落盘internal/core/runner/python/setup.gopython_lib (//go:embed) / releaseLibBinary
编译 c-shared 库build/build_amd64.sh-buildmode=c-shared 那行
UID 池(并发限流 + 隔离)internal/core/runner/uidpool/uid_pool.goUIDPool / AcquireUID / ensurePasswdEntries
沙箱组 ID 常量internal/static/user.goSANDBOX_GROUP_ID