1. 容器隔离的信任边界:从一个命名空间缺陷说起
容器技术这几年几乎成了后端部署的默认选项,Docker 把应用和依赖打包在一起,跑起来轻量、启动快、资源占用低,很多人下意识会觉得“容器就是一个轻量级虚拟机,天然安全隔离”。这个认知其实有偏差。容器本质上是宿主机上的一组进程,靠 Linux 内核的 namespace 做视图隔离、靠 cgroup 做资源限制、靠 capability 和 seccomp 做权限收敛。隔离的强度取决于这些内核机制配置得对不对,一旦某个环节有缺口,容器里的进程就可能摸到宿主机的资源。
CVE-2020-15257 就是这样一个典型的隔离边界缺陷。它影响的是 containerd 这个组件,具体出问题的地方在containerd-shim处理abstract unix domain socket的方式上。攻击者如果已经拿到了一个容器的 shell,并且这个容器和宿主机共享了网络命名空间(比如用了--net=host),就能通过这个 socket 与 shim 通信,进而以 root 身份在宿主机上执行操作,实现容器逃逸。这个漏洞的 CVSS 评分是 5.3,属于中危,但它的实际危害不小,因为它不需要什么复杂的利用链,条件满足时利用门槛很低。
我写这篇东西,是想把这类漏洞的来龙去脉讲清楚:它为什么会产生、影响范围到底有多大、怎么判断自己的环境是否中招、怎么修、修完之后怎么验证。适合已经用过 Docker、对容器有基本操作经验、但没深入过内核隔离机制的开发和运维同学。看完你应该能独立完成一次自查和修复,也能理解容器安全里“共享命名空间”这件事为什么值得警惕。
2. 漏洞的成因拆解:abstract socket 与共享命名空间的碰撞
2.1 containerd-shim 到底在干什么
要理解这个漏洞,得先知道containerd-shim的角色。Docker 的架构里,dockerd负责接收命令,真正干活的是 containerd,而 containerd 又会为每个容器启动一个containerd-shim进程。这个 shim 进程的作用是“托管”容器的主进程:它让容器进程和 containerd 解耦,这样即使 containerd 重启,容器也不会跟着挂掉;同时它负责收集容器的退出状态、转发信号、管理 stdin/stdout 等。
shim 和 containerd 之间需要通信,通信方式之一就是 unix domain socket。unix socket 有两种:一种是基于文件系统的,会在磁盘上生成一个 socket 文件,有路径;另一种是 abstract socket,它没有文件实体,名字存在内核里,通过一个前导的\0字节来标识。abstract socket 的好处是不依赖文件系统,不用担心路径权限和清理问题,所以 containerd 的 shim 用了它。
问题就出在这里:abstract socket 是属于网络命名空间的。也就是说,两个进程要能通过 abstract socket 通信,它们必须处在同一个网络命名空间里。正常情况下,每个容器有自己的网络命名空间,shim 在宿主机的命名空间,两者隔离,容器里的进程看不到 shim 的 socket。但如果容器被配置成共享宿主机的网络命名空间,这个隔离就没了。
2.2 共享网络命名空间为什么是导火索
docker run --net=host这个参数,会让容器直接使用宿主机的网络命名空间。用它的场景其实不少:需要极致网络性能、需要容器直接绑定宿主机端口、或者一些监控类容器需要看到宿主机所有网卡。用了这个参数之后,容器里的进程和宿主机进程共享同一套网络栈,包括所有的 abstract socket。
这时候,容器里的进程只要知道 shim 的 abstract socket 名字,就能连上去。而 shim 的 socket 名字是有规律的,它由 containerd 生成,格式大致是\0containerd-shim/<hash>/<id>这样的结构。攻击者可以通过遍历或者猜测的方式找到它。一旦连上,shim 提供的 API 里有一些操作是可以被利用的,比如它能接受创建新进程的请求。由于 shim 是以 root 身份运行的,攻击者就能借它之手在宿主机上以 root 执行命令。
这里的关键点在于:漏洞本身不是 shim 的 API 设计有逻辑漏洞,而是它暴露在了一个不该暴露的命名空间里。如果容器没有共享网络命名空间,容器进程根本看不到这个 socket,也就无从利用。所以这个漏洞的触发条件里,“共享网络命名空间”是必要的一环。
2.3 影响版本与组件范围
这个漏洞影响的是 containerd 1.3.x 系列(1.3.0 到 1.3.8)以及 1.4.x 系列(1.4.0 到 1.4.2)。修复版本是 1.3.9 和 1.4.3。需要注意的是,Docker 本身并不直接包含 containerd 的代码,但 Docker 依赖 containerd,所以受影响的 Docker 版本取决于它捆绑的 containerd 版本。一般来说,Docker 19.03.x 早期版本会受影响,具体要看发行版打包时用的 containerd 版本。
另外,Kubernetes 集群如果用了 containerd 作为运行时,也可能受影响,尤其是那些允许 Pod 使用hostNetwork: true的集群。不过 Kubernetes 默认的 Pod 网络是独立的,只有显式配置了 hostNetwork 的 Pod 才有风险。
3. 自查:判断你的环境是否在风险范围内
3.1 确认 containerd 版本
第一步是查 containerd 的版本。在宿主机上执行:
containerd --version或者如果 containerd 是通过 systemd 管理的,可以看服务状态:
systemctl status containerd输出里会带版本号。如果版本落在 1.3.0–1.3.8 或 1.4.0–1.4.2 之间,就需要警惕。如果用的是 Docker,可以用:
docker version看 Server 部分的输出,但 Docker 的版本号不一定直接对应 containerd 版本,更可靠的方式还是直接查 containerd。
3.2 检查是否有容器使用了 host 网络
这一步是判断实际暴露面。列出所有正在运行的容器,看它们的网络模式:
docker ps --format '{{.ID}} {{.Names}} {{.Networks}}'如果Networks字段显示host,说明这个容器共享了宿主机的网络命名空间。也可以用:
docker inspect --format '{{.Name}} {{.HostConfig.NetworkMode}}' $(docker ps -q)NetworkMode为host的就是风险容器。
对于 Kubernetes,检查是否有 Pod 设置了hostNetwork: true:
kubectl get pods --all-namespaces -o jsonpath='{range .items[?(@.spec.hostNetwork==true)]}{.metadata.namespace}{"/"}{.metadata.name}{"\n"}{end}'3.3 一个快速验证思路
如果你想确认漏洞是否真的可被利用,可以在一个--net=host的容器里尝试列出 abstract socket。Linux 下可以用ss命令:
ss -a -x | grep containerd如果能看到类似containerd-shim的 abstract socket 条目,说明容器确实能感知到 shim 的 socket,风险是实打实的。这个命令只是查看,不涉及任何攻击行为,属于安全自查的常规手段。
注意:自查操作请在你有权限的环境里进行,不要对生产环境做未授权的探测。
4. 修复方案:升级、配置收敛与纵深防御
4.1 首选方案:升级 containerd
最直接、最彻底的修复就是升级 containerd 到 1.3.9 或 1.4.3 及以上版本。官方在修复版本里改动了 shim 的 socket 处理逻辑,具体做法是给 abstract socket 加上了访问控制,使得只有特定命名空间里的进程才能连接。
升级步骤(以 systemd 管理的环境为例):
# 先停掉相关服务 systemctl stop docker systemctl stop containerd # 备份当前二进制 cp /usr/bin/containerd /usr/bin/containerd.bak cp /usr/bin/containerd-shim /usr/bin/containerd-shim.bak # 下载并替换新版本二进制(具体下载方式依发行版而定) # 假设已经拿到 1.4.3 的二进制包 tar -xzf containerd-1.4.3-linux-amd64.tar.gz cp bin/containerd /usr/bin/containerd cp bin/containerd-shim /usr/bin/containerd-shim cp bin/containerd-shim-runc-v2 /usr/bin/containerd-shim-runc-v2 # 重启服务 systemctl start containerd systemctl start docker升级完之后用containerd --version确认版本号已经变了。这里有个坑:有些发行版的包管理器和手动替换二进制会冲突,如果你是用 apt 或 yum 装的,优先用包管理器升级,避免版本混乱。
4.2 次选方案:避免不必要的 host 网络
如果因为某些原因暂时没法升级,或者升级窗口还没到,可以先把使用--net=host的容器梳理一遍。很多容器用 host 网络其实是不必要的,比如一些内部服务完全可以用 bridge 网络加端口映射来替代。把非必要的 host 网络容器改成默认网络,能直接消除这个漏洞的触发条件。
改法很简单,把docker run里的--net=host去掉,改用-p做端口映射:
# 原来 docker run --net=host myapp # 改成 docker run -p 8080:8080 myapp对于 Kubernetes,把 Pod spec 里的hostNetwork: true去掉,改用 Service 暴露。这个改动可能需要调整一些依赖宿主机网络的逻辑,比如某些监控 agent 需要看到所有网卡,那就得评估是否真的必须。
4.3 纵深防御:限制容器能力
即使升级了,也建议顺手做一层权限收敛。Docker 默认给容器的 capability 里有一些是不必要的,可以用--cap-drop去掉:
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myapp这样即使将来出现类似漏洞,攻击者能做的事情也被限制住了。另外,启用 seccomp 默认配置、避免用--privileged,都是常规的加固手段。--privileged会关掉大部分隔离,等于把容器变成半个宿主机进程,能不用就不用。
5. 验证与回归:修完之后怎么确认真的安全了
5.1 版本确认与 socket 行为验证
升级完成后,除了看版本号,还可以再跑一次之前的自查命令。在一个--net=host的容器里执行ss -a -x | grep containerd,如果修复生效,应该看不到 shim 的 abstract socket,或者即使看到也无法连接。这是因为修复后的 shim 对 socket 的访问做了限制,非授权命名空间的连接会被拒绝。
5.2 功能回归检查
升级 containerd 是有一定风险的操作,因为它直接关系到容器的生命周期管理。升级后要重点验证几件事:
- 已有容器是否还能正常重启、停止、删除
- 新建容器是否正常
- 容器的日志收集是否正常(shim 负责 stdout/stderr 转发)
- 如果用了 Kubernetes,节点上的 Pod 是否正常调度和运行
我自己的习惯是先在测试环境升级,跑一遍完整的容器生命周期操作,确认没问题再上生产。生产环境升级时,尽量选业务低峰期,并且准备好回滚方案——把旧版本的二进制备份好,万一出问题能快速切回去。
5.3 持续监控
漏洞修完了不代表一劳永逸。建议把 containerd 的版本纳入资产清单,定期检查是否有新版本发布。同时监控是否有容器异常地使用了 host 网络,可以在 CI/CD 流程里加一道检查,禁止--net=host出现在部署配置里,除非有明确的审批。
6. 常见问题与排查实录
6.1 升级后容器起不来怎么办
这是升级 containerd 后最常见的问题。原因通常是新旧版本的 shim 二进制不匹配,或者 runc 版本不兼容。排查思路:
- 看 containerd 的日志:
journalctl -u containerd -n 100 - 看具体容器的错误:
docker logs <container>或者docker inspect <container>看状态 - 确认
containerd-shim和containerd-shim-runc-v2都在正确路径下,且版本和 containerd 匹配
如果报的是 runc 相关错误,可能需要同步升级 runc。containerd 1.4.x 对 runc 的版本有最低要求,太老的 runc 会不兼容。
6.2 怎么判断一个容器是否真的需要 host 网络
这个问题经常被问到。我的判断标准是:如果容器需要绑定宿主机的特定网卡、需要看到宿主机的所有网络接口、或者对网络延迟极度敏感(比如某些高性能网关),才考虑 host 网络。普通的 Web 服务、数据库、缓存,用 bridge 网络加端口映射完全够用,性能差异在大多数场景下可以忽略。
6.3 漏洞速查表
| 检查项 | 命令/方法 | 风险判断 |
|---|---|---|
| containerd 版本 | containerd --version | 1.3.0–1.3.8 或 1.4.0–1.4.2 为受影响 |
| 容器网络模式 | docker inspect --format '{{.HostConfig.NetworkMode}}' <id> | host为高风险 |
| abstract socket 可见性 | 容器内ss -a -x | grep containerd | 能看到则暴露 |
| Kubernetes hostNetwork | kubectl get pods -o jsonpath=... | true为高风险 |
| 容器 capability | docker inspect --format '{{.HostConfig.CapAdd}}' <id> | 有SYS_ADMIN等为高风险 |
6.4 几个容易踩的坑
第一个坑是只升级了 Docker 没升级 containerd。Docker 和 containerd 是分开打包的,有些环境里 Docker 升级了,但 containerd 还是老的,漏洞依然在。一定要单独确认 containerd 版本。
第二个坑是以为不用 host 网络就绝对安全。这个漏洞的触发确实需要共享网络命名空间,但如果你环境里有其他机制能让容器进程进入宿主机命名空间(比如某些特权操作),风险依然存在。所以升级才是根本解法,配置收敛只是缓解。
第三个坑是升级后忘了重启容器。containerd 升级后,已经运行的容器还是用旧的 shim 进程,需要把容器重启一遍才能让新的 shim 生效。滚动重启是个稳妥的做法。
7. 从这一个漏洞看容器安全的通用思路
CVE-2020-15257 给我的最大启发是:容器的隔离不是靠单一机制实现的,而是 namespace、cgroup、capability、seccomp 等多层机制叠加的结果。任何一层被削弱,整体安全性都会下降。共享网络命名空间这件事,表面上看只是网络配置,实际上它打破了 namespace 隔离的完整性,让原本不可见的资源变得可见。
我在实际环境里做容器加固时,会遵循几个原则。第一,默认拒绝,能用默认网络就不用 host 网络,能用非 root 用户就不用 root。第二,最小权限,capability 按需添加,不要图省事直接--privileged。第三,及时更新,containerd、runc、Docker 这些底层组件要纳入常规补丁管理。第四,纵深防御,不指望某一个机制能挡住所有攻击,而是让攻击者即使突破一层也还有下一层拦着。
这套思路不只适用于这一个漏洞,对大多数容器相关的安全问题都通用。容器安全没有银弹,靠的是一层一层把口子收窄。