这一篇是作为ebpf的入门文章,所以会稍微介绍一下指令集,寄存器。可能篇幅略长。
漏洞简介
该漏洞最早是由17年12月21号Google Project Zero团队的Jann Horn发现并报告的,编号为CVE-2017-16995。
内核影响版本:Linux Kernel Version 4.14 ~ 4.4 。
eBPF指令集介绍
在认识一个新的指令集的第一步就是认识寄存器,在eBPF中存在十一个寄存器:
1 | R0:一般用来表示函数返回值,包括整个 BPF 代码块(其实也可被看做一个函数)的返回值; |
对于算数(ALU)和跳转(JMP)指令,8bit的代码字段分为三个部分:
1 | +----------------+--------+--------------------+ |
这里最后3bit LSB代表指令类型:
1 | eBPF classes: |
当BPF_CLASS(code) == BPF_ALU or BPF_JMP时,第四bit编码源操作数:
1 | BPF_K 0x00 ;代表将立即数作为源操作数 |
前4bit的MSB用来存储操作码:
- 如果 BPF_CLASS(code) == BPF_ALU or BPF_ALU64时,操作码会是下面的一个:
1 | BPF_ADD 0x00 |
- 如果BPF_CLASS(code) == BPF_JMP or BPF_JMP32时,操作码将会是下面的一个:
1 | BPF_JA 0x00 /* BPF_JMP only */ |
比如 BPF_ADD | BPF_X | BPF_ALU这条指令代表32位加法运算,将源寄存器的值加上目的寄存器的值,然后将结果存储到目的寄存器中:dst_reg = (u32) dst_reg + (u32) src_reg;
并且,在eBPF指令集中没有了BPF_RET指令,用BPF_JMP | BPF_EXIT仅代替函数执行完退出,在函数退出之前,eBPF需要将返回值存储在R0中。
对于加载(LOAD)和存储(STORE)指令,8-bit的代码域分为:
1 | +--------+--------+-------------------+ |
其中size分别有下面四种类型:
1 | BPF_W 0x00 ; word 4 byte |
并且在内核中,每一条指令的信息都储存在bpf_insn结构体中:
1 | struct bpf_insn { |
eBPF代码加载执行流程 & 检查分析
加载执行流程
用户可以用eBPF指令字节码的形式向内核输送代码,并通过事件来(如往socket写数据)来触发内核执行用户提供的eBPF代码。eBPF模块可以让用户加载数据包过滤代码(eBPF代码)进入内核,在收到数据包时触发eBPF代码执行。可以编写一个简单的用户数据包过滤程序来触发执行eBPF代码,具体流程如下:
1 | * syscall(__NR_bpf, BPF_MAP_CREATE, &attr, sizeof(attr)) ;创建全局变量map结构体,内核态用户态都可以访问。 |
检查分析
根据上述流程我们可以看到我们将BPF代码注入到内核中是发生在第二步中的,执行的函数为:bpf_prog_load
1 | static int bpf_prog_load(union bpf_attr *attr) |
可以看到在19行对ebpf license进行验证是否为GPL证书的一种,在21行检验了传入代码的长度,然后通过find_prog_type函数判断过滤模式,其中socket_filter是数据包过滤,而tracing_filter就是对系统调用号及参数的过滤,也就是我们常见的seccomp。最后进入bpf_check函数进行进一步验证。
1 | int bpf_check(struct bpf_prog **prog, union bpf_attr *attr) |
这里首先是使用check_cfg函数借用了程序控制流图的思路来检查这个EBPF程序中是否有死循环和跳转到未初始化的位置,避免造成无法预期的风险。最后则是使用do_check函数模拟执行一次注入代码,会将注入代码的所有逻辑分支从头到尾都会被完全跑上一遍,会模拟每条指令的执行,包括堆栈、寄存器、访问内存、调用函数等。
漏洞分析
这里的漏洞发生在do_check函数和最后真正运行的__bpf_prog_run翻译结果不一致导致的。这里用如下代码来演示:
1 | BPF_MOV32_IMM(BPF_REG_9, 0xFFFFFFFF), /* r9 = (u32)0xFFFFFFFF */ |
首先看一下do_check函数中处理的事情:
1 | init_reg_state(regs); |
可以看到这里存在一个for死循环,最后会返回BPF_CLASS获得的指令操作码类型。
第一条指令是BPF_MOV32_IMM(BPF_REG_9, 0xFFFFFFFF),BPF_MOV指令属于ALU大类中,在检测到指令类型是ALU,进入如下分支,调用check_alu_op()函数继续判断。
1 | if (class == BPF_ALU || class == BPF_ALU64) { |
根据check_alu_op()函数,上面这条代码的指令操作码为BPF_MOV,进入如下分支。
1 | else if (opcode == BPF_MOV) { |
可以看到这里最后是将指令的立即数保存到了reg_state结构体中的:
1 | struct reg_state { |
可以看到,这里的imm也是有符号整数,和bpf_insn结构体中的imm类型一致。
检查第二条指令BPF_JMP_IMM(BPF_JNE, BPF_REG_9, 0xFFFFFFFF, 2),这是一条JMP指令,在do_check函数中会进入如下分支:
1 | else if (class == BPF_JMP) { |
最后的else语句也就是JNE所进入的分支,所以会进一步进入check_cond_jmp_op函数。
1 | if (BPF_SRC(insn->code) == BPF_K && |
在函数内部存在这样一条if语句,由前面提到了在reg_state结构体中的imm和insn中的imm都是int类型,并且reg_state结构体中的imm是由insn中直接赋值过去的,所以这个条件会恒等。所以当操作码为BPF_JNE时,永远都不会跳转。
上面是模拟执行的情况,那么下面看看真实执行情况是什么样子的,首先真实执行情况的函数为__bpf_prog_run:
1 | static unsigned int __bpf_prog_run(void *ctx, const struct bpf_insn *insn) |
可以看到函数这里维护的是一个跳表,根据opcode来进行跳转,并且没有任何检测。这里重点关注一下上述例子中的跳转函数:
1 | /* Jumps */ |
可以看到这里赋值的是JMP_JNE_K:
1 | JMP_JNE_K: |
这里就比较关注DST和IMM的定义了:
1 | /* Named registers */ |
可以看到这里IMM的定义依旧是从insn中拿出来的,然而这里的DST是直接从约定的寄存器中拿出来,然而在__bpf_prog_run函数的开头可以看出来这里的寄存器定义为unsigned long long int类型,那么如果重新执行上述演示代码就会出现跳转,也就是说检查和实际运行的指令执行流程会不一致,可以利用这个绕过安全检测。
漏洞利用
退出do_check
但就目前来看依旧存在的一个问题就是虽然他会进入BPF_EXIT分支,但是在最后的pop_stack需要返回的值为负数才能结束循环退出do_check函数。
1 | else if (opcode == BPF_EXIT) { |
1 | static int pop_stack(struct verifier_env *env, int *prev_insn_idx) |
1 | struct verifier_env { |
所以我们在实现构造时需要将head位置为0,根据函数名pop_stack可以推测出来这里其实也就是栈的操作,所以大致的构造方法是:
1 | BPF_MOV32_IMM(BPF_REG_9, 0xFFFFFFFF), /* r9 = (u32)0xFFFFFFFF */ |
实现任意地址读写
内存读写需要用到的指令主要是BPF_LDX_MEM或者BPF_STX_MEM两类。如下,当r7和r8的值可控就可以达到内存任意写,类似于mov dword ptr[r7],r8这样的操作。
1 | else if (class == BPF_LDX) { |
check_mem_access函数会根据读写类型检查dst或src的值是否为栈指针、数据包指针、map指针,否则不允许读写。所以这里使用BPF_FUNC_map_lookup_elem这样的函数调用返回,再赋给某个寄存器,然后再进行读写。
最终构造的eBPF指令为:
1 | ----------------------------------------------part 1 ---------------------------------------------- |
综上,exp
1 | /* |

这里exp我是直接用的原文的exp,因为理解漏洞之后操作起来就比较简单了,主要麻烦的就是构造eBPF指令