跳到主要内容

巧妙之处、配置与边界局限

30 秒导读: 这是 Dify Sandbox 系列的收尾章。前面几章讲了"一段不可信代码怎么被关进笼子"(02)、"seccomp 与 chroot 两道锁"(03)、"沙箱文件系统怎么搭"(04)。本章不重复流程,只做两件事:提炼那些不显眼但值得抄走的取巧手法,再诚实地划出这套方案的配置面和刻意的边界

读这一章前,建议先扫一眼 index.md 的全景图,知道"控制器 → 运行器 → 子进程"这条主线;本章讲的都是这条线上的细节。


1. 巧妙之处(逐条:先白话,再 file:line)

每条都是这个套路:先用大白话说"妙在哪",再给出真实源码位置让你按图索骥。这几条是本章希望你带走的精华。

1.1 用 fd3 管道把代码"喂"进子进程,而不是写文件

白话: 一个天真的做法是——把用户代码写到磁盘上的某个 .py,再让解释器去执行它。但这有两个麻烦:一是落盘,多个并发请求容易在同名/权限上打架;二是这份代码明晃晃躺在文件系统里,chroot 后的进程理论上还能读回自己。

Dify Sandbox 的取巧:用户代码根本不写文件,而是通过一个额外的文件描述符(fd 3)用管道直接灌进子进程。

父进程开一个 os.Pipe(),把读端塞进子进程的 ExtraFiles——在 Go 里,ExtraFiles[0] 恰好对应子进程的 fd 3(fd 0/1/2 是 stdin/stdout/stderr):

// internal/core/runner/python/python.go:50-80 PythonRunner.Run
codeReader, codeWriter, err := os.Pipe()
...
cmd.ExtraFiles = []*os.File{codeReader} // 读端 → 子进程 fd 3

父进程另起一个 goroutine 把代码字符串写进管道的写端,写完即关:

// internal/core/runner/python/python.go:112-115 Run 内
go func() {
_, _ = io.WriteString(codeWriter, code)
codeWriter.Close()
}()

子进程侧,prescript 里用 os.fdopen(3) 直接从 fd 3 读回代码,当场编译执行:

# internal/core/runner/python/prescript.py:32-35
with os.fdopen(3, "rb") as code_fd:
code = code_fd.read().decode("utf-8")
exec(compile(code, "<fd3>", "exec"))

Node.js 侧一模一样,fs.readFileSync(3, 'utf8')(internal/core/runner/nodejs/prescript.js:16)。

妙在: 用户代码全程只活在内核管道缓冲区里,从不落盘,避免了同盘并发写的竞争,也让 chroot 后的进程无从读回自己的源码。落盘的只有那份引导脚本(bootstrap,即 prescript 的模板产物),它不含用户代码,只含"装 seccomp + 读 fd3 + exec"的骨架。

注意区分:引导脚本本身确实写到了 LIB_PATH/tmp/<uuid>.py(python.go:170-175),并 chown 给本次的沙箱 UID;真正不落盘的是用户代码

1.2 seccomp 装在 native Go 库里,由解释器 ctypes/koffi 加载

白话: seccomp 过滤器最终是一段 BPF 字节码,得通过 seccomp(2) 系统调用装进内核。问题是:装过滤器这件事必须发生在解释器进程内、且在跑用户代码之前——可解释器是 Python/Node,不是 Go。怎么让 Python 进程去执行一段 Go 写的"上锁"逻辑?

取巧:把上锁逻辑编译成一个 c-shared 的 native 库(python.so / nodejs.so),让解释器用 FFI 把它当普通动态库加载进来,调用里面导出的 DifySeccomp 函数。

Go 侧用 //export 导出一个 C ABI 函数:

// 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)
}
}

Python 侧用 ctypes.CDLL.so 加载进自己的地址空间,声明签名后直接调用:

# internal/core/runner/python/prescript.py:16-17, 29
lib = ctypes.CDLL("./python.so")
lib.DifySeccomp.argtypes = [ctypes.c_uint32, ctypes.c_uint32, ctypes.c_bool]
...
lib.DifySeccomp({{uid}}, {{gid}}, {{enable_network}})

Node.js 侧用 koffi(一个 Node 的 FFI 库)做同样的事(internal/core/runner/nodejs/prescript.js:4-13)。

