Docker 为什么会不安全:论文复现与本机实验

AI 摘要读完一篇 Docker Hub 漏洞论文后,我在本机扫了几个常用镜像,又试了 root、bind mount 和 Docker socket 的实际边界。

文章目录5 节
  1. 论文做了什么
  2. 镜像的名字很新,内容未必新
  3. root 和 bind mount
  4. Docker socket 的权限有多大
  5. 我现在会怎么用 Docker

我一直觉得 Docker 安全问题有点容易被讲偏:一说就是容器逃逸,好像只要没撞上内核漏洞,镜像里的东西就和宿主无关了。

实际上,我平时更容易遇到的是很普通的配置:两年没重建的镜像,默认 root 运行,为了方便把宿主目录可写挂载进去,或者直接把 /var/run/docker.sock 交给容器。这些都不神秘,却比一条完整的逃逸利用链更常见。

读完 Rui Shu、Xiaohui Gu 和 William Enck 在 CODASPY 2017 发表的 A Study of Security Vulnerabilities on Docker Hub,我在自己的机器上做了一组小实验。我想看看,论文里讲的镜像老化和依赖传播,放到现在一台普通开发机上,会长什么样。

这篇文章后面会分开讲镜像、身份、挂载和 Docker socket,但真实风险往往来自它们叠在一起。先用下面这个“配置放大器”拼一下自己的常见启动方式:它不是扫描器,分数也不是 CVSS,只用来显示哪些边界正在被逐层打开。

威胁模型 · 点击组合

一条 docker run,打开了几层边界?

教学权重,不是漏洞扫描结果;重点看配置如何互相放大。

3/ 10

需要关注

镜像可能老化;容器内 root 会放大后续错误配置的影响。

镜像层容器边界宿主资源Docker daemon
2 项已打开

先打开“宿主目录可写”,观察它怎样和 root 叠加;再打开 Docker socket。

论文做了什么

这篇论文写了一套 DIVA(Docker Image Vulnerability Analysis)框架,用来批量发现、下载和分析 Docker Hub 的官方与社区镜像。他们扫了 356,218 个镜像版本,看到了几个很稳定的现象:

  • 按全部版本统计,官方和社区镜像平均都带有超过 180 个漏洞;
  • 超过 80% 的两类镜像至少有一个高危漏洞;
  • 很多镜像几百天没有更新;
  • 父镜像的漏洞会沿着依赖关系传到子镜像。

2017 年的具体漏洞数当然不能套到今天。我觉得这篇论文现在还值得看,是因为它把镜像的性质讲清楚了:它是一个可复用、可传播,也会过期的软件快照。子镜像用起来再新,也不会自动洗掉父层的问题。

本文复现的是论文指出的镜像老化现象,另外补了几组本机权限边界实验。DIVA 框架本身需要枚举十万级仓库、下载几十万镜像版本,不在这篇文章的实验范围内。

实验环境如下:

日期2026-07-09
Docker Engine29.1.3
runc1.3.4-0ubuntu1~24.04.1
Storage driveroverlayfs
安全选项AppArmor、内置 seccomp、cgroup namespace
Docker rootlessfalse
当前用户treecatdocker

宿主文件的写入验证只用了 /tmp/spc-docker-proof,没有碰敏感路径,也没有去复现容器逃逸。拉镜像依然会写 Docker data root,Trivy 的报告和数据库缓存则在 /tmp/docker-security-repro

镜像的名字很新,内容未必新

我用 Trivy 0.72.0 扫了本地已有的几个 linux/amd64 镜像,保留 unfixed,只看 high 和 critical。同一个漏洞可能影响多个包或文件,所以我同时记了条目数和去重后的漏洞 ID。

扫描时间是 2026-07-09 11:51–11:52 UTC,Trivy DB 的 UpdatedAt2026-07-09T07:49:39Z。主命令如下,实际执行时为每个镜像换了输出文件名:

IMAGE_REF='nginx@sha256:516475cc129da42866742567714ddc681e5eed7b9ee0b9e9c015e464b4221a00'
OUTPUT='nginx_1.25-alpine.json'

docker run --rm \
  -v /tmp/docker-security-repro/trivy-cache:/root/.cache/ \
  -v /tmp/docker-security-repro/out:/out \
  aquasec/trivy@sha256:cffe3f5161a47a6823fbd23d985795b3ed72a4c806da4c4df16266c02accdd6f image \
  --db-repository ghcr.io/aquasecurity/trivy-db:2 \
  --timeout 20m --no-progress --scanners vuln \
  --severity HIGH,CRITICAL --format json \
  --output "/out/$OUTPUT" "$IMAGE_REF"

为避免 tag 漂移,这是当时五个本地镜像的完整 digest:

python@sha256:f5cf0344c9886ff24d34797578d5d7dd6e8911ae0fe5962bb55d0f89603ec361
node@sha256:1031993481795705055273f2eef0c24597abdcb277d6e058c82f78cbbdef92a6
ubuntu@sha256:4f838adc7181d9039ac795a7d0aba05a9bd9ecd480d294483169c5def983b64d
alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc
nginx@sha256:516475cc129da42866742567714ddc681e5eed7b9ee0b9e9c015e464b4221a00

镜像内容现在可以对上,但漏洞库会继续变,所以同一份镜像在未来也未必得到完全相同的 CVE 数。

镜像OSCritical 条目High 条目去重漏洞 ID涉及包数
python:3.11-slim-bookwormDebian 12.144191319
node:22-bookwormDebian 12.144960032590
ubuntu:22.04Ubuntu 22.040111
alpine:3.20Alpine 3.20.100000
nginx:1.25-alpineAlpine 3.19.13171610

