Agent 安全:环境变量泄露
最近在做一个简单的 Agent 产品,这个产品的定位是个人自部署,所以初期并没有做太高的安全隔离设计(也没有使用现成的 Sandbox)。但临近发布,我不得不考虑一个问题 —— 如果 AI 搞坏了事情,那会怎么样?
之前的一切都在一个 Docker 容器里,理论上 rm -rf / 也不会对宿主机造成太大的影响,但却可能导致一个错误的命令导致整个服务所依赖的文件系统被清空,这在我看来是不可接受的。我可以接受不去考虑 AI 刻意作恶,但也希望当 AI 犯错时不会对平台造成影响(这也是为什么我的 Agent 服务从第一个版本开始就做成了所有产出的版本管理、可回滚)。我的方案 —— 依然不去考虑使用沙盒做安全,但是隔离开平台和 Agent 的环境 —— Agent 单向连接平台后端,即使把自己的环境搞挂了也完全不会影响到平台 —— Agent 可以当作是 Stateless 的服务,随时可以删掉容器再建。
不过,虽然我没有考虑安全,但是 Codex Review 代码时提出了一个我从未想过的问题 —— 环境变量泄露。比较有意思,记录一下。
其实回想了一下,这个问题似乎曾经引起过一个非常严重的问题(虽然我没搜到),大概就是某个 AI 服务提供商在环境变量中暴露了 AI API 可公网访问的接口凭证……
(以下内容由 AI 主笔,我做一些审核与改写)
一、场景:一个会”自己敲命令”的后端服务
现在很多产品都在做同一件事:给大模型一套工具(tools),让它像工程师一样干活。其中最强大、也最危险的一个工具,几乎总是叫 run_command —— 允许模型生成一段 shell 命令并在服务器上执行。
一个典型的部署形态是这样的:
- 有一个执行进程(不妨叫它 Runner),它跑着 LLM 循环,并在收到指令时执行
run_command、读写文件、跑git等等。 - 这个 Runner 需要和”控制面”通信:领取任务、回传结果。为此它的环境变量里通常握着一个共享密钥(一个 bearer token、一个 API key,或者数据库连接串)。
- 为了安全,大家往往会加一道”环境变量白名单”:在 spawn 子进程时,把
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”:
- 拿到 Runner 的共享密钥后,攻击者可以冒充一个合法的 Runner 连上控制面;
- 控制面会把后续任务(包括其它用户的任务、以及任务里携带的 provider API key)派发给这个假 Runner;
- 相当于一次
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 变了,内核就直接拒绝。这就是我们要的边界。
四、解决方案: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,就要用别的隔离手段(见下文”没有条件降权时怎么办”)。
五、容易被忽略的第二条攻击路径:被 Agent 写过的文件
修好了 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 的读写; - 对历史遗留的、之前由 root 建立的工作目录,启动时
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 平台)。这时要有一个明确的策略,而不是”悄悄地不隔离”:
- 生产环境:拒绝启动。宁可起不来,也不要在没有隔离的情况下把密钥暴露给可执行任意命令的 Agent。可以留一个显式的逃生开关(例如
ALLOW_UNSANDBOXED=true),逼运维明确地为这个风险签字。 - 本地开发:降级 + 大声警告。打一条醒目的日志说明”当前未隔离,密钥对 Agent 命令可见,请勿接入不可信输入”。
在 macOS 上没有 /proc,这条具体的攻击路径不成立,但那里通常用 sandbox-exec(seatbelt)做另一套”文件读取 deny-list”防护 —— 思路是一样的:针对不同平台,为 Agent 子进程套上该平台最合适的隔离层。
七、更宏观地看:Agent 是不可信输入
这个案例最值得记住的,不是 /proc/environ 这个具体的洞,而是它背后的心智模型:
一旦模型能生成并执行命令,它产出的一切都应当被当作”来自互联网的不可信输入”来对待。 Prompt injection 让攻击者可以间接控制 Agent 的行为,所以”Agent 会不会主动作恶”根本不是重点 —— 只要它_能_执行任意命令,你就必须假设它_会_执行最坏的那条。
在这个假设下,很多”看起来够用”的防御都会露馅:
- env 白名单 → 挡不住
/proc/<pid>/environ; - “反正在容器里” → 容器边界防的是”逃逸到宿主机”,防不住”同容器内同 UID 读密钥”;
- “密钥只在内网用” → 内网的其它服务照样能被冒充。
而真正稳的边界,都建立在操作系统级别的隔离原语上:独立的 UID、能力(capabilities)裁剪、no_new_privs、必要时叠加 seccomp / namespace。 它们不依赖”我记得在每个 spawn 点都过滤了变量”这种脆弱的纪律,而是让内核来强制执行”沙箱用户看不到、也够不着”。
八、换个思路:用 namespace(unshare)隔离行不行?
前面用的是”UID 降权”。很自然会想到另一条路:Linux namespace。既然密钥的泄露依赖”子进程能通过 /proc/<runner-pid>/environ 寻址到主进程”,那我把子进程扔进一个独立的 PID namespace,让它根本看不见主进程,不就行了?
思路成立,而且比降权更彻底 —— 但有一个必须踩对的关键点,和一个绕不开的代价。
关键点:PID namespace 必须配合重挂 /proc
只开 PID namespace 是不够的。因为 /proc 是一个文件系统,如果你进了新的 PID namespace、却仍然沿用旧的 /proc 挂载,那么旧 /proc 里宿主的所有进程条目照样在,/proc/1/environ 照样能读。必须再开一个 mount namespace、把 /proc 重新挂一遍,让它只反映新的 PID namespace。
实测对比(--privileged 容器里,主进程 PID1 持有 RUNNER_SECRET,子进程已剥掉该变量):
1 | # 只开 PID namespace,不重挂 /proc —— 旧 proc 还在 |
重挂之后,主进程在子进程眼里根本没有可寻址的 /proc 条目 —— 这比 setpriv 的”看得见但读不了”更进一步:直接”看不见、够不着”,连 ptrace 之类的同 UID 攻击也一并没了目标。而且 namespace 还能顺手做更多:叠一个 mount namespace 把敏感目录直接从视野里 unmount,或者叠一个 network namespace 让 Agent 连控制面都够不到。
代价:它要的权限比降权高得多
问题出在”建 namespace”这个动作本身:创建 PID / mount namespace 需要 CAP_SYS_ADMIN。而一个加固过的容器默认是不给这个能力的。实测:
1 | # 默认 Docker(root 但无 CAP_SYS_ADMIN + 默认 seccomp 过滤) |
只有 --privileged(或显式 --cap-add SYS_ADMIN 并放宽 seccomp/AppArmor)才能完整跑通。这就很讽刺了:为了”把 Agent 关进沙箱”,反而要给它所在的容器开 --privileged —— 那等于为了关一扇窗把整面墙拆了,净攻击面不降反升。走 user namespace 绕开 root 要求也不是免费午餐:unprivileged user namespace 历史上是内核提权 CVE 的高发区,不少加固宿主干脆禁用了它。
对比之下,UID 降权只需要 CAP_SETUID / CAP_SETGID(容器里以 root 起步就够,不需要 privileged),简单、不额外扩大攻击面 —— 这也是本文主线选它的原因。
结论:这是一条谱系,而不是二选一
两种做法本质是同一条隔离谱系上的不同强度,可以叠加:
- UID 降权(**
setpriv**) —— 成本最低的稳健解,专门堵死 environ 读取;权限要求低,几乎哪都能用。 - namespace 隔离(
unshare) —— 更彻底,但要么容器-privileged/ 加CAP_SYS_ADMIN,要么依赖宿主允许 unprivileged userns。想用别自己手搓 flag:bubblewrap/nsjail就是unshare+ seccomp + cap 裁剪的成品封装,比裸调用少踩坑。 - 独立容器 / microVM(gVisor、Firecracker) —— 谱系的顶端。如果架构上本来就在做服务拆分,让 Agent 跑在它自己的容器里,其实就是”用 namespace 隔离”最干净的落地方式 —— 隔离交给容器运行时,而不是在单个容器里手动嵌套
unshare。
一句话:unshare 能解决,且机制更强;但在”已经加固过的容器里再嵌套一层”这个最常见的场景下,它的权限门槛往往让它不划算。先用 UID 降权拿到底线,需要更强隔离时直接上专门的沙箱工具或独立容器,而不是给主容器放开 **CAP_SYS_ADMIN**。 另外别忘了:无论 unshare 还是 setpriv,都不会自动堵上第五节那条”Agent 写文件、主进程后来执行”的路径 —— 除非主进程自己跑 git 之类命令时也套上同样的隔离。
九、落地清单
如果你正在做一个能执行代码的 AI Agent,照着过一遍:
- 为 Agent 子进程建一个专门的非特权用户,主进程与它不同 UID。
-
run_command(shell)经setpriv --reuid --regid --clear-groups --no-new-privs降权执行。 - 主进程对 Agent 可写目录发起的命令(尤其是
git)同样降权,别只盯着 shell。 - 给这些子进程一份收紧过的环境变量(白名单 + 关掉交互 prompt),双保险。
- 处理好
umask/ 属主 / 临时目录权限,别让隔离把正常流程搞崩。 - 隔离不可用时:生产拒绝启动(留显式逃生开关),开发降级并告警。
- 用一条命令(
cat /proc/1/environ)在容器里实测一遍,确认密钥确实读不到。
安全不是”我过滤了敏感变量”,而是”就算对手拿到了 shell,他也够不着密钥”。把边界画在 UID 上,你才真正有了这条底线。