妙在两点:

  1. 锁在解释器进程内落地。 FFI 调用不 fork、不换进程,DifySeccomp 就在当前 Python/Node 进程里执行,装的过滤器立刻对这个进程(及其后续线程,靠 TSYNC)生效。
  2. 复用 libseccomp 而不是手写 BPF。 Go 侧用 github.com/seccomp/libseccomp-golang 构造规则、导出 BPF,自己不手搓那堆 BPF_JMP/BPF_STMT 指令。见 internal/core/lib/seccomp.go:16sg.NewFilter(...)ctx.ExportBPF(file)。真正下 seccomp(2) 系统调用时才落到裸 syscall(见下一条)。

这里有个额外的小手法:seccomp.go 把 libseccomp 导出的 BPF 先写进一个管道,再从管道读回字节、组装成 SockFprog,最后自己发 SYS_SECCOMP 系统调用装载:

// internal/core/lib/seccomp.go:36-63
file := os.NewFile(uintptr(writer.Fd()), "pipe")
ctx.ExportBPF(file) // 把 BPF 吐进管道
... // 从管道读回,binary.Read 成 SockFilter 数组
_, _, err2 := syscall.Syscall(SYS_SECCOMP, uintptr(SeccompSetModeFilter),
uintptr(SeccompFilterFlagTSYNC), uintptr(unsafe.Pointer(&bpf)))

用管道当"内存中转"避免了写临时文件,和 1.1 是同一种"拿管道当零落盘的字节搬运工"的思路。

1.3 ActKillProcess 默认拒绝 + 精确白名单:fail-closed 姿态

白话: 安全过滤器有两种基调。fail-open:默认放行,列个黑名单堵已知的坏调用——一旦漏了一个就被穿。fail-closed:默认杀进程,只精确放行一个白名单——漏了顶多是"某个正常功能不能用",绝不会"某个危险调用被放过"。Dify Sandbox 选后者。

过滤器的默认动作就是 ActKillProcess——命中不在白名单里的任何系统调用,内核直接干掉整个进程:

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

然后逐条 AddRule(..., ActAllow) 把白名单里的调用放行;另有一小撮用 ActErrno(返回错误码而非杀进程,给那些"调了也没关系、返回失败即可"的调用留活路):

// internal/core/lib/seccomp.go:28-34
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)
}

白名单本身是硬编码的一张短表,按用途分组(文件 IO / 线程 / 内存 / 用户组 / 进程 / 时间…),网络相关的单独一组、只有 enable_network 时才追加:

// internal/static/python_syscall/syscalls_amd64.go:13-53
var ALLOW_SYSCALLS = []int{ /* file io, thread, memory, ... */ }
var ALLOW_ERROR_SYSCALLS = []int{ syscall.SYS_CLONE, syscall.SYS_MKDIRAT, syscall.SYS_MKDIR }
var ALLOW_NETWORK_SYSCALLS = []int{ syscall.SYS_SOCKET, syscall.SYS_CONNECT, ... }

组装白名单的逻辑在 InitSeccomp(internal/core/lib/python/add_seccomp.go:29-49):默认取 ALLOW_SYSCALLS,联网时再并上 ALLOW_NETWORK_SYSCALLS

妙在: 白名单短小可审计——你能一眼扫完"到底放了哪些"。像 SYS_CLONE(创建线程/进程)这种双刃调用,被刻意放进 ALLOW_ERROR_SYSCALLSActErrno 处理:程序调它不会被杀,但会拿到"操作不允许"的错误——既不误杀,又不真放行 fork 出逃逸子进程。

1.4 GODEBUG 关掉 Go runtime 善后 syscall,再在用户代码前 pop 掉

白话: 这是个很细的时序坑。装 seccomp 的那个 DifySeccomp 是 Go 编译出来的 native 库,它跑起来时会拖着一个 Go runtime。Go runtime 会在后台做一些"善后"系统调用(内存管理、maxprocs 探测之类)。麻烦在于:这些调用可能发生在 seccomp 过滤器已经装上之后——那它们就会撞上白名单外的 syscall,导致整个进程被 ActKillProcess 冤杀。

取巧分两步:

第一步——先关掉 Go runtime 那些爱惹事的行为。 父进程给子进程设 GODEBUG 环境变量,关掉几个会发额外 syscall 的 runtime 特性:

// internal/core/runner/python/python.go:72-78 Run 里设置 cmd.Env
cmd.Env = []string{
"GODEBUG=decoratemappings=0,containermaxprocs=0,updatemaxprocs=0",
}

第二步——装完锁、进用户代码前,把 GODEBUG 从环境里删掉。 prescript 里,DifySeccomp 调用返回(seccomp 已装、Go runtime 该善后的都善后完了)后,立刻 popGODEBUG,免得它污染到后面要跑的用户代码环境:

