这篇文章已发表一年多。较旧的文章可能包含过时内容。请检查页面信息自发布以来是否已不再正确。
修复 Kubernetes 中的 Subpath 卷漏洞
2018 年 3 月 12 日,Kubernetes 产品安全团队披露了 CVE-2017-1002101,该漏洞允许使用 subpath 卷挂载的容器访问卷外部的文件。这意味着容器可以访问宿主机上的任何文件,包括它本不应访问的其他容器的卷。
该漏洞已在最新的 Kubernetes 补丁版本中修复并发布。我们建议所有用户升级以获取修复。有关影响以及如何获取修复的更多详细信息,请参阅公告。(注意,在初始修复后发现了一些功能回归问题,正在issue #61563 中跟踪)。
本文对该漏洞和解决方案进行了技术深度解析。
Kubernetes 背景知识
要理解该漏洞,首先必须理解 Kubernetes 中卷和 subpath 挂载的工作原理。
在容器在节点上启动之前,kubelet 卷管理器在宿主机上将 PodSpec 中指定的所有卷挂载到该 Pod 的一个目录下。一旦所有卷成功挂载,它会构建要传递给容器运行时的卷挂载列表。每个卷挂载都包含容器运行时所需的信息,最相关的包括
- 容器中的卷路径
- 宿主机上的卷路径 (
/var/lib/kubelet/pods/<pod uid>/volumes/<volume type>/<volume name>
)
启动容器时,容器运行时在容器根文件系统中创建路径(如果需要),然后将其 bind mount 到提供的主机路径。
Subpath 挂载像其他任何卷一样传递给容器运行时。容器运行时不区分基础卷和 subpath 卷,并以相同方式处理它们。不是将主机路径传递到卷的根目录,Kubernetes 通过将 Pod 指定的 subpath(相对路径)追加到基础卷的主机路径来构建主机路径。
例如,这是一个 subpath 卷挂载的规约:
apiVersion: v1
kind: Pod
metadata:
name: my-pod
spec:
containers:
- name: my-container
<snip>
volumeMounts:
- mountPath: /mnt/data
name: my-volume
subPath: dataset1
volumes:
- name: my-volume
emptyDir: {}
在这个例子中,当 Pod 被调度到一个节点时,系统将
- 在
/var/lib/kubelet/pods/1234/volumes/kubernetes.io~empty-dir/my-volume
设置一个 EmptyDir 卷 - 构建 subpath 挂载的主机路径:
/var/lib/kubelet/pods/1234/volumes/kubernetes.io~empty-dir/my-volume/ + dataset1
- 将以下挂载信息传递给容器运行时
- 容器路径:
/mnt/data
- 宿主机路径:
/var/lib/kubelet/pods/1234/volumes/kubernetes.io~empty-dir/my-volume/dataset1
- 容器路径:
- 容器运行时将容器根文件系统中的
/mnt/data
bind mount 到宿主机上的/var/lib/kubelet/pods/1234/volumes/kubernetes.io~empty-dir/my-volume/dataset1
。 - 容器运行时启动容器。
该漏洞
subpath 卷的漏洞由 Maxim Ivanov 通过进行一些观察发现:
- Subpath 引用由用户而非系统控制的文件或目录。
- 卷可以由 Pod 生命周期中不同时间启动的容器共享,包括由不同的 Pod 共享。
- Kubernetes 将宿主机路径传递给容器运行时,以便 bind mount 到容器中。
下面的基本示例演示了该漏洞。它利用了上面概述的观察结果,通过:
- 使用 Init 容器设置带有符号链接的卷。
- 稍后使用普通容器将该符号链接挂载为 subpath。
- 导致 kubelet 在将其传递给容器运行时之前在宿主机上解析符号链接。
apiVersion: v1
kind: Pod
metadata:
name: my-pod
spec:
initContainers:
- name: prep-symlink
image: "busybox"
command: ["bin/sh", "-ec", "ln -s / /mnt/data/symlink-door"]
volumeMounts:
- name: my-volume
mountPath: /mnt/data
containers:
- name: my-container
image: "busybox"
command: ["/bin/sh", "-ec", "ls /mnt/data; sleep 999999"]
volumeMounts:
- mountPath: /mnt/data
name: my-volume
subPath: symlink-door
volumes:
- name: my-volume
emptyDir: {}
在这个例子中,系统将
- 在
/var/lib/kubelet/pods/1234/volumes/kubernetes.io~empty-dir/my-volume
设置一个 EmptyDir 卷 - 将以下挂载信息传递给 Init 容器的容器运行时
- 容器路径:
/mnt/data
- 宿主机路径:
/var/lib/kubelet/pods/1234/volumes/kubernetes.io~empty-dir/my-volume
- 容器路径:
- 容器运行时将容器根文件系统中的
/mnt/data
bind mount 到宿主机上的/var/lib/kubelet/pods/1234/volumes/kubernetes.io~empty-dir/my-volume
。 - 容器运行时启动 Init 容器。
- Init 容器在容器内创建一个符号链接:
/mnt/data/symlink-door
->/
,然后退出。 - kubelet 开始为普通容器准备卷挂载。
- 它构建 subpath 卷挂载的主机路径:
/var/lib/kubelet/pods/1234/volumes/kubernetes.io~empty-dir/my-volume/ + symlink-door
。 - 并将以下挂载信息传递给容器运行时
- 容器路径:
/mnt/data
- 宿主机路径:
/var/lib/kubelet/pods/1234/volumes/kubernetes.io~empty-dir/my-volume/symlink-door
- 容器路径:
- 容器运行时将容器根文件系统中的
/mnt/data
bind mount 到宿主机上的/var/lib/kubelet/pods/1234/volumes/kubernetes.io~empty~dir/my-volume/symlink-door
- 然而,bind mount 会解析符号链接,在本例中,它解析到宿主机上的
/
目录!现在容器可以通过其挂载点/mnt/data
查看宿主机上的整个文件系统。
这是符号链接竞争的一个表现,恶意用户程序可以通过诱使特权程序(本例中是 kubelet)跟随用户创建的符号链接来获取敏感数据。
需要注意的是,并非所有情况下都需要 Init 容器来实现此漏洞利用,这取决于卷类型。在 EmptyDir 示例中使用 Init 容器是因为 EmptyDir 卷不能与其他 Pod 共享,仅在创建 Pod 时创建,并在 Pod 被销毁时销毁。对于持久卷类型,此漏洞利用也可以跨两个共享同一卷的不同 Pod 来完成。
修复方案
根本问题在于 subpath 的宿主机路径不可信,可以指向系统中的任何位置。修复需要确保此宿主机路径既:
- 被解析并验证以指向基础卷内部。
- 在验证时间和容器运行时执行 bind mount 之间不能被用户更改。
Kubernetes 产品安全团队经历了多次可能的解决方案迭代,最后才确定了一个设计方案。
方案 1
我们的第一个设计相对简单。对于每个容器中的每个 subpath 挂载:
- 解析 subpath 的所有符号链接。
- 验证解析后的路径在卷内。
- 将解析后的路径传递给容器运行时。
然而,这种设计容易受到经典的“检查时间到使用时间”(TOCTTOU) 问题的影响。在步骤 2) 和 3) 之间,用户可以将路径改回为符号链接。正确的解决方案需要某种方式来“锁定”路径,这样它就不能在验证时间和容器运行时执行 bind mount 之间被更改。随后的所有方案都使用了 kubelet 进行的中间 bind mount 来实现此“锁定”步骤,在将其交给容器运行时之前。一旦执行 bind mount,挂载源就固定了,不能更改。
方案 2
我们对这个方案进行了一些大胆尝试:
- 在 kubelet 的 Pod 目录下创建一个工作目录。称之为
dir1
。 - 将基础卷 bind mount 到工作目录下,即
dir1/volume
。 - Chroot 到工作目录
dir1
。 - 在 chroot 环境中,将
volume/subpath
bind mount 到subpath
。这确保任何符号链接都被解析到 chroot 环境内部。 - 退出 chroot。
- 再次在宿主机上,将 bind mount 的
dir1/subpath
传递给容器运行时。
虽然这个设计确实确保符号链接不能指向卷外部,但最终被拒绝,因为在 Kubernetes 必须支持的各种发行版和环境中(包括容器化的 kubelet),实现步骤 4) 中的 chroot 机制非常困难。
方案 3
回归现实一点,我们的下一个方案是:
- 将 subpath bind mount 到 kubelet 的 Pod 目录下的一个工作目录。
- 获取 bind mount 的源路径,并验证它在基础卷内。
- 将 bind mount 传递给容器运行时。
理论上,这听起来很简单,但实际上,正确实现步骤 2) 相当困难。必须处理许多场景,例如卷(如 EmptyDir)可能位于共享文件系统、独立文件系统、根文件系统或非根文件系统上。NFS 卷最终将所有 bind mount 作为独立的挂载来处理,而不是作为基础卷的子挂载。对于无法测试的 out-of-tree 卷类型将如何表现,还存在额外的不确定性。
最终方案
考虑到前一个方案必须处理的场景和边界情况数量,我们非常想找到一个对所有卷类型都更通用的解决方案。我们最终采用的设计方案是:
- 解析 subpath 中的所有符号链接。
- 从基础卷开始,使用
openat()
系统调用逐个打开每个路径段,并禁止符号链接。对于每个路径段,验证当前路径是否在基础卷内。 - 将
/proc/<kubelet pid>/fd/<final fd>
bind mount 到 kubelet 的 Pod 目录下的一个工作目录。该 proc 文件是指向已打开文件的一个链接。如果该文件在 kubelet 仍然打开时被替换,则该链接仍将指向原始文件。 - 关闭文件描述符 (fd) 并将 bind mount 传递给容器运行时。
注意,此解决方案对于 Windows 主机不同,其挂载语义与 Linux 不同。在 Windows 中,设计方案是:
- 解析 subpath 中的所有符号链接。
- 从基础卷开始,使用文件锁逐个打开每个路径段,并禁止符号链接。对于每个路径段,验证当前路径是否在基础卷内。
- 将解析后的 subpath 传递给容器运行时,并启动容器。
- 容器启动后,解锁并关闭所有文件。
两种解决方案都能满足所有要求:
- 解析 subpath 并验证它指向基础卷内的路径。
- 确保 subpath 宿主机路径在验证时间和容器运行时执行 bind mount 之间不能更改。
- 足够通用以支持所有卷类型。
致谢
特别感谢参与处理此漏洞的许多人员:
- Maxim Ivanov,他负责任地向 Kubernetes 产品安全团队披露了该漏洞。
- 来自 Google、Microsoft 和 RedHat 的 Kubernetes 存储和安全工程师,他们开发、测试并评审了修复方案。
- Kubernetes test-infra 团队,负责搭建私有构建基础设施。
- Kubernetes 补丁版本发布经理,负责协调和处理所有版本发布。
- 所有在发布后迅速部署修复的生产发布团队。
如果您在 Kubernetes 中发现了漏洞,请遵循我们的负责任披露流程并告知我们;我们将尽最大努力使 Kubernetes 对所有用户都更安全。