跳到主要内容

数据截至 (上游 commit 97c8097d51d0)

05 · 巧妙之处、边界与横向对比

这章讲什么: 前四章讲「它怎么工作」,这章讲「哪些值得抄」和「它在哪会崩」。做技术选型的话,直接看第 2 节和第 3 节。


1. 六个值得带走的技巧

技巧一:让被关的进程自己上锁

妙在哪: 不需要特权 runtime、不需要 namespace 配置、不需要额外的 supervisor 进程。父进程只是普通地 exec 了一个解释器,隔离完全由子进程自己在运行时完成。

代价是上锁前有一段窗口期——解释器已经在跑,但还没上锁。项目把这段窗口压到最短:窗口里只有「加载 .so」和「chdir」两个动作(internal/core/runner/python/prescript.py:16-25),用户代码要到上锁之后才被读进来。

实现载体是 -buildmode=c-shared(build/build_amd64.sh:6-8),导出一个函数 DifySeccomp(cmd/lib/python/main.go:8-13)——同一份 Go 逻辑,Python 用 ctypes 调、Node 用 koffi 调,两个语言零重复实现

技巧二:用户代码走文件描述符,永不进模板

妙在哪: 从根上消灭模板注入。用户代码要是被拼进引导脚本,任何引号、缩进、占位符字面量的意外都可能改变引导逻辑,甚至跳过上锁那一行。

实现:cmd.ExtraFiles = []*os.File{codeReader} 让管道读端成为子进程的 fd 3(internal/core/runner/python/python.go:80),脚本里 os.fdopen(3, "rb") 读回来(prescript.py:32-35)。写入放在单独 goroutine 里避免大代码写阻塞(python.go:112-115)。

两侧各有一个单元测试守着「bootstrap 里不许出现用户代码」(internal/core/runner/python/python_test.go:10)。

技巧三:危险但常见的调用,降级成静默空操作

妙在哪: 把「安全上要拒绝」和「体验上不该炸」分开。clone/clone3/mkdirActErrno 而不是 ActKillProcess(internal/static/python_syscall/syscalls_amd64.go:47-52 + internal/core/lib/seccomp.go:32-34),于是 os.fork() 返回 0、脚本继续跑,但进程并没有真的分裂(tests/integration_tests/python_malicious_test.go:12-29)。

只有真正的越狱动作(execve)才触发杀招。

技巧四:扩展点是「合并」而不是「替换」

妙在哪: 运维要加一个 syscall 时,不必也不可能重写整张白名单。ALLOWED_SYSCALLS 环境变量的值被 MergeSyscalls 并进内置列表(internal/core/lib/python/add_seccomp.go:30),去重保序。

更狠的是紧接着那一行(add_seccomp.go:31):强制把 SYS_SETGROUPS 并进去。哪怕有人误删了它,降权也不会把自己锁死。 这是「关键路径自我保护」的好例子。

对称的反面教材:AllowedEnvVars 是纯白名单,没列名的环境变量一个都传不进子进程(python.go:98-102),测试成对验证(tests/integration_tests/nodejs_feature_test.go:181-215)。能力用合并,数据用白名单。

技巧五:汇报工具必须先自证清白

妙在哪: 库路径发现脚本用 json.dumps 输出结果,所以代码反过来强制校验:json 模块必须来自 stdlib 的规范位置(internal/static/python_lib_discovery.go:223-232361-370),并且探测进程要用 -P 加清空 10 个 PYTHON* 环境变量来跑(python_lib_discovery.go:174333-345)。

这是一个很少见但很值得学的思路:当你依赖某个探测结果做安全决策时,先假设探测通道本身会被污染。

技巧六:把内核信号翻译成人话

妙在哪: 进程被 SIGSYS 打死时,系统给的是「bad system call」,用户看不懂。项目在 wait 的返回状态里做字符串匹配,翻译成 operation not permitted(internal/core/runner/output_capture.go:212-214),再由 buildExecutionError 拼上退出码和 stderr 全文(internal/service/run_code.go:65-76)。

配套的还有 /etc/passwd 预填 1000 条记录那一手(internal/core/runner/uidpool/uid_pool.go:67-78)——为了让被关起来的进程能优雅退场而不是在收尾阶段被打死。这类补丁不体面,但是真实沙箱工程的常态。


2. 边界与局限(诚实版)

2.1 它刻意不做的事

不做说明
资源限额源码里没有任何 cgroup、setrlimit、内存/CPU 上限的调用。唯一的约束是超时 SIGKILL
持久化沙箱里写的东西随进程消失;没有卷、没有 session
多租户一把静态 API Key(internal/middleware/auth.go:11),没有租户维度的配额或审计
跨平台核心文件全带 //go:build linux,macOS/Windows 上跑不起来
交互式执行一次请求一段代码跑到底,没有 REPL、没有 stdin(Runstdin []byte 参数被调用方传 nil,internal/service/python.go:29)

