加固 Linux 主机上的 Docker 安全
Docker组权限等同于root权限。通过无根模式、sudo规则、Podman、容器加固和审计清单,保护Linux上的Docker。
把用户加入 docker 组,就等于把这台机器的 root 权限交给了他。这不是“接近 root”,而是完整的 root:任何能访问 Docker socket 的人,都可以把宿主机文件系统挂载进容器并随意修改。
为了不必每次都输入 sudo,你执行了 usermod -aG docker $USER,这也是 Docker 官方安装后指南建议的做法。可同一份 Docker Engine 的 Linux 安装后配置页面里还有一个提示框,大多数人都一扫而过:加入这个组等同于拥有 root 权限。如果你对基础概念还不太熟悉,可以先阅读 Docker 镜像与容器入门指南。本文先用一条命令证明这一风险,再按防护强度由弱到强介绍修复方案,最后给出一份清单,用于审计从别人手里接手的机器。
核心要点
docker组的任何成员,以及以该成员身份运行的任何进程,都可以通过docker run --rm -it -v /:/host alpine chroot /host sh在宿主机上拿到 root shell。- Rootless Docker 将
dockerd和容器都运行在 user namespace 中。即使守护进程被攻破或发生容器逃逸,攻击者拿到的也只是宿主机上的非特权 UID,而不是 root。 - 针对
/usr/bin/docker的受限 sudoers 规则仍然等同于 root,但它能在使用 Docker 前要求输入密码,并记录每一次调用。 - Podman 没有守护进程,以普通用户身份使用时默认即为 rootless 模式。其 CLI 与 Docker 基本兼容,
alias docker=podman足以覆盖大多数日常工作流。 - 接手一台主机时,应检查以下几项:哪些用户在
docker组中、dockerd以哪个用户身份运行、socket 的权限设置,以及守护进程是否监听 TCP 2375 端口。
为什么 docker 组等同于 root?
docker 组之所以等同于 root,是因为 Docker 守护进程以 root 身份运行,而该组控制着对其 Unix socket 的写权限。能写这个 socket,就能指挥一个 root 进程做任何事。如果想了解底层机制,Linux 文件权限指南解释了文件的组权限如何决定谁能打开它。就 Docker 而言,唯一需要关注的细节是:该 socket 对 docker 组可写。
证明如下:
docker run --rm -it -v /:/host alpine chroot /host sh
这条命令把宿主机的 / bind mount 到容器中,然后 chroot 进去。你现在就拥有了一个作用于真实宿主机文件系统的 root shell,可以修改 /etc/shadow 或 /etc/sudoers,也可以往 /root 中添加 SSH 密钥。整个过程既不需要密码,也不需要利用任何漏洞。以你的用户身份运行的任何脚本、依赖包或被入侵的编辑器扩展,都能做同样的事。Docker 文档在 Docker 守护进程攻击面一节中对此有专门说明。
配置 Rootless Docker
Rootless Docker 是一种运行模式,它将 dockerd 和所有容器都运行在归属于你账户的 user namespace 中,因此是解决 docker 组问题最直接的方案。其 socket 位于 $XDG_RUNTIME_DIR/docker.sock,而不是 /var/run/docker.sock。namespace 由 RootlessKit 创建。setuid 辅助程序 newuidmap 和 newgidmap 负责将 namespace 中的 UID 0 映射为你自己的 UID,并把 /etc/subuid 和 /etc/subgid 中的一段范围映射给容器内的其他 UID。网络则通过用户态网络栈实现,例如 slirp4netns、gvisor-tap-vsock 或 pasta。
先安装前置依赖,然后检查你的从属 ID(subordinate ID)范围。rootless 模式文档要求在 /etc/subuid 和 /etc/subgid 中为你的用户分配至少 65,536 个从属 ID。
sudo apt-get install -y uidmap docker-ce-rootless-extras
grep ^$(whoami): /etc/subuid /etc/subgid
停用 root 守护进程,然后以普通用户身份运行安装工具:
sudo systemctl disable --now docker.service docker.socket
dockerd-rootless-setuptool.sh install
systemctl --user enable --now docker
sudo loginctl enable-linger $USER
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/docker.sock
enable-linger 这一行能让你的守护进程在注销后继续运行。确认 rootless Docker 正常工作后,用 sudo gpasswd -d $USER docker 把自己从原来的组中移除。其他发行版的配置方法请参阅官方 rootless 模式文档。如果之后客户端无法连接守护进程,可参考排查“Docker daemon not running”错误一文,其中介绍了 socket 和 systemd 相关的检查步骤。
Rootless 模式依赖非特权 user namespace。Ubuntu 24.04 及更高版本默认通过 AppArmor 限制了这一功能。Ubuntu 的 apparmor 软件包已经自带 rootlesskit 所需的 profile,因此通过 apt 安装 docker-ce-rootless-extras 无需额外步骤。但如果使用 get.docker.com/rootless 脚本安装,就需要自行添加该 profile。不要通过 kernel.apparmor_restrict_unprivileged_userns=0 在全系统范围内关闭这一限制。其他故障场景请参阅 rootless 故障排查页面。
Rootless 模式有哪些代价?
与 rootful 守护进程相比,主要的权衡如下:
| 限制 | 解决办法 |
|---|---|
| 默认无法绑定 1024 以下的端口 | sysctl net.ipv4.ip_unprivileged_port_start=80,或使用反向代理 |
--privileged 不会授予真正的宿主机特权 | 仅 user namespace 内的 capabilities 生效 |
| 用户态网络比 bridge 驱动慢 | 使用实验性的 lxc-user-nic 驱动,或在上生产前做好基准测试 |
| 5.11 之前的内核需要 fuse-overlayfs | 5.11+ 内核原生支持 overlay2 |
故障排查页面还列出了其他一些限制,例如不支持 AppArmor、checkpoint 和 overlay 网络。
使用受限规则的 sudo
当 rootless 模式与你的工作负载不兼容时,改为要求通过 sudo 使用 Docker、而不是依赖 docker 组成员身份,是一种折中方案。这仍然等同于 root,因为 sudo docker 同样可以执行上面的 chroot 命令。但你换来的是:每次调用都需要输入密码,并会留下一条审计日志。以你的用户身份静默运行的代码也就无法再直接访问 socket。
sudo gpasswd -d alice docker
sudo visudo -f /etc/sudoers.d/docker
alice ALL=(root) /usr/bin/docker
该规则只允许执行 Docker 二进制文件,而不是任意命令。可以用 journalctl _COMM=sudo 审查使用记录。安装后配置文档中还介绍了一种 newgrp 方法:在 CLI 仍以你自己的用户身份运行的同时,为 Docker 加上一层组密码保护。
改用 Podman
Podman 没有守护进程,以普通用户身份运行时默认就是 rootless 模式。容器作为你用户的子进程运行在 user namespace 中,使用同样的 /etc/subuid 映射。它的 CLI 与 Docker 高度一致,大多数命令无需修改即可使用。
sudo apt-get install -y podman
alias docker=podman
podman run --rm -p 8080:80 docker.io/library/nginx
差异主要集中在边缘场景:需要使用完全限定的镜像名、Compose 支持程度不同,以及依赖 Docker socket 的工具。在让整个团队迁移之前,请先测试你的脚本。
加固容器本身
无论运行哪种守护进程,都应限制容器内进程的能力。以下四项设置可以覆盖大部分风险:使用非 root 的 USER;只保留应用必需的 capabilities;根文件系统只读;禁止通过 setuid 二进制文件提权。
FROM node:22-alpine
WORKDIR /app
COPY --chown=node:node . .
USER node
CMD ["node", "server.js"]
docker run -d \
--cap-drop=ALL \
--read-only --tmpfs /tmp \
--security-opt no-new-privileges \
-p 8080:8080 myapp
Dockerfile 的 USER 指令用于设置运行时 UID。--cap-drop=ALL 会移除所有 capabilities;如果应用确实需要某项能力,可以通过 --cap-add 加回来。本例中的应用监听 8080 端口,因此不需要任何 capability。--read-only 配合 --tmpfs,意味着只有 /tmp 可写。no-new-privileges 可以阻止 setuid 二进制文件提升权限。以上选项均可在 docker run 参考文档中查到。
审计接手的机器
面对一台别人配置的 VPS,需要回答四个问题:哪些用户在 docker 组中?dockerd 以哪个用户身份运行?谁可以写 socket?守护进程是否在网络上监听?
getent group docker
ps -eo user,cmd | grep [d]ockerd
ls -l /var/run/docker.sock
sudo ss -ltnp | grep -E ':2375|:2376'
systemctl cat docker | grep -- '-H'
grep -s hosts /etc/docker/daemon.json
getent 输出的每一个用户名都拥有 root 权限。如果 ps 显示的是 root,说明守护进程是 rootful 模式。socket 的权限应为 srw-rw---- root docker,一旦发现任何对所有人可写(world-writable)的权限位,必须立即修复。如果 2375 端口有监听,说明 Docker API 在没有 TLS 保护的情况下对外开放。当前版本的 Docker Engine 会拒绝以这种配置启动,但接手主机上的旧版引擎可能仍在这样运行。任何能访问该端口的人都拥有 root 权限。在 2376 端口使用 TLS 监听更安全一些,但持有客户端密钥的人依然拥有 root 权限。请按照保护守护进程 socket 指南改用 SSH 或双向 TLS。此外,还要检查是否有容器挂载了该 socket,因为每一个这样的容器都相当于一个 root 入口:
docker ps -q | xargs -r docker inspect \
--format '{{.Name}} {{range .Mounts}}{{.Source}} {{end}}' | grep docker.sock
对 Docker socket 的访问权就是 root 权限,没有任何余地。先在你使用的每台机器上运行 getent group docker,然后要么将这些用户迁移到 rootless Docker 或 Podman,要么把 socket 放到带日志记录的 sudo 规则之后。
常见问题
rootless Docker 和 userns-remap 有什么区别?
使用 userns-remap 时,dockerd 仍以 root 身份运行,只有容器运行在重新映射的从属 UID 下,例如在 /etc/docker/daemon.json 中将 userns-remap 设置为 'default' 时创建的 dockremap 用户。它无法堵住 docker 组的漏洞:组成员可以传入 --userns=host,为单个容器关闭重映射,然后执行 chroot 逃逸。而 rootless 模式会让守护进程本身以你的用户身份运行。
rootless 和 rootful Docker 可以在同一台机器上共存吗?
可以。两个守护进程使用不同的 socket:rootful 使用 /var/run/docker.sock,rootless 使用 $XDG_RUNTIME_DIR/docker.sock。运行 dockerd-rootless-setuptool.sh install 会创建一个名为 'rootless' 的 CLI context 并切换过去。使用 docker context use default 可指向 root 守护进程,使用 docker context use rootless 可切换回来。只要 rootful 守护进程仍在运行,任何能写其 socket 的人依然拥有 root 权限。
现有的镜像和容器会迁移到 rootless Docker 吗?
不会。rootless 守护进程使用独立的数据根目录,默认为 ~/.local/share/docker,与 root 守护进程使用的 /var/lib/docker 目录相互独立。在 rootful Docker 下创建的镜像、容器和卷对它不可见。你需要重新拉取镜像,或者在 rootful 守护进程上用 docker save 导出,再在 rootless 守护进程上用 docker load 导入。如果有备份任务只覆盖了 /var/lib/docker,也要相应更新。
为什么 rootless 容器写入的文件在宿主机上显示的属主很奇怪?
Rootless Docker 会把容器 UID 映射为宿主机 UID。容器内的 root 映射为你自己的 UID,因此它写入 bind mount 的文件在宿主机上显示为你的文件。容器内的其他 UID 则映射到你在 /etc/subuid 中的范围。如果该范围从 100000 开始,容器 UID 1000 就会变成宿主机 UID 100999,你的用户无法直接编辑这些文件。在容器内将文件 chown 为 0:0,即可把它们归还给你的用户。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k