程序在内部打印 uid=0,并不意味着它在宿主初始 user namespace 中获得了宿主 root 的身份。前提是内核如何把内部 UID/GID 映射到外侧身份,目录所有权从哪一个 user namespace 解释,以及该操作的权限检查针对哪个对象。先把04 的路径/挂载视图与身份映射分开;rootfs 的路径变化不能自动改变用户映射。

UID 是映射关系,不是全局标签

/proc/<pid>/uid_map 的每行含“内部起始 UID、外部起始 UID、数量”,gid_map 同理。进入新的 user namespace 后,内部 root 可以对应外侧非特权 UID,具体权限由映射、文件系统、被检查的 namespace 及内核能力决定;映射不意味着访问所有外侧 root 拥有的目录。写 gid_map 通常还涉及 /proc/.../setgroups 的限制,不能盲目照搬 uid_map 的写法。把进程设为容器内普通 USER 与以非特权身份运行 daemon 并使用 user namespace(rootless)是两个不同维度。

读映射时至少问三个问题

一行形如 0 1001 1 的 uid_map 表示:当前 user namespace 中的 UID 0 对应紧邻的父 user namespace 中的 UID 1001,长度为一个 ID。这里的外侧不是不经检查的“全局用户数据库”,而是当前 user namespace 的上一层;有嵌套时要逐层追溯到实际访问文件的节点。映射把数字身份联系起来,不会复制宿主的用户账户,也不会自动授予宿主初始 user namespace 的 capabilities。id 只报告调用进程看到的身份;要分析一个宿主目录为何可写,还要记录它在两个视角下的 stat、目标进程 /proc/<pid>/uid_map、gid_map 以及操作实际使用的有效 UID/GID。

假设外侧同一个所有者为 1001 的目录权限是 0700,user namespace 内的 root 若映射为 1001,访问该目录所用的外侧凭据与目录所有者匹配,所有者位可能允许写入。换成外侧 UID 2000 所有、模式同为 0700 的另一个自建目录,内部打印 UID 0 并不足以让外侧凭据变为 2000;是否被拒绝仍要看有无其他权限、挂载配置和安全策略。不能用某个绑定目录出现 EPERM 直接归因于映射:路径可达性、只读挂载、LSM 拒绝或目标文件系统策略都可能在别的检查环节导致失败。先控制变量,再据 errno 与配置缩小范围。

映射还可能是多行、部分范围,且 UID 与 GID 要分别核对。一个无法映射进当前 namespace 的文件所有者通常以溢出 ID 呈现,不能拿这个显示值当宿主真的创建了一个同名账号。/proc/<pid>/uid_map 是进程处在该 namespace 时的身份转换依据,而 /etc/passwd 只是名称解析资料:修改镜像内 passwd 文件可以改变 ls -l 上显示的名字,不会改变 inode 的所有者 ID,也不会凭空补全 user namespace 的映射。判断卷权限时优先保留数值 UID/GID,而非用户名。

创建 namespace 之后仍有哪些权限门槛

unshare -Ur 一类工具先建立 user namespace,再在允许的范围内准备 ID 映射。可为子 user namespace 中某些对象获得能力,不等于获得初始 user namespace 的 CAP_SYS_ADMIN 或可以修改宿主网络与 cgroup。uid_map、gid_map 文件的写入有限制:需要满足父 namespace 中的权限及映射范围规则,且不是想写多少次、映射任意宿主身份就写多少次。特别是非特权映射 GID 前通常需向对应 setgroups 文件写入 deny,从而不能先用补充组改变对文件的访问再绕开权限检查。使用 unshare --map-root-user 后也应实测两份映射,而非将命令行参数直接当作已生效的最终状态。

