BLOG · #Engineering

执行模型生成的代码:两层沙箱

让 LLM 写数据绘图代码并执行(Code Interpreter 模式)等于引入 RCE 风险:模型在同一 token 流里处理数据与指令,注入无法在模型层根治。本文拆解一个两层沙箱——执行前 AST 白名单静态拦截,运行时从零环境加隔离子进程兜底——附七类恶意样本的实测拦截结果,以及白名单在 PyInstaller 打包时带来的两个意外收益。

English version: Executing model-generated code: a two-layer sandbox。“LLM 应用工程”系列第 5 篇。

问题:为什么必须执行模型写的代码

自动报告场景里有一类内容绕不开代码执行:实验数据只有原始文件——.mat 扫场数据、csv 能带——而原有的图多是 MATLAB .fig,无法直接嵌入 PDF。LLM 读得懂数据格式,却画不了图:数据到图需要“计算 + 渲染”,这只能靠代码。于是设计与 Code Interpreter 相同:LLM 写一段 matplotlib 代码,后端在子进程里执行,产出 PNG,编译时嵌入报告。

在 agent 侧这是一个工具 make_figure,参数只有两个:{name, code}。agent 在工具循环里读懂数据后调用它;失败会收到报错,可改代码重试;成功后在报告里以 \includegraphics{用图/<name>} 引用。系统提示同时立规矩:有数据没图就画,不许伪造图片。

代价也很直接:这等于在后端执行一段由概率部件生成的任意 Python——一个内置的 RCE 入口。本文拆解为它设计的两层沙箱,以及实测结果。

威胁模型:模型分不清数据与指令

攻击链是清晰的:不可信的数据文件夹 → 文件内容进入提示词 → 提示注入 → LLM 写出恶意代码 → 后端执行。根因在于 LLM 在同一条 token 流里处理数据与指令(in-band signaling),与 SQL 注入(数据混入查询)、缓冲区溢出(数据混入控制流)同构。这类问题在模型层无法根治:对齐训练能降低概率,给不出保证。业界的共识路径因此是转向“假设注入会成功,控制损害”。

工程推论:安全设计不能依赖“模型不会写坏代码”这个假设,必须在代码离开模型之后、产生任何效果之前设关卡。我们设了两层:执行前静态拦截运行时隔离——第一层负责把已知危险模式挡在执行之前,第二层假设第一层会漏,把漏网代码能造成的损害压到最低。

第一层:AST 白名单——执行前静态拦截

ast_check 把模型代码 parse 成 AST(解析失败即以语法错误拒绝),遍历全部节点,任一违规即拒:

  1. import 白名单(17 个模块):只允许 numpy/scipy/matplotlib/pandas/math/csv/json 等数据绘图模块,importfrom ... import 都按顶层包名比对。立场是白名单而非黑名单——判断依据是“绘图任务需要什么”,不是“攻击者会用什么”。
  2. 危险名黑名单(37 个名字,查裸名而不只查导入):os/sys/subprocess/socket/ctypes/importlib/pickle/pathlib/glob……出现即拒。这一条不冗余:执行包装层为实现文件导航预导入了 os,模型代码不写 import os 也能直接调 os.remove——所以必须拦“使用”,不能只拦“导入”。
  3. 危险内置(17 个):eval/exec/compile/__import__/open/getattr/globals……既禁调用也禁裸名引用,e = eval 这类别名同样被拒。
  4. dunder 逃逸:任何 __xx__ 属性访问一律拒绝,堵住 ().__class__.__subclasses__() 这条经典逃逸链:
elif isinstance(node, ast.Attribute):
    if node.attr.startswith("__") and node.attr.endswith("__"):
        return False, f"禁止访问 {node.attr}"

拒绝不是终点。返回给 agent 的报错是教学式的:写明只能用哪些库、用 srcpath('文件名') 取数据、用 plt.savefig(OUT) 保存、禁了什么。agent 拿到这条确定性信号后改写重试——与编译自修复是同一个闭环模式:把确定性检查的输出回注给模型。

第二层:运行时隔离——漏网之后无损可造

第一层是文本层面的静态分析,理论上可能漏。第二层假设已经漏了,把执行环境削到最小:

措施针对的损害
环境从零构建:只带 PATH/MPLBACKEND/MPLCONFIGDIR/HOME/LANG 白名单变量API 密钥、代理配置自动出局,不随环境泄漏
_GUARD 序言注入在用户代码之前:os.system 换成抛 PermissionError 的 lambda、sys.modules['subprocess'] 置 None、socket.socket 禁用静态漏网后的命令执行与外联
隔离 cwd:每次执行 mkdtemp 一个空目录,跑完即删落盘污染宿主目录
python -I:isolated 模式PYTHONPATH / 用户 site-packages 注入
90 秒超时死循环与资源耗尽
源数据目录由 compose 只读挂载(容器部署形态)篡改源数据

