


























最近在做一个简单的 Agent 产品,这个产品的定位是个人自部署,所以初期并没有做太高的安全隔离设计(也没有使用现成的 Sandbox)。但临近发布,我不得不考虑一个问题 —— 如果 AI 搞坏了事情,那会怎么样?
之前的一切都在一个 Docker 容器里,理论上 rm -rf / 也不会对宿主机造成太大的影响,但却可能导致一个错误的命令导致整个服务所依赖的文件系统被清空,这在我看来是不可接受的。我可以接受不去考虑 AI 刻意作恶,但也希望当 AI 犯错时不会对平台造成影响(这也是为什么我的 Agent 服务从第一个版本开始就做成了所有产出的版本管理、可回滚)。我的方案 —— 依然不去考虑使用沙盒做安全,但是隔离开平台和 Agent 的环境 —— Agent 单向连接平台后端,即使把自己的环境搞挂了也完全不会影响到平台 —— Agent 可以当作是 Stateless 的服务,随时可以删掉容器再建。
不过,虽然我没有考虑安全,但是 Codex Review 代码时提出了一个我从未想过的问题 —— 环境变量泄露。比较有意思,记录一下。
其实回想了一下,这个问题似乎曾经引起过一个非常严重的问题(虽然我没搜到),大概就是某个 AI 服务提供商在环境变量中暴露了 AI API 可公网访问的接口凭证……
(以下内容由 AI 主笔,我做一些审核与改写)
现在很多产品都在做同一件事:给大模型一套工具(tools),让它像工程师一样干活。其中最强大、也最危险的一个工具,几乎总是叫 run_command —— 允许模型生成一段 shell 命令并在服务器上执行。
一个典型的部署形态是这样的:
run_command、读写文件、跑 git 等等。DATABASE_URL、XXX_TOKEN、各种 provider 的 API key 全部从子进程环境里剔除,只留 PATH、HOME、LANG 这些无害变量。看起来很稳妥。模型就算被 prompt injection 骗着去跑 env,也只能看到一堆没用的变量。
但这道防线有一个致命的盲区。
很多人对环境变量有一个直觉上的误解,以为”我没把变量传给子进程,子进程就看不到”。
在 Linux 上并非如此。每个进程的完整环境变量,都以明文形式躺在 /proc/<pid>/environ 这个文件里。而这个文件的可读性,取决于一条简单的规则:
你能不能读
/proc/<pid>/environ,取决于你的 UID 和目标进程的 UID 是否匹配(更准确地说,取决于 ptrace 访问权限)。同一个 UID 的进程之间,互相的 environ 是可读的。
于是攻击路径就非常直白了。假设 Runner 主进程的 PID 是 1(在容器里主进程通常就是 1),那么模型只要生成这么一条命令:
1 | cat /proc/1/environ | tr '\0' '\n' | grep TOKEN |
你精心设计的 env 白名单瞬间失效。子进程虽然自己的环境是干净的,但它和主进程同属一个 UID,直接去读主进程的 environ 就行了。
密钥一旦泄露,后果往往不止”少了一个 token”:
run_command 就撬开了整个执行集群的信任边界。这类问题的隐蔽之处在于:代码 review 时一切都看着很安全。env 白名单写得一丝不苟,密钥也确实没进子进程环境。漏洞不在你写的那几行代码里,而在于 Linux 进程模型本身的一个默认行为。
不用真搭一套系统,一个容器就能复现。下面这段直接在 node:24-slim 里跑:
1 | docker run --rm -e SECRET_TOKEN=super-secret node:24-slim bash -c ' |
输出会把 SECRET_TOKEN=super-secret 打印两次 —— 子进程读主进程的密钥,畅通无阻。
现在换成”降权到一个独立用户”再读一次:
1 | docker run --rm -e SECRET_TOKEN=super-secret node:24-slim bash -c ' |
这次会看到:
1 | cat: /proc/1/environ: Permission denied |
同一个文件,仅仅因为读取者的 UID 变了,内核就直接拒绝。这就是我们要的边界。
核心思路一句话:Agent 执行的每一个子进程,都不能和持有密钥的主进程共享 UID。
主进程(Runner)继续以较高权限运行(它需要能降权、能管理工作目录),但凡是模型可控的子进程,都降权到一个专门的、非特权的”沙箱用户”。在 Linux 上,setpriv 是最直接的工具:
1 | setpriv --reuid=sandbox --regid=sandbox --clear-groups --no-new-privs -- <command> |
各个参数的意义:
-reuid / -regid:把真实(和有效)uid/gid 切到沙箱用户;-clear-groups:清掉附加组,避免通过组权限绕过;-no-new-privs:设置 PR_SET_NO_NEW_PRIVS,从此这个进程及其后代再也无法通过 setuid 程序提权,堵死”跑一个 setuid 二进制爬回去”的路子。在容器镜像里预先建好这个用户即可:
1 | RUN useradd --system --user-group --no-create-home sandbox |
降权之后,模型再怎么 cat /proc/1/environ,拿到的都是 Permission denied。密钥安全地留在主进程的环境里,对沙箱用户不可见。
提示:
setpriv需要主进程有CAP_SETUID/CAP_SETGID(通常意味着以 root 起步)。如果你的运行环境里主进程本身就不是 root、或者拿不到这些 capability,就要用别的隔离手段(见下文”没有条件降权时怎么办”)。
修好了 shell,还有一个更隐蔽的口子:那些内容能被 Agent 修改、之后又被主进程执行的文件。
最经典的例子是 git。假设你的服务里,Agent 在一个工作目录里改代码、提交,而主进程(Runner,仍持有密钥)之后会在同一个目录里跑 git 命令(比如打包、检查状态)。问题在于:.git/config 是 Agent 能写的,而 git 的配置里藏着好几个”执行任意命令”的开关:
core.fsmonitor —— 指向一个会被自动执行的程序;core.pager / core.editor;filter.*、diff.*、hooks……也就是说,Agent 只要往 .git/config 写一行:
1 | [core] |
然后等主进程(root)自己来这个目录跑一次 git status,这段命令就会以主进程的 UID 执行 —— 密钥又漏了。
所以隔离的边界必须画在”信任等级”上,而不是”是不是 shell”上:
任何以 Agent 可控内容为输入、或在 Agent 可写目录中运行的子进程,都要降权。 不只是
run_command,也包括主进程自己对这些目录发起的git、构建工具等调用。
对 git 这类命令,除了降权,还建议顺手收紧它的环境(只给 PATH/HOME/LANG,并设 GIT_TERMINAL_PROMPT=0 防止配置里塞的远程 helper 弹出交互),做到既不泄露主进程 env、又不被配置牵着走。
真正落地时,UID 隔离会牵出几个”体感很小但不处理就会炸”的细节:
1. 文件属主与权限。
主进程(root)写出来的文件,默认属主是 root、权限 0644/0700。降权后的子进程去读写这些文件时可能没权限;反过来沙箱用户写的文件 root 也要能处理。实践中的做法:
umask 0(或 0002),让新建文件默认带上 group/other 的读写;chown -R sandbox:sandbox 一把。2. git 的 “dubious ownership”。
新版 git 有个安全检查:如果仓库目录的属主和当前运行 git 的用户不一致,会直接报 detected dubious ownership 拒绝执行。所以要让”创建仓库的 UID”和”之后操作仓库的 UID”一致 —— 比如让降权后的 git 自己去 git init <dir> / git clone <dir>,而不是由 root 先 mkdir 再交给沙箱用户。
3. 临时目录。mktemp -d 建出来的目录默认是 0700 且属主是调用者。如果降权后的进程要往里写东西,记得放宽权限或直接用沙箱用户来创建。
4. 没有条件降权时怎么办。
不是所有环境都能 setpriv(比如主进程不是 root、或者是非 Linux 平台)。这时要有一个明确的策略,而不是”悄悄地不隔离”:
ALLOW_UNSANDBOXED=true),逼运维明确地为这个风险签字。在 macOS 上没有 /proc,这条具体的攻击路径不成立,但那里通常用 sandbox-exec(seatbelt)做另一套”文件读取 deny-list”防护 —— 思路是一样的:针对不同平台,为 Agent 子进程套上该平台最合适的隔离层。
这个案例最值得记住的,不是 /proc/environ 这个具体的洞,而是它背后的心智模型:
一旦模型能生成并执行命令,它产出的一切都应当被当作”来自互联网的不可信输入”来对待。 Prompt injection 让攻击者可以间接控制 Agent 的行为,所以”Agent 会不会主动作恶”根本不是重点 —— 只要它_能_执行任意命令,你就必须假设它_会_执行最坏的那条。
在这个假设下,很多”看起来够用”的防御都会露馅:
/proc/<pid>/environ;而真正稳的边界,都建立在操作系统级别的隔离原语上:独立的 UID、能力(capabilities)裁剪、no_new_privs、必要时叠加 seccomp / namespace。 它们不依赖”我记得在每个 spawn 点都过滤了变量”这种脆弱的纪律,而是让内核来强制执行”沙箱用户看不到、也够不着”。
前面用的是”UID 降权”。很自然会想到另一条路:Linux namespace。既然密钥的泄露依赖”子进程能通过 /proc/<runner-pid>/environ 寻址到主进程”,那我把子进程扔进一个独立的 PID namespace,让它根本看不见主进程,不就行了?
思路成立,而且比降权更彻底 —— 但有一个必须踩对的关键点,和一个绕不开的代价。
/proc只开 PID namespace 是不够的。因为 /proc 是一个文件系统,如果你进了新的 PID namespace、却仍然沿用旧的 /proc 挂载,那么旧 /proc 里宿主的所有进程条目照样在,/proc/1/environ 照样能读。必须再开一个 mount namespace、把 /proc 重新挂一遍,让它只反映新的 PID namespace。
实测对比(--privileged 容器里,主进程 PID1 持有 RUNNER_SECRET,子进程已剥掉该变量):
1 |
|
重挂之后,主进程在子进程眼里根本没有可寻址的 /proc 条目 —— 这比 setpriv 的”看得见但读不了”更进一步:直接”看不见、够不着”,连 ptrace 之类的同 UID 攻击也一并没了目标。而且 namespace 还能顺手做更多:叠一个 mount namespace 把敏感目录直接从视野里 unmount,或者叠一个 network namespace 让 Agent 连控制面都够不到。
问题出在”建 namespace”这个动作本身:创建 PID / mount namespace 需要 CAP_SYS_ADMIN。而一个加固过的容器默认是不给这个能力的。实测:
1 |
|
只有 --privileged(或显式 --cap-add SYS_ADMIN 并放宽 seccomp/AppArmor)才能完整跑通。这就很讽刺了:为了”把 Agent 关进沙箱”,反而要给它所在的容器开 --privileged —— 那等于为了关一扇窗把整面墙拆了,净攻击面不降反升。走 user namespace 绕开 root 要求也不是免费午餐:unprivileged user namespace 历史上是内核提权 CVE 的高发区,不少加固宿主干脆禁用了它。
对比之下,UID 降权只需要 CAP_SETUID / CAP_SETGID(容器里以 root 起步就够,不需要 privileged),简单、不额外扩大攻击面 —— 这也是本文主线选它的原因。
两种做法本质是同一条隔离谱系上的不同强度,可以叠加:
setpriv**) —— 成本最低的稳健解,专门堵死 environ 读取;权限要求低,几乎哪都能用。unshare) —— 更彻底,但要么容器 -privileged / 加 CAP_SYS_ADMIN,要么依赖宿主允许 unprivileged userns。想用别自己手搓 flag:bubblewrap / nsjail 就是 unshare + seccomp + cap 裁剪的成品封装,比裸调用少踩坑。unshare。一句话:unshare 能解决,且机制更强;但在”已经加固过的容器里再嵌套一层”这个最常见的场景下,它的权限门槛往往让它不划算。先用 UID 降权拿到底线,需要更强隔离时直接上专门的沙箱工具或独立容器,而不是给主容器放开 **CAP_SYS_ADMIN**。 另外别忘了:无论 unshare 还是 setpriv,都不会自动堵上第五节那条”Agent 写文件、主进程后来执行”的路径 —— 除非主进程自己跑 git 之类命令时也套上同样的隔离。
如果你正在做一个能执行代码的 AI Agent,照着过一遍:
run_command(shell)经 setpriv --reuid --regid --clear-groups --no-new-privs 降权执行。git)同样降权,别只盯着 shell。umask / 属主 / 临时目录权限,别让隔离把正常流程搞崩。cat /proc/1/environ)在容器里实测一遍,确认密钥确实读不到。安全不是”我过滤了敏感变量”,而是”就算对手拿到了 shell,他也够不着密钥”。把边界画在 UID 上,你才真正有了这条底线。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。