# internal/core/runner/python/prescript.py:29-30
lib.DifySeccomp({{uid}}, {{gid}}, {{enable_network}})
os.environ.pop("GODEBUG", None)

Node.js 侧同样 delete process.env.GODEBUG(internal/core/runner/nodejs/prescript.js:14)。

妙在: 这一关一删,精确卡在"seccomp 已生效 / 用户代码尚未运行"的窄窗口里——既让 Go runtime 在上锁瞬间不因善后 syscall 自杀,又不把一个 Go 专用的调试开关泄露给用户代码去观察。

1.5 预填 /etc/passwd,绕过 getpwuid 触发的被封 syscall

白话: 每次执行都会用 setuid 把进程降权到一个临时沙箱 UID(10000–10999 区间,见 02)。但 Python/解释器在收尾时,有时会去查"当前 UID 对应哪个用户名"(getpwuid)。这个查询在底层可能触发一些被 seccomp 白名单挡掉的 syscall——结果是进程在收尾阶段被冤杀,或报出莫名其妙的错误。

正面解法(放开那些 syscall)会削弱白名单。Dify Sandbox 的取巧是从数据侧绕过:进程池初始化时,一次性把 10000–10999 每个 UID 都预先写进 /etc/passwd,让 getpwuid 能在本地文件里直接查到答案,根本不必走那些危险 syscall:

// internal/core/runner/uidpool/uid_pool.go:57-78
func AcquireUID(ctx context.Context) (int, error) {
globalPoolOnce.Do(func() {
globalPool = NewUIDPool(10000, 11000)
ensurePasswdEntries(10000, 11000) // 只在池首次创建时跑一次
})
return globalPool.Acquire(ctx)
}

// ensurePasswdEntries 往 /etc/passwd 追加沙箱 UID 条目
func ensurePasswdEntries(min, max int) {
f, _ := os.OpenFile("/etc/passwd", os.O_APPEND|os.O_WRONLY, 0644)
...
for i := min; i < max; i++ {
fmt.Fprintf(f, "sandbox%d:x:%d:0::/nonexistent:/usr/sbin/nologin\n", i, i)
}
}

每条形如 sandbox10000:x:10000:0::/nonexistent:/usr/sbin/nologin——一个不存在家目录、shell 是 nologin 的假账户,纯粹为了让 getpwuid(10000) 有东西可查。

妙在: 与其为了迁就一个 libc 收尾查询而放宽内核过滤器,不如在文件系统层面把答案预先摆好。这是典型的"改数据比改策略更安全"——白名单一寸不让,问题在更外层化解。UID 池本身也是这套设计的一部分:一个带缓冲 channel 的池(uid_pool.go:20-30),Acquire/Release 保证并发请求各拿各的独立 UID,互不串号。


2. 配置面(能调什么、优先级、被废弃的)

2.1 配置从哪来:YAML + env 覆盖

配置的结构体是 DifySandboxGlobalConfigurations(internal/types/config.go:3),字段涵盖端口、worker 数、超时、解释器路径、是否联网、代理、允许透传的环境变量等。

加载顺序在 InitConfig(internal/static/config.go:16):

┌─────────────────────┐
│ 1. 读 conf/config.yaml │ 基础默认值(app.port, max_workers, python_path...)
└──────────┬──────────┘
│ yaml.Unmarshal

┌─────────────────────┐
│ 2. 环境变量逐项覆盖 │ DEBUG / MAX_WORKERS / SANDBOX_PORT / PYTHON_PATH /
│ (env 优先级更高) │ ENABLE_NETWORK / ALLOWED_SYSCALLS / ALLOWED_ENV_VARS...
└──────────┬──────────┘
│ 若字段仍为空

┌─────────────────────┐
│ 3. 兜底默认值 │ python_path→/opt/python/bin/python3, nodejs→/usr/local/bin/node,
│ │ deps 更新间隔→30m
└─────────────────────┘

优先级一句话:env > YAML > 硬编码兜底。 例如端口:YAML 里 app.port: 8194(conf/config.yaml:2),但若设了 SANDBOX_PORT 环境变量就以它为准(config.go:53-56)。这对容器化部署很友好——镜像里放一份 YAML,运行时用环境变量按部署环境微调。

常用配置项:

配置项YAML 键env 覆盖说明
监听端口app.portSANDBOX_PORTHTTP 服务端口,默认 8194
API Keyapp.keyAPI_KEY请求鉴权用
并发 workermax_workersMAX_WORKERS01 的并发控制
单次超时worker_timeoutWORKER_TIMEOUT秒,默认 5
Python 解释器python_pathPYTHON_PATH沙箱据此发现 stdlib/site 路径
是否联网enable_networkENABLE_NETWORK决定是否追加网络 syscall 白名单
额外放行 syscallallowed_syscallsALLOWED_SYSCALLS覆盖式(见下)
透传环境变量名allowed_env_varsALLOWED_ENV_VARS白名单式透传宿主环境变量到用户代码

