eBPF介绍
eBPF 是一项革命性的技术,起源于 Linux 内核,它可以在特权上下文中(如操作系统内核)运行沙盒程序。它用于安全有效地扩展内核的功能,而无需通过更改内核源代码或加载内核模块的方式来实现。
历史上由BPF(伯克利包过滤器 Berkeley Packet Filter)发展而来(现在称为cBPF),直接在内核中运行特权代码,给予了Linux内核编程的极大的灵活性。
eBPF需要内核支持,用户可以通过一系列指令编写程序,在程序被加载后需要经过检验,验证通过后会通过JIT (Just-in-Time) 编译成机器码执行。
为了更加便捷,eBPF还支持在eBPF指令中直接调用函数(被称为Helper函数)。eBPF还提供map等数据结构用来与用户态之间传递信息;
在相关漏洞利用中,一般是想方法绕过严格的eBPF检验,通过漏洞混淆检验的过程,使得检验(模拟执行)结果和实际JIT编译运行结果不一致,从而造成安全漏洞,进一步转化为方便利用的类型。
我们见到的沙箱题一般是用seccomp来实现的,而seccomp-bpf便是采用了BPF技术实现过滤系统调用
相关文档1 相关文档2 Linux系统另见
/usr/include/linux/bpf.h
bpf设计相关,另见此
eBPF基础
基础知识
本章的讨论环境基于Linux v6.13.7
1 | // in /usr/include/asm/unistd_64.h |
通过系统调用号为321的系统调用bpf可以使用eBPF相关功能。glibc并没有提供了包装函数,需要我们进行raw syscall:
1 |
|
- 参数
int cmd
标识bpf的行为(类型应为enum bpf_cmd)。常用的有:BPF_PROG_LOAD
用于加载并验证eBPF程序,写好了eBPF程序就用此参数上传。返回一个文件描述符(fd)以供使用。BPF_MAP_CREATE
用于创建一个eBPF map,以键值对(key-value)的形式存储数据。返回一个文件描述符(fd)以供使用。BPF_MAP_LOOKUP_ELEM
与BPF_MAP_UPDATE_ELEM
、BPF_MAP_DELETE_ELEM
分别用来寻找、更新和删除map中的值(value)/键值对(key/value pair)BPF_MAP_GET_NEXT_KEY
寻找map中的下一个键(key)BPF_MAP_FREEZE
冻结map的写权限(包括通过bpf调用实现的),此时检验(verifier)过程会将map中的值当作常数BPF_PROG_TEST_RUN
用于测试运行eBPF,测试用输入(data/context)及输出在参数union bpf_attr *attr
中给出- 完整版见此
- 参数
unsigned int size
一般设置为sizeof(attr)
- 参数
union bpf_attr *attr
根据cmd
的不同有不同的格式,该参数类型为union bpf_attr *
,为一个包含多种结构体的联合体指针。该类型完整原型太长了,我们只看部分:(翻译并补充了部分注释)
1 | union bpf_attr { |
其中,map_type
的枚举类型为enum bpf_map_type
,部分内容为:
1 | enum bpf_map_type { |
prog
的枚举类型为enum bpf_prog_type
,部分内容为:
1 | enum bpf_prog_type { |
调用模板
由此可见,参数union bpf_attr *attr
是最重要且繁琐的参数。在eBPF Pwn中,我们往往只需要关注部分参数,下边给出一些调用模板:
需要的头文件/宏:
1
2
3
4
5
6
1 | // 创建map, 一般类型为BPF_MAP_TYPE_ARRAY或BPF_MAP_TYPE_HASH |
bpf_create_map
中的参数max_entries
指的是map的最大容量(key-value对大于这个值不允许插入)bpf_update_elem
的flags参数为BPF_NOEXIST
(key不存在时才会更新),BPF_EXIST
(仅在key存在时更新)或BPF_ANY
(两种情况均可)
1 |
|
1 | // 通过socket触发,要求cmd为BPF_PROG_TYPE_SOCKET_FILTER |
有关trigger函数原理/该选用哪一个的问题会在后几章详细解释
eBPF指令
在prog创建时需要提供指令struct bpf_insn *insns
,也是eBPF程序的核心部分。在eBPF Pwn中,指令的编写在触发/利用漏洞中扮演重要角色。
在编写指令之前,我们需要搞明白eBPF的工作模式
这是bpf指令结构体,看着好像并不复杂,但麻雀虽小五脏俱全
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指令分为五个部分,分别是操作码,目的寄存器,源寄存器,(有符号)偏移和(有符号)立即数,长度为64bit(8字节)
编码格式:
操作码(opcode) 8bit | 寄存器(regs) (4 + 4 =)8bit | 偏移(offset)16bit |
立即数(imm) 32bit |
存在宽编码格式指令,后接32bit无用保留区及第二个32bit的立即数,此时指令长度为128bit(16字节)(其实是两条指令拼一块)
操作码宏定义详细编码格式不再赘述,详见此处
操作码(code)分为8类,分别为:
BPF_LD
从64位立即数(imm64, 仅允许宽指令)地址取值存入目的寄存器(src_reg)BPF_LDX
从源寄存器(dst_reg)地址取值存入目的寄存器(src_reg)BPF_ST
把立即数(imm)存入目的寄存器(dst_reg)对应地址BPF_STX
把源寄存器(src_reg)数据存入目的寄存器(dst_reg)对应地址BPF_ALU
32位算术操作BPF_JMP
64位跳转操作BPF_JMP32
32位跳转操作BPF_ALU64
64位算术操作
BPF_LD(X)
/BPF_ST(X)
操作涉及取址,需同时提供取址大小(size),分别有:
BPF_W
word (32位)BPF_H
half word (16位)BPF_B
byte (8位)BPF_DW
double word (64位)
这里的word(字)的大小并不是16位而是32位
该操作还有不同的模式,参阅此处
其中BPF_ALU
/BPF_ALU64
中有14种不同的操作,常用的有:
名字 | 描述 |
---|---|
ADD | dst += src |
SUB | dst -= src |
MUL | dst *= src |
DIV | dst = (src != 0) ? (dst / src) : 0 |
MOD | dst = (src != 0) ? (dst % src) : dst |
AND | dst &= src |
OR | dst |= src |
XOR | dst ^= src |
NEG | dst = -dst |
MOV | dst = src |
MOVSX | dst = (s8,s16,s32)src (根据offset确定) |
带符号除法/取模操作在对应命令前加上
S
即可(SDIV
/SMOD
)
完整版参阅此处
BPF_JMP32
/BPF_JMP
也有14种不同的类型,常用的有:
名字 | 描述 |
---|---|
JA | PC += offset/imm (立即跳转) |
JEQ | PC += offset if dst == src (相等跳转) |
JNE | PC += offset if dst != src (不等跳转) |
JGT | PC += offset if dst > src (无符号) |
JGE | PC += offset if dst >= src (无符号) |
JLT | PC += offset if dst < src (无符号) |
JLE | PC += offset if dst <= src (无符号) |
JSET | PC += offset if dst & src |
带符号比较操作在对应命令中间加上
S
即可(JSGT
,JSLE
等)
比较特殊的指令有:
名字 | 源寄存器(src_reg) | 描述 |
---|---|---|
CALL | src_reg = 0x0 | 通过imm对应的static id调用Helper函数 |
CALL | src_reg = 0x1 | PC += imm (作为函数调用) |
CALL | src_reg = 0x2 | 通过imm对应的BTF id调用Helper函数 |
EXIT | src_reg = 0x0 | 从函数/eBPF程序返回 |
对于目的寄存器和源寄存器,有BPF_REG_0
到BPF_REG_10
一共11种,均为64位寄存器,这些寄存器的功能/对应关系如下(下用Rx
代替BPF_REG_x
):
参阅此处
- R0 (rax): 函数调用返回值/BPF程序返回值
- R1 (rdi): 在eBPF程序运行前自动赋值为ctx(不同的cmd参数对应着不同的类型,socket filter中为socket缓冲区),在调用函数时充当argv1
- R2 (rsi): argv2
- R3 (rdx): argv3
- R4 (rcx): argv4
- R5 (r8): argv5
- R6 (rbx): 被调用方保留
- R7 (r13): 被调用方保留
- R8 (r14): 被调用方保留
- R9 (r15): 被调用方保留
- R10 (rbp): 只读寄存器,指向栈帧,用于访问栈
eBPF仅允许参数小于等于5个的函数,在设计时也要考虑这一点
eBPF指令编写
以上便是eBPF指令(struct bpf_insn
)的细节,了解了这些我们便可以编写eBPF程序了。
一个eBPF程序事实上就是一个eBPF结构体数组,我们可以直接按照数组/结构体的声明方式直接一句一句地编写eBPF程序,但这样无异于手搓机器码,过于繁琐。
为了稍微简化eBPF程序的编写,Linux项目本身提供了一个宏定义头文件bpf_insn.h,我们可以把它复制下来作为头文件使用。
事实上这种方法只是将手搓机器码升级到了手搓汇编,在编写复杂功能eBPF时还是要用到第三方库(libbpf/libxdp等)
以下是一个示例程序:
需要的头文件:
1
2
1 | struct bpf_insn bpf_prog[] = { |
对应生成的汇编如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 0xffffffffc0000668: endbr64
0xffffffffc000066c: nop DWORD PTR [rax+rax*1+0x0]
0xffffffffc0000671: xchg ax,ax
0xffffffffc0000673: push rbp
0xffffffffc0000674: mov rbp,rsp
0xffffffffc0000677: endbr64
0xffffffffc000067b: sub rsp,0x10
0xffffffffc0000682: push r14
0xffffffffc0000684: mov r14,rdi
0xffffffffc0000687: mov edi,0x1
0xffffffffc000068c: mov esi,0x2
0xffffffffc0000691: mov QWORD PTR [rbp-0x10],0x3
0xffffffffc0000699: lfence
0xffffffffc000069c: mov QWORD PTR [rbp-0x8],0x4
0xffffffffc00006a4: lfence
0xffffffffc00006a7: mov rdx,QWORD PTR [rbp-0x8]
0xffffffffc00006ab: mov rcx,QWORD PTR [rbp-0x10]
0xffffffffc00006af: add rdx,rcx
0xffffffffc00006b2: xor eax,eax
0xffffffffc00006b4: pop r14
0xffffffffc00006b6: leave
0xffffffffc00006b7: ret
可以看出该程序仅仅是进行了一些意义不明的寄存器和栈操作,并没有什么实际用处。为了拓展程序功能,同时减少内核上下文切换的开销,ebPF提供了一系列Helper函数,来实现更强大方便的功能。
Helper函数
eBPF中的Helper函数是内核函数,运行在内核上下文,可以允许eBPF程序如同调用函数一样与内核交互
Helper函数功能强大,用途很广。目前一共有211个Helper函数,可以被分为map相关,trace程序相关,print相关,网络相关等类别
在eBPF pwn中,一般只需要关注部分常用/方便漏洞利用的Helper函数即可
调用函数的方法如下所示
1 BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_NAME)
传参规则
在调用Helper函数之前,我们先了解一下Helper函数的传参类型规则。以下是一个示例Helper函数原型:
1 | const struct bpf_func_proto bpf_map_pop_elem_proto = { |
其中我们需要注意三个类型:ret_type
(类型为bpf_return_type
), arg_type
(类型为bpf_arg_type
)及其后边或操作的参数标志(类型为bpf_type_flag
)
- 参数类型
bpf_arg_type
1 | /* 函数参数约束 */ |
ARG_CONST_MAP_PTR
: 该类型可以由宏BPF_LD_MAP_FD
(出自bpf_insn.h,见上文)传入map的文件描述符得到
ARG_PTR_TO_CTX
: 该类型为BPF_REG_1
在运行前被赋值的类型
- 返回值类型
bpf_return_type
1 | /* helper函数返回值的类型 */ |
扩展部分增加了不少
RET_PTR_TO_XXX_OR_NULL
类型,本质上是在原类型的基础上增加了标志PTR_MAYBE_NULL
这种类型在检验的时候会兼顾两种情况,在编写时要增加检验0的操作(if R0 == 0 then exit)
- 附加参数标志
bpf_type_flag
1 | enum bpf_type_flag { |
这些参数可以以按位或的方式与ARG_PTR_TO_xxx
组合
在寄存器章节有讲到,BPF_REG_1
~ BPF_REG_5
依次为Helper函数参数传递寄存器,BPF_REG_0
存放Helper函数返回值,我们便可以根据参数编写正确的Helper调用。
Helper函数介绍
map相关Helper函数
bpf_map_lookup_elem
1 | const struct bpf_func_proto bpf_map_lookup_elem_proto = { |
arg1 类型见上文
arg2 类型为ARG_PTR_TO_MAP_KEY
,事实上用一个指向栈的指针即可
在map中根据key来查找value
返回值为一个指向map value的指针或NULL(0),需要判断空指针操作
bpf_map_update_elem
1 | const struct bpf_func_proto bpf_map_update_elem_proto = { |
更新/插入key-value对,flag参数为BPF_NOEXIST
(key不存在时才会更新),BPF_EXIST
(仅在key存在时更新)或BPF_ANY
(两种情况均可)
和用户态调用参数一致
arg2, arg3类型均可以用指向栈的指针
如果成功返回0,失败则返回一个负的错误号
完整示例程序
1 |
|
执行结果:
1 | bash-5.2$ ./test |