Docker 为什么会不安全:论文复现与本机实验
AI 摘要读完一篇 Docker Hub 漏洞论文后,我在本机扫了几个常用镜像,又试了 root、bind mount 和 Docker socket 的实际边界。
我一直觉得 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,打开了几层边界?
教学权重,不是漏洞扫描结果;重点看配置如何互相放大。
需要关注
镜像可能老化;容器内 root 会放大后续错误配置的影响。
先打开“宿主目录可写”,观察它怎样和 root 叠加;再打开 Docker socket。
论文做了什么
这篇论文写了一套 DIVA(Docker Image Vulnerability Analysis)框架,用来批量发现、下载和分析 Docker Hub 的官方与社区镜像。他们扫了 356,218 个镜像版本,看到了几个很稳定的现象:
- 按全部版本统计,官方和社区镜像平均都带有超过 180 个漏洞;
- 超过 80% 的两类镜像至少有一个高危漏洞;
- 很多镜像几百天没有更新;
- 父镜像的漏洞会沿着依赖关系传到子镜像。
2017 年的具体漏洞数当然不能套到今天。我觉得这篇论文现在还值得看,是因为它把镜像的性质讲清楚了:它是一个可复用、可传播,也会过期的软件快照。子镜像用起来再新,也不会自动洗掉父层的问题。
本文复现的是论文指出的镜像老化现象,另外补了几组本机权限边界实验。DIVA 框架本身需要枚举十万级仓库、下载几十万镜像版本,不在这篇文章的实验范围内。
实验环境如下:
| 项 | 值 |
|---|---|
| 日期 | 2026-07-09 |
| Docker Engine | 29.1.3 |
| runc | 1.3.4-0ubuntu1~24.04.1 |
| Storage driver | overlayfs |
| 安全选项 | AppArmor、内置 seccomp、cgroup namespace |
| Docker rootless | false |
| 当前用户 | treecat 在 docker 组 |
宿主文件的写入验证只用了 /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 的 UpdatedAt 是 2026-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 数。
| 镜像 | OS | Critical 条目 | High 条目 | 去重漏洞 ID | 涉及包数 |
|---|---|---|---|---|---|
python:3.11-slim-bookworm | Debian 12.14 | 4 | 19 | 13 | 19 |
node:22-bookworm | Debian 12.14 | 49 | 600 | 325 | 90 |
ubuntu:22.04 | Ubuntu 22.04 | 0 | 1 | 1 | 1 |
alpine:3.20 | Alpine 3.20.10 | 0 | 0 | 0 | 0 |
nginx:1.25-alpine | Alpine 3.19.1 | 3 | 17 | 16 | 10 |
这五个样本不能代表 Docker Hub,但已经能看到论文里的问题。node:22-bookworm 创建于 2026-05-20,tag 看上去也很新,扫描结果仍然很长。它安装的系统包和语言生态依赖更多,因而 Trivy 要检查的包和漏洞条目也更多;这个数字不能直接换算成可利用的攻击面。
Trivy 还提醒,alpine:3.20.10 和 nginx:1.25-alpine 里的 Alpine 3.19/3.20 分支已不再获得发行版安全更新,检测结果可能不全。alpine:3.20 表面上是 0,却不能因此推出“没有安全问题”。支持周期结束后,弱化的不只是修复通道,连检测数据都可能变差。
我又做了一个更朴素的检查:只刷新包索引,不升级,统计已有新版本的系统包。
| 镜像 | 镜像创建时间 | 系统 | 可升级包数量 |
|---|---|---|---|
python:3.11-slim-bookworm | 2026-07-06 | Debian bookworm | 0 |
node:22-bookworm | 2026-05-20 | Debian bookworm | 29 |
ubuntu:22.04 | 2026-05-09 | Ubuntu jammy | 14 |
alpine:3.20 | 2026-04-16 | Alpine | 0 |
nginx:1.25-alpine | 2024-05-03 | Alpine | 18 |
可升级包数不是 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:1000 | uid=1000 | 0000000000000000 | 非 root,无有效 capability |
--cap-drop=ALL | uid=0(root) | 0000000000000000 | 仍是 root,但 capability 已清空 |
--privileged | uid=0(root) | 000001ffffffffff | capability 明显扩大,可见设备也多很多 |
对前三种普通运行方式,容器内 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
treecat 在 docker 组,所以不需要 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 mode、userns-remap与 bind mounts,以及 OWASP Docker Security Cheat Sheet。