资源限额这一条影响最大: while True: pass 会烧满一个核直到超时;x = " " * (10**10) 可能把容器内存吃光。max_workers: 4 只限制并发数,不限制单个进程的胃口。部署时必须靠外层容器的 cgroup 兜底。

2.2 preload 是一个完整的逃逸口

这是最需要点名的一条。看引导脚本的行序(prescript.py:27-29):

{{preload}} # 27 行

lib.DifySeccomp({{uid}}, {{gid}}, {{enable_network}}) # 29 行

preload 在上锁之前执行——此时进程还是 root、还没 chroot、seccomp 还没装。也就是说,能控制 preload 内容的人,可以在容器里以 root 身份做任意事。Node 侧同理(internal/core/runner/nodejs/nodejs.go:164-171,preload 被拼在 prescript 前面)。

项目自己知道这点,所以:

  • 配置默认 enable_preload: False,并写了注释「please keep it as False for security purposes」(conf/config.yaml:10)。
  • 关闭时 service 层直接把 preload 清空(internal/service/python.go:19-21)。

结论:ENABLE_PRELOAD=true 等价于把执行接口变成远程 root 执行。 只有在调用方 100% 可信、且 preload 内容由服务端而非终端用户提供时才能开。

2.3 共享内核 = 内核漏洞就是逃逸口

seccomp + chroot + setuid 都是同一个内核里的隔离手段。沙箱进程和宿主服务共享内核。

所以只要白名单里放行的某个系统调用存在内核漏洞,理论上就能提权逃逸。白名单裁得越小,攻击面越小——这也是为什么项目宁可用 syscall_dig 跑 500 遍进程去求最小集(cmd/test/syscall_dig/main.go)。

和 microVM 类方案(见下面的对比表)相比,这是架构级的差距,不是实现质量能弥补的。

2.4 Node 路径的全局 chdir

WithTempDir 用的是 os.Chdir(internal/core/runner/temp_dir.go:54)——这是进程级的全局状态,不是 goroutine 级的

默认 max_workers: 4 意味着最多 4 个请求并发。两个 Node 请求同时跑时,后一个的 os.Chdir 会改掉整个 Go 进程的 cwd,而前一个子进程的 cwd 是在 exec 时快照的——已经起来的进程不受影响,但在「chdir 完成」和「子进程 exec」之间的窗口里,谁的根被用是不确定的 (inferred:源码没有任何互斥保护,这是从 os.Chdir 的进程级语义推出的)。

另外这个 chdir 从不还原,而那个临时目录随后会被 os.RemoveAll 删掉,于是服务进程的 cwd 停在一个已删除的目录上 (inferred)。Python 路径没有这个问题,因为它用 cmd.Dir 显式指定子进程目录(internal/core/runner/python/python.go:79)。

2.5 其它已知粗糙处

问题位置说明
BPF 缓冲区写死 4096 字节internal/core/lib/seccomp.go:40白名单继续增长可能静默截断,没有长度校验
Python 沙箱根全局共享internal/core/runner/python/setup.go:26-29并发执行看得见彼此的 tmp/ 目录,只靠 0600 + 各自 UID 隔开
UID 池写死 1000 个internal/core/runner/uidpool/uid_pool.go:59不可配置;超出 max_workers 很多,实际不会用满
Node 借不到 UID 报 -500internal/service/nodejs.go:28-30Python 报 -429,两条路径不对称
/etc/passwd 每次启动追加 1000 行uid_pool.go:67-78只追加不去重(容器每次都是新的,实践中无碍)
鉴权 key 明文比较internal/middleware/auth.go:11非常数时间比较;内网场景影响有限
seccomp 规则不看参数internal/core/lib/seccomp.go:29放行 openat 就是放行所有 openat,路径限制全靠 chroot

2.6 它在哪些场景下会「不工作」

  • 代码需要白名单外的 syscall。 典型是某些 C 扩展。表现为 operation not permitted,解法见 FAQ.md 第 2 节。
  • 代码需要子进程。 subprocessmultiprocessingchild_process 一律不可用,而且这是设计意图。
  • 代码需要写文件并期望它还在。 沙箱根是临时的/共享的,写入没有任何持久化保证。
  • 执行超过 worker_timeout 默认 5 秒(conf/config.yaml:7),SIGKILL 无法挽救。

3. 横向对比:同一个 shelf 里的其它沙箱

所有这些项目都在回答「怎么安全地跑 AI 生成的代码」,但隔离层次差了好几个数量级。

