沙箱机制 0x00: 简介 沙箱机制,英文sandbox也就是我们常说的沙箱,是计算机领域的虚拟技术,常用于安全方向。一般说来,我们不会将不受信任的软件放在沙箱中运行,一旦该软件有恶意行为,则禁止该程序的进一步运行,不会对真实系统造成任何危害。
在CTF比赛中,pwn题中的沙箱一般都会限制execve的系统调用,这样一来one_gaget和system调用都不好使,只能采取open/read/write的组合方式来读取flag。当然有些题目还会砍掉一个系统调用,进一步限制我们获取flag。
0x01: 开启沙箱的两种方式 在CTF的pwn题中一般有两种函数调用的方式实现沙盒机制,第一种是采用prctl函数调用,第二种是使用seccomp函数。
在具体了解prctl函数之前,我们再了解这样一个概念,沙箱是程序运行过程中的一种隔离机制,其目的是限制不可信进程和不可信代码的访问权限。seccomp是内核中的一种安全机制,seccomp可以在程序中禁用掉一些系统调用来达到保护系统安全的目的,seccomp规则的设置,可以使用prctl函数和seccomp函数。
0x0: prctl函数初探 prctl是基本的进程管理函数,最原始的沙箱规则就是通过prcctl函数来实现的,它可以决定有哪些系统调用函数可以被调用,哪些系统调用函数不能被调用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 #include <sys/prctl.h> int prctl (int option, unsigned long arg2, unsigned long arg3, unsigned long arg4, unsigned long arg5) ;prctl(38 , 1LL , 0LL , 0LL , 0LL ); prctl(22 , 2LL , &v1);
如上所示,就是常见的prctl函数调用的功能。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 struct sock_filter { __u16 code; __u8 jt; __u8 jf; __u32 k; }; struct seccomp_data { int nr; __u32 arch; __u64 instruction_pointer; __u64 argv[6 ]; } struct sock_fprog { unsigned short len; struct sock_filter *filter ; }
include/linux/prctl.h里面存储着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 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 unsigned int sub_12A9 () { __int16 v1; __int16 *v2; __int16 v3; char v4; char v5; int v6; __int16 v7; char v8; char v9; int v10; __int16 v11; char v12; char v13; int v14; __int16 v15; char v16; char v17; int v18; __int16 v19; char v20; char v21; int v22; __int16 v23; char v24; char v25; int v26; __int16 v27; char v28; char v29; int v30; __int16 v31; char v32; char v33; int v34; unsigned __int64 v35; v35 = __readfsqword(0x28 u); setvbuf(stdout , 0LL , 2 , 0LL ); setvbuf(stdin , 0LL , 2 , 0LL ); setvbuf(stderr , 0LL , 2 , 0LL ); prctl(38 , 1LL , 0LL , 0LL , 0LL ); v3 = 32 ; v4 = 0 ; v5 = 0 ; v6 = 4 ; v7 = 21 ; v8 = 0 ; v9 = 5 ; v10 = -1073741762 ; v11 = 32 ; v12 = 0 ; v13 = 0 ; v14 = 0 ; v15 = 21 ; v16 = 0 ; v17 = 2 ; v18 = 0 ; v19 = 32 ; v20 = 0 ; v21 = 0 ; v22 = 16 ; v23 = 37 ; v24 = 1 ; v25 = 0 ; v26 = 1 ; v27 = 6 ; v28 = 0 ; v29 = 0 ; v30 = 2147418112 ; v31 = 6 ; v32 = 0 ; v33 = 0 ; v34 = 0 ; v1 = 8 ; v2 = &v3; prctl(22 , 2LL , &v1); return alarm(0x20 u); }
上面就是强网拟态的一道真实的pwn题,经过IDA反编译后的结果,这里最关键的代码是两个prctl函数的调用,其他看着零散的变量其实是用来设置沙盒的结构体,这里被IDA解释成了这样而已。
0x1:seccomp库函数 seccomp 是 Linux 内核提供的一种应用程序沙箱机制,seccomp 通过只允许应用程序调用 exit(), sigreturn(), read() 和 write() 四种系统调用来达到沙箱的效果。如果应用程序调用了除了这四种之外的系统调用, kernel 会向进程发送 SIGKILL 信号。
seccomp 很难在实际中得到推广,因为限制实在是太多了,Linus 本人也对它的应用持怀疑的态度,直到出现了 seccomp-bpf。seccomp-bpf 是 seccomp 的一个扩展,它可以通过配置来允许应用程序调用其他的系统调用。chrome 中第一个应用 seccomp-bpf 的场景是把 Flash 放到了沙箱里运行(实在是不放心),后续也把 render 的过程放到了沙箱里。
该函数就比较清晰和简单了,下面的代码也是取材与一道真实的pwn题。
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 __int64 sandbox () { __int64 v1; v1 = seccomp_init(0LL ); if ( !v1 ){ puts ("seccomp error" );exit (0 );} seccomp_rule_add(v1, 0x7FFF0000 LL, 2LL , 0LL ); seccomp_rule_add(v1, 0x7FFF0000 LL, 0LL , 0LL ); seccomp_rule_add(v1, 0x7FFF0000 LL, 1LL , 0LL ); seccomp_rule_add(v1, 0x7FFF0000 LL, 60LL , 0LL ); seccomp_rule_add(v1, 0x7FFF0000 LL, 231LL , 0LL ); if ( seccomp_load(v1) < 0 ){ seccomp_release(v1); puts ("seccomp error" );exit (0 );} return seccomp_release(v1);}
0x0:安装 1 2 3 sudo apt install gcc ruby-dev sudo gem install seccomp-tools
0x1: 使用 1 seccomp-tools dump ./shell
0x03: 自制一个沙箱 在第一章的prctl函数初探中我们介绍了prctl函数的一些参数和用法,现在我们来看看通过定义sock_fprog结构体来实现过滤任意系统调用和系统调用参数。
0x0: BDF过滤规则(伯克利封包过滤) 伯克利包过滤器是指类Unix系统上数据链路层的一种原始接口,提供原始链路层封包的收发。BPF也支持封包过滤,其过滤规则在linux中应用到了很多地方。xt_bpf 对netfilter,cls_bpf在内核的qdisk层,SECCOMP-BPF,以及一系列其他地方例如: team driver ,PTP code等BPF都被用到。
Seccomp Strict Mode Seccomp 在最初引入的时候只支持了strict mode ,意味着只有read
,write
,_exit
,_sigreturn
四个system call会被允许执行,一旦出现其他的syscall call ,进程便会被立刻终止(SIGJILL)。一个简单的例子如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 #include <stdio.h> #include <sys/prctl.h> #include <sys/socket.h> #include <linux/seccomp.h> int main (int argc, char * argv[]) { printf ("Install seccomp\n" ); prctl(PR_SET_SECCOMP, SECCOMP_MODE_STRICT); printf ("Creating socket\n" ); int sock = socket(AF_INET, SOCK_STREAM, 0 ); return 0 ; }
编译并执行
1 2 3 4 5 6 7 8 ┌──(kali㉿kali)-[~/…/CTF/competition/2022xiangyuncup/sadbox] └─$ gcc seccomp.c -o seccomp ┌──(kali㉿kali)-[~/…/CTF/competition/2022xiangyuncup/sadbox] └─$ ./seccomp Install seccomp Creating socket zsh: killed ./seccomp
上面的程序在seccomp被启动之后会有两个系统调用:write 和 socket ,printf这个函数本质上向stdout写了一些bytes,是被允许的。然而当进程想要创建sockte的时候,程序就被终止了。
Seccomp Filter Mode (Seccomp-BPF) strict mode 固然很棒,然而实用性却不够高。因为一个复杂的程序根本不可能只用到四个system call 。strict mode 下进程能够完成的任务却非常有限。于是Linux又引入了filter mode ,也就是BPF(Berkley Packet Filter)。BPF提供了更高的灵活度,赋予了开发者对于程序更细颗粒度的控制。
Linux 内核实现了一个能够执行BPF程序的虚拟机。对于每一次system call ,内核都会执行一遍开发者提供的BPF程序,用来确定是否需要过滤system call。BPF程序在c中表示为一个长度固定的指令数组,定义如下:
1 2 3 4 struct sock_fprog { unsigned short len; struct sock_filter *filter ; };
内核的虚拟机会按顺序读取BPF指令(sock_filter),读取完之后会解码(decode)指令并执行指令。指令的定义如下:
1 2 3 4 5 6 struct sock_filter { __u16 code; __u8 jt; __u8 jf; __u32 k; };
综上以上所述,我们 来看一个具体的例子:
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 #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> #include <sys/types.h> int main () { struct sock_filter filter []= { BPF_STMT(BPF_LD|BPF_W|BPF_ABS, 0 ), BPF_JUMP(BPF_JMP|BPF_JEQ, 59 , 1 , 0 ), BPF_JUMP(BPF_JMP|BPF_JGE, 0 , 1 , 0 ), 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 (filter[0 ]), .filter=filter, }; prctl(PR_SET_NO_NEW_PRIVS,1 ,0 ,0 ,0 ); prctl(PR_SET_SECCOMP,SECCOMP_MODE_FILTER,&prog); puts ("123" ); system("/bin/bash" ); return 0 ; }
我们之前说过,BPF代码在c中表示成一个指令数组。
还有一点值得注意的是我们一般会设置PR_SET_NO_NEW_PRIVS
(禁止execve调用)这个bit ,主要是为了防止untrusted code篡改已有BPF程序,进而导致安全隐患。
0x1: Kafel 如果你是一个硬核的程序员,当然可以直接手写 BPF 代码过滤 system call。然而对于大多数 程序员而言,这个过程可能并不轻松,稍有不慎就会引入 bug,导致一些 system call 没有被blacklist 掉。幸好,开源项目 Kafel 提供了解决方案。
Kafel 规定了一种更便于人理解的 policy file,并且提供了编译器,能把 policy file 编译成 BPF 代码。我这里直接放一个 policy file,不用我多解释,相信大家也能把这个文件的意思猜的差不多了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 #define CLONE_THREAD 0x00010000 POLICY foo { ALLOW { brk, clone { clone_flags & CLONE_THREAD != 0 }, exit , exit_group, futex, mmap, mprotect, read, write } } USE foo DEFAULT ERRNO(1 )
这个 policy 文件比较有意思的地方就clone
的规则,它规定了只允许 argument 中 CLONE_THREAD
的 bit 是被开启的。这个 policy 这样设置也很好理解:fork()
和 start a new thread 其实都需要用到 clone
这个 system call,只不过 argument 不同。很多时候我们任然希望被 sandbox 的进程能够多线程处理,但是我们却不希望进程 fork 出一个子进程。
接下里我们通过 kafel 生成 bpf 代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 $ dump_policy_bpf -c seccomp.policy BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)), BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, 0xc000003eu, 1, 0), BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL), BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, 0x39u, 0, 6), BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, 0xcau, 0, 3), BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, 0xe7u, 0, 1), BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, 0xe8u, 11, 12), BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, 0xcbu, 10, 11), BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, 0x3cu, 0, 9), BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, 0x3du, 8, 9), BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, 0xbu, 0, 5), BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, 0xdu, 0, 3), BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, 0x38u, 0, 5), BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[0])), BPF_JUMP(BPF_JMP | BPF_JSET | BPF_K, 0x10000u, 4, 3), BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, 0xcu, 3, 2), BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, 0x2u, 0, 2), BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, 0x9u, 1, 0), BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | 0x1u), BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
把生成的 bpf 代码放到应用程序中:
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 #include <sys/prctl.h> #include <sys/socket.h> #include <linux/filter.h> #include <linux/seccomp.h> #include <thread> #include <unistd.h> #include <stdio.h> void configure_seccomp () { struct sock_filter filter [] = { }; struct sock_fprog prog = { .len = sizeof (filter) / sizeof (filter[0 ]), .filter = filter, }; printf ("Configuring seccomp\n" ); prctl(PR_SET_NO_NEW_PRIVS, 1 , 0 , 0 , 0 ); prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog, 0 , 0 ); } int main (int argc, char * argv[]) { configure_seccomp(); std ::thread t ([]()->void { printf ("Hello from thread\n" );}) ; t.join(); pid_t ret = fork(); printf ("Return code from fork %d, errno: %d\n" , ret, errno); return 0 ; }
编译并执行:
1 2 3 4 5 $ g++82 -o seccomp seccomp.c -pthread $ ./seccomp Configuring seccomp Hello from thread Return code from fork -1, errno: 1
从上面的例子可以看出:程序可以多线程计算,但是却不能 fork 一个子进程。fork
返回 -1
,并且把errno
设置成了 1
,因为在 policy 中我们规定了当 system call violation 的时候会返回EPERM
。
0x2: Seccomp库函数 这个库可以提供一些函数实现prctl类似的效果,库中封装了一些函数,可以不用了解BPF规则而实现过滤。
但是在C程序中使用它,需要安装一些库文件
1 sudo apt install libseccomp-dev libseccomp2 seccomp
通过使用该库的函数实现禁用execve系统调用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 #include <unistd.h> #include <seccomp.h> #include <linux/seccomp.h> int main (void ) { scmp_filter_ctx ctx; ctx = seccomp_init(SCMP_ACT_ALLOW); seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(execve), 0 ); seccomp_load(ctx); char * str = "/bin/sh" ; write(1 ,"i will give you a shell\n" ,24 ); syscall(59 ,str,NULL ,NULL ); return 0 ; }
scmp_filter_ctx是过滤器的结构体 seccomp_init对结构体进行初始化,若参数为SCMP_ACT_ALLOW,则过滤为黑名单模式;若为SCMP_ACT_KILL,则为白名单模式,即没有匹配到规则的系统调用都会杀死进程,默认不允许所有的syscall。
1 seccomp_init(uint32_t def_action);
def_action为
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 #define SCMP_ACT_KILL 0x00000000U #define SCMP_ACT_TRAP 0x00030000U #define SCMP_ACT_ERRNO(x) (0x00050000U | ((x) & 0x0000ffffU)) #define SCMP_ACT_TRACE(x) (0x7ff00000U | ((x) & 0x0000ffffU)) #define SCMP_ACT_LOG 0x7ffc0000U #define SCMP_ACT_ALLOW 0x7fff0000U
seccomp_rule_add是添加一条规则
1 2 int seccomp_rule_add (scmp_filter_ctx ctx, uint32_t action, int syscall, unsigned int arg_cnt, ...) ;
参数的宏定义
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 #define SCMP_CMP(...) ((struct scmp_arg_cmp){__VA_ARGS__}) #define SCMP_A0(...) SCMP_CMP(0, __VA_ARGS__) #define SCMP_A1(...) SCMP_CMP(1, __VA_ARGS__) #define SCMP_A2(...) SCMP_CMP(2, __VA_ARGS__) #define SCMP_A3(...) SCMP_CMP(3, __VA_ARGS__) #define SCMP_A4(...) SCMP_CMP(4, __VA_ARGS__) #define SCMP_A5(...) SCMP_CMP(5, __VA_ARGS__) enum scmp_compare { _SCMP_CMP_MIN = 0 , SCMP_CMP_NE = 1 , SCMP_CMP_LT = 2 , SCMP_CMP_LE = 3 , SCMP_CMP_EQ = 4 , SCMP_CMP_GE = 5 , SCMP_CMP_GT = 6 , SCMP_CMP_MASKED_EQ = 7 , _SCMP_CMP_MAX, }; typedef uint64_t scmp_datum_t ;struct scmp_arg_cmp { unsigned int arg; enum scmp_compare op ; scmp_datum_t datum_a; scmp_datum_t datum_b; };
arg_cnt表明是否需要对对应系统调用的参数做出限制以及指示做出限制的个数,如果仅仅需要允许或者禁止所有某个系统调用,arg_cnt直接传入0即可,seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(execve), 0)即禁用execve,不管其参数如何。
如果考虑到更高的自定义,需要先去了解一下具体系统调用的参数情况,然后再利用SCMP_AX及SCMP_CMP_XX类的宏定义做一些过滤。以read为例,read函数原型
1 ssize_t read (int fd, void *buf, size_t count) ;
限制从标准输入stdin读入的字节数不能为100。
1 2 3 seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(read), 2 , SCMP_A0(SCMP_CMP_EQ, STDIN_FILENO), SCMP_A2(SCMP_CMP_EQ, 100 ))
seccomp_load是应用过滤,seccomp_reset是解除过滤。
1 2 int seccomp_load (const scmp_filter_ctx ctx) ;int seccomp_reset (const scmp_filter_ctx ctx) ;
参考:
https://docs.rs/seccomp-sys/0.1.0/seccomp_sys/fn.seccomp_rule_add.html
https://bbs.pediy.com/thread-258146.htm