本文发表于一年多前。旧文章可能包含过时内容。请检查页面中的信息自发布以来是否已变得不正确。

修复 Kubernetes 中的 Subpath 卷漏洞

2018年3月12日,Kubernetes 产品安全团队披露了 CVE-2017-1002101,该漏洞允许使用 subpath 卷挂载的容器访问卷外部的文件。这意味着容器可以访问主机上可用的任何文件,包括它不应访问的其他容器的卷。

该漏洞已在最新的 Kubernetes 补丁版本中修复并发布。我们建议所有用户升级以获取修复。有关影响和如何获取修复的更多详细信息,请参阅公告。(请注意,在初始修复后发现了一些功能回归,并在问题 #61563中进行跟踪)。

本文将对该漏洞和解决方案进行技术深入探讨。

Kubernetes 背景

要理解此漏洞,首先需要理解 Kubernetes 中卷和子路径挂载的工作原理。

在节点上启动容器之前,kubelet 卷管理器会在主机系统上为该 Pod 在一个目录下本地挂载 PodSpec 中指定的所有卷。一旦所有卷成功挂载,它会构建要传递给容器运行时的卷挂载列表。每个卷挂载都包含容器运行时所需的信息,其中最相关的是:

  • 容器中卷的路径
  • 主机上卷的路径(/var/lib/kubelet/pods/<pod uid>/volumes/<volume type>/<volume name>

启动容器时,容器运行时会在容器根文件系统中创建路径(如果需要),然后将其绑定挂载到提供的主机路径。

子路径挂载像其他任何卷一样传递给容器运行时。容器运行时不区分基础卷和子路径卷,并以相同的方式处理它们。Kubernetes 不会将主机路径传递到卷的根目录,而是通过将 Pod 指定的子路径(相对路径)附加到基础卷的主机路径来构建主机路径。

例如,这是一个子路径卷挂载的规范:

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 卷
  • 构建子路径挂载的主机路径:/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 绑定挂载到主机上的 /var/lib/kubelet/pods/1234/volumes/kubernetes.io~empty-dir/my-volume/dataset1
  • 容器运行时启动容器。

漏洞

Maxim Ivanov 通过以下几个观察发现了子路径卷的漏洞:

  • 子路径引用由用户而非系统控制的文件或目录。
  • 卷可以由在 Pod 生命周期不同时间启动的容器共享,包括由不同的 Pod 共享。
  • Kubernetes 将主机路径传递给容器运行时以绑定挂载到容器中。

下面的基本示例演示了该漏洞。它通过以下方式利用了上述观察:

  • 使用一个 init 容器来设置带有一个符号链接的卷。
  • 稍后使用一个常规容器将该符号链接挂载为子路径。
  • 导致 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 绑定挂载到主机上的 /var/lib/kubelet/pods/1234/volumes/kubernetes.io~empty-dir/my-volume
  • 容器运行时启动 init 容器。
  • init 容器在容器内部创建一个符号链接:/mnt/data/symlink-door -> /,然后退出。
  • Kubelet 开始为普通容器准备卷挂载。
  • 它构建子路径卷挂载的主机路径:/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 绑定挂载到 /var/lib/kubelet/pods/1234/volumes/kubernetes.io~empty~dir/my-volume/symlink-door
  • 然而,绑定挂载会解析符号链接,在这种情况下,它解析到主机上的 /!现在容器可以通过其挂载点 /mnt/data 查看主机的所有文件系统。

这是符号链接竞争的一种表现,恶意用户程序可以通过导致特权程序(在本例中为 kubelet)跟随用户创建的符号链接来获取敏感数据。

需要注意的是,对于此漏洞利用,init 容器并非总是必需的,具体取决于卷类型。在 EmptyDir 示例中使用它是因为 EmptyDir 卷不能与其他 Pod 共享,并且只在创建 Pod 时创建,在销毁 Pod 时销毁。对于持久卷类型,此漏洞也可以在共享相同卷的两个不同 Pod 之间进行。

修复

根本问题在于子路径的主机路径不可信,并且可以指向系统中的任何位置。修复需要确保此主机路径既:

  • 已解析并验证为指向基础卷内部。
  • 在验证时间和容器运行时绑定挂载它之间不能被用户更改。

Kubernetes 产品安全团队经历了多次可能的解决方案迭代,最终才同意一个设计。

想法 1

我们的第一个设计相对简单。对于每个容器中的每个子路径挂载:

  • 解析子路径的所有符号链接。
  • 验证解析后的路径是否在卷内。
  • 将解析后的路径传递给容器运行时。

然而,这种设计容易受到经典的检查时到使用时 (TOCTTOU) 问题的影响。在步骤 2) 和 3) 之间,用户可以将路径改回符号链接。正确的解决方案需要某种方法来“锁定”路径,使其在验证和容器运行时绑定挂载之间无法更改。所有后续的想法都使用 kubelet 的中间绑定挂载来实现此“锁定”步骤,然后将其交给容器运行时。一旦执行了绑定挂载,挂载源就固定了,无法更改。