关于 allowed_syscalls:它不是"追加",而是覆盖。一旦非空,InitSeccomp只用你给的这份 + 一个 SYS_SETGROUPS,完全不再拼接内置的 ALLOW_SYSCALLS/网络组(见 add_seccomp.go:33-49if allowed_syscall != "" 分支)。所以 config.yaml:12 才特意注释"没搞懂 seccomp 就别填"。

2.2 被废弃的 python_lib_path / PYTHON_LIB_PATH

早期版本允许用户手工指定"要拷进沙箱的 Python 库路径"。现在这被刻意废弃并忽略——库路径改为在启动时python_path 自动发现(跑一次解释器问它自己的 sys.path,见 04python_lib_discovery.godiscoverPythonLibPaths)。

结构体里这个字段被标成"内部派生、不由用户配":

// internal/types/config.go:14-16
// PythonLibPaths is internal-only. InitConfig derives it from PythonPath at
// startup, and legacy python_lib_path / PYTHON_LIB_PATH user inputs are
// intentionally ignored.
PythonLibPaths []string `yaml:"-"`

若你仍在 YAML 或环境里设了旧键,加载时会打一条 Warn 提醒它被忽略:

// internal/static/config.go:34-36 (YAML 里的 python_lib_path)
if _, ok := rawConfig["python_lib_path"]; ok {
slog.Warn("python_lib_path is deprecated and ignored; ...discovered from python_path")
}
// internal/static/config.go:77-79 (环境变量 PYTHON_LIB_PATH)
if os.Getenv("PYTHON_LIB_PATH") != "" {
slog.Warn("PYTHON_LIB_PATH is deprecated and ignored; ...discovered from python_path")
}

取舍: 把"要拷哪些库"从用户手里收回、改为程序自动发现,减少了配错路径导致的 xxx.so: cannot open shared object file 类问题(FAQ 第 1 节),代价是灵活性——想加库只能装进 python_path 指向的那个 Python 环境,不能再随手指个外部目录。


3. 边界与局限(诚实)

安全沙箱最该讲清楚的是"它到底护不住什么"。以下都基于源码,不粉饰。

3.1 只支持 Linux,且强依赖 libseccomp

README 开宗明义:只支持 Linux,因为它就是为 docker 容器设计的,依赖 libseccomp(README.md:7-11)。所有上锁逻辑的文件都带 //go:build linux 约束——seccomp.go:1add_seccomp.go:1set_no_new_privs.go:1 皆是。在非 Linux 上这些代码根本不编译,也就没有沙箱可言。

3.2 seccomp 白名单需按需手工维护

白名单是硬编码的一张表。新装一个库(比如 numpy)若用到表外的 syscall,就会被 KillProcess,需要人工把缺的调用补进 syscalls_amd64.go

项目为此提供了一个半自动挖掘工具 cmd/test/syscall_dig(FAQ 第 2 节详述):它的思路很直接——穷举 0–499 号 syscall,逐个从白名单里剔掉再跑你的 test.py,哪个一剔掉就"bad system call"报错,哪个就是必需的:

// cmd/test/syscall_dig/main.go:38-49 main 循环
for i := 0; i < SYSCALL_NUMS; i++ {
syscall := list[0]; list = list[1:]
err := run(list) // 用剩下的白名单跑一次
if err != nil && strings.Contains(err.Error(), "bad system call") {
list = append(list, syscall) // 剔掉它就崩 → 说明它必需,加回
}
}

跑完打印出"这段代码到底需要哪些 syscall",你再和内置表比对、把差集补进去。这仍是人工闭环:工具帮你找出清单,但编辑白名单、重新 build 得自己来(FAQ 第 2 节步骤 4–5)。这是 fail-closed 的必然代价——安全换来了维护成本。

3.3 网络隔离靠 syscall 白名单,而非网络命名空间

不用 network namespace 做网络隔离,而是用 seccomp 白名单:不联网时,ALLOW_NETWORK_SYSCALLS(socket/connect/bind…)压根不加进白名单,用户代码一调 socket() 就被杀。