这五个样本不能代表 Docker Hub,但已经能看到论文里的问题。node:22-bookworm 创建于 2026-05-20,tag 看上去也很新,扫描结果仍然很长。它安装的系统包和语言生态依赖更多,因而 Trivy 要检查的包和漏洞条目也更多;这个数字不能直接换算成可利用的攻击面。

Trivy 还提醒,alpine:3.20.10nginx:1.25-alpine 里的 Alpine 3.19/3.20 分支已不再获得发行版安全更新,检测结果可能不全。alpine:3.20 表面上是 0,却不能因此推出“没有安全问题”。支持周期结束后,弱化的不只是修复通道,连检测数据都可能变差。

我又做了一个更朴素的检查:只刷新包索引,不升级,统计已有新版本的系统包。

镜像镜像创建时间系统可升级包数量
python:3.11-slim-bookworm2026-07-06Debian bookworm0
node:22-bookworm2026-05-20Debian bookworm29
ubuntu:22.042026-05-09Ubuntu jammy14
alpine:3.202026-04-16Alpine0
nginx:1.25-alpine2024-05-03Alpine18

可升级包数不是 CVE 数,也不代表每个包都能被利用。它只是把“快照会过期”这件事显示得更直观。本机当时缓存的 nginx:1.25-alpine digest 创建于 2024 年 5 月,两年后里面有 18 个可升级包。tag 自身可以移动,真正固定这份内容的是上面记录的 digest。

所以我现在不太信镜像 tag 给人的时间感。安全状态还是得看扫描结果、发行版支持周期和实际重建时间。

root 和 bind mount

同一个 alpine:3.20,我分别用默认参数、非 root、丢掉所有 capability 和 privileged 运行了一遍:

启动方式容器内身份CapEff现象
默认uid=0(root)00000000a80425fb容器内 root,带 Docker 默认 capability 集
--user 1000:1000uid=10000000000000000000非 root,无有效 capability
--cap-drop=ALLuid=0(root)0000000000000000仍是 root,但 capability 已清空
--privilegeduid=0(root)000001ffffffffffcapability 明显扩大,可见设备也多很多

对前三种普通运行方式,容器内 root 不等于已经拿到宿主 root,namespace、capability、seccomp 和 AppArmor 仍然构成边界。--privileged 是表里的例外:它授予全部 capability 和宿主设备访问,也不再按 Docker 默认的 seccomp / AppArmor 配置来限制进程。

即便没有 --privileged,容器内 root 也不是一个无害的普通用户。再叠加可写挂载、设备权限或 runtime 漏洞,root 会把影响放大。

比较合适的起点是先把权限收紧,确认业务需要什么再加回去:

docker run --rm \
  --user 1000:1000 \
  --cap-drop=ALL \
  --security-opt no-new-privileges \
  alpine:3.20 id

接着我把 /tmp/spc-docker-proof bind mount 进容器。默认 root 容器写出来的文件,在宿主上是:

-rw-r--r-- 1 0 0 15 Jul  9 04:29 /tmp/spc-docker-proof/owned-by-container

换成 --user 1000:1000 后:

-rw-r--r-- 1 1000 1000 20 Jul  9 04:29 /tmp/spc-docker-proof/owned-by-user

在本文这台 rootful、没有开启 user namespace remap 的主机上,bind mount 里的 UID 没有被额外映射:容器进程以什么 UID 写,宿主就看到什么 UID。这也是这类开发环境里目录突然冒出 root-owned 文件的常见原因。rootless Docker 或 userns-remap 会做 UID/GID 映射,不能把这个现象直接套过去。

权限错乱只是表象。如果挂载的是源码、SSH 目录、Docker 配置甚至系统路径,容器进程就真实拿到了这些宿主文件的读写权。一份不可信镜像和宿主文件系统之间,此时只隔着 mount 参数。

Docker socket 的权限有多大

我这台机器上的 Docker socket 是:

srw-rw---- 1 root docker 0 Jun  6 03:23 /var/run/docker.sock

treecatdocker 组,所以不需要 sudo 就能请求 daemon 创建容器、挂载路径和启动工作负载。使用上很方便,但这也说明 docker 组实际上是一个高权限控制面。

验证到这一层已经足够:在容器里运行 Docker CLI,并把宿主 socket 挂载进去,容器里直接读到了宿主 daemon:

inside container can talk to host docker: server=29.1.3

到这一步就够了,没必要在博客里展示如何继续放大权限。/var/run/docker.sock 后面是 Docker daemon API,不是日志文件。拿到这个 API 的容器,可以请求 daemon 创建新容器、挂宿主路径、运行高权限工作负载。CI 里为了构建镜像而挂 socket 时,它应该被当成 root 级权限设计,不能当成一个普通 volume。

我现在会怎么用 Docker

这组实验只测了镜像新鲜度和 rootful 配置的几个边界,没有测内核逃逸,也不能用来证明 Docker “天生不安全”。它证明的只是,容器和虚拟机不共用同一种隔离模型。容器共享宿主内核,边界由 namespace、cgroup、capability、seccomp 和 AppArmor/SELinux 一起构成。

我现在会在 CI 里扫镜像,并用 digest 固定每次构建的输入。但“固定”不是永久冻结:自动化仍然要跟踪上游更新,通过重建、扫描和测试后主动刷新 digest。

运行时则从非 root、少 capability 和只读挂载开始,业务容器不碰 Docker socket。如果 CI 确实需要这个 socket,我会把那个构建环境直接当成 root 级控制面隔离,而不是在 compose 里多挂一个 volume 就算完事。rootless mode 和 user namespace 值得用,却不会替一个可写的宿主挂载或已经暴露的 Docker socket 收拾后果。

延伸参考:NIST SP 800-190、Docker 的 Rootless modeuserns-remapbind mounts,以及 OWASP Docker Security Cheat Sheet