权限检查要指向被操作的对象。对刚创建、处于本 user namespace 所拥有的 UTS namespace 修改 hostname,和对属于宿主初始 namespace 的挂载资源执行系统管理操作,虽然工具都可能显示 root,内核检查的对象与所属 namespace 却不同。一个进程内部有 CAP_SYS_ADMIN 并不意味着它能给宿主初始 mount namespace 里的任何目录挂载;还会受外侧文件权限、LSM 与宿主策略约束。需要进一步研究内核上的真实权限路径时,应记录 syscall、目标对象、相关 user namespace 的所属关系和返回 errno,不把“内部 root”单独当作安全结论。

对同一目录从两侧读写

在本环境创建仅属于当前用户的空目录,宿主 stat 报 1001:1001;运行 unshare -Ur python3 uid_probe.py <目录>,内部 getuid/getgid 都是 0,uid_map 和 gid_map 均含 0 1001 1,内部同一目录所有者读成 0。探针写入一个自有文件后,宿主 stat 新文件仍报 1001:1001;清理该目录成功。原始日志保留完整路径、命令、退出码及清理;源码包及说明允许在同类环境重跑。这说明两个视图如何解释同一身份,不说明任意共享目录可写。

这组数据里的 0 1001 1 比单独记录 uid=0 更有信息:它让内部创建文件的“root”与外侧 1001:1001 所有者串成同一条因果链。内部写入成功的前提是该目录确实由这次实验的外侧用户持有;换成宿主别的目录、只读挂载或不可写目标时不能照抄这一结果。这里的命令没有运行镜像,也没有使用容器 daemon;只是直接证明 user namespace 中的数字视图和同一底层文件的外侧所有者能够不同。把这个证据升级为“某容器的绑定卷已经安全隔离”还缺容器 ID、daemon 的 userns 配置、实际 bind mount、运行进程 UID 与该卷的两侧访问结果。

观察结果还要排除两种伪对照。只在内部创建文件后用 ls 看名字,可能因 /etc/passwd 中恰好有 root 名称而忽略数值映射;只在外部看到文件属主 1001,却不知道内部是否真的有映射,也不能证明身份关系。当前记录同时有内部 getuid/getgid、两份映射和宿主 stat,但没有模拟未映射目录的拒绝条件,所以只支持“映射成功且自有目录可写”的有限结论。原始日志中若清理目录失败,须先检查文件所有者和实验进程,不能用特权删除命令掩盖问题。

反例是一个没有被映射到该 user namespace 的宿主 UID 所有的路径:内部打印 root 仍不足以推断写入权限;绑定卷上的权限还要核对文件所有者、模式、挂载选项、LSM 及 userns 与文件系统支持情况。不能在共享宿主尝试改写真实 root 文件来证明拒绝。专用 VM 可新建一个不同宿主 UID 所有的测试目录,只绑定该目录,逐项记录映射、权限和 EPERM/EACCES,再清理目录;本环境无专用 VM,该拒绝路径 NOT_RUN。

为了让拒绝案例可解释,应在专用 VM 先为本次实验创建两个与其他任务无关的目录,令它们的权限模式、父目录可遍历性与挂载选项一致,仅改变一个目录的宿主数值所有者。先用外侧凭据、再用映射后的内侧凭据对两者做固定长度的创建与删除测试,分别记录 stat -c '%u:%g %a'、程序捕获的 errno、mountinfo 和清理结果。若内外都失败,需要先确认目录是否真的可写;若都成功,要检查是不是 ACL 或其他权限掩盖了所要对照的身份因素。没有这一控制步骤,单个 EACCES 只能说明访问被拒,不能定位拒绝是因为映射范围。所有写入都限定在自建目录,绝不触碰宿主系统文件。

USER、userns 和 rootless 分别改变了什么

