这周帮朋友用 eBPF/SystemTap 这样的动态 tracing 工具做了一些很有趣的功能。这篇文章算是一个总结
开篇 实际上这周的一些想法,最开始是实际上来源于某天一个朋友问我的一个问题
我们能不能监控机器上哪些进程在发出 ICMP 请求?需要拿到 PID,ICMP 包出口地址,目标地址,进程启动命令
很有趣的问题。实际上首先拿到这个问题时候,我们第一反应肯定是 “让机器上的进程在发 ICMP 包的时候”直接往一个地方写日志不就好了,emmmm,用一个 meme 镇楼吧
嗯,可能大家都知道我想说什么了,我们这种场景实际上只能选择旁路,无侵入的方式去做。
那么涉及到包的旁路的 trace,大家第一反应肯定是 tcpdump 去抓包。但是在我们今天的问题下,tcpdump 只能拿到包信息, 但是拿不到具体的 PID,启动命令等信息。
所以我们可能需要用另外一些方式去实现我们的需求
在需求最开始之初,我们还可能的选择的方式有这样一些
走 /proc/net/tcp 去拿具体的 socket 的 inode 信息,然后反查 pid 关联
eBPF + kprobe 内核打点做监控
SystemTap + kprobe 内核打点做监控
第一种方式,实际上只能拿到 TCP 一层的信息,但是 ICMP 并不是 TCP 协议啊(衰(虽然同属 L4
那么看到最后,我们貌似就只有用 eBPF/SystemTap 配合 kprobe 的一条路可以走了
基础的 trace Kprobe 在继续下面的代码实际操作之前,我们首先要来认识一下 Kprobe
先引用一段官方文档的介绍
Kprobes enables you to dynamically break into any kernel routine and collect debugging and performance information non-disruptively. You can trap at almost any kernel code address 1, specifying a handler routine to be invoked when the breakpoint is hit. There are currently two types of probes: kprobes, and kretprobes (also called return probes). A kprobe can be inserted on virtually any instruction in the kernel. A return probe fires when a specified function returns. In the typical case, Kprobes-based instrumentation is packaged as a kernel module. The module’s init function installs (“registers”) one or more probes, and the exit function unregisters them. A registration function such as register_kprobe() specifies where the probe is to be inserted and what handler is to be called when the probe is hit.
简单来说,kprobe 是内核的一个提供的一个 trace 机制,在执行我们所设定特定的内核函数时/后,会按照我们所设定的规则触发我们的回调函数。用官方的话来说,“You can trap at almost any kernel code address”
在我们今天的场景下,不管利用 eBPF 还是 SystemTap 都需要依赖 Kprobe 并选择合适的 hook 点来完成我们内核调用的 trace
那么,在我们今天的场景下,我们应该选择在什么函数上加上对应的 hook 呢?
首先我们来想一下,ICMP 是一个四层的包,最终封装在一个 IP 报文中分发出去,那么我们来看一下,内核中 IP 报文发送中的关键调用,参见下图
在这里我选择将 ip_finish_output 作为我们的 hook 点。
OK,Hook 点确认后,在开始正式编码前,我们来大概介绍下 ip_finish_output
ip_finish_output 首先来看下这个函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 static int ip_finish_output (struct net *net, struct sock *sk, struct sk_buff *skb) { int ret; ret = BPF_CGROUP_RUN_PROG_INET_EGRESS(sk, skb); switch (ret) { case NET_XMIT_SUCCESS: return __ip_finish_output(net, sk, skb); case NET_XMIT_CN: return __ip_finish_output(net, sk, skb) ? : ret; default : kfree_skb(skb); return ret; } }
具体细节先不在这里展开(因为实在是太多了Orz),在系统调用 ip_finish_output
时,会触发我们设定的 kprobe 的钩子,在我们所设定的 hook 函数中会收到 net
, sk
, skb
三个参数(这三个参数也是调用 ip_finish_output
时的值。
在这三个参数中,我们主要来将视线放在 struct sk_buff *skb
上。
熟悉 Linux Kernel 协议栈实现的同学肯定对 sk_buff
这个数据结构非常非常熟悉了。这个数据结构是 Linux Kernel 中网络相关的核心数据结构。通过不断的偏移指针,这个数据结构能够很方便帮助我们确认我们待发送/已接收的数据在内存中所存放的位置。
空口直说好像有点抽象,我们来看个图
以发送一个 TCP 包为例,我们能看到这个图中,sk_buff 经历了六个阶段
a. 根据 TCP 中的一些选项如 MSS 等,分配一个 buffer b. 根据 MAX_TCP_HEADER 在我们申请好的内存 buffer 中预留一段足够容纳所有网络层的 header 的空间(TCP/IP/Link等) c. 填入 TCP 的 payload d. 填入 TCP header e. 填入 IP header d. 填入 link header
可以参照一下 TCP 报文结构,这样大家会有一个更直观的理解
大家能看到,通过 sk_buff 的一些指针的操作,我们就能很方便的获取到其中不同 layer 的 header 和具体的 payload
OK,现在让我们正式的来开始实现我们所需要的功能
eBPF + KProbe 首先简单介绍下 eBPF。BPF 指 Berkeley Packet Filter ,最早期是用来设计在内核中实现一些网络包过滤的功能。但是后续社区对其做了非常多的强化增强,使其不仅能应用于网络目地。这也是名字中 e 的来历(extend)
本质上而言,eBPF 在内核维护了一层 VM,可以加载特定规则生成的代码,让内核变得更具有可编程性(后面我争取写一篇 eBPF 从入门到入土的介绍文章)
Tips: Tcpdump 的背后就是 BPF
然后在这次实现中,我们使用了 BCC 来简化我们 eBPF 相关的编写难度
OK,先上代码
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 86 87 88 89 from bcc import BPFimport ctypesbpf_text = """ #include <linux/ptrace.h> #include <linux/sched.h> /* For TASK_COMM_LEN */ #include <linux/icmp.h> #include <linux/ip.h> #include <linux/netdevice.h> struct probe_icmp_sample { u32 pid; u32 daddress; u32 saddress; }; BPF_PERF_OUTPUT(probe_events); static inline unsigned char *custom_skb_network_header(const struct sk_buff *skb) { return skb->head + skb->network_header; } static inline struct iphdr *get_iphdr_in_icmp(const struct sk_buff *skb) { return (struct iphdr *)custom_skb_network_header(skb); } int probe_icmp(struct pt_regs *ctx, struct net *net, struct sock *sk, struct sk_buff *skb){ struct iphdr * ipdata=get_iphdr_in_icmp(skb); if (ipdata->protocol!=1){ return 1; } u64 __pid_tgid = bpf_get_current_pid_tgid(); u32 __pid = __pid_tgid; struct probe_icmp_sample __data = {0}; __data.pid = __pid; u32 daddress; u32 saddress; bpf_probe_read(&daddress, sizeof(ipdata->daddr), &ipdata->daddr); bpf_probe_read(&saddress, sizeof(ipdata->daddr), &ipdata->saddr); __data.daddress=daddress; __data.saddress=saddress; probe_events.perf_submit(ctx, &__data, sizeof(__data)); return 0; } """ class IcmpSamples (ctypes.Structure): _fields_ = [ ("pid" , ctypes.c_uint32), ("daddress" , ctypes.c_uint32), ("saddress" , ctypes.c_uint32), ] bpf = BPF(text=bpf_text) filters = {} def parse_ip_address (data ): results = [0 , 0 , 0 , 0 ] results[3 ] = data & 0xFF results[2 ] = (data >> 8 ) & 0xFF results[1 ] = (data >> 16 ) & 0xFF results[0 ] = (data >> 24 ) & 0xFF return "." .join([str (i) for i in results[::-1 ]]) def print_icmp_event (cpu, data, size ): event = ctypes.cast(data, ctypes.POINTER(IcmpSamples)).contents daddress = parse_ip_address(event.daddress) print ( f"pid:{event.pid} , daddress:{daddress} , saddress:{parse_ip_address(event.saddress)} " ) bpf.attach_kprobe(event="ip_finish_output" , fn_name="probe_icmp" ) bpf["probe_events" ].open_perf_buffer(print_icmp_event) while 1 : try : bpf.kprobe_poll() except KeyboardInterrupt: exit()
OK,这段代码严格意义上来说是混编的,一部分是 C,一部分是 Python,。Python 部分大家肯定都很熟悉,BCC 帮我们加载我们的 C 代码,并 attch 到 kprobe 上。然后不断输出我们从内核中往外传输的数据
那我们重点来看看 C 部分的代码(实际上这严格来说不算标准 C,算是 BCC 封装的一层 DSL)
首先看一下我们辅助的两个函数
1 2 3 4 5 6 7 8 9 static inline unsigned char *custom_skb_network_header (const struct sk_buff *skb) { return skb->head + skb->network_header; } static inline struct iphdr *get_iphdr_in_icmp (const struct sk_buff *skb) { return (struct iphdr *)custom_skb_network_header(skb); }
如前面所说,我们可以根据 sk_buff 中的 head 和 network_header 就能计算出我们 IP 头部在内存中的地址,然后我们将其 cast 成一个 iphdr
结构体指针
我们还得再来看一下 iphdr
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 struct iphdr {#if defined(__LITTLE_ENDIAN_BITFIELD) __u8 ihl:4 , version:4 ; #elif defined (__BIG_ENDIAN_BITFIELD) __u8 version:4 , ihl:4 ; #else #error "Please fix <asm/byteorder.h>" #endif __u8 tos; __be16 tot_len; __be16 id; __be16 frag_off; __u8 ttl; __u8 protocol; __sum16 check; __be32 saddr; __be32 daddr; };
熟悉 IP 报文结构的同学肯定就很眼熟了对吧,其中 saddr
和 daddr
就是我们的源地址和目标地址,protocol
代表着我们 L4 协议的类型,其中为1的时候代表着 ICMP 协议
OK 然后来看一下我们的 trace 函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 int probe_icmp (struct pt_regs *ctx, struct net *net, struct sock *sk, struct sk_buff *skb) { struct iphdr * ipdata = get_iphdr_in_icmp(skb); if (ipdata->protocol!=1 ){ return 1 ; } u64 __pid_tgid = bpf_get_current_pid_tgid(); u32 __pid = __pid_tgid; struct probe_icmp_sample __data = {0 }; __data.pid = __pid; u32 daddress; u32 saddress; bpf_probe_read(&daddress, sizeof (ipdata->daddr), &ipdata->daddr); bpf_probe_read(&saddress, sizeof (ipdata->daddr), &ipdata->saddr); __data.daddress=daddress; __data.saddress=saddress; probe_events.perf_submit(ctx, &__data, sizeof (__data)); return 0 ; }
如前面所说,kprobe 触发调用时,会将 ip_finish_output
的三个参数传入到我们的 trace 函数中来,那我们就可以根据传入的数据做很多的事了,现在来介绍下上面的代码中所做的事
将 sk_buff 转换成对应的 iphdr
判断当前报文是否为 ICMP 协议
利用内核 BPF 提供的 helper bpf_get_current_pid_tgid
获取当前调用 ip_finish_output
进程的 pid
获取 saddr 和 daddr。注意我们这里用的 bpf_probe_read 也是 BPF 提供的 helper function,原则上来讲,在 eBPF 中为了保证安全,我们所有从内核中读取数据的行为都应该利用 bpf_probe_read
或 bpf_probe_read_kernel
来实现
通过 perf 将数据提交出去
这样一来,我们就能排查到机器上具体什么进程在发送 ICMP 请求了
来看下效果
OK,我们的需求基本上达到了,不过这里算是留了一个小问题,大家可以思考下,我们怎么样根据 pid 获取启动进程时的 cmdline ?
SystemTap + kprobe eBPF 的版本实现了,但是有个问题啊,eBPF 只能在高版本的内核中使用。一般而言,在 xb86_64 上,Linux 3.16 中支持了 eBPF。而我们依赖的 kprobe 对于 eBPF 的支持则是在 Linux 4.1 中实现的。通常而言,我们一般推荐使用 4.9 及以上内核来配合 eBPF 使用
那么问题来了。实际上我们现在有很多 Centos 7 + Linux 3.10 这样的传统的搭配,那么他们怎么办呢?
Linux 3.10 live’s matter! Centos 7 live’s matter!
那没办法,只能换一个技术栈来做了。这个时候,我们就首先考虑由 RedHat 开发,贡献进入社区,低版本可用的 SystemTap
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 %{ %} function isicmp:long (data:long)%{ struct iphdr *ip; struct sk_buff *skb; int tmp = 0; skb = (struct sk_buff *) STAP_ARG_data; if (skb->protocol == htons(ETH_P_IP)){ ip = (struct iphdr *) skb->data; tmp = (ip->protocol == 1); } STAP_RETVALUE = tmp; %} function task_execname_by_pid:string (pid:long) %{ struct task_struct *task; task = pid_task(find_vpid(STAP_ARG_pid), PIDTYPE_PID); // proc_pid_cmdline(p, STAP_RETVALUE); snprintf(STAP_RETVALUE, MAXSTRINGLEN, "%s" , task->comm ); %} function ipsource:long (data:long)%{ struct sk_buff *skb; struct iphdr *ip; __be32 src; skb = (struct sk_buff *) STAP_ARG_data; ip = (struct iphdr *) skb->data; src = (__be32) ip->saddr; STAP_RETVALUE = src; %} /* Return ip destination address */ function ipdst:long (data:long)%{ struct sk_buff *skb; struct iphdr *ip; __be32 dst; skb = (struct sk_buff *) STAP_ARG_data; ip = (struct iphdr *) skb->data; dst = (__be32) ip->daddr; STAP_RETVALUE = dst; %} function parseIp:string (data:long) %{ sprintf(STAP_RETVALUE,"%d.%d,%d.%d" ,(int)STAP_ARG_data &0xFF,(int)(STAP_ARG_data>>8)&0xFF,(int)(STAP_ARG_data>>16)&0xFF,(int)(STAP_ARG_data>>24)&0xFF); %} probe kernel.function("ip_finish_output" ).call { if (isicmp($skb )) { pid_data = pid() /* IP */ ipdst = ipdst($skb ) ipsrc = ipsource($skb ) printf ("pid is:%d,source address is:%s, destination address is %s, command is: '%s'\n" ,pid_data,parseIp(ipsrc),parseIp(ipdst),task_execname_by_pid(pid_data)) } else { next } }
实际上大家可以看到,我们思路还是一样,利用 ip_finish_output
来作为 kprobe 的 hook 点,然后我们获取对应的 iphdr 然后进行操作。
嗯,我们的需求的基础功能差不多就是这样了,大家可以在额外进行一些功能增强,比如获取完整的进程 cmdline 等等
更近一步的想法和实验 大家可能对于 ICMP 这样的冷门协议没有太明显的感觉,那么我们换个需求大家可能就更为有感觉了
监控机器上哪些进程在发出 HTTP 1.1 请求
嗯,一如往的,我们先来看一下系统中的关键调用
嗯,这里我们选择 tcp_sendmsg
来作为我们的切入点
1 2 3 4 5 6 7 8 9 10 int tcp_sendmsg (struct sock *sk, struct msghdr *msg, size_t size) { int ret; lock_sock(sk); ret = tcp_sendmsg_locked(sk, msg, size); release_sock(sk); return ret; }
嗯,其中 sock
是包含我们一些关键元数据的结构体
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 struct sock { struct sock_common __sk_common ; ... } struct sock_common { union { __addrpair skc_addrpair; struct { __be32 skc_daddr; __be32 skc_rcv_saddr; }; }; union { unsigned int skc_hash; __u16 skc_u16hashes[2 ]; }; union { __portpair skc_portpair; struct { __be16 skc_dport; __u16 skc_num; }; }; ... }
大家可以看到,我们能在 sock
中获取到我们端口的五元组数据,然后我们从 msghdr
中能获取到具体的数据
那么,以我们需求中的 HTTP 为例,我们实际上只需要判断,我们获取到的 TCP 包中是否包含 HTTP/1.1 ,便可粗略判断,这个请求是否是 HTTP 1.1 请求(很暴力的做法Hhhhh
OK,我们来看下代码
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 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 from bcc import BPFimport ctypesimport binasciibpf_text = """ #include <linux/ptrace.h> #include <linux/ip.h> #include <linux/tcp.h> #include <uapi/linux/ptrace.h> #include <net/sock.h> #include <bcc/proto.h> #include <linux/socket.h> struct ipv4_data_t { u32 pid; u64 ip; u32 saddr; u32 daddr; u16 lport; u16 dport; u64 state; u64 type; u8 data[300]; u16 data_size; }; BPF_PERF_OUTPUT(ipv4_events); int trace_event(struct pt_regs *ctx,struct sock *sk, struct msghdr *msg, size_t size){ if (sk == NULL) return 0; u32 pid = bpf_get_current_pid_tgid() >> 32; // pull in details u16 family = sk->__sk_common.skc_family; u16 lport = sk->__sk_common.skc_num; u16 dport = sk->__sk_common.skc_dport; char state = sk->__sk_common.skc_state; if (family == AF_INET) { struct ipv4_data_t data4 = {}; data4.pid = pid; data4.ip = 4; //data4.type = type; data4.saddr = sk->__sk_common.skc_rcv_saddr; data4.daddr = sk->__sk_common.skc_daddr; // lport is host order data4.lport = lport; data4.dport = ntohs(dport); data4.state = state; struct iov_iter temp_iov_iter=msg->msg_iter; struct iovec *temp_iov=temp_iov_iter.iov; bpf_probe_read_kernel(&data4.data_size, 4, &temp_iov->iov_len); u8 * temp_ptr; bpf_probe_read_kernel(&temp_ptr, sizeof(temp_ptr), &temp_iov->iov_base); bpf_probe_read_kernel(&data4.data, sizeof(data4.data), temp_ptr); ipv4_events.perf_submit(ctx, &data4, sizeof(data4)); } return 0; } """ bpf = BPF(text=bpf_text) filters = {} def parse_ip_address (data ): results = [0 , 0 , 0 , 0 ] results[3 ] = data & 0xFF results[2 ] = (data >> 8 ) & 0xFF results[1 ] = (data >> 16 ) & 0xFF results[0 ] = (data >> 24 ) & 0xFF return "." .join([str (i) for i in results[::-1 ]]) def print_http_payload (cpu, data, size ): event= bpf["ipv4_events" ].event(data) daddress = parse_ip_address(event.daddr) body = bytearray (event.data).hex () if "48 54 54 50 2f 31 2e 31" .replace(" " , "" ) in body: print ( f"pid:{event.pid} , daddress:{daddress} , saddress:{parse_ip_address(event.saddr)} , {event.lport} , {event.dport} , {event.data_size} " ) bpf.attach_kprobe(event="tcp_sendmsg" , fn_name="trace_event" ) bpf["ipv4_events" ].open_perf_buffer(print_http_payload) while 1 : try : bpf.perf_buffer_poll() except KeyboardInterrupt: exit()
OK,我们来看下效果
实际上这个我们还可以再扩展一下。比如针对 Go 这样,所发出的 HTTPS 连接有着固定特征的语言,我们也可以用相对简单的做法去完成机器上的包来源的溯源(大家可以参考下无辄的这篇文章,为什么用 Go 访问某网站始终会 503 Service Unavailable ? )
我自己也做了一个测试,大家可以参考下代码:https://github.com/Zheaoli/linux-traceing-script/blob/main/ebpf/go-https-tracing.py
总结 实际上无论是 eBPF 还是 SystemTap ,这类动态 tracing 技术可以 Linux Kernel 变得更具被可编程性。相较于传统的 recompile kernel 这些手段来说,更为方便快捷。而 BCC/BPFTrace 这类的更进一步的封装框架的出现,更进一步的降低了我们去观测内核的难度
很多时候我们很多需求都可以选择旁路的方式去更快捷的实现。但是要注意的一点是,动态 tracing 技术的引入势必增加了内核的不稳定性,而且一定程度上会影响性能。所以我们需要根据具体的场景去做 trade-off
好了,这篇文章差不多就水到这里,后面有时间争取出一个 eBPF 从入门到入土的系列文章(flag++