这篇文章已发表一年多。较旧的文章可能包含过时内容。请检查页面信息自发布以来是否已不再正确。

修复 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 对所有用户都更安全。