eBPF:使用bcc调试BGP MD5(TCP MD5 Signature Option)过程
我为什么要做这个事情
在路由器上配置BGP之后,BGP进程会打印一些BGP日志,例如邻居UP,邻居Down
*Nov 24 09:18:40.993: %BGP-5-NBR_RESET: Neighbor 10.0.0.50 active reset (Peer closed the session)
这些其实还是BGP进程在做的事情。但如果配置了BGP MD5认证,遇到的报错就是这样
*Nov 24 09:14:05.326: %TCP-6-BADAUTH: No MD5 digest from 10.0.0.50(35796) to 10.0.0.231(179) tableid - 0
这个认证的过程是TCP协议栈处理的,那么异常也是内核抛出的,这其实就跟BGP应用没什么关系了。因此想研究一下这个过程。
BGP MD5认证
BGP的 MD5认证是在Peer双方都配置一串密码。其机制是双方都生成一个MD5签名附加在每一个TCP报文的option字段里。对面收到报文后会来校验签名,如果校验有问题,则会丢弃数据包并打印日志。
这种签名的机制是RFC2385 Protection of BGP Sessions via the TCP MD5 Signature Option 设计的机制。
RFC 2385 TCP MD5 Signature Option
这个option的长度是18字节,其中MD5签名占了16字节,还有1字节的类型和1字节的长度。
2 bytes 4 bytes
+---------+---------+-------------------+
| Kind=19 |Length=18| MD5 digest... |
+---------+---------+-------------------+
| |
+---------------------------------------+
| |
+---------------------------------------+
| |
+-------------------+-------------------+
| |
+-------------------+
计算签名需要使用四个信息:
- TCP 伪首部 , 按顺序是 源IP,目的IP,1字节的0填充,1字节协议号,2字节TCP报文长度。
- TCP 首部, 不包括选项,校验和预设为0.
- TCP 报文数据字段,如果有的话。
- 秘钥,例如BGP Password。
实验方式
一台Cisco路由器和一个装了Quagga的Linux机器。二者建立BGP邻居,之后配置BGP MD5 认证。
- 10.0.0.50 - Quagga 内核版本 5.10.228-219.884.amzn2.x86_64
- 10.0.0.231 - Cisco Router Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 17.9.1a, RELEASE SOFTWARE (fc2)
日志展示
先看一下原生的各种日志
路由器
密码不匹配的时候
*Nov 25 12:32:40.375: %TCP-6-BADAUTH: Invalid MD5 digest from 10.0.0.50(38918) to 10.0.0.231(179) tableid - 0
本端要求密码,对面没有配置密码的时候
*Nov 24 09:14:05.326: %TCP-6-BADAUTH: No MD5 digest from 10.0.0.50(35796) to 10.0.0.231(179) tableid - 0
经过抓包可以看到,只有路由器在179端口收到数据包的时候才会打印日志,如果是路由器去连接对方的179端口,并不会打印日志。
Quagga
密码不匹配的时候, /var/log/message 会输出下面的内容
Nov 25 12:36:08 ip-10-0-0-50 kernel: TCP: MD5 Hash failed for (10.0.0.231, 33440)->(10.0.0.50, 179) L3 index 0
对面没有配置密码的时候这个时候不会打印日志,会在一个计数器上+1。
[root@ip-10-0-0-50 ec2-user]# netstat -s | grep -i md5
TCPMD5NotFound: 17037 -> 本端要求md5签名,但对面没有携带签名
TCPMD5Unexpected: 88 -> 本端不要求md5, 但对方携带了。
TCPMD5Failure: 409 -> 本端要求md4签名,但对方的签名不匹配。
调试过程
调试主要就是在装Quagga的Linux上进行的,路由器是黑盒所以不方便做。 Quagga目前启动了两个进程,zebra和bgpd。 zebra是核心进程,用于更新内核的路由规则,而bgpd是负责bgp协议工作的进程。
先找到bgpd的PID,用strace看一下能否找到和md5签名相关的调用。
# strace -p 9192
****
setsockopt(15, SOL_TCP, TCP_MD5SIG, "\2\0\0\0\n\0\0\347\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"..., 216) = 0
capset({version=_LINUX_CAPABILITY_VERSION_3, pid=0}, {effective=0, permitted=1<<CAP_NET_BIND_SERVICE|1<<CAP_NET_BROADCAST|1<<CAP_NET_ADMIN|1<<CAP_NET_RAW, inheritable=0}) = 0
fcntl(15, F_GETFL) = 0x2 (flags O_RDWR)
fcntl(15, F_SETFL, O_RDWR|O_NONBLOCK) = 0
connect(15, {sa_family=AF_INET, sin_port=htons(179), sin_addr=inet_addr("10.0.0.231")}, 16) = -1 EINPROGRESS (Operation now in progress)
可以看到setsockopt设置了TCP_MD5SIG,这代表本端配置了MD5校验。不过这个是系统调用,我是想找到内核函数怎么处理的。tracepoint是针对一些系统调用,这些调用是内核代码中定义好的。
# perf trace --event 'net:*' -p 9192
这个命令没有输出内容。
既然这样,需要看一下到底调用了哪些内核函数,这个时候可以使用kprobes。
kprobes允许用户定义回调函数,然后在内核函数中动态地插入探测点,当内核执行到指定的探测函数时,会调用该回调函数,用户即可收集所需的信息,同时内核最后还会回到原本的正常执行流程。
在eBPF中,也可以使用kprobe,在指定内核函数入口插入探测点,并执行用户定义的eBPF程序。
BCC是一个框架,它允许用户编写python程序,并将eBPF程序嵌入其中。该框架主要用于应用程序和系统的分析/跟踪等场景,其中eBPF程序用于收集统计数据或生成事件,而用户空间中的对应程序收集数据并以易理解的形式展示。运行python程序将生成eBPF字节码并将其加载到内核中。
因此下一步就编写python程序,跟踪相关的内核函数并打印。
为了找到相关的函数翻了一下源码,是tcp_v4_inbound_md5_hash这个函数负责tcp选项里的md5签名校验。
tcp_v4_inbound_md5_hash 中会首先查询是否期望对方发送md5签名,这是tcp_md5_do_lookup函数来做的; 其次会解析数据包中是否有这个md5签名的选项,这是tcp_parse_md5sig_option函数来做的。之后,会出现以下五种情况:
- 本端不期望md5签名,对端没有携带md5签名。
- 本端不期望md5签名,对端携带了md5签名。
- 本端期望md5签名,对端没有携带md5签名。
- 本端期望md5签名,对端携带了md5签名,但是校验md5签名没有通过。
- 本端期望md5签名,对端携带了md5签名,校验md5签名通过。
情况1和5是正常的情况。 2,3和4会使得对应的计数器+1,并且4还会打印日志。
BCC代码编写过程
tcp_v4_inbound_md5_hash 函数传入了sk和skb两个指针,从skb中获取数据包的IP地址信息和端口号信息。
tcp_v4_inbound_md5_hash内部还会调用 tcp_md5_do_lookup 和 tcp_parse_md5sig_option 这两个函数,拿到返回后去做判断。因此给这两个函数加kretprobe,获取其返回值。我曾经在两个不同的内核版本中运行过bcc程序,4.14版本一切正常,5.10版本会告诉我找不到tcp_md5_do_lookup探针,因此查了一下探针改为了__tcp_md5_do_lookup。
cannot attach kprobe, probe entry may not exist
[root@ip-10-0-0-50 ec2-user]# bpftrace -l "kretprobe:*" | grep tcp_md5_do_lookup
kretprobe:__tcp_md5_do_lookup
kretprobe:tcp_md5_do_lookup_exact
__tcp_md5_do_lookup
这些信息拿到后,传输给python程序进行进一步处理和输出。
tcp_v4_inbound_md5_hash这个函数是对于收到每一个TCP报文都会调用一次,而我只打算调试BGP的报文,因此需要对输出的内容做一下过滤,对于输出,原本是使用bpf_trace_printk,但它最多只能传三个参数,并且还有类型的限制。因此改用BPF_PERF_OUTPUT来输出。
运行效果
在Quagga的上配置BGP md5,但Cisco上没有配置md5,在Quagga上运行这个程序,可以得到下面的输出。
[root@ip-10-0-0-50 ec2-user]# python3 tcp.py
TIME(s) COMM PID SADDR DADDR SPORT DPORT
No MD5 digest
12.836720539 swapper/1 0 10.0.0.231 10.0.0.50 23876 179
No MD5 digest
13.601122181 swapper/0 0 10.0.0.231 10.0.0.50 179 58978
No MD5 digest
附上最终代码:
from __future__ import print_function
from bcc import BPF
from bcc.utils import printb
import socket
import struct
from ctypes import *
bpf_text = """
#include <uapi/linux/ptrace.h>
#include <net/sock.h>
#include <bcc/proto.h>
#include <uapi/linux/icmp.h>
#include <linux/icmp.h>
#include <uapi/linux/ip.h>
#include <linux/ip.h>
#include <linux/tcp.h>
BPF_HASH(expect, u32);
BPF_HASH(location, u32);
struct pktdata{
u32 pid;
u64 ts;
char comm[TASK_COMM_LEN];
u16 sport;
u16 dport;
u32 saddr;
u32 daddr;
};
BPF_PERF_OUTPUT(events);
int tcp_md5(struct pt_regs *ctx,struct sock *sk, struct sk_buff *skb)
{
struct pktdata data = {};
data.pid = bpf_get_current_pid_tgid();
data.ts = bpf_ktime_get_ns();
bpf_get_current_comm(&data.comm,sizeof(data.comm));
struct tcphdr *tcp = (struct tcphdr *)(skb->data);
data.sport = bpf_htons(tcp->source);
data.dport = bpf_htons(tcp->dest);
struct iphdr *iph = (struct iphdr *)(skb->head + skb->network_header);
data.saddr = bpf_htonl(iph->saddr);
data.daddr = bpf_htonl(iph->daddr);
expect.update(&data.pid, &data.ts);
location.update(&data.pid, &data.ts);
events.perf_submit(ctx,&data,sizeof(data));
return 0;
};
int tcp_md5_do_lookup_return(struct pt_regs *ctx)
{
unsigned long long ret = PT_REGS_RC(ctx);
u32 pid = bpf_get_current_pid_tgid();
expect.update(&pid, &ret);
return 0;
};
int tcp_parse_md5sig_option_return(struct pt_regs *ctx)
{
unsigned long long ret = PT_REGS_RC(ctx);
u32 pid = bpf_get_current_pid_tgid();
location.update(&pid, &ret);
return 0;
};
"""
# initialize BPF
b = BPF(text=bpf_text)
b.attach_kprobe(event="tcp_v4_inbound_md5_hash", fn_name="tcp_md5")
b.attach_kretprobe(event = "__tcp_md5_do_lookup", fn_name = "tcp_md5_do_lookup_return")
b.attach_kretprobe(event = "tcp_parse_md5sig_option", fn_name = "tcp_parse_md5sig_option_return")
print("%-18s %-16s %-6s %-6s %-6s %-6s %-6s" % ("TIME(s)","COMM","PID","SADDR", "DADDR","SPORT", "DPORT"))
#end format output
expect = b["expect"]
location = b["location"]
start = 0
def print_event(cpu,data,size):
global start
event = b["events"].event(data)
if start == 0:
start = event.ts
time_s = (float(event.ts - start)) / 1000000000
# Convert IP from numeric to string.
sipstr = socket.inet_ntoa(struct.pack('>I', event.saddr))
dipstr = socket.inet_ntoa(struct.pack('>I', event.daddr))
if ((sipstr == "10.0.0.231" or dipstr == "10.0.0.231") and (event.sport == 179 or event.dport == 179)):
# Convert IP from string to bytes for printb
sip = sipstr.encode('utf-8')
dip = dipstr.encode('utf-8')
try:
for k,v in location.items():
if int(expect[k].value) != 0 and int(location[k].value)==0:
print("No MD5 digest")
printb(b"%-18.9f %-16s %-6d %-15s %-15s %-6d %-6d" % (time_s,event.comm,event.pid,sip, dip,event.sport,event.dport))
except Exception as e:
print(e)
exit()
b["events"].open_perf_buffer(print_event)
exiting = False
while not exiting:
try:
b.perf_buffer_poll()
except KeyboardInterrupt:
exiting = True