0x00 序言#
在xint团队公开Copy Fail漏洞本地利用的技术细节的两周之后,5月19日,该团队进一步公开了Copy Fail漏洞在容器逃逸方面的应用,主要目标为Kubernetes容器。本文在上篇介绍了Copy Fail本地利用原理的基础上,进一步介绍这一漏洞在容器环境下的利用。
0x01 前置知识#
inode与i_mapping:inode是Linux中文件系统用于管理文件的数据结构,记录对应文件的属性和存储的磁盘块等信息。i_mapping是i_node结构体中的一个指针,指向结构体address_space,该结构体用于管理页面缓存。file与f_mapping:进程通过file结构体维护打开的文件。f_mapping是file结构体中的一个指针,一般与inode->i_mapping一致。因此,如果两个文件描述符具有相同的f_mapping,则它们具有相同的页面缓存。- overlay文件系统:一种叠加式的文件系统,通过多个不同层级的文件系统叠加形成合并的文件系统,目前广泛用于云原生技术中。由于云原生场景下Copy Fail的利用使用了较多Overlayfs的特性。因此在0x02节中加以介绍。
workload:工作负载,指云原生系统为了完成业务目标而正在运行的“实际任务”或“应用程序本身”。对于K8s而言,主要分为以下几类:- 无状态
workload:以最常见的Deployment为代表,每个实例之间没有区别,无需记录上下文信息。如微服务接口、前端界面等。 - 有状态
workload:以StatefulSet为代表,实例独一无二,具备独立的存储和网络,如数据库实例。 - pod守护
workload:以DaemonSet为代表,不负责具体的业务实现,负责针对节点物理基础设施本身的任务,如日志收集、节点状态监控、网络服务和策略配置等,每个节点只会运行一个pod。需要注意的是,由于DaemonSet的任务与物理设备关联较大,因此常通过hostPath挂载敏感目录。 - 定时任务
workload:以Job和CronJob为代表,定时任务,执行完自动退出。
- 无状态
0x02 Overlay Filesystem简介#
Overlay Filesystem主要分为四个目录:
lowerdir:底层目录,提供整个容器的基础数据,权限为只读。可以由多个目录构成,上级目录中的同名文件会屏蔽下级目录中的文件,在容器中作为镜像层。upperdir:上层目录,提供数据修改功能,权限为读写。在容器中作为容器层。workdir:工作目录,对用户和进程透明,可读写,初始为空,用于保证合并视图的一致性和完整性。merged:合并视图,由内核通过lowerdir和upperdir合并而成,为用户直接所见的容器根目录。
为节省和复用存储空间,基础镜像所在的lowerdir是由多个只读的目录组成的,多个不同的容器可以共享相同的lowerdir。对于读取操作,overlayfs会从上到下依次尝试读取,直到读到匹配文件名的文件为止。因此,上层会屏蔽下层的同名文件。对于对lowerdir中文件的修改操作,overlayfs采用写时复制(CoW, Copy on Write)的方式进行处理。即系统首先调用copy-up,复制一个新的文件到pod的upperdir并在新文件中修改。对于删除操作,overlayfs会在upperdir中创建whiteout文件,设备号为0/0,在Merged视图中会隐藏下层同名文件,实现删除的效果。
需要注意的是,overlayfs借助CoW实现在镜像层只读前提下的修改操作,从而保持下层文件的共享。但一旦攻击者通过某种手法在不触发CoW的情况下修改了文件,就会造成对共享文件的投毒,影响同节点上的其它pod甚至宿主机自身。
0x03 攻击场景分析#
当Copy Fail在容器中触发时,由于其修改了目标页面缓存而非目标页面本身,因此不会触发overlayfs的copy-up操作。与镜像层在各不同pod之间共享类似,不同pod间相同的共享文件所使用的也是一样的页面缓存。因此,通过Copy Fail写入的恶意shellcode会进入目标共享文件的页面缓存中,当其它pod后续读取这些文件时,所读取到的就是被投毒的文件。
对于检测而言,由于没有文件落地和修改,磁盘上的本地文件没有发生变化,计算文件散列值等操作无法检测到攻击。基于上述分析,我们进一步分析Copy Fail在两个威胁模型下的利用。
1. 跨容器投毒#
这一威胁模型下,攻击者无需具有特权,唯一的前置条件为已控制某个pod或具备create pods权限。这些前提在云原生架构中非常常见,如在多租户场景下,租户常具有在给定的命名空间下操作和创建pod的权限,在CI/CD中,自动化托管工具也常具有构建pod的能力。因此,前置条件的达成对于攻击者而言具有较强的可行性。此外,如果攻击者具有create pods权限,那么即可通过nodeAffinity和nodeName属性精确指定目标pod,使其所创建的pod与目标pod位于同一个物理节点上。
达成前置条件后,攻击者可以任意选择一个共享范围较广的文件进行投毒,如在Python的site-packages/下寻找常用库,或选择glibc等动态库。选定目标后,攻击者可在pod内通过Copy Fail对目标文件的页面缓存投毒,而不会触发CoW。当其它共享该文件的pod引入或运行该文件时,控制流就会执行shellcode。
由于DaemonSet相对于普通pod具有更高权限,大多数情况下还挂载了主机的敏感目录。在这种情况下,通过跨容器投毒直接能够实现容器逃逸,而无需场景2中的方法。
2. 容器逃逸#
该场景下攻击者的能力与前述场景一致。逃逸的出发点来源于对CVE-2019-5736的修复方式。CVE-2019-5736的核心insight在于,在某些场景下,需要runC在容器内运行,执行用户定义的二进制文件。runC的实现方式是创建一个runC init的子进程,调用execve,用用户提供的二进制文件覆盖自身。而攻击者能够使用/proc/self/exe使其用宿主机的runC覆盖自身,从而在容器内获得对宿主机runC的控制,写入恶意代码后重新等待runC执行即可实现逃逸。
有经验的读者可能会发现上述利用过程中的问题。首先,当runC在容器内运行的时候,runC文件处于运行时,是无法对其写入的,Linux会报错ETXTBSY。PoC中首先获得只读的文件描述符,然后循环尝试以写入方式打开。当runC进程结束时即可打开文件并写入。其次,为什么不选择直接覆盖/proc/self/exe而还要让runC运行一次自身?其原因在于runC init进程有non-dumpable标志,其它进程无法解引用,而execve会去除这个标志。以上本属于CVE-2019-5736自身的tricks,但为方便理解还是稍提一嘴。
具体的漏洞利用过程分为以下四个步骤:
- 强制runC循环运行。首先,将
/bin/sh覆盖为#!/proc/self/exe,使runC在执行shell时重新执行runC,保证runC进程在容器内的命名空间中存活足够长时间。 - 找到runC的pid。通过遍历
/proc/<pid>/exe找到runC的pid。 - 投毒。打开
/proc/<runc_pid>/exe,利用Copy Fail对runC的页面缓存进行投毒,将其修改为恶意ELF文件。 - 等待下一次runC的执行。在页面缓存失效前,宿主机上的任何对runC的调用都会引用被投毒的页面缓存,从而实现shellcode的执行。
0x04 小结#
在Copy Fail本地利用的基础上,衍生出上述两种场景的利用方式是比较显然的。利用Copy Fail针对的是页面缓存的特性,容器级虚拟化的内核共享特性使其失去了隔离攻击的能力,而拥有独立内核的虚拟机则能够抵抗Copy Fail的逃逸,这又一次体现出安全与性能之间的权衡取舍。