Skip to content

Latest commit

 

History

History
776 lines (640 loc) · 89.2 KB

ebpf的由来与安全性.md

File metadata and controls

776 lines (640 loc) · 89.2 KB

前言

如果要问起现在kernel圈中最炙手可热的技术,那想必是非eBPF莫属了,它的出现使得kernel可编程,从长期来看会使得内核的更新不再会用到fast path或者是hard code,同时也会慢慢的减少子系统的复杂度而是仅仅保留了核心功能,用户或者开发者希望内核实现的能力将会以一段代码的形式动态加载到内核中。

  • 简单的来讲现在的eBPF就是一种将用户态编写的代码插入到内核态运行的技术

这个技术能力听起来和LKM十分的类似但事实上却并非如此,这点主要涉及到eBPF的实现方式和实现目的可以自行了解,但是随着eBPF技术的高速发展使得其功能不再局限于最初的网络协议栈而是能够覆盖相当一部分LKM的能力,那么自然而然的eBPF也逐渐开始面对着LKM所具有的安全性问题 -- 内核恐慌

eBPF的由来

BPF

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则被设计成跑在这个虚拟机中的代理程序。

beb25319-f5c1-4a34-a8b8-745f6aefabb7.png

适用于虚拟机的指令集用以编写过滤规则,长度固定都是32位。

a3744947-aaed-4479-97f9-ec375896495c.png

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技术逐渐在各大主流系统中被实现,虽然其是将用户提交的规则程序跑在自身实现的虚拟机当中,但是实现上来说依然是需要把伪指令转换成真实指令执行,本质上来说还是一种代码注入技术,那自然会有安全风险,因此当规则程序在虚拟机中执行前会先进行一次安全检查以确认不会对内核造成影响。

详情见sk_chk_filter

安全检测内容包括:

  1. 是否存在非预期指令
  2. 是否有指令的非法配合
  3. 是否读取了非法地址
  4. 是否以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 系统调用读取套接字的接收队列也就完成了。

摘抄自理解 Linux Kernel (14) - cBPF

eBPF

[PATCH net-next v4 0/9] BPF updates这是在内核中的第一个eBPF patch,在v3.15被合并入内核当中,针对原始的BPF做了极大的改动:

  1. 增加了jited flag来确认filter是否已经被jit
  2. 添加struct sock_fprog_kern来保留原始的BPF program
  3. 迁移filter accountingnet/core/filter.c
  4. BPF能力暴露到UAPI
  5. 扩展优化了BPF指令集,重新设计了BPF解释器并保持对原始BPF的支持

其中第4点是eBPF得以大力发展的关键原因,而第5点则代表了BPFeBPF的区别,从此以后原始的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

eBPFextend 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中增加了哪些能力:

  1. sys_bpf and BPF MAP
  2. 针对BPF MAPlookup/update/delete/iterate
  3. eBPF verifier
  4. 针对socketseventsattach
  5. samples/bpf/下添加多个样例代码

eBPF相比于cBPF能力上的扩展除了map以外最实用的莫过于针对eventsattach,这个能力直接将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 verifiereBPF的发展轨迹其实光从verifier就可以窥探一二,因为整个eBPF程序的安全性都依靠它来静态判定。先前说过eBPF的能力发展到现在其实已经相当于一种LKM那么其安全性就必须要加以报障,其中一条红线就是绝对不能造成kernel panic,因此在最初的verifier中就有如下几个检查项:

  1. 循环
  2. 数组越界
  3. 无法访问的指令
  4. 无效指令
  5. 未初始化的寄存器访问
  6. 未初始化的栈地址访问
  7. 没有对齐的栈访问
  8. 栈越界
  9. 非法调用

verifier实际上做的东西可以参照作者写的文档:filter.txt,检查主要分为两步:

  1. 对注入的代码进行一次DAG检测和一些其余的CFG验证
  2. 从第一个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的讨论放到后面安全样例中一一探讨

eBPF的坑

这部分内容其实偏向于开发因此对我自身来说也是浅尝辄止,而且技术上也会因为时间的问题存在差异

