Agent 在 Docker 沙箱里留下的一个文件,可能在你电脑上以 root 身份运行

你让 agent 在 Docker 沙箱里跑一段构建脚本。它装依赖、编译、打了个包,还把产物放在 /workspace/dist/ 里——这个目录通过 bind mount 直接落在你的宿主机上。第二天你在自己电脑上执行这个产物,发现它以 root 权限跑了起来,往你的 ~/.ssh 里写了一堆东西。
听起来像恐怖故事,但机制非常朴素:Docker 容器默认以 root 运行,容器写进 bind mount 的文件在宿主机上就是 root 的文件;如果这些文件带着 setuid/setgid 位或者 file capabilities,你在宿主机上执行它们时,提权效果跟着文件一起回来了。容器里的 nosuid 挂载、--cap-drop ALL、no-new-privileges 全都管不到这一步——它们约束的是容器内的进程,不是宿主机上那个文件的权限位。
为什么容器里的加固不会跟着文件回家
先把责任边界说清楚,这样才知道该防哪里。
| 加固手段 | 约束的是谁 | 对宿主机文件有效吗 |
|---|---|---|
--cap-drop ALL |
容器内的进程 | 无效 |
nosuid 挂载 |
容器内的挂载视图 | 无效 |
no-new-privileges |
容器内的 exec | 无效 |
文件自己的权限位(u+s、g+s、file caps) |
任何执行它的人 | 有效,而且跨容器边界 |
关键在最后一行:setuid 位是文件属性,不是容器属性。容器把文件写进 bind mount 时,位就一起写上去了;容器被删除,位还在。你想在宿主机上安全执行它,唯一可靠的办法是在宿主机侧把这些位剥掉——用 chmod ug-s 和清 file capabilities,而不是指望容器里的挂载选项。
Hermes 现在做了什么
改动落在 tools/environments/docker_ownership.py,思路是:先交接所有权,再统一剥权限位。
- 顺序:拆除容器时先跑所有权交接(把 root 拥有的文件 chown 回你的用户,chown 本身就会顺带清掉 setuid/setgid 和 capabilities),然后单独跑一遍 strip。两步各有 10 秒预算,一棵巨大的目录树也不会把交接饿死。
- 怎么剥:起一个一次性的加固容器,执行一条
find:
find -P <sandbox-binds> -xdev <prune-nested-mounts> \
-type f -perm /6000 -exec chmod ug-s {} +
这条命令不改变 owner,也不需要 stat,所以在没有 /bin/stat 的精简镜像(如 bullseye-slim)里同样能跑;GNU findutils 和 busybox 都验证过。
- 文件能力(file capabilities):
find看不了 xattr,所以当容器被授予SETFCAP、ALL或者以--privileged运行时,同一个一次性容器会把每个普通文件重新 chown 给它自己(uid 只读一次,每个 owner 走一遍),用“换 owner”的方式清掉 capabilities,最后 owner 不变。基础参数里已经去掉了SETFCAP,所以默认路径还是走那条便宜的 strip。 - 持久沙箱也覆盖:这是上一版(#141033)最大的漏洞——它只在容器被删除时执行,而默认的持久沙箱根本不会被删除。现在 strip 会在三个时机补上:
- 会话从持久容器分离时(
cleanup()的 persist 模式,在 atexit 排空的拆除线程里); - 重新附着后、执行第一条命令之前;
reap_orphan_containers里、在删除一个已退出的容器之前。
- 会话从持久容器分离时(
- 范围严格限定:strip 的目标来自
docker inspect的挂载点,而且只有当realpath(Source)落在宿主机自己推导出的<sandbox>/docker根目录下(自己的沙箱目录,或 Hermes 根目录与每个profiles/*的默认目录)时才保留。这一条覆盖了多个 profile 共用同一个容器的情况。容器 label 和其它元数据不会扩大这个集合——因为额外参数和镜像LABEL都能设置它们。 - 挂载方式:只按解析后的真实路径挂载这些沙箱目录,用
--mount type=bind(源不存在就跳过,绝不创建一个 root 拥有的目录),不使用--volumes-from。这样即使有人在扫描过程中换掉路径,最多也只能指到沙箱文件。 - 失败不静默:strip 跑不了(缺工具、非零退出、超时)会打一条 WARNING,带沙箱路径,每个沙箱只打一次。
没变的部分同样重要:find -P 不跟随符号链接、嵌套挂载会被 prune、PATH/loader 环境被重置、--cap-drop ALL 加 CHOWN/DAC_OVERRIDE/FOWNER、no-new-privileges、设备节点和 FIFO 不碰、整体 best-effort。整套逻辑的启用条件是 Linux、Hermes 自身不是 root、不是 rootless Docker、不是 userns-remap。
自己检查一下(十分钟)
如果你现在的版本还不包含这个修复,完全可以手工确认并清理。先拿到容器真实的挂载路径:
docker ps --format '{{.ID}} {{.Image}} {{.Names}}'
docker inspect <container> --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}'
然后在宿主机上找带特权的文件:
find -P <那个 Source 路径> -xdev -type f -perm /6000 -print
有输出就说明确实存在风险,手工剥掉:
find -P <那个 Source 路径> -xdev -type f -perm /6000 -exec chmod ug-s {} +
再看文件能力(需要 libcap 工具,很多精简系统没有):
getcap -r <那个 Source 路径>
# 逐个清除
setcap -r <文件路径>
注意这些命令是 Linux 写法(GNU find 的 -perm /6000 表示“任一位置位”)。如果你在 macOS 上跑 Docker Desktop,容器侧的 root 文件映射行为与 Linux 不同,宿主机上的文件通常由你本人拥有,风险面小得多——真正要小心的是 Linux 生产机、CI runner 和共享开发服务器。
三个可以立刻做的决定
一、等更新,或者先手工清理。 这个改动 2026 年 10 月 18 日合入主分支,晚于 v0.22.2(10 月 15 日发布),所以当前 release 还没有它。hermes update 到包含它的版本后自动生效,不需要任何配置开关——它默认就是开的。
二、长期方案:让容器以你的用户身份跑。 如果沙箱主要用来编辑挂载进来的文件,而不是在里面 apt install,打开这个开关可以从根上消除“root 拥有的文件”:
terminal:
backend: docker
docker_run_as_host_user: true # default: false
它的代价写得很清楚:容器不能再写 root 拥有的路径(比如 /root/.npm),需要 apt install 的场景要换成以非 root 用户为 HOME 的镜像。相关配置细节可以对照 配置文档里的 Docker 章节。
三、别在宿主机上直接执行沙箱产物。 这条和版本无关:来自不可信代码的二进制,最安全的执行方式是回到容器里跑,或者在一个一次性 VM 里跑。权限位剥除解决的是“误伤”,不是“恶意构建”。
这不是一个孤立的小修补
从 #141033 到 #141086 的两步走,说明了一类很容易被忽略的安全边界:隔离的承诺只覆盖隔离域内部。容器里做的一切加固,在文件跨出容器、落到宿主机文件系统的那一刻就失效了。任何一个允许 agent 往宿主机写文件的后端(Docker bind mount、SSH 远端、云沙箱的同步目录)都要问同一个问题:写出来的东西,回到“可信侧”时是什么权限?
如果你正在搭建这类环境,站内还有两篇可以接着看:Docker 共享容器的取舍 讲多会话共用一个容器时的隔离边界,沙箱里的媒体文件怎么送出来 讲产物跨边界传递的路径。第一次装 Hermes 的话,安装指南 里有各后端的启用方式。
小结
- 症状:容器写进 bind mount 的文件在宿主机上带着 setuid/setgid 或 file caps,执行即提权。
- 原因:这些是文件属性,容器侧的
nosuid、cap-drop、no-new-privileges都管不到宿主机。 - 修复:宿主机侧的一次性 strip(
chmod ug-s+ 换 owner 清 caps),覆盖拆除、分离、重连、孤儿清扫四个时机,范围只限沙箱 bind 目录。 - 现在做:
docker inspect找挂载路径、find -perm /6000自查、等更新;长期用docker_run_as_host_user或别在宿主机执行不可信产物。