
前几天一个做网络监控的朋友火急火燎地找到我说自己在socket上挂了一个eBPF的sk_filter过滤程序本来只是想抓一下访问来源的IP地址结果程序一挂上去业务进程直接就崩了内核日志里还刷了一堆看不懂的报错。我让他把代码发过来扫了一眼发现问题根本不在eBPF而是出在对IP地址的读取和格式化的理解上。这类“读IP地址翻车”的案例我在实际项目里见过太多次了。今天把这个案例完整拆一遍sk_filter里怎么安全读IP、为什么一个看似简单的地址读取会触发崩溃以及一套真正稳的写法长什么样。这篇文章适合正在折腾eBPF、尤其是刚接触socket filter的同学看。你也许已经会用bpf_printk打印一些调试信息但一涉及到skb-data、data_end、网络字节序和用户态格式化就容易掉坑。我会把原理、代码、踩坑过程都铺开讲尽量让你看完之后能直接照着重写自己的程序。1. 案例背景一次“抓IP”需求引发的连锁故障1.1 业务需求与最初方案当时朋友的需求很简单公司有个网关程序需要记录每个外连请求的来源IP。常规做法无非是改业务代码在accept之后用getpeername拿一遍IP。但他不想动业务代码也不想引入防火墙、tcpdump之类的旁路组件因为要跨好几个模块维护。于是想到了eBPF想把过滤逻辑送到内核态在socket层看到每个包的时候就把IP记录下来。选型的时候他考虑了XDP和TC但XDP太靠前对硬件和网卡驱动有要求而且拿到的数据还要重新解析TC hook倒是通用但要绑定到qdisc上部署起来相对繁琐。而sk_filter正好长在socket接收路径上系统在把数据包交给应用层之前会先跑一段挂载在socket上的BPF程序天然适合做统计、拦截和过滤。加上它可以用SO_ATTACH_BPF方便地挂到一个已经存在的fd上对业务进程侵入性非常小所以就定了这条路。想法本身没问题问题出在实现上。他照着网上一些旧博客写了个BPF程序直接解引用skb-data去拿IP头然后把读到的地址塞进map。代码短是短但一挂上去先是BPF程序加载失败再然后业务进程开始大批量报错最后直接崩了。他一度怀疑是eBPF把内核搞崩了实际上内核好好的症结都在用户态和BPF边界处理上。1.2 现象与第一波排查先说说他看到的表面现象。第一次加载BPF对象时bpftool prog load直接报错内核verifier给出了一段类似invalid access to packet, off14 size20的日志。他以为是自己环境里的内核版本太老或者工具链不兼容换了好几个内核版本都没解决。这个报错其实非常典型就是BPF程序尝试访问一个越出数据包范围的地址被安全机制拦下来了而不是让机器崩溃。等他把边界检查加上、程序能加载之后又出现了新问题。BPF程序不扔包了但业务进程开始各种断连最后用户态程序直接段错误退出。用gdb看core dump发现崩溃指令是在printf附近被打印的指针值是一串跟IP地址毫无关系的数。到这里他才意识到eBPF程序本身并没有直接“崩”是用户态代码把IP地址当成了一个内存指针去读字符串才炸了锅。整个过程其实分成了两段第一段是BPF程序没有通过verifier属于代码安全性问题第二段是用户态处理数据不正确属于数据解释问题。这两个问题在eBPF开发里都非常典型很多人会把它们混在一起越查越乱。下面我先把原理拆开再给你一套能直接落地的完整写法。2. sk_filter读IP地址的底层逻辑和三大暗雷2.1 先搞明白sk_filter在哪儿执行sk_filter是历史遗留的名字现在内核里的正式名称是BPF_PROG_TYPE_SOCKET_FILTER也就是socket类型的BPF程序。它通过SO_ATTACH_BPF挂载到某个socket上在协议栈把数据包交给应用进程之前执行。程序返回0表示丢弃这个包返回一个正数表示放行并且可以指定拷贝给用户态的长度。所以我们写的filter程序要么返回skb-len要么返回0绝对不能随便返回个数字。这类程序拿到的是struct __sk_buff *skb它是内核sk_buff结构体对外暴露的轻量视图。这里最关键的两个字段是data和data_end分别指向当前数据包在内存里的起始位置和结束位置。很多第一次接触eBPF的同学会忽略一个关键点skb-data指向的是哪个协议层取决于你挂在哪类socket上。如果挂在AF_PACKET raw socket上data通常指向链路层头也就是以太网头的起始位置如果挂在AF_INET、AF_INET6这类高层socket上data往往已经指向网络层头比如IP头。这个差异会让同一套代码在一种场景下跑得好好的换到另一种场景就出错。网上不少老旧教程默认所有场景都从以太网头开始解析实际上并不通用。我在下面的标准代码里先按AF_PACKET场景写同时会给出调整方法。2.2 暗雷一忘记做边界检查被verifier一巴掌拍死eBPF最大的特点就是安全而安全的根基是内核verifier。它会静态分析你写的每一条指令确认程序不会访问非法内存之后才允许加载。在socket filter里verifier允许你直接读skb-data到skb-data_end范围内的数据但前提是它能通过你代码里的指针比较证明访问偏移没有越过data_end。很多人写出来的代码是这样的SEC(socket) int wrong_skfilter(struct __sk_buff *skb) { void *data (void *)(unsigned long)skb-data; struct ethhdr *eth (struct ethhdr *)data; struct iphdr *ip (struct iphdr *)(eth 1); if (eth-h_proto htons(ETH_P_IP)) { __u32 saddr ip-saddr; bpf_printk(src ip value: %x\n, saddr); } return skb-len; }看起来逻辑很简单先拿以太网头再跳到IP头读取源地址。但它有两个问题。第一程序访问eth-h_proto之前没有检查data sizeof(struct ethhdr) data_end也就是说连以太网头都不一定有20多个字节。第二即使以太网头够长也不能保证后面的IP头完整存在。verifier在静态分析时会发现ip-saddr这个偏移可能越过data_end于是直接拒绝加载报错就像开头说的那样。这不是“崩溃”而是eBPF的安全机制在保护整个系统。如果你的目标是让程序通过verifier那就必须用显式的长度比较告诉它我访问的范围是安全的。后面的正确代码里我会展示具体写法。2.3 暗雷二把非IPv4包当IP包解析即使加上了边界检查还有一个让人头疼的问题sk_filter能看到的流量不只有IPv4。以太网帧里还跑着ARP、IPv6、LLDP、VLAN Tag等各种各样的协议。如果只判断“这是不是IPv4”就往下按struct iphdr去解析很可能把一个ARP包当成IP包读出来的地址完全是垃圾。更麻烦的是如果链路上有802.1Q VLAN Tag那么真实的IP头不是从data 14开始而是从data 18开始。如果代码没有处理VLANeth-h_proto读到的可能是0x8100之后的所有解析全都偏了。对于这类场景我建议优先使用内核帮你维护好的skb-protocol字段来做网络层协议判断而不是只依赖手工读取以太网头因为内核处理VLAN剥离之后会把skb-protocol更新成真正的三层协议类型。在标准代码里我会先用eth-h_proto判断一次但实际项目里你还要考虑VLAN和协议层的差异。判断完协议之后还要再检查一次IP头长度是否真的够才允许读取ip-saddr。每一步都加检查看起来啰嗦但这是让verifier通过、避免运行时错误的根本保障。2.4 暗雷三网络字节序和用户态格式化是隐藏崩溃源第三个暗雷也是朋友案例里业务进程真正崩掉的原因跟eBPF关系不大但同样隐蔽。IP地址在报文里是网络字节序也就是大端顺序。你从ip-saddr读出来的是一个四字节整数比如127.0.0.1在内存里对应的字节是7f 00 00 01。把这个整数塞进map再拿到用户态很多人的第一反应是__u32 addr; bpf_map_lookup_elem(map_fd, key, addr); printf(ip is %s\n, (char *)addr);这个写法把addr的地址当成一个指向以\0结尾字符串的指针。但addr本身是一个数值比如0x0100007f那么这个指针指向的是地址0x0100007f这块内存根本不属于你的进程printf一访问就直接段错误。正确的做法是使用标准库的inet_ntop传入一个指向网络字节序地址的指针让它帮你转换成点分十进制字符串。这个函数天生就是按网络序来解析的不需要手动转换。如果你非要用printf(%d.%d.%d.%d, ...)自己拼字符串也要记得按字节指针顺序读取而不是把一个整型随便移位。此外在BPF程序内部bpf_printk这类辅助函数对格式化字符串有严格限制。你想用类似%pI4这样的内核专用格式化符来打印IP在很多内核版本上是不支持的甚至会导致程序加载失败。最稳妥的方式是内核侧把原始四字节网络序地址写进map用户态拿到后再格式化。3. 一个能跑稳的完整方案从BPF到用户态3.1 环境准备与编译骨架要跑下面的代码你需要一个内核版本不低于5.x的Linux环境推荐至少5.10以上并开启CONFIG_BPF和CONFIG_BPF_SYSCALL。编译工具方面我会用clang、libbpf和bpftool组合。这里以我实测过的环境为例内核5.15.0clang 12.0libbpf 1.0bpftool 7.0。编译一条命令就够clang -O2 -g -target bpf -c filter_ip_kern.c -o filter_ip_kern.o然后加载到内核可以使用bpftool也可以写一个简单的用户态loader。为了把SO_ATTACH_BPF的完整流程讲清楚我会写几段关键代码片段。3.2 BPF侧代码边界、协议、地址提取先给出一个能用的BPF程序。它的功能是从每个经过socket的IPv4数据包中把源IP地址写进一个array map。#include linux/bpf.h #include linux/if_ether.h #include linux/ip.h #include bpf/bpf_helpers.h #include bpf/bpf_endian.h struct { __uint(type, BPF_MAP_TYPE_ARRAY); __uint(max_entries, 1); __type(key, __u32); __type(value, __u32); } src_ip_map SEC(.maps); SEC(socket) int filter_ip(struct __sk_buff *skb) { __u8 *data (__u8 *)(unsigned long)skb-data; __u8 *data_end (__u8 *)(unsigned long)skb-data_end; if (data sizeof(struct ethhdr) data_end) return skb-len; struct ethhdr *eth (struct ethhdr *)data; if (eth-h_proto ! bpf_htons(ETH_P_IP)) return skb-len; struct iphdr *ip (struct iphdr *)(data sizeof(struct ethhdr)); if ((__u8 *)ip sizeof(struct iphdr) data_end) return skb-len; __u32 key 0; __u32 saddr ip-saddr; bpf_map_update_elem(src_ip_map, key, saddr, BPF_ANY); return skb-len; } char LICENSE[] SEC(license) Dual BSD/GPL;逐段看。先取data和data_end这是后面所有指针比较的基础。然后第一层检查data指针到以太网头末尾是否越界。这里不是判断包必须有多长而是防止极小包导致结构体访问越界。接着检查以太网协议类型是不是IPv4如果不是就直接放行。第三个检查是重点在把data 14强转成struct iphdr *之后还要检查IP头是否完整。因为以太网头之后至少还有20字节才是iphdr的完整范围但以太网帧里完全可能只有14字节加几字节的零碎数据。这一步让verifier知道后续读取ip-saddr是在data_end范围之内的。还有一个细节必须强调所有提前返回的地方返回值都是skb-len不是0。我这个程序只做记录和统计不做拦截所以无论协议是否匹配、包是否太短都要把它放行。如果你在某个分支返回0那就等于把这个包丢掉了对上层业务影响会非常大。朋友的业务进程最开始出现的断连问题就是因为他把非IPv4包直接return 0了。3.3 用户态读取与IP格式转换有了BPF程序接下来是用户态loader。关键流程分四步打开并加载BPF对象、找到map fd、把程序挂到socket上、最后从map里取值并格式化IP。struct bpf_object *obj; struct bpf_program *prog; struct bpf_map *map; int prog_fd, map_fd, sock; obj bpf_object__open_file(filter_ip_kern.o, NULL); bpf_object__load(obj); prog bpf_object__find_program_by_name(obj, filter_ip); prog_fd bpf_program__fd(prog); map bpf_object__find_map_by_name(obj, src_ip_map); map_fd bpf_map__fd(map); sock socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)); setsockopt(sock, SOL_SOCKET, SO_ATTACH_BPF, prog_fd, sizeof(prog_fd));这里我以AF_PACKET raw socket为例因为这样能抓到完整的链路层数据和上面BPF程序里用ethhdr的假设一致。如果你是挂到普通TCP socket上需要把BPF程序里的以太网头解析去掉直接从IP头开始读这个我在后面问题梳理里会说。从map取值的时候addr保存的是网络字节序的地址原始值。下面这段是正确格式化方式__u32 key 0; __u32 addr; char ip_str[INET_ADDRSTRLEN]; if (bpf_map_lookup_elem(map_fd, key, addr) 0) { inet_ntop(AF_INET, addr, ip_str, sizeof(ip_str)); printf(source ip: %s\n, ip_str); }inet_ntop解决了我前面提到的段错误问题。它接收的是一个指向二进制地址的指针而不是一个整型变量本身。这也是整个案例最容易崩的地方把它修改掉业务进程就再也没出现过地址相关的core dump了。3.4 验证结果与性能影响把程序加载并挂到一个持续有流量的socket上之后再用bpftool map dump看一下map内容bpftool map dump name src_ip_map [ { key: [0x00000000 ], value: [0x0100007f ] } ]value的值在终端里显示为十六进制看着不像IP但这是网络序的内存表示。通过用户态loader打印出来就能看到127.0.0.1这样的点分十进制。如果你只跑内核BPF程序、不想写用户态代码也可以用bpftool或bpftrace直接读取map但格式化仍然需要你在脑内换算字节序。性能方面我实测对比了挂载前后的socket收发延迟和吞吐。挂载这个只做读地址、什么都不改的filter对正常业务流量的影响很小在单包固定数据量的测试场景下延迟增加基本在微秒级吞吐下降也就在百分之一左右。eBPF的优势就在这里你把观察点放进了内核却不需要复制整包数据到用户态省掉的拷贝往往比filter本身的代价大得多。4. 常见问题排查与避坑清单4.1 加载失败并提示invalid access to packet这是新手最常遇到的verifier报错。它的含义很明确BPF程序访问了数据包之外的内存。解决办法是回到代码里检查所有直接解引用skb-data偏移的位置是否都先做过data offset length data_end的判断。我在调试时会写一个专门检查边界的辅助宏避免手滑#define CHECK_RANGE(ptr, len, end) \ if ((__u8 *)(ptr) (len) (__u8 *)(end)) return skb-len;然后每个结构体访问前都调用一次。这样做虽然代码看起来变长了但verifier通过率非常高。记住eBPF verifier不做全局变量推导它需要看到你同一段代码里明确的指针比较逻辑。不要试图通过循环或者复杂指针运算来“蒙混过关”它只会认为你的访问范围不可证明。4.2 流量抓到了但IP完全不对IP显示成0.0.0.0或者一堆乱码通常有三个原因。第一协议判断不完整把非IPv4包当成IPv4解析了。第二data指针指向的协议层和代码假设不一致比如你挂在AF_INET socket上程序却按以太网头解析导致偏移全部错位。第三字节序没有处理好拿到的是网络序整数用户态却没有按网络序格式化。针对不同挂载点我整理了一个快速对照表挂载socket类型skb-data实际指向位置IP头偏移AF_PACKET / tcpdump链路层头以太网头data 14无VLANAF_INET / AF_INET6网络层头IP头dataTC / XDP hook因网卡模式和协议栈位置不同而变化需额外验证表里AF_INET/AF_INET6的结论在大多数正常流程下成立但你如果碰到带tunnel、veth等复杂环境仍要以实际数据为准。最直接的验证方式是在BPF程序里先用bpf_printk打一份skb-len和data的前几个字节和tcpdump抓到的包对比一下就能看出偏移差在哪里。4.3 用户态进程一跑就段错误如果你确认BPF程序工作正常map里也有值但用户态程序一读取就崩几乎可以肯定你处理IP地址的方式不对。比较常见的错误就是直接把addr整数当作字符串指针。一个容易踩的现象是addr数值比较小的时候比如1.2.3.4的整型值是0x01020304printf(%s, addr)会尝试读取地址0x01020304这在用户态进程的地址空间里通常是非法地址直接段错误。如果数值碰巧落在某个合法地址附近它可能会打印出一堆随机字节甚至不崩但数据必然错误。总之凡是要把二进制IP地址变成点分字符串就老老实实用inet_ntop或者逐字节打印不要用%s去打印一个整数。另外还有一个隐蔽点BPF map的value大小。如果你在map定义里把value类型定义成__u16或者一个太小的结构体而在用户态用__u32去读内核会返回空间不足错误或者更糟读到部分数据。务必保证map的value定义和用户态读写的类型完全一致。4.4 调试工具组合与实战经验最后分享一套我自己用的调试组合拳。加载BPF程序的时候一定要打开verifier日志bpftool prog load filter_ip_kern.o /sys/fs/bpf/filter_ip log_level 2这样你能看到完整的拒绝原因而不是只有一个笼统的Operation not permitted。程序加载成功后可以用bpftool prog list确认程序状态用bpftool map dump确认map数据用cat /sys/kernel/debug/tracing/trace_pipe观察bpf_printk输出。如果遇到用户态core dump先不要怀疑BPF用gdb看崩溃栈。我那个朋友最开始一直以为是eBPF把内核搞崩了结果gdb定位到printf之后才恍然大悟。这个排查过程也提醒我eBPF开发里内核侧和用户侧要分开排查不要混为一谈。BPF程序被verifier拒绝、BPF程序运行出错、用户态读取数据崩溃这三类问题各有各的日志和技巧。我个人在实际操作中的体会是eBPF确实不会因为你读了个IP地址就把内核搞崩它强大的验证器会把绝大多数危险操作挡在加载阶段。真正会崩的往往是用户态代码对数据语义理解不到位或者是BPF程序返回了不该返回的值导致业务链路断裂。遇到“崩了”的情况先别急着看内核日志静下心把“数据从内核到用户态的流转”每一环过一遍问题一般都能快速定位。后面再写类似功能我建议直接把边界检查、协议判断、字节序转换这三个点当成模板固化下来能省掉很多弯路。