Linux | eBPF:扩展伯克利包过滤器

2022年8月10日 400点热度 1人点赞 0条评论

        eBPF (扩展伯克利包过滤器)起源于Linux内核,可以在操作系统内核中运行的沙盒程序。其技术安全有效地扩展内核功能。而无需更改内核源代码或者加载内核模块。

        eBPF 被广泛用于:

  • 内核性能追踪

  • 网络安全和可观测性

  • 应用程序和容器运行时安全

……


1.eBPF程序执行一般性流程



Image


        eBPF 程序的首先使用 C 或 Rust 编写 eBPF 程序,LLVM编译为字节码,用户态程序通过 eBPF 库,使用 bpf 系统调用将 eBPF 字节码加载到Linux 内核。

        内核 ebpf 验证器,校验 BPF 字节码:

  • 发起bpf系统调用的进程是否具有相应权限,要求进程具有相关的Linux Capabilities(CAP_BPF)或root权限;

  • 检查程序是否会导致内核崩溃,例如是否有未初始化的变量,是否有可能导致数组越界、空指针访问的语句;

  • 检查程序是否有限时间执行完,eBPF 只允许有限的循环和跳转,且只允许执行有限的指令条数。

        eBPF 程序在完成构建后,挂载到内核上的对应事件上如系统调用,当某个系统调用产生时,触发内核调用对应的 eBPF 程序。内核 ebpf 程序通过map 数据结构与用户态程序进行交互,完成相应功能。

2.eBPF跟踪

2.1 探针类型

        内核探针:提供对内核中内部组件的动态访问

        跟踪点:提供对用户空间运行的程序的动态访问

        用户空间探针:提供对用户空间运行的程序的动态访问

        用户静态定义跟踪点:提供对用户空间运行的程序的静态访问

2.2 内核探针

        内核探针可以在任何内核指令上设置动态标志或中断,当内核到达这些标志时,附加到探针的代码将被执行,之后内核将恢复正常模式。

        内核探针分为两类:kprobes 和 kretprobes。

2.2.1 kprobes

        kprobes允许在执行任何内核指令之前插入BPF程序,需要知道插入点的函数签名,内核探针不是稳定的ABI(程序二进制接口),所以需要谨慎在不同的内核版本中运行设置探针的程序。当内核执行到设置探针的指令时,它将从代码执行处开始运行BPF程序,在BPF程序执行完成后将返回至插入BPF程序处继续执行。

2.2.2 kretprobes    

        kretprobes是在内核指令有返回值时插入BPF程序。通常,我们会在一个BPF程序中同时使用kprobes和kretprobes,以便获得对内核指令的全面了解。

2.3 跟踪点

        跟踪点是内核代码的静态标记,可用于将代码附加在运行的内核中。跟踪点与kprobes的主要区别在于跟踪点由内核开发人员在内核中编写和修改。由于跟踪点是静态存在的,所以跟踪点的ABI是最稳定的。

        跟踪点是内核开发人员添加的,所以跟踪点可能并不会涵盖到内核的所有子系统。

        /sys/kernel/debug/tracing/events目录下的内容可以查看系统中所有可用的跟踪点。

        上面输出中的每个子目录对应一个BPF程序可附加的跟踪点。还有两个额外文件:第一个文件为enable,允许启用和禁用BPF子系统的所有跟踪点。如果该文件内容为0,表示禁用跟踪点。如果该文件内容为1,表示跟踪点已启用。

        内核探针与跟踪点提供了对内核的完全访问。由于跟踪点更加安全,尽可能使用跟踪点。

2.4 用户空间探针

        用户空间探针允许在用户空间运行的程序中设置动态标志。它们等同于内核探针,用户空间探针是运行在用户空间的监测系统。当我们定义uprobe时,内核会在附加的指令上创建trap,当程序执行到该指令时,内核将触发事件已回调函数的方式调用探针函数。uprobes也可以访问程序链接到的任何库,只要知道指令的名称,就可以跟踪对应的调用。

    与内核探针非常相似,用户空间探针也分为两类:uprobes和uretporbes,依赖于插入BPF程序在指令执行周期的哪个阶段。

2.4.1 uprobes

        uprobes是内核在程序特定指令执行之前插入该指令集的钩子。不同内核版本的函数签名可能有所变化。Linux中可以使用nm命令列出ELF对象文件中包括的所有符号,并检查跟踪指令在程序中是否存在。

2.4.2 uretprobes

        uretprobes是kretprobes并行探针,适用于用户空间程序使用。它将BPF程序附加到指令返回值之上,允许通过BPF代码从寄存器中访问返回值。

uprobes和uretprobes的结合使用可以编写更复杂的BPF程序。


● eBPF 允许在以下位置创建内核中的跟踪点(tracepoint)

○ 系统调用

