如果要问起现在kernel圈中最炙手可热的技术,那想必是非eBPF
莫属了,它的出现使得kernel可编程,从长期来看会使得内核的更新不再会用到fast path
或者是hard code
,同时也会慢慢的减少子系统的复杂度而是仅仅保留了核心功能,用户或者开发者希望内核实现的能力将会以一段代码的形式动态加载到内核中。
- 简单的来讲现在的
eBPF
就是一种将用户态编写的代码插入到内核态运行的技术
这个技术能力听起来和LKM
十分的类似但事实上却并非如此,这点主要涉及到eBPF
的实现方式和实现目的可以自行了解,但是随着eBPF
技术的高速发展使得其功能不再局限于最初的网络协议栈
而是能够覆盖相当一部分LKM
的能力,那么自然而然的eBPF
也逐渐开始面对着LKM
所具有的安全性问题 -- 内核恐慌
。
1992 年在当时的时代多种版本的Unix
系统都提供出了用于捕获分析网络数据包的工具,而Steven McCanne 和 Van Jacobson 写了一篇名为《The BSD Packet Filter: A New Architecture for User-level Packet Capture》的论文。在文中,作者描述了他们实现了一种新的数据包过滤技术BPF
,并阐述了其两个新的架构使得性能比当时最先进的数据包过滤技术快 20 倍。
- BPF uses a re-designed,register-based 'filter machine' that can be implemented efficiently on today's register based RISC CPU. CSPF used a memory-stack-based filter machine that worked well on the PDP-11 but is a poor match to memory-bottlenecked modern CPUS(基于寄存器的虚拟机)
- BPF uses a simple, non-shared buffer model made possible by today's larger address spaces. The model is very effcient for the 'usual cases' of packet capture(非共享缓冲区)
在最初的设计中,BPF vm
由一个抽象累加器
,一个索引寄存器
,一个临时存储器
和一个隐式PC指针
组成,而filter
则被设计成跑在这个虚拟机中的代理程序。
适用于虚拟机的指令集用以编写过滤规则,长度固定都是32位。
tcpdump
是基于BPF
实现的,因此可以通过tcpdump
的输出来当作一个实际的例子,观察一条过滤规则经过解释后会变成怎样的一段汇编程序
:
$ tcpdump -d 'ip and tcp port 8080'
(000) ldh [12]
(001) jeq #0x800 jt 2 jf 12
(002) ldb [23]
(003) jeq #0x6 jt 4 jf 12
(004) ldh [20]
(005) jset #0x1fff jt 12 jf 6
(006) ldxb 4*([14]&0xf)
(007) ldh [x + 14]
(008) jeq #0x1f90 jt 11 jf 9
(009) ldh [x + 16]
(010) jeq #0x1f90 jt 11 jf 12
(011) ret #262144
(012) ret #0
可以看出来在最初的设计上,BPF
仅仅是为了解决快速捕获过滤网络数据包而被设计出来的,其能力也十分有限且并不易用,更谈不上什么安全不安全的问题。
那么总结一下来说:
BPF
定义就是通过在内核中部署一个内核代理程序实现在内核空间下对网络数据包进行过滤从而仅拷贝指定数据,目的是提高数据包捕获和分析的性能BPF虚拟机
本质上来说就是一段实现了提供了有限指令与寄存器的处理器
功能的内核代码逻辑filter
是指由BPF指令
构成的程序,运行在BPF虚拟机
中
可以通过阅读sk_run_filter的代码加深对于
BPF虚拟机
的认识,这个函数实现了一个简单的处理器,同时内部也实现了一个BPF解释器
。
随着BPF
技术逐渐在各大主流系统中被实现,虽然其是将用户提交的规则程序跑在自身实现的虚拟机当中,但是实现上来说依然是需要把伪指令转换成真实指令执行,本质上来说还是一种代码注入技术,那自然会有安全风险,因此当规则程序
在虚拟机中执行前会先进行一次安全检查以确认不会对内核造成影响。
安全检测内容包括:
- 是否存在非预期指令
- 是否有指令的非法配合
- 是否读取了非法地址
- 是否以
BPF_RET
指令结尾
当规则程序
在BPF虚拟机
中运行起来后,大量的网络包都将经过这个虚拟机
中程序的筛选规则,为了提高执行速度linux 3.x
版本中BPF
引入了JIT
技术来实时的将BPF指令
翻译成本机指令,也许这么说有点笼统,看一个例子就能明白了:
v2.6
case BPF_S_ALU_ADD_X:
A += X;
continue;
v3.10 (基于x86)
case BPF_S_ALU_ADD_X: /* A += X; */
seen |= SEEN_XREG;
EMIT2(0x01, 0xd8); /* add %ebx,%eax */
break;
如果是简化成执行路径来说的话:
BPF解释器
BPF指令 -> BPF解释器 -> BPF处理器运算 -> 本地指令
JIT
BPF指令 -> JIT编译器 -> 本地指令
当然这个功能不是强制开放的,而是得满足一定的配置即开启CONFIG_BPF_JIT
static int __sk_prepare_filter(struct sk_filter *fp)
{
int err;
fp->bpf_func = sk_run_filter;
err = sk_chk_filter(fp->insns, fp->len);
if (err)
return err;
bpf_jit_compile(fp);
return 0;
}
参照3.10
的源码来说,首先被配置的处理函数还是sk_run_filter
,而进入到bpf_jit_compile()
中执行完后倘若CONFIG_BPF_JIT
开启后最终这个函数会被替换掉
if (bpf_jit_enable > 1)
bpf_jit_dump(flen, proglen, pass, image);
if (image) {
bpf_flush_icache(image, image + proglen);
fp->bpf_func = (void *)image;
}
说起来的话
JIT
后的BPF
其实有点脱离了最初的BPF vm
的概念
至此为止BPF
就不再有什么大动作,也仅仅是作为一个优秀的网络数据包
捕获实现被应用于内核之中。而在具体的过滤的实现上,则是在网络包收发达到数据链路层时都将触发预置的hook
static int packet_rcv(struct sk_buff *skb, struct net_device *dev,
struct packet_type *pt, struct net_device *orig_dev)
{
......
res = run_filter(skb, sk, snaplen);
if (!res)
goto drop_n_restore;
......
}
static unsigned int run_filter(const struct sk_buff *skb,
const struct sock *sk,
unsigned int res)
{
struct sk_filter *filter;
rcu_read_lock();
filter = rcu_dereference(sk->sk_filter);
if (filter != NULL)
res = SK_RUN_FILTER(filter, skb);
rcu_read_unlock();
return res;
}
总的来说,创建的套接字 socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)) 就相当于在数据链路层挂上了一个窃听程序,负责将命中的包全部提取到这个窃听套接字的接收队列中。至于内核空间到用户空间的数据提取工作,就像普通的套接字一样。直接使用 recv 系统调用读取套接字的接收队列也就完成了。
[PATCH net-next v4 0/9] BPF updates这是在内核中的第一个eBPF patch
,在v3.15
被合并入内核当中,针对原始的BPF
做了极大的改动:
- 增加了
jited flag
来确认filter
是否已经被jit
- 添加
struct sock_fprog_kern
来保留原始的BPF program
- 迁移
filter accounting
到net/core/filter.c
中 - 将
BPF
能力暴露到UAPI
- 扩展优化了
BPF指令集
,重新设计了BPF解释器
并保持对原始BPF
的支持
其中第4
点是eBPF
得以大力发展的关键原因,而第5
点则代表了BPF
和eBPF
的区别,从此以后原始的BPF
就被称为cBPF
,而现代BPF
都指的是eBPF
:
- Number of registers increase from 2 to 10 寄存器的增加到10个 (现代已经有了11个r0-r10外加一个PC寄存器)
- Register width increases from 32-bit to 64-bit 寄存器宽度变成64位
- Conditional jt/jf targets replaced with jt/fall-through
- Adds signed > and >= insns
- 16 4-byte stack slots for register spill-fill replaced 512字节堆栈
with up to 512 bytes of multi-use stack space
- Introduction of bpf_call insn and register passing convention 支持bpf_call指令从而实现调用/被调用
for zero overhead calls from/to other kernel functions
- Adds arithmetic right shift and endianness conversion insns
- Adds atomic_add insn
- Old tax/txa insns are replaced with 'mov dst,src' insn
eBPF
是extend BPF
的缩写
这一份PATCH
只是加强了原本的BPF
的能力,正如其名字一样是针对原本的BPF
能力的扩展,而底层运行流程其实并没有什么太大的变化,依然是需要在用户态编写复杂的类汇编
的代码,依靠setsockopt
提供的SO_ATTACH_FILTER
载入filter
到内核态当中执行,只不过是BPF vm
的能力得到了加强与JIT
更加合理高效而已,且适用的能力依然是局限在net_filter
上。
这个状况直到[PATCH RFC net-next 00/14] BPF syscall, maps, verifier, samples被提交到社区而正式宣告了eBPF
革命性的改变,简单概括一下这个PATCH
中增加了哪些能力:
sys_bpf
andBPF MAP
- 针对
BPF MAP
的lookup/update/delete/iterate
eBPF verifier
- 针对
sockets
,events
的attach
samples/bpf/
下添加多个样例代码
eBPF
相比于cBPF
能力上的扩展除了map
以外最实用的莫过于针对events
的attach
,这个能力直接将eBPF
从一个网络数据包过滤系统
转变成了一个与Systemtap
并列的内核追踪工具
,在最初的设计中eBPF
仅支持到了static tracepoint event
,甚至于实际能力的实现比如获取到函数传参都是借用的ftrace
的能力来实现的。
/* call from ftrace_raw_event_*() to copy tracepoint arguments into ctx */
kprobe events will be supported in the future patches
说完了基础能力就要着重来关注一下eBPF verifier
,eBPF
的发展轨迹其实光从verifier
就可以窥探一二,因为整个eBPF程序
的安全性都依靠它来静态判定。先前说过eBPF
的能力发展到现在其实已经相当于一种LKM
那么其安全性就必须要加以报障,其中一条红线就是绝对不能造成kernel panic
,因此在最初的verifier
中就有如下几个检查项:
- 循环
- 数组越界
- 无法访问的指令
- 无效指令
- 未初始化的寄存器访问
- 未初始化的栈地址访问
- 没有对齐的栈访问
- 栈越界
- 非法调用
verifier
实际上做的东西可以参照作者写的文档:filter.txt,检查主要分为两步:
- 对注入的代码进行一次
DAG
检测和一些其余的CFG
验证 - 从第一个
insn
开始延伸所有可能的路径,模拟执行每一个insn
的执行已经观察寄存器
和stack
的状态变化
而现代eBPF
的指令是固定64位
的编码,发展至今有100多条,具体的可以看IOVisor
维护的指令集,其结构如下:
msb lsb
+------------------------+----------------+----+----+--------+
|immediate |offset |src |dst |opcode |
+------------------------+----------------+----+----+--------+
- 8 bit opcode 操作码
- 4 bit destination register (dst) 目的寄存器
- 4 bit source register (src) 源寄存器
- 16 bit offset 偏移值
- 32 bit immediate (imm) 立即数
但并不是所有的区域都会被用到的,因此当此区域在指令中无用时就置为0
其中8 bit opcode
的低3位代表了指令的所属类别即instruction class
.
例如LD/LDX/ST/STX指令的
opcode`就可以划分为3部分:
msb lsb
+---+--+---+
|mde|sz|cls|
+---+--+---+
2位的sz
代表了load/store
的大小:
SIZE | value | byte |
---|---|---|
BPF_DW | 0x18 | 8 |
BPF_W | 0x00 | 4 |
BPF_H | 0x08 | 2 |
BPF_B | 0x10 | 1 |
3位的mde
则代表了操作模式(举例):
BPF_IMM
:load立即数到寄存器BPF_MEM
:store到内存
再比如ALU/ALU64/JMP指令的opcode
划分:
msb lsb
+----+-+---+
|op |s|cls|
+----+-+---+
s
只占了1位,如果为BPF_K(0)
的话则代表源操作数是imm
,如果是BPF_X(1)
的话则来源于src
,而op
的话则可以对照着指令集学习。
因为
eBPF
发展速度的问题verifier
的逻辑在不断的加强,因此关于verifier
的讨论放到后面安全样例中一一探讨
这部分内容其实偏向于开发因此对我自身来说也是浅尝辄止,而且技术上也会因为时间的问题存在差异
一些简单的思想与未来的规划可以直接参照一下BPF Design Q&A,这是设计者们的回答十分的简洁明了
eBPF
无疑是方便的,但是它真的安全吗?纵观eBPF
执行的上下逻辑上其实仅有一个verifier
来确认用户态输入的代码的安全性而且还是一个静态检查器,如果在之前eBPF
能力还十分受限的时候verifier
的逻辑也许还好写,但是随着eBPF
的能力逐渐的开放这个检查器必然会有兼顾不到的地方出现那么安全问题是不是就来了?
2018年Google Project Zero
放出了两个漏洞,代号是Meltdown
和Spectre
,前者适用于当时所有支持乱序执行
的处理器中而后者则适用于支持分支预测
的处理器,总共包含三个变种:
- Meltdown -
CVE-2017-5754
- Spectre v1 -
CVE-2017-5753
- Spectre v2 -
CVE-2017-5715
本篇还是需要讲
eBPF
的东西,三个漏洞中只有Spectre v1
和eBPF
有关联因此不会对另外两个漏洞过多调研(主要是三个漏洞底层共性比较多)
在一个计算机系统中,一个用户态程序是不能任意访问其余用户态程序的内存或者是内核内存的,因此内核通过虚拟内存为用户态进程还有内核自身创建了独立的内存空间并设置了相应的访问权限,而CPU
则通过硬件来实现支持虚拟内存和相应的访问权限,这样当一个程序指令试图访问内核内存或是其余程序内存地址时,就会因为CPU检测到没有访问权限而触发中断导致程序结束。
举个例子来说:
mov rax byte[0xabc]
操作系统在启动时候会有初始化内存的操作这时候内核地址的范围就是被明确的,假如一个用户态进程的指令如上且0xabc
是内核内存地址的话,CPU
会因为当前处于非ring0
模式而将该指令标记非法并触发中断进而进行异常处理 -- 把rax
清空为0。
check -> exception handling -> rax =0
这样处理以后的好处就在于当后续指令再来读取rax
的值时都只能读到0
,流程看起来仿佛是无限可击的,如果没有cache
的话 :)
随着硬件的升级导致cpu
的处理速度与读取内存的速度差异越来越大,为了解决这个问题而在cpu
中添加了一个中间层当作是内存的cache
,当cpu
需要处理数据时都会先从cache
中获取如果没有的话就会从内存中先读到cache
中,这样可以极大的提高cpu
的处理速度
- 当一个数据不在
cache
中,cpu
从将该数据读入cache
到执行至少要花费200个时钟周期
,而如果数据已经在cache
中的话则可能仅花费几十个时钟周期
便可以完成该指令的执行
继续看回上面的那个指令,当这个指令执行的时候是需要byte[0xabc]
的数据的,但是这个数据此刻如果不在cache
中的话则会先调入到cache
中,然后才是刚才所说的检查流程
RAM -> CPU cache -> check -> exception handling -> rax = 0
这儿可以看出一个问题就是byte[0xabc]
此时可不仅仅在RAM
中存在,而是在cache
中也存在,因为异常处理
是重置了rax
而没有针对cache
进行flush
。
那这有什么用?最终这个指令还是得不到执行那么数据就不会被mov
到rax
里,那能得到什么利用呢?
这个需要涉及到cpu
的另一项为了性能而诞生的能力:
分支预测执行
这项技术主要目的就是为了在进行逻辑判断处理时提速,因为一个逻辑判断的条件有极大可能需要去从内存中取数据,那么CPU
在等待的这段时间内,就可以优先去执行逻辑分支中的指令并将需要的数据以及执行的结果预先放入cache
中,这样的话如果分支判断正确的话就可以直接从cache
中提取结果极大的提高了处理速度,而当分支判断错误的话就将寄存器状态回滚然后去执行正确的分支。
关于分支预测,
C
上提供了两个宏来支持了分支预测
优化:
#define likely(x) __builtin_expect(!!(x), 1) //x经常成立
#define unlikely(x) __builtin_expect(!!(x), 0) //x经常不成立
这么说可能还是不能理解,因为上述的流程中其实是没有探讨到CPU
的指令执行过程。CPU
采用了一种被称为Pipeline
的技术来实现多条指令并行
处理,简单来说就是将一个时序过程拆成了如下5个子过程:
Fetch(取指)
Decode(译码)
Execute(执行)
Mem(访存)
WB(写回)
而最终会对系统造成实质影响的其实仅限于WB
阶段,而因为流水线控逻辑的原因使得指令的写回顺序与其在非流水线中的执行顺序相同,即一定是在前一条指令写回完成后才能进行写回而无论自身已经等待了多久。回到先前所说的安全检查的问题,流水线结构中存在着异常处理逻辑,当一条指令在某个子过程中产生了异常(非法指令
,非法访问
等)就会修改流水线寄存器中的状态码,异常状态和指令信息会在写回
阶段时被控制逻辑发现并触发异常处理。
而用流水线结构来阐述先前的分支预测
的话,分支的选择本需要等待一个条件分支指令
的Execute
通过后才能知道,但是通过分支预测
可以在该指令刚进行完Fetch
后就先预测一个分支的指令并加入流水线中,等分支明确后如果正确的话则进行写回
,而如果错误的话则直接清空该流水线然后去执行正确的分支,而这样的代价仅仅是取指
到执行
的流水线级数而已。
回到Spectre v1
本身上来套用Google Project Zero
的例子讲解:
struct array {
unsigned long length;
unsigned char data[];
};
struct array *arr1 = ...; /* small array */
struct array *arr2 = ...; /* array of size 0x400 */
/* >0x400 (OUT OF BOUNDS!) */
unsigned long untrusted_offset_from_caller = ...;
if (untrusted_offset_from_caller < arr1->length) {
unsigned char value = arr1->data[untrusted_offset_from_caller];
unsigned long index2 = ((value&1)*0x100)+0x200;
if (index2 < arr2->length) {
unsigned char value2 = arr2->data[index2];
}
}
当执行到line 9
时如果arr1->length
是uncached
会进行分支预测直接执行到line 10
,这儿的untrusted_offset_from_caller
是攻击者控制的一个变量长度实际上是大于arr1->length
的那么就会出现一个越界读取的问题,接着进入到line 11
的逻辑通过位运算
计算出index2
的值,这儿的值要么是0x200
要么是0x300
,那么当执行到line 13
时就会因为赋值操作而把arr2->data[0x200]/arr2->data[0x300]
放到cache
中,然后当处理器正确判断出untrusted_offset_from_caller
是大于arr1->length
时会回滚寄存器到预执行前的状态,然而这时候arr2->data[index2]
却已经存在于L1 cache
中,那么攻击者只要接下来通过判断访问arr2->data[0x200]
和arr2->data[0x300]
的速度就可以确定index2
的值进而推算出arr1->data[untrusted_offset_from_caller]
这个越界地址上的数据是0
或者1
。
详细利用可以参照附件中的
spectre1.c
说了这么多这个漏洞和BPF
有啥关系呢?Spectre
这个漏洞要想被成功利用的话那么就需要攻击者运行一个类似于上述模式的代码即利用到一个越界索引,因此GPZ
提出了两种利用方式:
- Attacks using Native Code
- Attacks using JavaScript or eBPF
如果要获取到内核数据
的话,那么最终能够成功访问数据的程序就得运行在内核当中,不然任何涉及到内核地址
的指令都无法被写回
因此攻击者实际上需要在内核中找到一段类似如上的代码片段后再去想办法进行控制,然而eBPF
使得攻击者可以直接在内核中插入自己制作的代码,虽然能力是受限的但是上述模式却恰巧不在verifier
检查范围之内,为了解决这个问题社区发布了一份补丁bpf: prevent out of bounds speculation on pointer arithmetic,其中大概的逻辑就是定义index
的上下限导不能越界,但是很快这个逻辑又被突破了:
// r0 = pointer to a map array entry
// r6 = pointer to readable stack slot
// r9 = scalar controlled by attacker
r0 = *(u64 *)(r0) // cache miss
if r0 != 0x0 goto line 4
r6 = r9
if r0 != 0x1 goto line 6
r9 = *(u8 *)(r6)
// leak r9
可以看到两个分支中,第一个是将r9
的值复制给r6
,而第二个则是会将r6
作为指针获取到指向的数据然后赋值给r9
,如果两者能同时执行的话岂不是可以通过r9
传入一个地址而导致任意内存访问
,如果从单纯从逻辑上来,r6 = r9
和r9 = *(u8 *)(r6)
是不可能同时被执行的因为r0
的值不可能既是0x0
又是0x1
,实际上BPF verifier
也是这么想的,它通过遍历所有的代码分支认定这个流程不会导致把可控数据当作是指针访问的情况,所以上述的代码就被成功加载了。
但是由于分支预测
的存在,因为两个条件分支指令
的执行结果都需要先加载r0
,如果此时r0
不在cache
中的话CPU
就会主动去将分支中的指令加入到执行流水线中并将所需数据载入到cache
中,这就导致r6 = r9
和r9 = *(u8 *)(r6)
因为预测
的关系被先后执行,而一些特定的eBPF
可以在开启配置后无需root
权限运行直接导致了一个低权限任意内存访问
漏洞,补丁则不是很难理解,就是去验证了所有的路径来确保不会有自定义指针的出现。
这个漏洞应该说是
eBPF
最为出名的漏洞了,因为其被运用在了2020年的pwn2own
中进而使得后续的一系列漏洞都是沿着这个漏洞的思路被相继挖掘出来
eBPF
的寄存器宽度统一增加到了64位
但是这不代表eBPF
就不再支持32位
指令了,而是通过32-bit sub-register
的方式来执行,说白了就是将64bit寄存器
的高32位
置零。但是在早期却缺少一个JMP32指令
导致生成的代码在subregister
上运行并不是很高效,最典型的例子就是在进行有符号比较时需要进行从32bit
符号扩展到64bit
。因此社区在ebpf
中提供了jmp32
指令来完成针对32bit eBPF架构
的支持,但是很可惜的很多的bug
也因此而来。
问题依旧出现在verifier
中,先前就曾说过verifier
检查一个eBPF
程序的一个重要手段就是模拟执行,然后保留并检查寄存器的状态那正如上述的如果此刻模拟执行到的指令是jmp32
的话会发生什么呢?
static int do_check(struct bpf_verifier_env *env)
{
......
} else if (class == BPF_JMP || class == BPF_JMP32) {
u8 opcode = BPF_OP(insn->code);
env->jmps_processed++;
......
} else {
err = check_cond_jmp_op(env, insn, &env->insn_idx);
if (err)
return err;
}
} else if (class == BPF_LD) {
在大部分情况下jmp/jmp32
都会进入到check_cond_jmp_op
的逻辑当中也就是条件判断后跳转,那么既然是个跳转当然会验证跳转的地址是不是在规范的范围内,不然不就会出现OOB
了吗?简单的看一下这个函数的逻辑,可以看出来前半部分相当的逻辑都是为了获取到src_reg = ®s[insn->src_reg];
和dst_reg = ®s[insn->dst_reg];
已经相应的跳转分支(branch
)。接着根据src_reg
和dst_reg
的类型调用reg_set_min_max
设置最大最小取值范围
。
在跟入函数前先优先看一下bpf_reg_state
这个结构体以及大概说明下设计逻辑
struct bpf_reg_state {
enum bpf_reg_type type;
union {
u16 range;
struct bpf_map *map_ptr;
unsigned long raw;
};
s32 off;
u32 id;
u32 ref_obj_id;
struct tnum var_off;
s64 smin_value; /* minimum possible (s64)value */
s64 smax_value; /* maximum possible (s64)value */
u64 umin_value; /* minimum possible (u64)value */
u64 umax_value; /* maximum possible (u64)value */
struct bpf_reg_state *parent;
u32 frameno;
s32 subreg_def;
enum bpf_reg_liveness live;
bool precise;
};
为了防止OOB
的情况,eBPF
中设计了一个bpf_reg_state
的结构体用来维护某个操作数的属性以及寄存器的状态,但是这儿就有一个问题出来了就是这个操作数很有可能是等待用户传入的而非一个确定值,这样的话当发生运算之后一个操作数很有可能就发生了越界,为了防止这种情况如同上述的结构体成员所示一个操作数被限制了最大最小值
,当运算后如果超过这个范围的话就会被禁止掉,同时还设计了tnum
结构体用于描述操作数的每一位,对于确定的值来说最大最小值自然没有什么意义,但是对于未确定的值来说用tnum
和最大最小值
就可以将操作数尽可能的预测出来以用于后续的运算当中。
对于verifer
中的操作数来说,某一位只会有三种状态:
0
1
未知
那么该怎么用一个tnum
来涵盖所有的可能呢?
struct tnum {
u64 value;
u64 mask;
};
value | mask | 预测值 |
---|---|---|
0 | 0 | 0 |
1 | 0 | 1 |
0 | 1 | 未知 |
1 | 1 | 禁止 |
方式就可以用一个tnum 将可能出现的值全部涵盖住进而进行模拟执行。 |
||
回到函数逻辑当中可以看到这样的一个函数调用: |
/* We might have learned some bits from the bounds. */
__reg_bound_offset(false_reg);
__reg_bound_offset(true_reg);
if (is_jmp32) {
__reg_bound_offset32(false_reg);
__reg_bound_offset32(true_reg);
}
简单来说的话在reg_set_min_max
中会根据opcode
的类型再次给出新的预测数和预测范围,然后就可以用两个预测数获取一个更加精确的预测数,其逻辑就是如果一个预测数的某位已知且另一位为未知则该位已知。
那么说白了__reg_bound_offset
就是一个针对预测数的二次精确预测而已,而__reg_bound_offset32
则是为了契合jmp32
这种32位指令而仿照出来的在32位
下有更好预测能力的函数,这个函数好在哪里可以简单说明一下。
193: (85) call bpf_probe_read_user_str#114
R0=inv(id=0)
194: (26) if w0 > 0x1 goto pc+4
R0_w=inv(id=0,umax_value=0xffffffff00000001)
195: (6b) *(u16 *)(r7 +80) = r0
196: (bc) w6 = w0
R6_w=inv(id=0,umax_value=0xffffffff,var_off=(0x0; 0xffffffff))
197: (67) r6 <<= 32
R6_w=inv(id=0,smax_value=0x7fffffff00000000,umax_value=0xffffffff00000000,
var_off=(0x0; 0xffffffff00000000))
198: (77) r6 >>= 32
R6=inv(id=0,umax_value=0xffffffff,var_off=(0x0; 0xffffffff))
可以看到第196
行对64位
进行了截断了高32位
,然后在197
进行<< 32
的操作,到了198
再进行>> 32
导致本该限制在0/1
的r6
变成了var_off(0x0, 0xffffffff)
,为了解决这个问题添加了补丁针对32位
的指令则只取低32位
然后再计算
193: (85) call bpf_probe_read_user_str#114
R0=inv(id=0)
194: (26) if w0 > 0x1 goto pc+4
R0_w=inv(id=0,smax_value=0x7fffffff00000001,umax_value=0xffffffff00000001,
var_off=(0x0; 0xffffffff00000001))
195: (6b) *(u16 *)(r7 +80) = r0
196: (bc) w6 = w0
R6_w=inv(id=0,umax_value=0xffffffff,var_off=(0x0; 0x1))
197: (67) r6 <<= 32
R6_w=inv(id=0,umax_value=0x100000000,var_off=(0x0; 0x100000000))
198: (77) r6 >>= 32
R6=inv(id=0,umax_value=1,var_off=(0x0; 0x1))
看一眼__reg_bound_offset32
的具体实现:
static void __reg_bound_offset32(struct bpf_reg_state *reg)
{
u64 mask = 0xffffFFFF;
struct tnum range = tnum_range(reg->umin_value & mask,
reg->umax_value & mask); //只取低32bit
struct tnum lo32 = tnum_cast(reg->var_off, 4); //取原reg->var_off的低32bit
struct tnum hi32 = tnum_lshift(tnum_rshift(reg->var_off, 32), 32); //取高32bit,低32bit为0
reg->var_off = tnum_or(hi32, tnum_intersect(lo32, range));
}
然而恰恰好是这个函数中的tnum_range
出现了问题,因为只取低32bit
的话倘若此刻reg->umin_value = 0x1
,reg->umax_value = 0x?0000001
那么经过tnum_range
后获得的结果就会是range{0x1, 0x0}
,再看tnum_intersect
的实现:
struct tnum tnum_intersect(struct tnum a, struct tnum b)
{
u64 v, mu;
v = a.value | b.value;
mu = a.mask & b.mask;
return TNUM(v & ~mu, mu);
}
因为b{0x1, 0x0}
的原因导致v = 0x1
,mu = 0x0
进而使得无论真实的操作数是什么最终的预测值是固定值0x1
,这就导致了一个可控的数据绕过了verifier
的检查。
再从溯源来看如何触发,核心的就是让一个未知的操作数进入到__reg_bound_offset32
中,那毫无疑问的就是需要运用到BPF_JMP32
并且Dst
是一个不定的操作数,如果要让逻辑从check_cond_jmp_op
进入到reg_set_min_max
中,BPF_SRC(insn->code) != BPF_X
就是一个非常好分支潜在含义就是操作源需要是一个BPF_K
,而进入到reg_set_min_max
又为了不让因为不同的opcode
导致的原始的最大最小值
与预测值
发生变化,那么就很显然了能够触发到漏洞的eBPF
写法如下类似:
BPF_JMP32_IMM(BPF_JEQ, BPF_REG_num, imm, off)
当然前提条件就是此时BPF_REG_num
的umin_value = 0x1
且umax_value = 0x?00000001
,这个构造的实现依然要回到reg_set_min_max
中,当opcode
为BPF_JGE/BPF_JGT/BPF_JLE/BPF_JLT
的时候会在进入到__reg_bound_offset32
就更改umax/umin
的值:
BPF_JMP32_IMM(BPF_JGE, BPF_REG_num, 1, off) // umin = 0x1
BPF_MOV64_IMM(8,0x1)
BPF_ALU64_IMM(BPF_LSH,8,32)
BPF_ALU64_IMM(BPF_ADD,8,1)
BPF_JMP_REG(BPF_JLE, BPF_REG_num,8,off) // umax = 0x100000001
举个例子:
BPF_JMP_IMM(BPF_JNE, BPF_REG_6, 1, 2),
BPF_MOV64_IMM(BPF_REG_0, 0),
BPF_EXIT_INSN(),
shellcode,
在verifier
中最终BPF_REG_6
的预测值会是一个恒定的0x1
,那么在检查器中是否会因为REG_6
的值恒定和IMM
相等直接导致代码逻辑会直接进入到exit(0)
的阶段而永远无法执行shellcode
这也就导致verifier
不会检查shellcode
呢?
这需要重新回过看一下在do_check()
中BPF_EXIT
的逻辑,在process_bpf_exit
的逻辑如下:
process_bpf_exit:
update_branch_counts(env, env->cur_state);
err = pop_stack(env, &prev_insn_idx,
&env->insn_idx);
if (err < 0) {
if (err != -ENOENT)
return err;
break;
} else {
do_print_state = true;
continue;
}
在正式退出前会调用pop_stack
弹出分支,如果没有分支的话则直接返回但是如果还有分支存在的话则重复分支检查的逻辑,所以很可惜的是在check_cond_jmp_op
的逻辑中是先去圧入分支的,因此阻断verifier
的利用方式并不成立,而真正的利用思路则是利用reg
的真实值和预测值的不同来产生两个完全不同的结果,即让verifier
去模拟shellcode
的执行但是却因为reg
的问题而只能获得一个安全的结果。
// shellcode reg6 = 1
BPF_ALU64_IMM(BPF_AND, BPF_REG_6, 2), // r6 &= 2
BPF_ALU64_IMM(BPF_RSH, BPF_REG_6, 1), // r6 >>= 1
// r6 = 0x1 ; r6 = 0x0 in verifier
BPF_ALU64_IMM(BPF_MUL, BPF_REG_6, 0xd0),
因为在预测值中r6
是恒定为1的,因此在verifier
的模拟执行下,BPF_ALU64_IMM(BPF_MUL, BPF_REG_6, 0xd0),
这行的结果中应该是0 * 0xd0 =0x0
,然而实际结果中主要r6 = 2
的话就会导致该结果为1 * 0xd0 = 0xd0
,这就获得了一个可控且逃逸了检测的偏移量并可以藉此偏移量实现任意地址读取/写入。
当然在实际利用中会有kaslr
的问题因此要先绕过这一个部分,可以通过对比一个全局变量地址的偏移来算出kaslr
的偏移:
BPF_LD_MAP_FD(BPF_REG_1, poc_map_fd), // r1 = poc_map_fd
BPF_ALU64_IMM(BPF_MOV, BPF_REG_7, 0), // r7 = 0
BPF_MAP_GET_ADDR(0, BPF_REG_7), // r7 = &poc_map_fd[0]
BPF_ALU64_REG(BPF_SUB, BPF_REG_7, BPF_REG_6),
BPF_MAP_GET_ADDR(0, BPF_REG_7), // r7 = &poc_map_fd[0]
会使得r7
指向array[0]
的地址,而在内核的数据结构中的位置即当前r7
指向的位置如下:
struct bpf_array {
struct bpf_map map;
u32 elem_size;
u32 index_mask;
struct bpf_array_aux *aux;
union {
char value[]; // <--- r7
void *ptrs[];
void *pptrs[];
};
}
而在struct bpf_map map
存在一个在内核初始化时就确定了位置的成员变量ops
,这是一个操作集指针指向的是当前map
类型的ops地址
,因为创建的是一个array_map
因此这儿指向的应该是array_map_ops
:
$ objdump -D vmlinux |grep array_map_ops
ffffffff8231a6a0 <array_map_ops>:
ffffffff8231a6e9: 7f 2c jg ffffffff8231a717 <array_map_ops+0x77>
ffffffff8231a741: 7e 2c jle ffffffff8231a76f <array_map_ops+0xcf>
上面的结果是在没有kaslr
的情况下array_map_ops
的地址,而BPF_ALU64_REG(BPF_SUB, BPF_REG_7, BPF_REG_6),
则可以获取到当前实际情况下的array_map_ops
的地址这样的话通过计算就可以获取到kaslr
的偏移量从而实现kaslr
的绕过。
在我测试的内核版本中
bpf_array
中value
到map.ops
的偏移量为0xd0
,这个每个版本可能不太相同因此需要注意一下
当绕过了kaslr
后就可以实现任意读和任意写,但是这儿的任意读却要用点取巧的手段,因为verifier
中有一系列复杂的检测规则来监控到寄存器
和栈
的变化,其中针对指向一些未知地址的指针会有严格内存校验:
check_helper_mem_access
check_mem_access
简单来说就是通过map
我们只能读取到从value
开始偏移到的任何地址的数据,如果这个数据也是一个ptr
的话就无法读取到真正的值,说白了就是仅仅能读取/修改一个可以无限扩展的bpf_array
上的任意的信息,那么如果要任意读的话就可以从内核中找个函数能够读取到bpf_array
上的某个指针指向的值,这样我们只要通过修改该指针就能实现任意地址读取。
bpf_obj_get_info_by_fd
无疑是最合适的一个函数,这个函数在bpf_helper
中支持可以用户态调用,然后将指定的内核中的数据信息copy
到用户态内存里。上面说了我们可以控制到整个bpf_array
这本质上就是一个bpf_map
,而这个获取信息的函数中就正好有针对map
信息获取的逻辑存在:bpf_map_get_info_by_fd
。
而其中可以修改的指针的信息就有btf
:
map = {
......
btf = 0x0 <fixed_percpu_data>,
.......
}
/* bpf_map_get_info_by_fd */
if (map->btf) {
info.btf_id = btf_id(map->btf); // 此处返回的是map->btf->id,因为是个u32变量因此每次只能传输4个字节出来
info.btf_key_type_id = map->btf_key_type_id;
info.btf_value_type_id = map->btf_value_type_id;
}
而从任意写的实现则稍显麻烦,因为你并不清楚你想要写的地址距离当前地址的偏移是多少,那么一个合理的利用的想法就是让bpf
自己去向我们给定的地址去写入数据,当然还要绕过verifier
的各种检查才行,然而ops
本身的地址却是可写的,那么可以在用户态伪造一个hack_ops
然后通过update_map
写入到map
当中,之后再把ops
地址修改成map
中的hack_ops
这样的话就可以通过触发的方式让内核执行特定的函数且传参可控,而我们要传的当然是address
和value
,这样的话就需要找一个函数能够满足*address = value
的逻辑。
这儿直接用别人已经研究出来的结果
array_map_get_next_key
/* Called from syscall */
static int array_map_get_next_key(struct bpf_map *map, void *key, void *next_key)
{
struct bpf_array *array = container_of(map, struct bpf_array, map);
u32 index = key ? *(u32 *)key : U32_MAX;
u32 *next = (u32 *)next_key;
if (index >= array->map.max_entries) {
*next = 0;
return 0;
}
if (index == array->map.max_entries - 1)
return -ENOENT;
*next = index + 1;
return 0;
}
可以看出来这个函数只要next_key
是address
,而key
是value
就能实现地址值的控制。
关于写入地址的问题在ebpf中是无法将一个指针写入map的,因此就需要预先就知道map的地址,在5.5版本以下bpf_map的结构中不存在
struct mutext freeze_mutex
这就导致无法泄露出map_elem
的地址从而加大了利用难度,我的测试环境是5.4恰好无法利用起来 T^T
当能够重写ops
就可以通过修改modprobe_path
的值来实现提权。
2021年的pwn2own的ebpf漏洞,和
8835
类似却又更加纯粹是一个完全的64
与32
之间的转换错误导致的漏洞,因此利用上可以参照8835
这个漏洞是从这个[bpf-next PATCH v2 2/7] bpf: verifier, do explicit ALU32 bounds tracking引入的,主要的功能是用64位
寄存器的已知范围来推测出该寄存器的32位
情况下的最大最小值,然而却又在计算时出现了问题。现有的verifier
会认为32
位的最小值是等于64
位的,但当一个寄存器的64
位下的无符号最小值是0x100000000
时,32
位的无符号最小值却是0x0
二者并不相等,这就造成了verifier
在预测的时候和实际情况产生了误差从而造成与8835
一样的情况产生。
从调用链来看,当执行一个BPF_JMP_IMM(BPF_JGE, BPF_REG_7, 1, 1)
指令的过程会如下:
do_check => check_cond_jmp_op => reg_set_min_max => __reg_combine_64_into_32
这个指令是一个64
位的跳转应当设置64
位的最大最小值,但是在__reg_combine_64_into_32
却会同时预设32
位的最大最小值
static void __reg_combine_64_into_32(struct bpf_reg_state *reg)
{
__mark_reg32_unbounded(reg);
if (__reg64_bound_s32(reg->smin_value) && __reg64_bound_s32(reg->smax_value)) {
reg->s32_min_value = (s32)reg->smin_value;
reg->s32_max_value = (s32)reg->smax_value;
}
if (__reg64_bound_u32(reg->umin_value)) // [1]
reg->u32_min_value = (u32)reg->umin_value;
if (__reg64_bound_u32(reg->umax_value)) // [2]
reg->u32_max_value = (u32)reg->umax_value;
/* Intersecting with the old var_off might have improved our bounds
* slightly. e.g. if umax was 0x7f...f and var_off was (0; 0xf...fc),
* then new var_off is (0; 0x7f...fc) which improves our umax.
*/
__reg_deduce_bounds(reg);
__reg_bound_offset(reg);
__update_reg_bounds(reg);
}
[1]
处会顺利执行从而导致reg->u32_min_value = 0x1
,但其实逻辑是不对的,因为只有最大最小实际值都在32
位范围内32
位的预测最大最小预测值才能相等于64
位的最大最小预测值,所以如上逻辑其实只有[1]
执行了而[2]
却没有被执行,那么仅仅是这样会产生什么样的问题呢?
在有漏洞的版本中上面的指令已经顺利执行了并且导致u32_min_value
被修改成了0x1
,那么当下一条指令是BPF_JMP32_IMM(BPF_JLE, BPF_REG_7, 1, 1)
就会产生奇妙的变化,这是一个32
位的条件跳转那么自然要走的是32
位的执行逻辑,第一步要执行的就是修改u32_max_value = 0x1
:
static void reg_set_min_max(struct bpf_reg_state *true_reg,
struct bpf_reg_state *false_reg,
u64 val, u32 val32,
u8 opcode, bool is_jmp32)
{
......
case BPF_JLE:
case BPF_JLT:
{
if (is_jmp32) {
u32 false_umin = opcode == BPF_JLT ? val32 : val32 + 1;
u32 true_umax = opcode == BPF_JLT ? val32 - 1 : val32;
false_reg->u32_min_value = max(false_reg->u32_min_value,
false_umin);
true_reg->u32_max_value = min(true_reg->u32_max_value,
true_umax);
因为是条件跳转所以只需要关注
true_reg
的变化即可
在reg_set_min_max
的逻辑最后因为是is_imp32 = 1
的原因会进入到__reg_combine_32_into_64 -> __update_reg_bounds -> __reg32_deduce_bounds
的逻辑当中且命中如下判断:
if ((s32)reg->u32_max_value >= 0) {
/* Positive. We can't learn anything from the smin, but smax
* is positive, hence safe.
*/
reg->s32_min_value = reg->u32_min_value;
reg->s32_max_value = reg->u32_max_value =
min_t(u32, reg->s32_max_value, reg->u32_max_value);
该逻辑之后true_reg
的属性将会变成如下情况:
s32_min_value = 0x1,
s32_max_value = 0x1,
u32_min_value = 0x1,
u32_max_value = 0x1,
这些值会用作于接下来的__reg_bound_offset
函数来修改寄存器的var_off
:
/* Attempts to improve var_off based on unsigned min/max information */
static void __reg_bound_offset(struct bpf_reg_state *reg)
{
struct tnum var64_off = tnum_intersect(reg->var_off,
tnum_range(reg->umin_value,
reg->umax_value));
struct tnum var32_off = tnum_intersect(tnum_subreg(reg->var_off),
tnum_range(reg->u32_min_value,
reg->u32_max_value));
reg->var_off = tnum_or(tnum_clear_subreg(var64_off), var32_off);
}
最终var32_off
的预测值为var_off(0x1, 0x0)
成了一个固定值,那么如果再接下来的指令是BPF_MOV32_REG(BPF_REG_7, BPF_REG_7)
的话在verifier
的眼中r7
将会是一个确定值1
但是实际上如果不是呢?那自然就回到了上一个漏洞中的利用问题上 : )
这个文章磨了很久很久,其实本来想写4个漏洞的,但是后面发现ebpf的漏洞大同小异因此就不想花过多的时间放在写文章上转而是自己去了解相关的漏洞并脑补一下流程,说真的这篇帖子虽然写的很乱但是确实是收获了不少,特别是关于insn
的学习和编写是超出了我的学习计划以外的。关于ebpf
的安全的学习还是要继续的并且也确定了学习如何fuzz
的计划因此后面再陆续补全相关的知识吧。
- Berkeley_Packet_Filter
- Berkeley Packet Filter (BPF) (Kernel Document)
- [译] 大规模微服务利器:eBPF + Kubernetes(KubeCon, 2020)
- A medley of performance-related BPF patches
- The BSD Packet Filter: A New Architecture for User-level Packet Capture
- Dive into eBPF (1): 从 BPF 说起
- 理解 Linux Kernel (14) - cBPF
- [PATCH net-next v4 0/9] BPF updates
- A JIT for packet filters
- net: filter: Just In Time compiler
- [PATCH RFC net-next 00/14] BPF syscall, maps, verifier, samples
- [PATCH v3 net-next] net: filter: cleanup sk_* and bpf_* names
- Reading privileged memory with a side-channel
- 解读 Meltdown & Spectre CPU 漏洞
- Spectre Attacks: Exploiting Speculative Executio
- Spectres And Meltdown
- Instruction pipelining
- bpf: prevent out of bounds speculation on pointer arithmetic
- Spectre revisits BPF
- bpf: Fix leakage under speculation on mispredicted branches
- [原创]Linux内核eBPF模块源码分析——verifier与jit
- 浅谈处理器级Spectre Attack及Poc分析
- Issue 1711: Linux: eBPF Spectre v1 mitigation is insufficient
- BPF and XDP Reference Guide
- BPF Design Q&A
- bpf: propose new jmp32 instructions
- IOVisor md
- 【译】eBPF 概述:第 2 部分:机器和字节码
- bpf: Provide better register bounds after jmp32 instructions
- ebpf-信息泄露漏洞分析
- ebpf原理分析
- [PATCH v2 bpf-next 1/7] bpf: implement BPF ring buffer and verifier support for it
- 210401_pwn2own
- CVE-2020-8835 pwn2own 2020 ebpf 提权漏洞分析
- Linux内核eBPF verifier边界计算错误漏洞分析与利用(CVE-2021-31440)