容器里不用 root 运行程序:UID 泄漏、User Namespace 与最小权限
容器里不用 root 运行程序UID 泄漏、User Namespace 与最小权限实验环境Ubuntu 24.04内核 6.8.0-106-genericDocker 29.1.3Cgroup v2。本文所有「危害演示」均在本机实验容器内、以及主机/tmp下的临时目录中完成演示后相关目录均已清理未对宿主机造成持久改动。docker run不指定用户时默认以rootuid 0身份运行进程。很多人觉得「容器里是隔离的root 无所谓」。这句话对了一半容器里确实有一层 capability 沙箱但在文件系统这个维度上不开启 User Namespace 时容器里的 root 就是宿主机上的 root。本文用一组对照实验说清四件事root 容器如何悄悄「污染」宿主机文件属主、用 USER/-u 如何止血、手动 User Namespace 把 root 变成「假 root」、以及 Docker 的 userns-remap 怎么从全局把 root 映射成高 UID。引子一个被忽视的「属主泄漏」先看一个最朴素、也最容易踩坑的场景。我们在宿主机上建一个共享目录分别用「默认 root 容器」「-u 1000容器」「Dockerfile 里USER的镜像」往里写文件然后回到宿主机用数字方式看这些文件到底归谁所有。# 宿主机准备共享目录$HD/tmp/shared_root;rm-rf$HD;mkdir-p$HD;chmod777$HD# ① 默认 root 容器写文件$dockerrun--rm-v$HD:/data busyboxsh-cid; echo leak-from-root-container /data/c_root.txtuid0(root)gid0(root)groups0(root),10(wheel)# 宿主机看到的所有者数字形式$ls-n$HD/ -rw-r--r--10025... c_root.txt注意最后那一行宿主机上c_root.txt的属主是0 0也就是宿主机的 root。容器里那个「root」写的文件在宿主机看来就是 root 写的。这就是「UID 泄漏」。1. 复现三种运行方式三种属主把另外两种方式一起跑出来对比# ② 运行时用 -u 1000$dockerrun--rm-u1000-v$HD:/data busyboxsh-cid; echo from-uid1000 /data/c_user.txtuid1000gid0(root)groups0(root)# ③ 用 Dockerfile 里写了 USER appuser(uid1000) 的镜像 userimg$dockerrun--rm-v$HD:/data userimgsh-cid; echo from-userimg /data/c_img.txtuid1000(appuser)gid1000(appuser)groups1000(appuser)# 宿主机最终看到的属主$ls-n$HD/ -rw-r--r--10100025... c_root.txt# root 容器 → 宿主机 uid 0-rw-r--r--11000013... c_user.txt# -u 1000 → 宿主机 uid 1000-rw-r--r--11000100013... c_img.txt# USER 镜像 → 宿主机 uid 1000结论一目了然运行方式容器内身份宿主机看到文件属主默认rootuid0(root)0宿主机 root危险docker run -u 1000uid10001000宿主机某个普通用户镜像USER appuseruid1000(appuser)1000如果一个「root 容器」挂载了宿主目录比如日志目录、配置文件目录它写出来的文件全都归宿主机 root所有。后患是宿主机上的普通运维脚本、其它服务、甚至备份工具可能因为「文件被 root 占了」而写不进去或权限错乱更糟的是万一这个容器有漏洞被入侵攻击者在容器内是 root他对挂载进来的宿主目录拥有 root 级文件权限。注意UID 泄漏和「capability 沙箱」是两件事。即便 Docker 默认砍掉了大部分 capability容器进程在文件系统上仍然是宿主机 uid 0——capability 限制的是「能调用哪些特权系统调用」而「文件的属主」直接由内核里的 UID 数字决定沙箱管不到它。2. 原理剖析容器的「root」到底是什么Linux 的 UID 是内核全局的整数。容器能「看见」的进程、文件本质上都跑在同一个内核上。所谓「命名空间隔离」默认只隔离了 PID、Mount、Network、UTS、IPC并没有隔离 User Namespace除非显式开启。这就带来一个关键事实没开 User Namespace 时容器里的 uid 0和宿主机上的 uid 0是内核里同一个数字。所以容器里id显示uid0(root)内核记录的也是0它往一个宿主挂载目录写文件内核按0记属主宿主机ls自然看到0 0它读宿主/etc/shadow若挂载进来就是宿主 root 在读——和上一篇 privileged 逃逸是同一类根因。理解这一点后「为什么不用 root 跑容器」就不是一句口号而是一个具体的文件系统安全问题。3. 手动 User Namespace把 root 变成「假 root」User Namespace 的机制是给 UID 做一层映射uid_map在某个命名空间内部你是 0但在命名空间外部宿主机视角你其实是另一个普通 UID。最直观的手动演示需要一定权限下面会解释$ unshare--user--map-root-useriduid0(root)gid0(root)groups0(root)$ unshare--user--map-root-usercat/proc/self/uid_map001uid_map的三列含义是「命名空间内 UID」 「命名空间外(父) UID」 「映射长度」。这里0 0 1表示把内部的 0 映射到外部的 0也就是执行unshare的那个真实用户只映射 1 个。也就是说--map-root-user让你在新命名空间内部成了 root但这个「root」在宿主机上只是你自己原本的身份——它没有任何真实特权只是「在自己小圈子里说了算」。这正好解释了 User Namespace 的安全价值进程在自己 namespace 里是 root可以干 mount、改网络等需要 root 的事但在宿主机上它依旧是无特权的普通用户即使逃逸也伤害有限。但有个前提——你得能创建这个命名空间。在默认加固的服务器上普通用户通常不能随便建 userns# 用普通用户 demouser 尝试$ runuser-udemouser -- unshare--user--map-root-useridunshare:writefailed /proc/self/uid_map: Operation not permitted被Operation not permitted挡住原因通常是内核参数kernel.unprivileged_userns_clone0禁止了无特权用户建 userns或该用户在/etc/subuid、/etc/subgid里没有可分配的子 UID 区间。换句话说User Namespace 是好东西但它本身也需要「有权限去开」——这正是容器运行时Docker/containerd替我们做的事。4. 方案 ADocker userns-remap运维侧全局加固手动开 userns 太底层生产上更实用的是让 Docker 帮我们全局开启 User Namespace 重映射userns-remap所有容器里的 root都会被映射成宿主机上一个「高 UID」的普通用户从根上杜绝 UID 泄漏。4.1 配置与重启编辑/etc/docker/daemon.json加入userns-remap:default{registry-mirrors:[https://docker.1ms.run,https://docker.xuanyuan.me,https://hub.rat.dev],userland-proxy:false,userns-remap:default}default让 Docker 自动创建一个名为dockremap的系统用户并从/etc/subuid、/etc/subgid里读取它的可映射区间本机为dockremap:100000:65536。然后重启 dockerd$ systemctl restartdocker4.2 验证重映射生效# docker info 中出现了 userns$dockerinfo2/dev/null|grep-iuserns userns# Docker 自动创建了 dockremap 用户$grepdockremap /etc/passwd dockremap:x:112:114::/nonexistent:/bin/false# 容器的存储目录变成了按映射 UID 隔离的目录$ls-ld/var/lib/docker/100000.100000 drwx--x---12root1000004096... /var/lib/docker/100000.1000004.3 容器内 root 到底映射到宿主哪个 UID# 容器内看到的 uid_map$dockerrun--rmbusyboxcat/proc/self/uid_map010000065536含义容器内的 uid 0 → 宿主机的 uid 100000连续映射 65536 个。也就是说容器里的 root在宿主机上真实身份是100000这个普通用户。4.4 实打实的证据写文件看属主我们在宿主机上建两个目录做对照——一个归宿主机 root所有一个归映射出的 100000所有# 归宿主机 root 的目录容器 root 想写 → 被拒证明它已不是真 root$dockerrun--rm-v/tmp/uxtest:/data busyboxsh-ctouch /data/x.txttouch: /data/x.txt: Permission denied# 归 100000 的目录容器 root 能写且宿主机看到属主是 100000$chown100000:100000 /tmp/uxtest2 $dockerrun--rm-v/tmp/uxtest2:/data busyboxsh-cid; touch /data/inside_root.txtuid0(root)gid0(root)groups0(root),10(wheel)$ls-n/tmp/uxtest2/ -rw-r--r--11000001000000... inside_root.txt这个对照极其关键容器内id依旧显示uid0(root)程序毫无感知但宿主机上文件属主是100000且它无法写入宿主机 root 的目录直接Permission denied换句话说即便这个「root 容器」哪天真的逃逸出了 capability 沙箱它在宿主机上也只是个 uid 100000 的普通用户碰不到宿主机 root 的文件。这就是 userns-remap 提供的「纵深防御」。提醒userns-remap 会重启 dockerd且改变所有容器的 UID 视图与存储目录属于全局性变更需在维护窗口操作开启后部分依赖「容器 root 宿主 root」的存量脚本需要适配。5. 方案 B镜像里 USER 指令 / 运行时 -u应用侧最小权限如果暂时不能上 userns-remap应用侧的最低成本止血手段就是别用 root 跑业务进程。两种做法5.1 Dockerfile 里用 USER推荐固化到镜像FROM busybox RUN adduser -D -u 1000 appuser # 镜像内创建普通用户 USER appuser # 后续命令及容器默认进程都用 appuser # CMD [myapp]镜像构建后无论谁来docker run这个镜像默认都是appuseruid 1000从镜像层面把 root 交付给堵死。第 1 节的userimg就是这么做的宿主机看到文件属主为1000。5.2 运行时 -u 覆盖dockerrun--rm-u1000-v$HD:/data busyboxsh-ciduid1000gid0(root)groups0(root)# 临时以 uid 1000 运行适合没法改镜像、或想临时降权的场景。5.3 注意事项非 root 用户可能没有权限绑定 1024 以下的特权端口如 80。要么用反向代理转发要么给NET_BIND_SERVICEcapability要么用非特权高位端口。镜像内最好用adduser真正建好这个用户否则ls显示的是数字1000而不是名字但属主数字本身已生效不影响安全。若同时开启 userns-remap容器内uid 1000还会再叠加一层映射最终在宿主机上落到100000 1000附近的高 UID双重隔离。6. 进阶Rootless Docker真正「不用 root」上面两种方案dockerd 本身还是以宿主机 root 在跑。更彻底的思路是Rootless 模式让 dockerd 自己也运行在某个普通用户的 userns 里整个 Docker 栈都不碰宿主 root。要点与代价依赖/etc/subuid、/etc/subgid给该用户分配子 UID 区间并要求newuidmap/newgidmap提权助手存储驱动通常退化为vfs或用 overlay 但有额外要求磁盘与性能开销更大因不在宿主 root很多需要特权的能力天然受限无法使用--privileged、无法直接使用宿主1024端口需rootlesskit端口转发、Cgroup 限制依赖用户态委托安全收益最大连 dockerd 故障/被攻破影响范围也被锁死在那个用户的 userns 内。对绝大多数业务来说「镜像 USER 非 root 必要时 userns-remap」已经足够Rootless 多用于多租户、强隔离、或监管要求「全程无 root」的场景。7. 总结默认容器 root 宿主机 uid 0往宿主挂载目录写的文件属主是宿主机 root0 0这就是 UID 泄漏且与 capability 沙箱无关。应用侧止血DockerfileUSER appuser或运行时-u 1000让文件在宿主机上归普通 UID是最低成本的修复。User Namespace 本质在内层是 root、在外层是普通 UID 的映射。unshare --user --map-root-user能在小圈子里当 root但普通用户建 userns 常被内核/subuid限制挡住。运维侧纵深防御Dockeruserns-remap: default把容器内 root 统一映射到宿主机高 UID本机0→100000实测容器 root无法写入宿主 root 目录、写出的文件属主为100000——即使逃逸也伤不到宿主。终极形态 Rootlessdockerd 自身也跑在 userns 里安全收益最大但运维代价更高。一句话能用非 root 就别用 root能开 userns-remap 就开着能 Rootless 就更好。8. 思考题既然 userns-remap 把容器 root 映射成宿主 100000那容器里chown 0:0一个文件在宿主机上会是什么属主为什么它「改不了宿主 root 的文件」如果两个不同的容器、都开了 userns-remap它们的 uid 100000 区间会重叠冲突吗Docker 如何避免业务容器已经用USER appuseruid 1000运行还有必要再开 userns-remap 吗二者防护的是同一件事吗unshare --user --map-root-user后在命名空间内id是 root但去cat /etc/shadow宿主文件能读到吗为什么这与容器挂载宿主根目录读 shadow 有何本质区别本篇为容器安全模块第 2 篇。安全模块完结第 1 篇讲清 privileged 与 capabilities 的「权限口子」第 2 篇讲清「不用 root 运行」的 UID 治理。二者合起来正是「最小权限」原则在容器上的两面——既不该多给能力也不该默认站在 root 的高台上。