○ 网络接口(socket/xdp)

○ 函数入口/退出

○ 内核跟踪点

○ 容器(cgroup)

○ 用户模式功能

……

● eBPF 允许创建探针(probe):

○ 内核探针(kprobe)

○ 用户探针(uprobe)


Image

3.eBPF程序组成部分


Image


4.eBPF映射

        BPF映射以键/值保存在内核中,可以被任何BPF程序访问。用户空间的程序也可以通过文件描述符访问BPF映射。BPF映射中可以保存实现指定大小的任何类型的数据。内核将键和值作为二进制块,这意味着内核并不关心BPF映射保存的具体内容。

        BPF验证器使用多种保护措施确保创建和访问BPF映射的方式是安全的。

        创建BPF映射的最直接方式是使用bpf系统调用。如果该系统调用的第一个参数设置为BPF_MAP_CREATE,则表示创建一个新映射。改调用将返回与创建映射相关的文件描述符。bpf系统调用的第二个参数是BPF映射的设置。

union bpf_attr(){    struct {        __u32 map_type;    /*bpf_map_type*/        __u32 key_size;        __u32 value_size;        __u32 max_entries;        __u32 map_flags;    };}

        bpf系统调用的第三个参数是设置属性的大小,创建一个键和值为无符号整数的哈希表映射:

union bpf_attr_my_map {    .map_type = BPF_MAP_TYPE_HASH,    .key_size = sizeof(int),    .value_size = sizeof(int),    .max_entries = 100,    .map_flags = BPF_F_NO_PREALLOC,};int fd = bpf(BPF_MAP_CREATE, &my_map, sizeof(my_map));

        如果系统调用失败,内核返回-1,失败原因有三种,通过errno来进行区分。

  • 如果属性无效,内核返回EINVAL。

  • 如果没有足够的权限执行操作,内核返回EPERM。

  • 如果没有足够的内存保存映射,内核将返回ENOMEM。

4.1 使用ELF约定创建BPF映射

        内核存在一些约定和帮助函数,用于生成和使用BPF映射。这些约定即使运行在内核中,底层仍然是通过bpf系统调用来创建映射。

        帮助函数bpf_map_create封装了我们上面使用的代码,可以容易地按需初始化映射。

int fd;fd = bpf_map_create(BPF_MAP_TYPE_HASH, sizeof(int), sizeof(int), 100, BPF_F_NO_PREALOC);

4.2 使用BPF映射

        内核和用户空间之间的通信是编写BPF程序的基础。内核程序和用户空间程序代码都可访问映射,但它们使用的API签名不同。

4.2.1 更新BPF映射元素

        创建映射更新内容,内核提供了帮助函数bpf_map_update_elem来实现。

        内核程序需要从bpf/bpf_helpers.h文件加载bpf_map_update_elem函数,而用户程序需要从tools/lib/bpf/bpf.h文件加载,所以内核程序访问的函数签名与用户空间访问的函数签名是不同的。

        内核程序可以直接访问映射,而用户程序需要使用文件描述符来引用映射。

int key, value, result;key = 1, value = 1234;
result = bpf_map_update_elem(map_data[0].fd, &key, &value, BPF_ANY);if(result == 0) printf("Map updated with new element\n");else printf("Failed to update map with new value: %d (%s)\n", result, strerror(errno));

4.2.2 读取BPF映射元素

    BPF根据程序执行位置提供了两个不同的帮助函数用来读取映射元素。这两个函数名都为bpf_map_lookup_elem。

从内核空间读取映射:

int key, value, result;key = 1;
result = bpf_map_lookup_elem(&my_map, &key, &value);if(result == 0) printf("Value to read from the map: '%d'\n", value);else printf("Failed to read value from the map: %d (%s)\n", result, strerror(errno));

从用户空间读取映射:

int key, value, result;key = 1;
result = bpf_map_lookup_elem(map_data[0].fd, &key, &value);if(result == 0) printf("Value to read from the map: '%d'\n", value);else printf("Failed to read value from the map: %d (%s)\n", result, strerror(errno));

        bpf_map_lookup_elem中的第一个参数将替换为映射的文件描述符。帮助函数的行为与上面示例的行为相同。

4.2.3 删除BPF映射元素

BPF根据程序执行位置提供了两个不同的帮助函数用来删除映射元素。这两个函数名都为bpf_map_delete_element。

从内核空间删除插入映射中的值:

int key, value, result;key = 1;
result = bpf_map_delete_element(&my_map, &key);if(result == 0) printf("Element deleted from the map\n");else printf("Failed to delete element from the map: %d (%s)\n", result, strerror(errno));

从用户空间读取映射:

int key, value, result;key = 1;
result = bpf_map_delete_element(map_data[0].fd, &key);if(result == 0) printf("Element deleted from the map\n");else printf("Failed to delete element from the map: %d (%s)\n", result, strerror(errno));