项目隔离手段隔离单位内核状态起步开销
dify-sandboxseccomp + chroot + setuid一次请求一个进程与宿主共享无状态毫秒级
e2b-infraFirecracker microVM一台 VM独立可暂停/快照恢复秒级(靠快照压到亚秒)
microsandboxlibkrun microVM一台 microVM独立长驻会话~100ms
beta9runc / gVisor 容器容器共享或半共享CRIU 检查点亚秒级(冷启动优化)
aio-sandbox一个 Docker 容器装全套工具容器共享有状态共享 FS容器级
k8s-agent-sandboxKubernetes Pod + CRDPod取决于 runtime持久卷Pod 调度级

怎么读这张表

dify-sandbox 站在「最轻、最快、隔离最弱」这一端。 它的定位非常明确:嵌在 Dify 主服务旁边,跑几秒钟的短代码片段,追求的是每次执行几乎零开销。它没打算和 Firecracker 比隔离强度。

三条选型判断:

  • 代码来自你自己的用户、且外层已有容器/K8s 兜底 → dify-sandbox 这类进程级方案性价比最高。
  • 代码来自公网匿名用户、要跑几分钟到几小时、要装任意包 → 必须上 microVM(e2b-infra、microsandbox),共享内核的风险不能接受。
  • 要长时会话、要文件持久、要浏览器/终端等一整套工具 → aio-sandbox、k8s-agent-sandbox 这类「给 agent 一台电脑」的方案。

一个可以单独抄走的点

即使你最终选了 microVM,dify-sandbox 的 seccomp 白名单和 syscall_dig 工具仍然值得抄——在 VM 里再叠一层 seccomp 是纵深防御的标准做法,而「怎么求出一段代码的最小 syscall 集」这个问题,它给了一个可运行的答案。


4. 全库代码地图

按「从外到内」排序。想读源码的话,从上往下读一遍就是一次完整的请求旅程。

主题文件路径符号名
入口进程入口cmd/server/main.gomain
入口启动编排internal/server/server.goRuninitConfiginitServerinitDependencies
配置配置装载与环境变量覆盖internal/static/config.goInitConfigGetDifySandboxGlobalConfigurations
配置配置结构internal/types/config.goDifySandboxGlobalConfigurations
HTTP路由internal/controller/router.goSetupInitRunRouter
HTTP鉴权internal/middleware/auth.goAuth
HTTP两道并发闸门internal/middleware/cocrrent.goMaxRequestMaxWorker
HTTP链路追踪internal/middleware/trace.goTraceMiddleware
HTTP请求绑定与分发internal/controller/base.gorun.goBindRequestRunSandboxController
service网络许可校验internal/service/check.gocheckOptions
servicePython / Node 执行internal/service/python.gonodejs.goRunPython3CodeRunNodeJsCode
service输出聚合与错误拼装internal/service/run_code.gocollectRunCodeResponsebuildExecutionError
runnerPython 编排internal/core/runner/python/python.goPythonRunner.RunbuildBootstrap
runnerNode 编排internal/core/runner/nodejs/nodejs.goNodeJsRunner.RunREQUIRED_FS
runner引导脚本模板python/prescript.pynodejs/prescript.jsDifySeccompos.fdopen(3, "rb")koffi.load
runner临时根目录internal/core/runner/temp_dir.goTempDirRunner.WithTempDir
runnerUID 池internal/core/runner/uidpool/uid_pool.goAcquireUIDReleaseUIDensurePasswdEntries
runner输出捕获与超时internal/core/runner/output_capture.goCaptureOutput
runner沙箱用户创建internal/core/runner/init.goinit
安全四步自锁internal/core/lib/python/add_seccomp.goInitSeccomp
安全BPF 构建与装载internal/core/lib/seccomp.goSeccomp
安全no_new_privsinternal/core/lib/set_no_new_privs.goSetNoNewPrivs
安全白名单合并internal/core/lib/syscalls.goMergeSyscallsSyscallsFromEnv
安全syscall 白名单internal/static/python_syscall/nodejs_syscall/ALLOW_SYSCALLSALLOW_ERROR_SYSCALLSALLOW_NETWORK_SYSCALLS
安全c-shared 导出cmd/lib/python/main.gocmd/lib/nodejs/main.goDifySeccomp
文件系统库路径发现internal/static/python_lib_discovery.godiscoverPythonLibPathsbuildPythonLibPaths
文件系统硬链接搬运internal/core/runner/python/env.goenv.shPreparePythonDependenciesEnvcopy_and_link
文件系统依赖安装与登记internal/core/runner/python/setup.goInstallDependenciesExtractOnelineDepency
工具最小 syscall 集挖掘cmd/test/syscall_dig/main.gomain
工具镜像内初始化cmd/dependencies/init.gomain
构建c-shared + 主程序build/build_amd64.shbuild_arm64.sh
构建Dockerfile 生成docker/generate.shdocker/versions.yaml
测试越狱行为tests/integration_tests/python_malicious_test.goTestSysForkTestExecTestReadEtcPasswd
测试输出协议tests/integration_tests/python_feature_test.goTestPythonLoggingStaysInStderrTestPythonFd3Transport