想法 2

我们对这个想法有点疯狂:

  • 在 kubelet 的 pod 目录下创建一个工作目录。我们称之为 dir1
  • 将基础卷绑定挂载到工作目录 dir1/volume 下。
  • Chroot 到工作目录 dir1
  • 在 chroot 内部,将 volume/subpath 绑定挂载到 subpath。这确保任何符号链接都在 chroot 环境内部解析。
  • 退出 chroot。
  • 在主机上再次将绑定挂载的 dir1/subpath 传递给容器运行时。

虽然这种设计确实确保了符号链接不能指向卷外部,但最终被拒绝,因为在 Kubernetes 必须支持的所有各种发行版和环境中(包括容器化 kubelet)实现步骤 4) 中的 chroot 机制存在困难。

想法 3

回归现实一点,我们的下一个想法是:

  • 将子路径绑定挂载到 kubelet 的 pod 目录下的工作目录。
  • 获取绑定挂载的源,并验证它是否在基础卷内。
  • 将绑定挂载传递给容器运行时。

理论上,这听起来很简单,但实际上,正确实现第 2) 步相当困难。必须处理许多场景,其中卷(如 EmptyDir)可能位于共享文件系统上、单独的文件系统上、根文件系统上或非根文件系统上。NFS 卷最终将所有绑定挂载作为单独的挂载处理,而不是作为基础卷的子级。对于我们无法测试的树外卷类型将如何表现,存在额外的A不确定性。

解决方案

鉴于上一个设计需要处理的场景和边缘情况数量,我们真的希望找到一个对所有卷类型更通用的解决方案。我们最终采用的最终设计是:

  • 解析子路径中的所有符号链接。
  • 从基础卷开始,逐一打开每个路径段,使用 openat() 系统调用,并禁止符号链接。对于每个路径段,验证当前路径是否在基础卷内。
  • /proc/<kubelet pid>/fd/<final fd> 绑定挂载到 kubelet 的 pod 目录下的工作目录。proc 文件是指向已打开文件的链接。如果该文件在 kubelet 仍打开它时被替换,则该链接仍将指向原始文件。
  • 关闭文件描述符并将绑定挂载传递给容器运行时。

请注意,此解决方案对 Windows 主机是不同的,因为其挂载语义与 Linux 不同。在 Windows 中,设计是:

  • 解析子路径中的所有符号链接。
  • 从基础卷开始,逐一打开每个路径段,带文件锁,并禁止符号链接。对于每个路径段,验证当前路径是否在基础卷内。
  • 将解析后的子路径传递给容器运行时,并启动容器。
  • 容器启动后,解锁并关闭所有文件。

这两个解决方案都能够满足所有要求:

  • 解析子路径并验证它指向基础卷内的路径。
  • 确保子路径主机路径在验证时间和容器运行时绑定挂载它之间无法更改。
  • 足够通用以支持所有卷类型。

致谢

特别感谢许多参与处理此漏洞的人员:

  • Maxim Ivanov,他负责任地向 Kubernetes 产品安全团队披露了该漏洞。
  • 来自 Google、Microsoft 和 RedHat 的 Kubernetes 存储和安全工程师,他们开发、测试并审查了修复程序。
  • Kubernetes test-infra 团队,负责搭建私有构建基础设施。
  • Kubernetes 补丁发布经理,负责协调和处理所有发布。
  • 所有在发布后迅速部署修复程序的生产发布团队。

如果您在 Kubernetes 中发现漏洞,请遵循我们的负责任披露流程并告知我们;我们希望尽最大努力使 Kubernetes 对所有用户都安全。