最近研究内核代码,发现内核代码为了执行效率,损失了很多安全检查。但是由于用户空间和内核空间是隔离的,就算发现了内核漏洞,想要从用户态去触发和利用还是特别困难的。所以内核漏洞往往集中在能与用户空间进行交互的地方,比如之前的netlink、xfrm等模块。而BPF给用户空间提供了一个让内核代码执行用户代码的能力,对于内核漏洞利用十分有利。所以本篇文章将作为对于BPF漏洞利用的一个初始学习,后续将会更加深入了解。
基础知识
eBPF简介
linux
的用户层和内核层是隔离的,如果想让内核空间执行用户的代码,正常流程是编写内核模块。但是内核模块的编写执行需要有root
权限,这对于攻击者是不理想的。而 BPF(Berkeley Packet Filter)
则使普通用户拥有了让内核执行用户代码并共享数据的能力。用户可以将eBPF
指令字节码传输给内核,然后通过socket
写事件来触发内核执行代码。并且用户空间和内核空间会共享同一个map
内存,且用户空间和内核空间都对其拥有读写能力。这就为攻击者提供了极大的便利。BPF
发展经历了 2 个阶段,cBPF(classic BPF)
和 eBPF(extend BPF)
,cBPF
已退出历史舞台,所以后文的BPF
都指eBPF
。
eBPF虚拟指令系统
eBPF
虚拟指令系统属于 RISC
,拥有 10 个 虚拟寄存器, r0-r10
在实际运行时,虚拟机会把这 10 个寄存器——对应于硬件 CPU
的 10 个物理寄存器,以 x64
为例,对应关系如下:
1 | R0 – rax |
每一条指令的格式如下:
1 | struct bpf_insn { |
如一条简单的 x86
赋值指令:mov eax, 0xffffffff
,对应的 BPF
指令为:BPF_MOV32_IMM(BPF_REG_2, 0xFFFFFFFF)
,其对应的数据结构为:
1 |
|
其在内存中的值为:\xb4\x09\x00\x00\xff\xff\xff\xff
。
关于 BPF
需要明确两点:1. 其为 RISC
指令系统,也即每条指令大小都是一样的;2. 其虚拟的 10 个寄存器——对应于物理 cpu 的寄存器,且功能类似,比如 BPF
的 r10
寄存器和 rbp
一样指向栈,r0
用于返回值。
BPF加载过程
一个典型的 BPF
程序流程为:
- 用户程序调用
syscall(__NR_bpf, BPF_MAP_CREATE, &attr, sizeof(attr))
申请创建一个map
,在attr
结构体中指定map
的类型、大小、最大容量等属性。之后调用sys_bpf
进而使用系统调用syscall(__NR_bpf, BPF_MAP_CREATE, attr, size);
创建一个map
数据结构,最终返回map
的文件描述符。这个文件是用户态和内核态共享的,因此后续内核态和用户态可以对这块共享内存进行读写:
1 | //lib/bpf.c |
用户程序调用
syscall(__NR_bpf, BPF_PROG_LOAD, &attr, sizeof(attr))
来将我们写的BPF
代码加载进内核,attr
结构体中包含了指令数量、指令首地址、日志级别等属性。在加载之前会利用虚拟执行的方式来做安全行校验,这个校验包括对指定语法的检查、指令数量的检查、指令中的指针和立即数的范围及读写权限检查,禁止将内核中的地址暴露给用户空间,禁止对BPF
程序stack
之外的内核地址读写。安全校验通过后,程序被成功加载至内核,后续真正执行时,不再重复做检查;用户程序通过调用
setsocopt(sockets[1], SOL_SOCKET, SO_ATTACH_BPF, &progfd, sizeof(progfd))
将我们写的BPF
程序绑定到指定的socket
上,Progfd
为上一步骤的返回值;用户程序通过操作上一步骤中的
socket
来触发BPF
真正执行。
eBPF sample
eBPF
在 cBPF
的基础上做了很多改变,这里以一个 sample
来了解 eBPF
的 sample
交互方式。
第一个示例只有一个 .c
文件,BPF
代码需要自己构造,可以类比成 C 里嵌入了汇编:
1 | //./linux-4.4.110/samples/bpf/sock_example.c |
第2个示例的功能和第一个的一致,但是这里事用 c 的形式写 eBPF
代码。
首先编译 sockex1_kern.c
到 sockex1_kern.o
,在这个代码文件中定义了 eBPF
规则:
1 | //./linux-4.4.110/samples/bpf/sock_example.c/sockex1_kern.c |
之后使用 sockex1_user.c
记载 eBPF
代码到内核进而执行代码,过滤数据包得到 value
并输出:
1 |
|
eBPF代码执行过程
对 eBPF
指令的解释执行,最后会进入 __bpf_prog_run
函数。可以看到这里是根据指令,对寄存器进行了相应的操作。如果我们后续要分析 eBPF
指令的执行过程,就需要对这个函数进行深入分析。
1 | /** |
eBPF函数介绍
eBPF
是通过 执行不同的函数,来实现各种功能,参考手册在这。可以使用的函数如下:
1 | //创建一个map内存,返回一个执行map的文件指针 |
接下来,我们依次介绍各个函数的用法。
BPF_MAP_CREATE
该函数用于创建一个新的 map
内存,返回一个新的文件描述符,并指向该内存。
1 | int |
首先将传入的四个参数,分别赋值给 bpf_attr
数据结构,其原型如下,包含了使用 BPF
函数时所需要的各个参数。
1 | union bpf_attr { |
需要传入的4个参数,含义分别为:
bpf_map_type
,指定 创建的map
的类型,所有类型如下,用于指定建立映射的方式
1 | enum bpf_map_type { |
key_size
指定了key
的数据大小,用于在后续验证bpf
程序时使用,防止越界访问。例如当 一个map
创建的key_size
为8,那么此时如下函数将会被阻止。因为对于内核,其希望从源地址读取 8字节的数据,但是此时源地址为fp-4
,如果读取8字节,就会超出当前栈的边界,所以会被阻止
1 | bpf_map_lookup_elem(map_fd, fp - 4) |
- 同理,
value_size
指定了value
的数据大小。例如,当使用value_size=1
创建了map
之后,使用 如下代码则会被阻止。因为,这里的value
大小为 1 字节,而却想要将其赋值为 4字节,超出了value_size
。
1 | value = bpf_map_lookup_elem(...); |
max_entries
指定了map
的大小
BPF_MAP_LOOKUP_ELEM
BPF_MAP_LOOKUP_ELEM
函数根据传入的 key
执行寻找其对应的 元素。
1 | int bpf_lookup_elem(int fd, const void *key, void *value) |
如果一个元素被找到,则返回0,并将该值存入 存入的value
参数里,其指向了一个 上一步提到的 value_size
大小的 buffer
。如果没有被找到,则返回 -1
,并设置 errno
。
BPF_MAP_UPDATE_ELEM
BPF_MAP_UPDATE_ELEM
函数使用传入的 key
或 value
创建或者更新一个map
中的元素
1 | int bpf_update_elem(int fd, const void *key, const void *value, |
flag
参数必须为如下选项,
1 | BPF_ANY |
如果成功,返回 0。若失败,则返回 -1。
BPF_MAP_DELETE_ELEM
BPF_MAP_DELETE_ELEM
函数用于根据传入的 key
或 value
来删除一个元素:
1 | int bpf_delete_elem(int fd, const void *key) |
如果成功,则返回 0。如果元素为被找到找到,则返回 -1。
BPF_MAP_GET_NEXT_KEY
该含糊根据传入的 key
值寻找到对应的元素,然后返回 其下一个元素:
1 | int bpf_get_next_key(int fd, const void *key, void *next_key) |
如果 key
被找到,则返回0,并将 next_key
指向 key
值得下一个元素。如果key
未找到,则返回 0,并将 next_key
指向 第一个元素。如果 key
是最后一个元素,则返回 -1,并将 next_key
设置为 ENOENT
。
BPF_PROG_LOAD
该函数用于加载一个 eBPF
程序到内核,返回一个新的指向 eBPF
程序的文件指针。
1 | char bpf_log_buf[LOG_BUF_SIZE]; |
map
内存可以被 eBPF
程序访问,并且实现从 eBPF
程序和用户空间程序 交互数据。例如,eBPF
程序可以获取进程数据(例如kprobe
、packets
)并将数据存储到map
,然后用户空间程序就可以通过访问 map
来获取数据。反之亦然。
BPF的安全校验
这里我们分析一下 Verifier
机制,主要检测函数为 bpf_check
:
1 | int bpf_check(struct bpf_prog **prog, union bpf_attr *attr) |
其中主要是使用 do_check
来根据不同的指令类型来做具体的合法性判断。使用的核心数据结构是 reg_state
,bpf_reg_type
枚举变量用来表示寄存器的类型,初始化为 NOT_INIT
:
1 | struct reg_state { |
replace_map_fd_with_map_ptr
这个函数主要是判断当前 BPF
指令是否满足两个条件:insn[0].code == (BPF_LD | BPF_IMM | BPF_DW)
和 insn->src_reg == BPF_PSEUDO_MAP_FD
。如果满足这两个条件,那么 会将根据imm
的值进行map
查找,并将得到的地址分成两部分,分别存储于该条指令和下一条指令的imm
部分。满足上述两个条件的语句又被命名为BPF_LD_MAP_FD
,即把map
地址放到寄存器里,该指令写完后,下一条指令应为无意义的填充。
1 | /* look for pseudo eBPF instructions that access map FDs and |
do_check
1 | static int do_check(struct verifier_env *env) |
BPF
指令的校验是在函数 do_check
中,代码路径为 kernel/bpf/verifier.c
,do_check
通过一个无限循环来遍历提供的 bpf
指令。
漏洞分析
漏洞概述
漏洞存在于内核版本小于 4.13.9
的系统中,漏洞成因为 kernel/bpf/verifier.c
文件中的 check_alu_op
函数的检查问题,这个漏洞可以允许一个普通用户向系统发起拒绝服务攻击(内存破坏)或者提升到特权用户。
漏洞分析
漏洞成因是内核在对 ALU
指令和 JMP
指令在检测时和真正运行的语义解释不一样导致。
理论上虚拟执行和真实执行的执行路径应该是完全一致的,如果步骤2安全校验过程中的虚拟执行路径和步骤4 bpf
的真实执行路径不完全一致的话,则会发生以下问题,示例如下:
1 | 1.BPF_MOV32_IMM(BPF_REG_9, 0xFFFFFFFF), /* r9 = (u32)0xFFFFFFFF */ |
第一条指令是个赋值语句,将 oxffffffff
这个值赋值给 r9
;
第二条指令是个条件跳转指令,如果 r9
等于 0xffffffff
,则退出程序,终止执行;如果 r9
不等于 0xffffffff
,则跳过后面2条指令继续执行第5条指令。
虚拟执行的时候,do_check
检测到第2条指令等式恒成立,所以认为 BPF_JNE
的跳转永远不会发生,第 4 条指令之后的指令永远不会执行,所以检测结束,do_check
返回成功。
下面我们分析一下do_check
中对 ALU
指令进行检查 ,check_alu_op
函数会对操作数进行检查,该代码的最后一个分支处会对如下两种情况进行检查:
- BPF_ALU64|BPF_MOV|BPF_K,把 64 位立即数赋值给目的寄存器
- BPF_ALU|BPF_MOV|BPF_K,把 32 位立即数赋值给目的寄存器可以看到对于
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22if (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 {
/* 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;
}BPF_ALU64
或者BPF_ALU
最后都是 将 立即数insn->imm
赋值给regs[insn->dst_reg].imm
。而imm
是 32位有符号立即数:所以就导致当我们调用1
2
3
4
5
6
7struct 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 */
};BPF_ALU64|BPF_MOV|BPF_K
指令时,传入的 值是0xffffffff
给寄存器,会只是一个有符号的 32位数据。
而在eBPF
程序真实执行时,对这两条指令的解释如下(__bpf_prog_run
):可以看到1
2
3
4
5
6ALU_MOV_K:
DST = (u32) IMM;
CONT;
ALU64_MOV_K:
DST = IMM;
CONT;ALU_MOV_K
,仅仅是将32位无符号的数传递给了目的寄存器,而ALU64_MOV_X
却是将立即数IMM
赋值给了 64位目的寄存器,这里如果IMM
是 32位数据,会对其进行一个sign extension
,导致这里DST
获得值与原IMM
并不相等。
所以在do_check
检查时,这两条指令并无区别。但是在实际解释执行时,这两条指令的结果并不相同。运用这个差异即可对do_check
进行绕过。
在对 BPF_JMP|BPF_JNE|BPF_IMM
指令解释时,当 IMM
为有符号或无符号时,因为 sign extension
,DST != IMM
结果是不一样的:
1 | JMP_JNE_K: |
但是,这是怎么确定在赋值时,会有符号拓展,从源码上我无法直接看到。所以还是得看汇编最好,真实执行时的汇编指令却如下所示:
1 | ► 0xffffffff81173e7f movsxd rdx, dword ptr [rbx + 4] |
可以看到这里 第一条指令赋值时 汇编使用的是 movsxd
,这就是会进行符号拓展。可以看到这里原本的值为 0xffffffff
,但是执行完该指令,进行了符号拓展,真正赋值的值为 0xffffffffffffffff
。所以,后续的第2条指令 判断会永远不成立。
真实执行的时候,由于一个符号拓展的 bug
,导致第2条指令中的等式不成立,于是 cpu
就跳转到第5条指令继续执行,这里是漏洞产生的原因,这4条指令,可以绕过 BPF
的代码安全检查。当安全检查被绕过了,用户就可以随意往内核中注入代码,也就能够提权。总体思路为:
- 先获取到
task_struct
的地址,然后定位到cred
的地址,然后定位到uid
的地址; - 然后直接将
uid
的值改为0,然后启动/bin/bash
漏洞利用
上述漏洞分析已经分析的很完整,即我们可以在输入的 bpf
指令前4条指令用于绕过 do_check
。在随后的指令中用于执行恶意指令。那么后续提权的恶意指令应该怎么布置呢?这里基于原本的exp
先进行一个静态分析。
BPF指令静态编写
这里讲述一下,如何编写 exp
中需要是用到的各项功能。建议可以参考 linux
源码中 sample/bpf
目录下的示例,其给出了各项指令,只需要调用即可。
绕过do_check
1 | BPF_MOV32_IMM(BPF_REG_2, 0xFFFFFFFF), \ //mov32 r2, 0xffffffff |
寄存器获取map值
1 | BPF_LD_MAP_FD(BPF_REG_9, mapfd), //r9=mapfd |
r2存储map[2]地址
1 | BPF_MOV64_REG(BPF_REG_2, BPF_REG_0), /* r2 = r0=&map[2] */ |
获取栈地址
1 | BPF_JMP_IMM(BPF_JNE, BPF_REG_6, 0, 2), //if(r6==0){r2=map[2]=r10=fp}else{exit(0)} |
r10
是 fp
,其值是一个内核栈地址,r2
的值是 map[2]
的地址。相当于将 r10
的值 赋值给 map[2]
任意读
1 | //read |
这里 r7
的值是需要读取的 地址 addr
,r2
的值是 map[2]
的地址,相当于把 addr
的值 赋值给 map[2]
,用户态读取 map[2]
即可获得 addr
的值
任意写
1 | BPF_STX_MEM(BPF_DW, BPF_REG_7, BPF_REG_8, 0), //*r7=r8 |
r7
的值是需要写的地址 addr
,r8
的值是 需要写入的值。
利用 r6
作为指令判断,当map[0]
输入为 0、1、2时,r6
也分别为对应的值。
- 当
r6==0
时,可以将r7
所指向的值赋值给r2
,而这里r7
的值由map[1]
控制,而r2
的值由r0==map[2]
,所以这里就相当于实现如下指令,能够实现一个任意地址读。
1 | map[2] = *map[1] |
- 当
r6==1
时,将r10
所指向的值赋值给r2
,而这里r10
为rbp
,也就相当于将rbp
的值赋值给了map[2]
,可以读取栈地址。 - 当
r6==2
时,将r8
的值赋值给r7
所指向的地址,实现了一个任意地址写。
在用户空间创建的 map[0]
用来存放操作指令,map[1]
用来存放需要进行读写的内存地址,map[2]
用来存放泄露的地址。
map[0].value = 0
,表示读取 map[1]
中存放的地址的内容,放到 map[2]
中。这里就实现了任意地址读。
map[0].value = 1
,表示读取内核栈基址,放到 map[2]
中。这里就实现了泄露内核基地址。
map[0].value = 2
,表示将 map[2]
的值写入到 map[1]
中的地址中。实现了任意地址写。
这里 r6
用于 op
,r7
用于输入 address
,r8
用于输入或获取value
。
利用方法
这里原exp
中是使用覆写 cred
结构体来提权。而这里已经实现了任意地址读和任意地址写,所以这里能够用于提权的方法十分多样,下面分别讲述两种提权方法:一种是简单的覆盖 modprobe_path
,另一种即覆写 cred
。
覆写modprobe_path
这种方法十分简单。首先需要泄露内核基址,这里由于我们有一个任意地址读,而经过调试 r10(即fp)
的值加上0x28
处的地址的值就是 __bpf_prog_run
函数的返回地址。所以我们可以直接将返回地址泄露出来,以此来获得内核基址。同时,由于有一个任意地址写,所以可以直接向 modprobe_path
的地址写上 /tmp/l.sh
的16进制数字。完成覆写 modprobe_path
。
下面是执行 r3 = *(u64 *)(fp+0x28); *(u64 *)r2=r3;
指令时的汇编,可以看到此时 fp+0x28
的值被存储到了 RAX
中是返回地址 0xffffffff817272bc
,而 r2
此时的值为 0xffff8800077e59f0
,该地址是 map[2]
的地址,现在的值为 0。而执行完这两条指令后 map[2]
的值已经变为返回地址0xffffffff817272bc
。
1 | *RAX 0xffffffff817272bc ◂— test byte ptr [r13 + 2], 4 /* 0xad850f040245f641 */ |
覆写 cred
覆写 cred
关键就是如何找到 cred
所在的地址。这里最常见的思路就是通过任意读,不断爆破其地址,但是由于任意读每次只能读8字节,所以爆破稍微需要一点时间。然后参考别人的exp
,又有两种思路:一种是根据 内核栈地址,找到位于栈顶的 tread_info
地址,其第一个数据就存储了 task_struct
地址,再获得 cred
结构体地址;另一种是根据位于 bpf_reg_1
中的 skbuff
结构体,其中存储了 task_struct
结构体,然后获得 cred
结构体。第2种,这里我不太清楚为什么 bpf_reg_1
中会存储 skbuff
地址,所以我不做讲述。重点使用第1种方法。
首先简述一下内核栈与 thread_info
的关系。
由于task_struct
随着版本的更新,其一直在不断增大,所以直接将 task_struct
放入栈中会十分浪费栈空间,因此选择将 task_struct
地址存储到 threadinfo
结构体中,而将 thread_info
放入栈中。thread_info
结构体如下:
1 | struct thread_info { |
而 thread_info
与 内核栈 stack
一起组成了一个 thread_union
结构体:
1 | union thread_union { |
内核定义了一个 thread_union
联合体,将 thread_info
和 stack
共用一块内存区域。而 thread_size
就是内核栈的大小,如下图所示:
那么内核是如何获取 task_struct
结构呢,内核实现了一个 current
宏:
1 |
|
可以看到其获取了一个内核栈地址 sp
,然后通过对齐 THREAD_SIZE
就可以获取 thread_info
结构的基地址了。这里的 THREAD_SIZE
为 16384
即 0x4000
,所以后面用 0x4000
来对齐。
所以这里如果想找到 cred
的地址,可以先泄露一个内核栈地址,再通过对齐获得 thread_info
地址,再获得 task_struct
地址,最后获得 cred
地址。
得到 task_struct
之后还需要确定 cred
在 task_struct
中的偏移,这里目前没有找到好的办法,不同版本各有不同,需要自行调试。
EXP
覆写 modprobe_path
1 | //Author: A1ex |
覆写 cred
:
1 | //Author: A1ex |
总结
本次分析,仍然有部分不是太清楚的地方,比如 eBPF
指令执行时,各虚拟寄存器是如何初始化的。以及 eBPF
的规则编写仍不是太熟练,这些都是后续需要深入研究的。
参考
深入分析Ubuntu本地提权漏洞—【CVE-2017-16995】
Linux ebpf模块整数扩展问题导致提权漏洞分析(CVE-2017-16995
- 本文作者: A1ex
- 本文链接: http://yoursite.com/2021/04/24/CVE-2017-16995-内核提权漏洞分析/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!