一些简单的思想与未来的规划可以直接参照一下BPF Design Q&A,这是设计者们的回答十分的简洁明了

eBPF安全吗?

eBPF无疑是方便的,但是它真的安全吗?纵观eBPF执行的上下逻辑上其实仅有一个verifier来确认用户态输入的代码的安全性而且还是一个静态检查器,如果在之前eBPF能力还十分受限的时候verifier的逻辑也许还好写,但是随着eBPF的能力逐渐的开放这个检查器必然会有兼顾不到的地方出现那么安全问题是不是就来了?

Spectre(幽灵)

HyOOgUaSYockAAAAAElFTkSuQmCC

2018年Google Project Zero放出了两个漏洞,代号是MeltdownSpectre,前者适用于当时所有支持乱序执行的处理器中而后者则适用于支持分支预测的处理器,总共包含三个变种:

  1. Meltdown - CVE-2017-5754
  2. Spectre v1 - CVE-2017-5753
  3. Spectre v2 - CVE-2017-5715

本篇还是需要讲eBPF的东西,三个漏洞中只有Spectre v1eBPF有关联因此不会对另外两个漏洞过多调研(主要是三个漏洞底层共性比较多)

在一个计算机系统中,一个用户态程序是不能任意访问其余用户态程序的内存或者是内核内存的,因此内核通过虚拟内存为用户态进程还有内核自身创建了独立的内存空间并设置了相应的访问权限,而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。 那这有什么用?最终这个指令还是得不到执行那么数据就不会被movrax里,那能得到什么利用呢? 这个需要涉及到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个子过程:

  1. Fetch(取指)
  2. Decode(译码)
  3. Execute(执行)
  4. Mem(访存)
  5. 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->lengthuncached会进行分支预测直接执行到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提出了两种利用方式:

  1. Attacks using Native Code
  2. 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 = r9r9 = *(u8 *)(r6)是不可能同时被执行的因为r0的值不可能既是0x0又是0x1,实际上BPF verifier也是这么想的,它通过遍历所有的代码分支认定这个流程不会导致把可控数据当作是指针访问的情况,所以上述的代码就被成功加载了。 但是由于分支预测的存在,因为两个条件分支指令的执行结果都需要先加载r0,如果此时r0不在cache中的话CPU就会主动去将分支中的指令加入到执行流水线中并将所需数据载入到cache中,这就导致r6 = r9r9 = *(u8 *)(r6)因为预测的关系被先后执行,而一些特定的eBPF可以在开启配置后无需root权限运行直接导致了一个低权限任意内存访问漏洞,补丁则不是很难理解,就是去验证了所有的路径来确保不会有自定义指针的出现。

CVE-2020-8835

这个漏洞应该说是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 = &regs[insn->src_reg];dst_reg = &regs[insn->dst_reg];已经相应的跳转分支(branch)。接着根据src_regdst_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中的操作数来说,某一位只会有三种状态:

  1. 0
  2. 1
  3. 未知

那么该怎么用一个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/1r6变成了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 = 0x1reg->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 = 0x1mu = 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_numumin_value = 0x1umax_value = 0x?00000001,这个构造的实现依然要回到reg_set_min_max中,当opcodeBPF_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_arrayvaluemap.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这样的话就可以通过触发的方式让内核执行特定的函数且传参可控,而我们要传的当然是addressvalue,这样的话就需要找一个函数能够满足*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_keyaddress,而keyvalue就能实现地址值的控制。

关于写入地址的问题在ebpf中是无法将一个指针写入map的,因此就需要预先就知道map的地址,在5.5版本以下bpf_map的结构中不存在struct mutext freeze_mutex这就导致无法泄露出map_elem的地址从而加大了利用难度,我的测试环境是5.4恰好无法利用起来 T^T

当能够重写ops就可以通过修改modprobe_path的值来实现提权。

CVE-2021-31440

2021年的pwn2own的ebpf漏洞,和8835类似却又更加纯粹是一个完全的6432之间的转换错误导致的漏洞,因此利用上可以参照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的计划因此后面再陆续补全相关的知识吧。

参考文档