从零构建环境有平台代价:Windows 上 CPython 子进程启动需要 SystemRoot 等系统变量(加密 RNG、系统 DLL),matplotlib 需要可写的 MPLCONFIGDIR/TEMP——这些必须显式回填,否则出图在 Windows 分发包上必然失败。

还有一个容易被忽略的配套设计:禁用与替代必须成对出现。白名单禁了 os/glob/pathlib,模型就没法列目录、拼路径——不给替代品,等于逼模型违规。所以包装层注入三个导航原语,全部限定在源数据目录 SRC 之内:srcpath('相对名') 取路径、listdir(sub='') 列目录、walkfiles() 递归列出全部相对文件名;工具说明里同步写清约束与用法,让模型首次就写出合规代码。失败时也只回传 stderr 尾部 1500 字符——报错同样是 token。

flowchart TD A["agent 调用 make_figure {name, code}"] --> B{"第一层:ast_check 静态白名单"} B -->|拒绝| E["教学式报错:写明可用与禁用项"] E --> F["agent 改写代码重试"] F --> B B -->|通过| W["包装:预导入 np/plt/loadmat,注入 SRC/OUT/srcpath/listdir/walkfiles + _GUARD"] W --> R["第二层:隔离子进程执行
从零环境 · 隔离 cwd · python -I · 90s 超时"] R -->|"OUT 存在且非空"| P["PNG 入会话图目录,编译时嵌入"] R -->|失败或超时| E

实测:七类恶意样本全部执行前拦截

容器内实跑验证。正当路径两条通过:基本 matplotlib 绘图、经 srcpath 读真实数据绘图。恶意样本七类,全部在执行前被 AST 层拒绝:

恶意样本命中规则
os.system 执行命令危险名黑名单(os 为预导入,拦裸名而非 import)
os.remove 删文件(不 import,直接用预导入的 os)同上
import socket 外联import 白名单
eval 动态执行危险内置
__import__ 绕过 import 语句危险内置
dunder 逃逸(__class__/__subclasses__dunder 属性拦截
open 读 /etc/passwd危险内置

第二层在这组样本里没有被触达——这正是预期:兜底层的价值在未知样本,不在测试集。

两个意外收益:安全约束反哺打包

单机分发时后端用 PyInstaller 冻结为二进制。两处与沙箱的交互原本可能是深坑,白名单反而把它们变浅了。

**收益一:可 import 集有限已知,collect_all 精确堵坑。**PyInstaller 靠静态分析收集依赖,而沙箱里的绘图代码是运行时才存在的字符串——它 import 什么,打包器看不见。若可 import 集无界,这个问题无解;但 AST 白名单恰好把它限定为有限已知集合,对 numpy/scipy/matplotlib 等整体 collect_all 即可收入。Windows 实测:冻结产物 onedir 约 260MB(exe 30MB),selftest 结果 imports 10/10、savefig 18250 字节——科学栈在冻结包内完全可用。

**收益二:冻结态 sys.executable 的自我分流。**开发态的执行命令是 [sys.executable, "-I", 脚本];冻结后 sys.executable 是本二进制而非 python,直接 exe -I script 会被当成服务启动而失败。解法:run_plot 检测到 is_compiled() 后改为设置环境变量 PDFAGENT_PYRUN=<脚本路径>、再起一个本 exe;入口 run_server._pyrun() 发现该变量即用 runpy.run_path 执行脚本并退出,不启动服务。进程隔离、清洗环境、超时、AST 白名单、_GUARD 全部保留,仅少了 -I 标志。这条分流由 test_pyrun.py 的两个用例(有变量则执行脚本、无变量则空转)钉死。

适用边界

  1. **这不是完整沙箱。**无 microVM、无容器级隔离,子进程与宿主同内核、同文件系统权限——AST 被绕过且 _GUARD 被规避的组合攻击在理论上存在。项目对此的定位写在模块 docstring 里:面向本地单用户场景(执行的是用户本机自己数据上的绘图代码,等同于用户手动跑脚本),两层已足够;若输入是“不可信文件夹/多用户”,根治要上“一次性无网络容器执行”。
  2. **白名单牺牲表达力。**getattr/open 被禁;h5py 等不在白名单,遇到 HDF5 类数据需要显式扩充白名单,并重新评估新库的能力面(能否发网络请求、写文件)。白名单每宽一格,第一层的保证就弱一分。
  3. **域限定是前提。**此方案可行的根本原因是任务域窄:“数据绘图”所需的模块集小且稳定。若要执行的是通用代码——agent 自由写任意工具脚本——白名单会宽到失去意义,应直接上容器/microVM 级隔离,AST 层最多做启发式预检。
  4. **第二层与平台细节耦合。**从零构建环境意味着平台的隐式依赖要自己踩全:Windows 的系统变量、matplotlib 的可写配置目录。跨平台分发的项目要为此准备实测验证,而不是推理验证。