LLM 生成代码的执行红线:安全沙箱的隔离与能力裁剪

发布时间:2026/7/28 16:59:17

LLM 生成代码的执行红线:安全沙箱的隔离与能力裁剪 LLM 生成代码的执行红线安全沙箱的隔离与能力裁剪一、当 LLM 写出的代码要跑起来不可信执行的信任边界大模型已经能写出可运行的代码。代码解释器、自动修复、在线评测都要真正执行它。但 LLM 的输出其实是不可信文本。它可能写出删库、挖矿、反弹 shell 的片段未必出于恶意。更多时候是模型自作主张地调用了系统命令。把这段代码直接跑在主机上等于把机器交给模型。文件系统全盘可见网络畅通无阻子进程随意拉起。一次 prompt 注入就可能让执行环境彻底失守。安全沙箱要解决的就是把执行与信任解耦。让代码能跑但只能在受限的笼子里跑。本文讨论沙箱的四层隔离机制并给出一个最小可用实现。二、从进程到能力裁剪沙箱隔离的四层防御沙箱不是单一技术而是多层叠加。任何一层被突破还有下层兜底。纵深防御是它的核心思路。第一层是 OS 级隔离。用 namespace 或容器给进程一个独立的文件系统视图与 PID 空间。主机上的/etc/passwd在沙箱里看不见。第二层是系统调用过滤。用 seccomp 限制可调用的 syscall 编号。fork、execve、socket这类高危能力直接禁掉。第三层是资源限制。用 cgroup 限定 CPU、内存、PID 数量、写入字节量。防止 fork 炸弹和死循环把宿主拖垮。第四层是能力裁剪与环境清洗。只读挂载工作目录清空环境变量禁用网络出口。即便代码想外联也发不出包。四层的协作关系如下flowchart TD A[待执行代码] -- B[OS 隔离: namespace/容器] B -- C[syscall 过滤: seccomp 白名单] C -- D[资源限制: cgroup v2] D -- E[能力裁剪: 只读/禁网/清环境] E -- F{执行} F --|越界调用| G[seccomp SIGSYS 终止] F --|超限| H[cgroup OOM/超时 kill] F --|正常| I[采集 stdout/stderr 审计] style G fill:#ffebee style H fill:#ffebee style I fill:#e8f5e9关键设计是默认拒绝。不在白名单的 syscall 一律拦截。不在挂载表的路径一律不可见。不显式放行的网络一律不通。三、基于 cgroup 与子进程的最小沙箱实现下面用 Python 实现一个最小沙箱。它用 cgroup v2 限制资源用 subprocess 隔离进程清洗环境并捕获输出。生产环境会再加 seccomp 与 namespace这里聚焦核心骨架。import os import subprocess import time import shutil from pathlib import Path from typing import Optional class CgroupSandbox: 基于 cgroup v2 的最小沙箱限制 CPU/内存/PID超限即杀。 为什么用 cgroup v2统一层级写入即生效无需 root 切换 cgroup。 前提是 /sys/fs/cgroup 可写且进程有 delegation 权限。 CGROOT Path(/sys/fs/cgroup) def __init__(self, name: str, mem_max: int, cpu_quota: int, pid_max: int, time_limit: float) - None: # 资源上限必须显式传入禁止默认无限避免失控 self.name name self.mem_max mem_max # 内存上限字节 self.cpu_quota cpu_quota # CPU 配额微秒/周期 self.pid_max pid_max # 最大进程数 self.time_limit time_limit # 执行超时秒 self.path self.CGROOT / name def setup(self) - None: 创建 cgroup 目录并写入限制。 为什么每次重建避免上次残留进程占用导致新任务被误杀。 try: if self.path.exists(): # 先迁移残留进程到根 cgroup再删除 self._migrate_to_root() shutil.rmtree(self.path) self.path.mkdir(parentsTrue) # 写入限制文件任一失败都回滚 (self.path / memory.max).write_text(str(self.mem_max)) (self.path / cpu.max).write_text(f{self.cpu_quota} 100000) (self.path / pids.max).write_text(str(self.pid_max)) except PermissionError as e: # 没有 delegation 权限是常见坑必须明确报错而非静默 raise RuntimeError( f无 cgroup 写入权限请检查 delegation: {e} ) from e def _migrate_to_root(self) - None: procs (self.path / cgroup.procs).read_text().split() # 残留进程移回根组否则 rmtree 报设备忙 for pid in procs: try: (self.CGROOT / cgroup.procs).write_text(pid) except OSError: # 进程可能已退出忽略即可 continue def run(self, cmd: list[str], cwd: Path, env_whitelist: Optional[set[str]] None) - dict: 在沙箱内执行命令返回结构化结果。 env_whitelist仅保留白名单环境变量默认只留 PATH。 为什么白名单而非黑名单黑名单容易漏掉新增的敏感变量。 # 清洗环境只保留白名单杜绝密钥与凭证泄露给不可信代码 allowed env_whitelist or {PATH} clean_env { k: v for k, v in os.environ.items() if k in allowed } try: proc subprocess.Popen( cmd, cwdstr(cwd), envclean_env, stdoutsubprocess.PIPE, stderrsubprocess.PIPE, # 子进程立刻加入 cgroup防止启动瞬间逃逸 start_new_sessionTrue, ) except FileNotFoundError as e: return {ok: False, error: f命令不存在: {e}} # 把进程写入 cgroup.procs使限制生效 try: (self.path / cgroup.procs).write_text(str(proc.pid)) except OSError: proc.kill() return {ok: False, error: 加入 cgroup 失败} # 轮询超时避免 wait(timeout) 在被信号打断时行为不一致 deadline time.monotonic() self.time_limit while True: ret proc.poll() if ret is not None: break if time.monotonic() deadline: # 先发 SIGKILL 整组杀再收尸防止 fork 出的子进程残留 os.killpg(os.getpgid(proc.pid), 9) proc.wait() return { ok: False, error: 超时终止, killed: True, } time.sleep(0.05) stdout proc.stdout.read() if proc.stdout else b stderr proc.stderr.read() if proc.stderr else b return { ok: proc.returncode 0, code: proc.returncode, stdout: stdout[:65536].decode(errorsreplace), stderr: stderr[:65536].decode(errorsreplace), } def teardown(self) - None: 清理 cgroup防止资源常驻。 为什么放在 finally执行异常也不能留下 cgroup 残骸。 try: self._migrate_to_root() shutil.rmtree(self.path) except OSError: # 清理失败记录即可不应阻塞主流程 pass if __name__ __main__: box CgroupSandbox( namellm_run_001, mem_max256 * 1024 * 1024, cpu_quota50000, pid_max32, time_limit5.0, ) box.setup() try: result box.run( [python3, -c, print(11)], cwdPath(/tmp), ) print(result) finally: box.teardown()生产系统会在setup后再挂seccomp过滤器。并把工作目录设为只读 bind mount输出走单独的 tmpfs。stdout 截断到 64KB防止日志炸弹撑爆磁盘。四、隔离的代价启动开销、逃逸面与运维复杂度沙箱保命但每一层都有代价。启动开销。cgroup 与 namespace 的创建有毫秒级成本。短任务批量执行时开销会被放大。高频小任务应复用沙箱池而非每次新建。逃逸面始终存在。容器与内核共享宿主。历史上容器逃逸 CVE 不少seccomp 配置错了等于裸奔。沙箱不是保险箱是提高攻击成本。权限依赖。cgroup v2 delegation 在多租户环境难配置。seccomp 需要保留兼容的 syscall 集合配错会误杀正常代码。运维门槛比普通服务高一个量级。审计成本。输出采集看似简单实际要防日志注入。stdout 里塞 ANSI 转义、超长字符串、二进制都会污染审计链。适用边界。沙箱适合短时、无状态、可重跑的代码。长时任务、需持久化的服务、强 IO 场景都不适合塞进沙箱。一个常被忽视的点是输出大小的硬上限。除了 stdout 截断还要对写入文件、网络出口即便放行设字节配额防止不可信代码用海量输出拖垮采集与存储。另一个实践要点是fork 炸弹的双重防护pids.max 限制进程数但仍要在执行前用 setrlimit 限制 RLIMIT_NPROC作为 cgroup 失效时的兜底。最后沙箱宿主本身要隔离部署绝不与生产核心库混部逃逸发生时影响面可控。五、总结LLM 生成代码的执行必须以不可信为前提。沙箱用 namespace、seccomp、cgroup、能力裁剪四层纵深把执行关进笼子。机制上默认拒绝资源上有界输出上可审计。落地路线先上 cgroup 限资源与超时再清洗环境与白名单接 seccomp 收紧 syscall最后容器化隔离部署。代码可以跑但笼子要先焊好。

相关新闻