2.2 Rootless 机制:UID 映射的魔术


2.2 Rootless 机制:UID 映射的魔术

本节摘要:rootless Podman 的安全性建立在一个内核机制上——用户命名空间(user namespace)通过 UID/GID 映射,让容器内的 root 对应宿主机上一个无特权的高位 UID。本节从 /etc/subuid 的分配讲起,手推映射关系,用 podman unshare 做视角切换实验,并解释挂载目录时权限错乱的根源。迁移排错的一半弹药都存放在这一节。

先看现象:容器里的 root 是宿主机上的谁

不谈理论,先跑一个对照实验。以普通用户运行容器,在容器内外各看一眼同一个进程的属主:

# 宿主机上:普通用户 dev,UID 1000 id # uid=1000(dev) gid=1000(dev) # 容器里:我是 root podman run --rm alpine:3.19 id # uid=0(root) gid=0(root) # 那宿主机视角下,容器进程是谁? podman run -d --name whoami alpine:3.19 sleep 300 ps -o pid,user,cmd -C sleep | head -3 # PID USER CMD # 9012 231073 sleep 300 # 宿主机上属主是 UID 231073——一个既不是 root 也不是 dev 的高位数字

231073 不是随便来的数。它是 /etc/subuid 里给 dev 分配的子 UID 区间中的一个,是"容器内 root"在宿主机上的真实身份。理解这个数字怎么算出来,就理解了 rootless 的一半。

映射是怎么建立的

分两步。第一步是系统管理员(或发行版工具包)给每个用户分配一段"子 UID 池":

# 查看当前用户的子 UID 分配 cat /etc/subuid # dev:100000:65536 # 解读:用户 dev 拥有从 100000 开始、长度 65536 的子 UID 区间 # (100000 到 165535)。subgid 同理,用于组 ID cat /etc/subgid # dev:100000:65536

第二步发生在容器启动时:Podman 请求内核创建一个用户命名空间,并把映射表写进 /proc/对应进程/uid_map。默认映射策略是——容器内 UID 0(root)映射到宿主机当前用户(1000,你的真实身份,保证容器能读写你挂载进来的自己的文件);容器内 UID 1 到 65536 映射到子 UID 区间的 100000 到 165535。

推一遍数字:容器里的 UID 0 在宿主机是 1000;容器里 UID 1 在宿主机是 100000;容器里 UID 1000 在宿主机是 100999。刚才实验里看到的 231073,反推回去就是容器内 UID 131073+……不必真算,重点是方向:容器内的一切特权身份,落到宿主机上都在 100000–165535 这段无特权区间里,或者就是你本人。逃逸者拿到 231073 这个身份时,能做的事和街上一台无人看管的普通账号没区别。

02-02-fig01

用 podman unshare 切换视角

映射表最大的工程价值是解释"挂载目录权限错乱"。Podman 提供了官方实验工具 podman unshare——在用户命名空间内执行命令,让你以"容器视角"看世界:

# 场景:把家目录的一个文件夹挂进容器,容器内进程却写不进去 mkdir -p ~/data && echo hello > ~/data/a.txt podman run --rm -v ~/data:/data alpine:3.19 sh -c 'echo world >> /data/a.txt' # sh: can't create /data/a.txt: Permission denied # 用 unshare 切到容器视角看属主 podman unshare ls -ln ~/data # -rw-r--r-- 1 0 0 12 ... a.txt # 解读:容器视角里这文件属于 UID 0(映射后即你的真实身份 1000), # 看起来"应该是自己的文件",为什么写不进? # 因为容器里的进程若以非 root 用户运行(如 UID 100 的 nginx), # 它在宿主机身份是 100999,文件属主 1000 与它无交集

排错路径由此清晰:要么让容器内进程以 root 跑(映射回你本人),要么用 podman unshare chown 把文件属主改成容器内目标用户对应的宿主机 UID,要么启用容器内根与宿主机用户的 ID 对齐选项(--userns=keep-id,第 5 章与第 9 章会反复用到)。

rootless 的能力边界

诚实起见,把受限项列出来。下列操作在 rootless 下要么不可行要么需要绕行:绑定 1024 以下特权端口(绕法:容器内监听高位端口,发布映射时再降到低位的做法在 rootless 下也受限,常用方案是用户级 systemd 的端口转发或直接用高位端口);挂载任意宿主机文件系统(仅限你有权访问的路径);某些 cgroup 资源控制(v2 委派配置后可用);修改内核级网络参数。

这些边界不是 bug,而是"你的进程本来就不能做的事"——rootless 只是不替你越权。对比 rootful Docker 的做法(root 守护进程替你绕过一切),你大概能体会两种哲学在体验上的差别:一个处处绿灯直到出事,一个先讲清楚规矩。

一个常被忽略的推论:区间是有宽度的

subuid 区间长度(示例里是 65536)不是随便选的数字,它决定了容器内能同时"扮演"多少个不同身份。多数应用镜像只需要 root 加几个系统账户,6 万多的区间绰绰有余。但有一类边缘场景会撞上宽度上限:在 rootless 容器里再跑一层 rootless 容器(嵌套),内层需要的区间要从外层剩下的额度里扣。碰到的报错形态是"没有可用的 subordinate UID",解法要么让管理员给你扩区间,要么避免嵌套(用 Pod 或别的组织方式替代)。日常开发几乎不会遇到,知道这条推论的存在,遇到报错时能少走弯路。

另一个实用细节:两个用户的区间不该重叠——发行版的 user manager 工具(分配 subuid 的那套机制)会自动错开,但手工编辑配置文件的团队要自己保证。区间一旦重叠,A 用户的容器进程与 B 用户的容器进程在宿主机上可能落进同一段 UID,隔离出现裂缝。审计多用户机器时,把 /etc/subuid 拉出来按区间排个序,重叠一眼便知。

本节要点回顾

  • 映射两步走:/etc/subuid 分配区间,容器启动时写入 uid_map
  • 默认策略:容器 UID 0 映射到真实用户,UID 1 起映射到子 UID 区间
  • 逃逸落点:宿主机高位无特权 UID,无系统文件访问权
  • unshare 是排错利器:以容器视角查看文件属主,权限错乱一眼定位
  • 边界是特性:特权端口、任意挂载等限制源于"不替用户越权"的设计立场
  • keep-id 是常用绕法:让容器内进程直接使用你在宿主机的 UID

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U