反过来,一旦 enable_network 打开,网络组被整体放行(add_seccomp.go:46-48),用户代码就能自由发起连接——控制权转交给外部代理:运行器会把 HTTP_PROXY/HTTPS_PROXY/SOCKS5 等环境变量注入子进程(python.go:82-96)。也就是说,联网模式下的出站管控靠的是代理层,而不是内核隔离。config.yaml 直接标注了这点的风险:enable_network: True # please make sure there is no network risk in your environment(conf/config.yaml:9)。

3.4 是 eval/exec 执行 + 进程级隔离,不是 VM/容器级

两个层面的局限要分开看:

其一,执行方式是直接 exec/eval 用户代码是被 exec(compile(code, ...))(Python,prescript.py:35)/ eval(code)(Node,prescript.js:17)在解释器进程内当场执行的,不是跑在独立虚拟机或语言级沙箱里。防护完全依赖它外面套的那几层——chroot 换根、seccomp 过滤、setuid 降权、SetNoNewPrivs 禁提权(add_seccomp.go:18-71)。

其二,隔离粒度是进程级,不是 VM/容器级。 它靠 chroot + seccomp + 降权把单个进程关进笼子,但同一台宿主上所有沙箱进程共享同一个内核。对比:

方案隔离粒度内核共享相对开销逃逸面
Dify Sandbox进程级(chroot+seccomp+setuid)是,共享宿主内核极低(直接起进程)内核漏洞、白名单疏漏
gVisor / 用户态内核进程级 + 拦截式用户态内核部分(自建 syscall 层)用户态内核自身 bug
容器(namespace+cgroup)容器级内核漏洞、配置疏漏
microVM(Firecracker 等)VM 级否,各自独立内核较高(起 VM)虚拟化层漏洞(更小)

弱点面小结:

  • 共享内核 = 内核 0-day 是天花板。 seccomp 缩小了攻击面(能碰的 syscall 少了),但没消除它;白名单内某个 syscall 若有内核漏洞,仍可能被利用。
  • 白名单疏漏即穿。 fail-closed 让"漏放"倾向于"功能不可用"而非"防线被穿",但如果为了兼容某库过度放宽白名单(尤其 allowed_syscalls 覆盖模式),等于自己开口子。
  • eval 本身无语言级沙箱。 若外面几层锁中任意一层配置失效(比如某环境上 seccomp 没装成),eval 就是在裸解释器里跑任意代码。
  • 进程级隔离下的旁路。 共享内核意味着侧信道、资源耗尽(白名单允许 mmap/线程相关调用)等进程级手段仍在射程内,超时(worker_timeout)是主要的资源兜底而非硬隔离。

一句话:Dify Sandbox 用极低开销换来了"够用"的隔离——适合"多租户跑短小、半可信代码片段"的场景(正是 Dify 工作流里的 code 节点);但它不声称能抵御有内核漏洞储备的定向攻击者,那是 microVM 的领域。


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

主题文件路径符号名
fd3 传代码 / 组装子进程internal/core/runner/python/python.go:29PythonRunner.Run(cmd.ExtraFiles、写端 goroutine)
子进程侧读 fd3 并 execinternal/core/runner/python/prescript.py:32os.fdopen(3) + exec(compile(...))
Node 侧读 fd3 并 evalinternal/core/runner/nodejs/prescript.js:16fs.readFileSync(3, 'utf8') + eval
导出给 FFI 的上锁入口cmd/lib/python/main.go:9DifySeccomp(//export)
组装白名单 + chroot + 降权internal/core/lib/python/add_seccomp.go:17InitSeccomp
fail-closed 过滤器构造internal/core/lib/seccomp.go:15Seccomp(ActKillProcessExportBPFSYS_SECCOMP)
白名单表(amd64)internal/static/python_syscall/syscalls_amd64.go:13ALLOW_SYSCALLS / ALLOW_ERROR_SYSCALLS / ALLOW_NETWORK_SYSCALLS
GODEBUG 设置internal/core/runner/python/python.go:72cmd.Env(GODEBUG=...)
GODEBUG popinternal/core/runner/python/prescript.py:30os.environ.pop("GODEBUG")
UID 池 + 预填 passwdinternal/core/runner/uidpool/uid_pool.go:57AcquireUID / ensurePasswdEntries
配置结构internal/types/config.go:3DifySandboxGlobalConfigurations
配置加载 + env 覆盖 + 废弃告警internal/static/config.go:16InitConfig
默认配置样例conf/config.yaml:1enable_network / allowed_syscalls 注释
syscall 挖掘工具cmd/test/syscall_dig/main.go:31main(穷举剔除法)

上一章: 04-fs-and-multilang.md(沙箱文件系统的搭建与多语言支持) · 回到: index.md