4.2.4 迭代BPF映射元素

        BPF中查找任意元素。BPF提供bpf_map_get_next_key指令,该指令仅仅适用于用户空间上运行的程序。

int next_key, lookup_key;lookup_key = -1;
while(bpf_map_get_next_key(map_data[0].fd, &lookup_key, &next_key) == 0){ printf("The next key in the map is: '%d'\n", next_key); lookup_key = next_key;}

4.2.5 查找和删除映射元素

        bpf_map_lookup_and_delete_elem。此功能是在映射中查找指定的键并删除元素。同时,程序将该元素的值赋予一个变量。

int key, value, result, it;key = 1;
for (it =0; it < 2; it++){ result = bpf_map_lookup_and_delete_element(map_data[0].fd, &key, &value); if(result == 0) printf("Value read from the map: '%d'\n", value); else printf("Failed to read value from the map: %d (%s)\n", result, strerror(errno));}

4.2.6 并发访问映射元素

        并发访问相同的映射元素,可能会在BPF程序中产生竞争条件。BPF增加了BPF自旋锁的概念,可以在操作映射元素时对访问的映射元素进行锁定。自旋锁仅适用于数组、哈希、cgroup存储映射。

    内核中有两个帮助函数与自旋锁一起使用:bpf_spin_lock锁定、bpf_spin_unlock解锁。用户程序可以使用BPF_F_LOCK标志。

使用自旋锁首先需要创建要锁定访问的元素,然后为该元素添加信号。

struct concurrent_element{    struct bpf_spin_lock semaphore;    int count;}

        我们可以声明持有这些元素的映射。该映射必须使用BPF类型格式(BTF)进行注释,以便验证器知道如何解释BTF。BTF可以通过给二进制对象添加调试信息,为内核和其他工具提供更丰富的信息。在内核中,我们可以使用libbpf的内核宏来注释这个并发映射。

struct bpf_map_def SEC("maps") concurrent_map = {    .type = BPF_MAP_TYPE_HASH,    .key_size = sizeof(int),    .value_size = sizeof(struct concurrent_element),    .max_entries = 100,};
BPF_ANNOTATE_KV_PAIR(concurrent_map, int, struct concurrent_element);

        使用这两个帮助函数保护这些元素防止竞争条件。

5. BPF映射类型



5.1 哈希表映射

        哈希表映射是添加到BPF中的第一个通用映射。映射类型定义为BPF_MAP_TYPE_HASH。

5.2 数组映射

    数组映射是添加到内核的第二个BPF映射。映射类型定义为BPF_MAP_TYPE_ARRAY。对数组映射初始化时,所有元素在内存中将预分配空间并设置为零。键是数组中的索引,大小必须恰好为四个字节。数组映射中的元素不能删除。

5.3 程序数组映射

    程序数组映射添加到内核的第一个专用映射。映射类型定义为BPF_MAP_TYPE_PROC_ARRAY。这种类型保存对BPF程序的引用,即BPF程序的文件描述符。程序数组映射类型可以与帮助函数bpf_tail_call结合使用,实现在程序之间跳转,突破单个BPF程序最大指令的限制,并且降低实现的复杂度。键和值的大小必须为四个字节。跳转到新程序时,新程序将使用相同的内存栈,因此程序不会耗尽所有有效的内存。如果跳转到不存在的程序时,尾部调用将失败,返回继续执行当前程序。

5.4 Perf事件数组映射

        这种类型映射将perf_events数据存储在环形缓存区中,用于BPF程序和用户空间程序进行实时通信。

        映射类型定义为BPF_MAP_TYPE_PERF_EVENT_ARRAY。它可以将内核跟踪工具发出的事件转发给用户空间程序,做进一步处理。

        声明event结构体:

struct data_t{    u32 pid;    char program_name[16];}

        创建映射用来发送event到用户空间:

struct bpf_map_def SEC("maps") events = {    .type = BPF_MAP_TYPE_PERF_EVENT_ARRAY,    .key_size = sizeof(int),    .value_size = sizeof(u32),    .max_entries = 2,}

        声明数据类型和映射后,我们可以创建BPF程序用来捕获数据并发送到用户空间:

SEC("kprobe/sys_exec")int bpf_capture_exec(struct pt_regs *ctx){    data_t data;    data.pid = bpf_get_current_pid_tgid() >> 32;    bpf_get_current_comm(&data.program_name, sizeof(data.program_name));    bpf_perf_event_output(ctx, &events, 0, &data, sizeof(data));    return 0;}

 

reference


《Linux内核观测技术BPF》

https://ebpf.io/zh-cn/

79230Linux | eBPF:扩展伯克利包过滤器

这个人很懒,什么都没留下

文章评论