镜像配置中的 USER 决定运行进程默认使用哪个用户身份,具体在进程开始时如何转换仍要看运行时配置及映射。普通 daemon 以宿主高权限运行,容器内部进程使用非零 UID,是一种情况;daemon 自己由宿主非特权用户运行、容器使用 user namespace,则是另一种情况。两者可以组合:内部非零 UID 在父 namespace 中对应另一非特权 UID,文件能否写入仍按目标目录判定。配置 USER 1001 不等于 daemon 已 rootless;内部显示 UID 0 也不等于 daemon 必然以宿主 root 运行。20 篇会补完整的 daemon/容器身份对照,本篇只建立 Linux 身份转换的基线。

这一区分有实际排障价值:同一探针读镜像内公开文件正常,写绑定目录却失败,优先核对运行进程的有效 UID、user namespace 映射、外侧目录数值所有者,再查只读挂载和 LSM 日志;不应该立刻把权限改成 0777。如果没有宿主侧权限观测条件,必须把未知的映射或拒绝来源写成 NOT_RUN,而不是从镜像里的 passwd 文件名推测内核身份。可迁移判断顺序是“进程实际身份 → 映射链 → 对象所有权 → 路径/挂载权限 → 拒绝发生的层”。

考虑一个嵌套的例子:第一层把内部 UID 0 映射为外层 1001,第二层又把新内部 UID 0 映射为第一层的 UID 0。在最内层打印 0,只知道新内部的数值;需要逐层合成映射,才知道最终访问外侧目录所对应的身份仍是 1001。若只拿最内层的一行 0 0 1 并声称“映射到宿主 root”,就是把父 namespace 误认成初始 namespace。反过来,若进程经 setuid 选择一个没有被映射的 ID,身份切换也可能失败,不能把失败条件含糊写成“容器内不能用非 root”。内核接口的有效返回、各层映射和实际进程凭据要分别留证。

本篇实验可重复性的边界也要清楚:某台云端允许非特权 user namespace 不意味着所有 Linux 节点都允许,发行版安全策略、内核配置、被调用程序的条件和宿主授权都可能不同。重跑时先记录 unshare 返回码,再看新进程的 namespace inode 与两份 map;如果命令失败,不要用旧进程的 /proc/self/uid_map 冒充新映射。复现实验的清理只删除自己在 mktemp 目录中创建的文件,并验证目录不存在;无法清理时原样记录原因,而不通过提高全局目录权限让测试“通过”。这与读取系统目录做权限探针不同:后者即使未写入,也不能成为一个适于在共享机器上执行的故障注入方案。

这套证据还必须在时间上能对齐:宿主检查的是正在运行的那一个被映射进程及它实际触及的文件,而不是容器停止后另一个进程复用同一 PID 的属性。记录采样时刻、外侧 PID、user namespace inode、映射、目标目录数值所有者和写入返回码;进程退出后保留原始日志,但不要再读取已经可能被重用的 /proc/<pid> 路径来“补证”。如果绑定卷是在进程启动后被替换的,还需核对当时的 mountinfo,不能把后来目录的 stat 写成先前那次写入的前提。

因此,读到内部 root 只是诊断的起点。是否能越过宿主目录权限,需要上述映射和对象证据;是否能改变宿主级资源,还需要核对操作对应的 user namespace、能力检查及宿主安全策略,不能把两类结论混为一谈。

现象 对照对象 判断条件
内部 root、外部 UID 1001 uid_map 与两侧 stat 不是宿主 root
bind 目录不能写 gid_map、所有者/模式、LSM 不要只看 id
容器 USER 非 root 应用 UID、daemon 身份、映射 不自动等于 rootless

练习

  1. 预测把自有目录设为 0700 后,内部映射到同一外侧 UID 的进程能否写入;在独立临时目录内实测两侧所有者并说明依据。
  2. 在专用 VM 预测一个未映射 UID 所有的绑定目录写入结果,再比较错误码和两侧 stat;不要在共享宿主测试系统目录,当前环境记 NOT_RUN。

上一篇:04:根目录与挂载;下一篇:06:CPU 配额。

系列总目录:从进程隔离到运行时与编排。

参考资料