linux3.5以后linux支持了Will Drewry的Seccomp-BPF的特性。Seccomp-BPF是建立在能够发送小的BPF(BSD Packet Filter)程序,有内核来执行。这个特性起先源于tcpdump的设计,因为性能的原因,可以直接在内核中运行。BPF相对与内核是不可信的,因此, 限制了有限的数量。值得注意的是,他们不能循环,限定单一函数的执行时间,以及大小,并允许内核知道他们的永远终止。
BPF(BSD Packet Filter)是一种用于Unix内核网络数据包的过滤机制。Linux之前包过滤LPF。Linux随着版本的不同,所支持的捕获机制也有所不同。2.0之前的内核版本,对Socket使用类型SOCK_PACKET,调用形式是Socket(PF_INET,SOCK_PACKET,int proctol),但这种用法已经过时,2.2之后,提出的一种新的协议簇,PF_PACKET来实现捕获机制,调用形式为:socket(PF_PACKET, int socket_type, int protocol),其中socket类型可以是SOCK_RAW,和SOCK_DGRAM,SOCK_RAW 类型使得数据包从数据链路层取得后,不做任何修改,直接传递给用户数据,而SOCK_DGRAM,则要对数据包进行加工(cooked),把数据包的数据链路层头部去掉,而使得一个通用结构sockaddr_ll来保存链路信息。
使用2.0版本内核捕获数据包存在多个问题,首先,SOCK_PACKET方式使用结构sockaddr_pkt来保存数据链路层信息,该结构缺乏包类型信息;其次,如果参数MSG_TRUNC传递给读包函数recvmsg, recv, recvfrom 等,则函数返回的数据包长度是实际读到的包数据长度,而不是数据包的真正长度。
PF_PACKET方式规避上以上的问题,在实际应用中,用户程序显然希望直接得到“原始”数据包,因此使得SOCK_RAW类型最好。Libpcap使用的SOCK_DGRAM.从而也必须为数据包合成一个“伪”链路层头部(sockaddr_ll).1,某些类型的设备数据链路层头部不可用,例如linux 内核的ppp协议,实现代码对ppp数据包的支持的不可靠,2在捕获设备为any时,所有设备意味着libpcap对所有的接口进行捕获。需要要求对所有数据包有相同的数据链路头部。具体的详细描述:http://www.linuxjournal.com/article/4852
BPF是一种过滤机制,有网络阀(Network tap)和过滤包组成,网络阀用于从网卡设备驱动处收集网络数据包拷贝,并负责向已注册监听的用户态应用程序递交数据,过滤器则是用于决定收集的网络数据是否满足过滤器要求,满足条件的则进行后继的拷贝工作,否则丢弃该数据。
该图的过程为:当数据包到达网络接口时,链路层的设备驱动程序通常是将数据包直接传送给协议栈进行处理。而当BPF在该网络接口注册监听时,链路层驱动程序将数据包传送给协议栈之前,先会首先调用BPF。BPF负责将此数据包指针传递至每个监听进程指定的内核过滤器,这些用户自定义的过滤器决定数据包是否被接受,以及数据包中的那些内容将被接受,对于每一个决定接受数据包的过滤器,BPF此时才进行数据拷贝工作,将所需的数据拷贝至于过滤器相连的缓冲区中,以供用户监听进程读取。数据拷贝结束后,网络设备驱动程序重新获得系统控制权,系统进行正常的网络协议处理。我们注意到BPF是先对数据包过滤,在缓冲,避免了类似SUN的NIT过滤机制,先缓冲每个数据包直到用户读取数据时在过滤造成的效率问题。
BPF 的设计思路和当时的计算机的硬件发展有大关系,相对较老的CSPF(CMU/Stanford Packet Filter)它有两大特点,1 :基于寄存器的过滤机制,而不是早期的内存堆栈过滤机制,2:直接使用独立的,非共享的内存缓冲区。同时,BPF在过滤算法上也有很大进步(有人也做了一些优化BPF+),它使用了无环控制流图(CFG,control flow graph).而不是老式的布尔表达式树(Boolean expression tree)。
下面说一下BPF的包过滤模式:
包过滤可以看作是针对数据包的布尔函数运算。如果返回值为真,那么内核为监听程序拷贝数据,反之则丢弃。形式上包过滤由一个或者多个谓词判断的AND操作和OR操作组成。
包过滤在具体的实现上是通过将数据包简单的看作一个字节数组,谓词判断根据具体的协议映射到数组的特定位置进行判断过滤工作。例如,判断数据包是否是ARP协议,只需要盘点数组的第13,14个字节是否为0x0806。因此,包过滤的算法的中心任务,是使用最少的判断操作。
布尔表达式树理解上比较直观,它的每一个叶子结点即一个谓词判断,而非叶子结点则为AND或OR操作。而CFG算法是一种特殊的状态机,图中每一个非叶节点代表的是对数据包进行谓词判断操作,而边则是判断的结果。在CFG中,只存在2个叶子结点,分别代表过滤通过True和未通过Flase。
CSPF主要有3个缺点,1:过滤操作使用的栈在内存中被模拟,维护栈指针需要使用若干的加/减等操作,而内存操作时现代计算机架构的主要瓶颈。2:布尔表达树造成了不需要的重复计算。3不能分析数据包的变长头部。BPF使用的CFG算法,实际上是一种特殊的状态机,每一个节点代表了一个谓词判断,而左右分别对应了判断失败和成功后的跳转,跳转后又是谓词判断,这样反复操作,直到到达成功或失败的终点。CFG算法的优点在于把对数据包的分析信息直接建立在图中。从而不需要重复计算。CFG是一种“快速的,一直向前”的算法。
BPF包过滤模式的实现,BPF采用了一种过滤器伪机的方式(filter Pseudo-machine)。BPF伪机器方式是一个轻量级,高效的状态机。对BPF过滤代码进行解释,BPF过滤机过滤代码形式为”opcode jt jf k”,分别代表了操作码和寻址方式,判断正确的跳转和失败的跳转,以及操作所使用的通用数据域。
BPF过滤代码从逻辑上看,很类似汇编语言,但它实际上是机器语言,注意到上述的3个域的数据都是int和char型。显然,有用户来写过滤代码太过于复杂,但这种设计更适合用于操作硬件。特别用来编写需要写少量固定序列的硬件驱动程序。BPF实际上是一组基于状态机匹配的过滤序列,用于简单的数据包模式匹配。用指令集简单的将上图描述如下:
Ldh [12] //装载以太网数据包封装的上层协议类型值 Jep #ETHERTYPE_IP, L2, L1 //将其与IP类型(0X0800)比较,如果相等调到L2,否跳L1 L1:#ETHERTYPE_ARP, L2, L3//将L1的值与ARP类型(0X0806)比较,如果相等调L2,不等跳L3 L2:ret #TURE L3:ret#FLASE
每个匹配包至少包含4个元素,定义为一个结构体如下:
Struct socket_file{ __u16 code; //操作码,可以实现数值运算,加载,比较等操作 __u8 jt; //如果匹配跳转到哪里 __u8 jf; //如果不匹配跳转到哪里 __32 k; //参数字段,对于不同的操作码有不同的用途,比如在操作码是比较时存放比较键,操作码为加载时存放载入数据在数据包(链路帧/数据报)的偏移 }
其很显然应该是一个状态驱动的循环:
While(序列中还有匹配){ Switch(当前的操作) Case 加减乘除: … Case 加载: 载入当前匹配项的k值偏移的数据,设为d。 下一个匹配项 Case 比较跳转: 程序计数器 += 比较结果?当前匹配项jt:jf字段 }
看看linux实现的代码,基本上是这样实现的。
Int sk_run_filter(struct sk_buff *skb,struct sock_filter *filter,int flen) { ..//定义中间变量,保存临时计算结果。 Int k; Int pc; //程序计数器,用于分支跳转 For (pc = 0 ,pc < flen, pc ++){ Fentry = &filter[pc]; Switch (fentry->code){ Case BPF_ALU|BPF_ADD|BPF_X: A += X; Continue; ..//类似实现减法,乘法,除法,取反,与,或等操作。 Case BPF_JMP|BPF_JA://涉及分支跳转 Pc += fentry->k; Continue; Case BPF_JMP|BPF_JGT|BPF_K://大于 Pc += (A->fentry->k)?fentry->jt:fentry->jf; Continue; ..//类似实现小于,等于,比较操作,然后分支跳转 } } Load_w: ///加载操作,类似汇编mov,这样load也是区分大小的,比如是load一个字还是双字,还是字节。 If(k >=0 &&(unsigned int)(k+sizeof(u32)) <=len){ A = ntohl(*(u32*)&data[k]); Continue; } }
BPF用于很多的抓包程序,在linux中,一般内核自动编译进了af_packet这个驱动,因此只需要在用户态配置一个PACKET的socket,然后将filter配置进内核即可,使用setsockopt的SO_ATTACH_FILTER 命令,这个filter是在用户空间配制的,比如tcpdump应用程序,tcpdump和内核BPF过滤器的关系类似iptables与netfilter的关系,只是Netfilter实现了match/target的复合配合,而BPF的target则只是选择是否要与不要。
回到主题上来,seccomp-bpf在使用BPF的时候,借鉴了其思想。
大量的系统调用暴露在用户空间,在程序的整个生命周期内并没有使用。而随着系统调用的改变和成熟,bug的发现和根除,一套可用的系统调用是满足用户程序基本的调用。将可以产生出一组尽量少的系统调用暴露在用户空间。系统调用的过滤就是在应用程序的使用中。
Seccomp过滤器提给了一种手段,为一个进程调用系统调用时指定了过滤器,而这个过滤器则是BPF。BPF作为socket过滤器不同之处则是操作当前的user_regs_struct数据结构,这需要预先定义好系统调用的ABI和定义过滤规则。但系统调用的参数保存在用户空间,但要验证该参数是否安全是非常困难的。
Seccomp用户无法避免TOCTOU (time-of-check-time-of-use)系统调用对框架的注入式攻击。如一个恶意进程可能会在“参数被安全检查”之后,而在实际使用之前,将参数换掉,这边使节或系统调用时所作的参数检查变得没有意义。但要解决这个问题,也不能仅把目光盯住系统的调用入口。
针对系统调用的过滤,不是一种沙箱的模式,其更多的提供了减少内核暴露的表面,除此之外,需要和其他的规则和方案进行配合。
当seccomp模式被添加,但它们不是直接设置超时的过程,是一种新的模式, 仅当CONFIG_SECCOMP_FILTER设置,并用prctl启用PR_ATTACH_SECCOMP_FILTER参数。
与seccomp过滤器交互式通过prctl调用。
PR_ATTACH_SECCOMP_FILTER ,允许一个新的过滤器使用BPF程序的规范,BPF程序将被执行user_regs_struct结构的数据。用法为
prctl(PA_ATTACH_SECCOMP_FILTER,prog);
+#include <asm/unistd.h> +#include <linux/filter.h> +#include <stdio.h> +#include <stddef.h> +#include <sys/user.h> +#include <unistd.h> + +#define regoffset(_reg) (offsetof(struct user_regs_struct, _reg)) +int install_filter(void) +{ + struct sock_filter filter[] = { + /* Grab the system call number */ //BPF_LD 将值拷贝进寄存器(accumulator) //BPF_W Word BPF_H half Word //BPF_IND 可变的偏移 //BPF_ABS 固定的偏移 //BPF_k 常数 BPF_A 累加器 //BPF_JEQ 判断是否相等 //#define BPF_STMT(code, k) { (unsigned short)(code), 0, 0, k } //#define BPF_JUMP(code, k, jt, jf) { (unsigned short)(code), jt, jf, k } + BPF_STMT(BPF_LD+BPF_W+BPF_IND, regoffset(orig_eax)), //物理头,偏移regoffset(orig_eax) byte后,指向type*。 + //* Jump table for the allowed syscalls */ //进行比较,是否为I__NR_rt_sigreturn。 true的话 0, false 10 + BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, __NR_rt_sigreturn, 10, 0), + BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, __NR_sigreturn, 9, 0), + BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, __NR_exit_group, 8, 0), + BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, __NR_exit, 7, 0), + BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, __NR_read, 1, 0), + BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, __NR_write, 2, 6), + + /* Check that read is only using stdin. */ + BPF_STMT(BPF_LD+BPF_W+BPF_IND, regoffset(ebx)), + BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, STDIN_FILENO, 3, 4), + + /* Check that write is only using stdout/stderr */ + BPF_STMT(BPF_LD+BPF_W+BPF_IND, regoffset(ebx)), + BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, STDOUT_FILENO, 1, 0), + BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, STDERR_FILENO, 0, 1), + + /* Put the "accept" value in A */ + BPF_STMT(BPF_LD+BPF_W+BPF_LEN, 0), + + BPF_STMT(BPF_RET+BPF_A,0), + }; + struct sock_fprog prog = { + .len = (unsigned short)(sizeof(filter)/sizeof(filter[0])), + .filter = filter, + }; + if (prctl(36, &prog)) { // prctl(PA_ATTACH_SECCOMP_FILTER,prog); + perror("prctl"); + return 1; + } + return 0; +} + +#define payload(_c) _c, sizeof(_c) +int main(int argc, char **argv) { + char buf[4096]; + ssize_t bytes = 0; + if (install_filter()) + return 1; + syscall(__NR_write, STDOUT_FILENO, payload("OHAI! WHAT IS YOUR NAME? ")); + bytes = syscall(__NR_read, STDIN_FILENO, buf, sizeof(buf)); + syscall(__NR_write, STDOUT_FILENO, payload("HELLO, ")); + syscall(__NR_write, STDOUT_FILENO, buf, bytes); + return 0; +}