☰
sk_filter读取IP地址崩溃复盘:BPF偏移与VLAN陷阱
2026/10/11 18:41:45 网站建设 项目流程

那天下午,内部群里突然甩进来一条消息:“sk_filter 读 IP 的 BPF 上线之后,采集服务崩了,连接也断了一批,谁看下?”我第一反应是“这又是哪个程序没校验 helper 返回值”,结果拉上内核日志和用户态 core 一看,问题比想象中深一点:一个看起来人畜无害的 socket filter,在特定报文下读出来的“IP 地址”根本不是 IP 地址,而是被错位解析后的垃圾数据,一路传到了用户态,直接把哈希表相关逻辑打崩了。

这篇文章就把整个事故从现象到根因、再到修复和回归的链路完整复盘一遍。当时我们把这类 BPF 程序叫“sk_filter 读取 IP 地址崩溃”,排查完才发现,根子不在于 eBPF 本身有多危险,而在于我们对 socket filter 的执行上下文和报文锚点理解不够。适合刚开始写BPF_PROG_TYPE_SOCKET_FILTER的开发者,以及被“BPF 程序导致线上故障”这类问题折磨过的排查人员。文章里我会把正确的偏移算法、可复现的测试矩阵和几条保命经验都写出来,尽量让你少走一圈弯路。

1. sk_filter 不是你想的“一个过滤器”那么简单

先回到基础:sk_filter在 Linux 网络栈里的真实身份,是挂在 socket 上的 eBPF 程序,类型是BPF_PROG_TYPE_SOCKET_FILTER,用户态通过setsockopt(fd, SOL_SOCKET, SO_ATTACH_BPF, &prog_fd, sizeof(prog_fd))挂上去。挂上之后,这个 socket 收到的每一个报文,都会在协议栈内部经过这段 BPF 逻辑,返回值决定了报文是被放行还是被丢弃。

这里有一个九十年代就存在的经典用法:tcpdump 的经典 BPF 过滤表达式,底层就是这类 socket filter 的 cBPF 形态。现在换成 eBPF 写,能做的事更多了,比如按 IP 黑白名单过滤、按四元组做流量统计、在 socket 层做轻量观测。听起来很简单,但问题恰恰出在“轻量”两个字上——它运行在软中断上下文里,不能睡眠,不能调用大部分内核函数,你能碰的只有 helper 函数和一块 512 字节的栈空间。

1.1__sk_buff和struct sk_buff不是同一个东西

新手最容易踩的坑,就是把 eBPF 程序入口参数里的struct __sk_buff *skb当成内核里那个完整的struct sk_buff。这是两个完全不同的结构体。__sk_buff是一个面向 BPF 的字段投影,只暴露len、data、data_end、mark、protocol、cb等少量字段。你在程序里写skb->data,得到的其实是一个偏移量/地址数值,不是可以直接解引用的真实指针。

很多线上“崩溃”案例的起点就在这里:有人写了类似char *data = (char *)skb->data; struct iphdr *ip = (struct iphdr *)(data + 14);的代码。在部分程序类型下 verifier 会直接拒绝这种直接解引用,但在某些上下文或者某些内核版本里,你侥幸通过了加载,运行时的行为却和“解引用一个不可控地址”没有本质区别。真正的稳定写法是调用bpf_skb_load_bytes这类 helper 把报文内容拷贝到栈上,而不是拿data去做指针运算。

1.2 “data 指向哪”直接决定你的 IP 偏移从几开始

这次事故里最隐蔽的问题,是大家都默认“offset 0 就是以太网头”。但 socket filter 是挂在 socket 层面的,报文的skb->data到底指向哪一层,取决于你挂的是哪种 socket,以及在协议栈哪个处理阶段被调用。

我整理了一张简化对照表,这也是排查时的第一张地图:

挂载场景skb->data 锚点读 IPv4 源地址的偏移
AF_PACKET / SOCK_RAW socketL2 以太网头14(以太网头) + 12(IP 头内 saddr 偏移)
AF_INET / SOCK_STREAM(TCP socket)多数情况已到 L3/L4 交界,锚点不稳定需要以实际抓包为准,不能写死
XDP 程序(旁路参考)L2 以太网头14 + 12
tc 程序(旁路参考)L2 以太网头视bpf_skb_load_bytes的 offset 语义而定

注意 AF_INET 那行,别以为“AF_INET 下 offset 0 就是 IP 头”。在 TCP 收包路径上,调用 socket filter 时 skb 可能已经被 IP 层剥掉了部分头部,skb->data不一定指着 IP 头起点。这也就是为什么同一个“读 IP 地址”的 BPF 程序,在 A 环境正常、在 B 环境读出来的是一堆错位字节。

1.3 协议栈对 VLAN 的处理,让“固定 14 字节”彻底失效

另一个隐患是 VLAN。很多人代码里写死ETH_HLEN = 14,觉得以太网头永远是 14 字节。实际上带 802.1Q 标签的帧,L2 头是 18 字节。但更坑的是:协议栈在收包时经常已经把 VLAN tag 剥离出来放进了skb->vlan_tci字段,此时skb->data里并没有 VLAN 头;而在某些 offload 未开启或驱动路径特殊的情况下,VLAN 头又真实存在。于是同一个过滤器,在这个网卡上偏移 14 能读到 IP,换个网卡偏移 14 就读到了 VLAN tag 后的错误字节。

业务代码不能只写ETH_HLEN,至少得判断skb->protocol == ETH_P_8021Q或者用bpf_skb_load_bytes先读前两个字节看是不是 VLAN 协议号。当时事故现场的过滤器就是这里漏了,后面会展开。

2. 那起“读取 IP 地址导致崩溃”的事故复盘

2.1 现象:不是必现,是特定报文触发

事故的直接影响是采集服务崩溃,同时一堆业务连接异常。第一轮排查时,所有人都盯着 dmesg 看有没有内核 Oops,结果内核日志里干干净净,没有 panic,也没有 memory fault。这就排除了“BPF 程序直接把内核搞崩”的剧本。

随后用户态 core 文件的栈回溯显示,崩溃点在哈希表查找函数里,传入的 key 明显不是一个正常四元组:端口号大得离谱,IP 地址也有 0xffffffff 这种值。再往上游看,这个 key 来自 BPF 程序通过 ring buffer 上报的报文摘要。也就是说,BPF 程序把自己解析出来的“源 IP、源端口、目的 IP、目的端口”打包发给用户态,用户态拿这个摘要做哈希统计,哈希表实现在异常 key 下触发了段错误。

提示:BPF 程序里“读出来一个数”和“读出来一个正确的数”之间隔着一整套协议偏移规则。这一层错了,崩溃往往发生在下游消费者身上。

2.2 用 bpftool 反向定位到具体 BPF 指令

因为崩溃点不在内核,先看 BPF 程序本身。确认加载状态:

bpftool prog list | grep socket

找到对应程序 ID 后,导出它的指令级代码和 JIT 后的汇编:

bpftool prog dump xlated id <id> bpftool prog dump jited id <id>

xlated 输出的是 eBPF 字节码翻译后的指令,能看到每条指令对应的 C 代码行号(前提是编译时保留了 BTF 和调试信息)。我们很快发现一个可疑分支:程序在读 IP 头之前,只判断了“偏移 14 处是不是 0x0800(IPv4)”,如果读到的前两字节不是 IPv4,就直接返回 0 放行。看起来没问题,但问题在于“偏移 14”在带 VLAN 的帧上读到的根本不是协议号,而是 VLAN 头里的一部分。

把现场报文的协议号打印出来之后,确认了触发条件:流量里混入了一批带 802.1Q 标签的报文,程序按 14 字节偏移去读“以太网类型”,读到的值落在了 VLAN 头内部,于是走了错误的分支。坏地址、坏端口全部由此产生。

2.3 三层错误叠加,才最终酿成“崩溃”

复盘下来,这是一个三层错误叠加的典型事故:

  1. 第一层:锚点判断错误。程序挂在 AF_PACKET 的 raw socket 上,skb->data指向 L2 头,代码却用了一个从 AF_INET socket 场景抄来的偏移逻辑。
  2. 第二层:VLAN 处理缺失。即使锚点没错,写死 14 字节也无法应对 802.1Q 标签帧,协议号读取直接错位。
  3. 第三层:helper 返回值被忽略。bpf_skb_load_bytes读取失败时返回负数,代码没检查,继续使用栈上的旧值或未初始化区域,最终把垃圾数据当成 IP 地址传了出去。

这三个问题单独拎出来,每一个都不至于让进程崩溃。但叠加在一起,就变成了“BPF 给用户态喂毒,用户态无力反抗”。这也是我后来在团队里反复强调的一句话:eBPF 程序里的每一次读取失败,都是给下游消费者埋雷。

2.4 错误代码长什么样

给出一段高度还原事故现场的简化代码:

SEC("socket") int filter_wrong(struct __sk_buff *skb) { __u16 proto; __u32 saddr; __u32 sport; long ret; // 错误一:假定 skb->data 一定指向 L2 头,且以太网类型固定偏移 14 ret = bpf_skb_load_bytes(skb, 14, &proto, 2); if (ret < 0) return 0; // 这里没有处理 VLAN,也没有处理字节序 if (proto != 0x0008) // 本意是 ETH_P_IP,但字节序和偏移都错了 return 0; // 错误二:忽略 14 字节 L2 头只对纯 IPv4 成立 ret = bpf_skb_load_bytes(skb, 14 + 12, &saddr, 4); ret = bpf_skb_load_bytes(skb, 14 + 20, &sport, 2); // 错误三:没有检查 ret,saddr/sport 可能完全没被更新 // 直接把 saddr/sport 拼进 ringbuf 事件 struct event e = {}; e.saddr = saddr; e.sport = sport; bpf_ringbuf_output(&events, &e, sizeof(e), 0); return 0; }

这段代码里有三个典型气味:固定偏移、忽略返回值、没处理协议版本。后面我们逐一拆掉。

3. 从崩溃现场反推:IP 头偏移到底该怎么算

3.1 第一步:确定你的锚点

不要猜,不要抄。在 BPF 程序里用bpf_skb_load_bytes读第一个字节,直接看内容推断当前锚点是最稳的:

  • 如果第一字节高四位是0x4,说明skb->data已经指向 IPv4 头。
  • 如果第一字节高四位是0x6,说明已经指向 IPv6 头。
  • 如果前 6 字节看起来像 MAC 地址(例如0x开头但高四位是0x3、0x0等混合值),说明还停在 L2。

更可靠的做法是在加载前就把挂载场景定死,并在代码注释里写明。比如我们这次最终决定统一挂在 AF_PACKET raw socket,那么锚点固定为 L2,所有偏移都从以太网头开始算。

3.2 第二步:兼容 IPv4 / IPv6 / VLAN 的动态偏移

一份能过 verifier 并且能扛住边界报文的解析逻辑,大致长这样。注释里标注了每一步“为什么这么写”。

#include <linux/bpf.h> #include <linux/if_ether.h> #include <linux/ip.h> #include <linux/ipv6.h> #include <bpf/bpf_helpers.h> #define ETH_P_8021Q 0x8100 SEC("socket") int filter_ip(struct __sk_buff *skb) { __u8 buf[sizeof(struct ipv6hdr)]; __u16 proto; __u64 offset = 0; long ret; // 锚点:固定挂 AF_PACKET,skb->data 指向 L2 头 // 先读以太网头里的协议类型字段 ret = bpf_skb_load_bytes(skb, 12, &proto, 2); if (ret < 0) return 0; // 字节序统一转为主机序再比较 proto = bpf_ntohs(proto); // 处理 VLAN 标签:如果是 802.1Q,以太网类型字段往后挪 4 字节 if (proto == ETH_P_8021Q) { ret = bpf_skb_load_bytes(skb, 12 + 4, &proto, 2); if (ret < 0) return 0; proto = bpf_ntohs(proto); offset = 4; // 后续 L3 头起点多了 4 字节 } if (proto == ETH_P_IP) { // IPv4: L3 头从 L2 + 14 + offset 开始 // 读第一个字节:高 4 位版本号,低 4 位 IHL __u8 vihl; ret = bpf_skb_load_bytes(skb, 14 + offset + 0, &vihl, 1); if (ret < 0) return 0; // IHL 单位是 4 字节,至少是 5,最大 15 __u8 ihl = vihl & 0x0f; if (ihl < 5 || ihl > 15) return 0; __u32 saddr, daddr; // saddr 在 IPv4 头内偏移 12,daddr 偏移 16 ret = bpf_skb_load_bytes(skb, 14 + offset + 12, &saddr, 4); if (ret < 0) return 0; ret = bpf_skb_load_bytes(skb, 14 + offset + 16, &daddr, 4); if (ret < 0) return 0; // 这里 saddr/daddr 已经是网络字节序,按需 bpf_ntohl // 做你的事:黑白名单 / 统计 / 上报 return 1; } if (proto == ETH_P_IPV6) { // IPv6 头固定 40 字节 ret = bpf_skb_load_bytes(skb, 14 + offset + 8, buf, 16); if (ret < 0) return 0; // buf 里是源地址 128 bit,按需处理 return 1; } return 1; // 其他协议,放行 }

这段代码解决了两件事:一是 VLAN 标签导致偏移变化;二是 IPv4 头长度可变(IHL 字段),没有再用“固定 20 字节”的假设去读地址。单纯读 saddr/daddr 其实不受 IHL 影响,因为它们在 IPv4 头的前 20 字节内固定位置;但如果你后续要读 TCP/UDP 端口,就必须先算ip_header_len = ihl * 4,再做14 + offset + ip_header_len + 端口偏移,不然带 IP options 的报文会直接读错。

3.3 用bpf_skb_load_bytes而不是裸解引用ctx->data

你可能想问:既然ctx->data可以直接拿到报文地址,为什么非要用 helper 拷到栈上?

原因有三层:

  1. verifier 对 socket filter 的直接数据访问控制很严格。直接data + offset解引用很容易触发invalid mem access拒绝加载,或者需要在代码里维护复杂的data_end边界判断。
  2. 报文可能不是线性存储的。内核 skb 的数据可能分片在多个 page 里,ctx->data只覆盖线性数据区。bpf_skb_load_bytes内部会调用skb_copy_bits这样的函数把数据完整拷贝出来,帮你处理非线性区。
  3. 栈拷贝的开销在 socket filter 场景完全可接受。读取 IP 头最多 40 字节,一次 helper 调用纳秒级,远不至于成为瓶颈。

一句话:helper 是经过验证的安全路径,裸解引用是自己给自己挖坑。能不用就不用。

3.4 别忘了签名和字节序

这次事故还有一个小插曲:代码里if (proto != 0x0008)就是一个典型的字节序错误。以太网类型字段在网络字节序下 IPv4 是0x0800,在小端主机上按__u16直接读出来是0x0008。正确写法是先用bpf_ntohs转成主机序再和ETH_P_IP比较。

同理,IPv4 地址在网络字节序里是“大端”,在 x86 上要用bpf_ntohl才能得到直觉上“1.2.3.4 对应 0x01020304”的值。不要为了省一次 helper 调用而把字节序搞混,否则统计出来全是反的。

4. 边界报文测试矩阵:把“不崩”变成可验证的

修复完代码,上线前必须做回归。我们的做法是构造一批边界报文,逐一验证 BPF 程序解析结果是否和预期一致。这里的“预期”不只是“不崩”,还包括读出来的 IP、端口、协议必须和真实报文一致。

4.1 测试用例设计

用例编号报文特征期望行为实际行为(修复后)
T01普通 IPv4 UDP 报文正确读到 saddr/daddr通过
T02带 802.1Q VLAN 标签的 IPv4 报文正确跳过 VLAN 额外 4 字节通过
T03IPv4 带 8 字节 IP optionssaddr/daddr 仍正确,读端口用动态 IHL通过
T04IPv6 普通报文正确读到 128 位源地址通过
T05ARP 报文走“其他协议放行”分支,不读 IP通过
T06超短畸形报文(长度 < 以太网头 + IP 头)helper 返回负值,程序安全返回通过
T07802.1Q + IPv6 组合正确识别 VLAN 后再识别 IPv6通过

构造这些报文,我一般用 Python 的 scapy 直接发到测试 socket 上,同时用 tcpdump 抓包确认发出去的内容。不需要搞复杂的流量发生器,重点是构造准确。

from scapy.all import Ether, IP, UDP, Dot1Q, IPv6, Raw, sendp # T01 普通 IPv4 UDP pkt = Ether(dst="00:11:22:33:44:55", src="aa:bb:cc:dd:ee:ff") / \ IP(src="1.2.3.4", dst="5.6.7.8") / \ UDP(sport=12345, dport=54321) / Raw(b"test") sendp(pkt, iface="eth0") # T02 带 VLAN 标签的 IPv4 pkt_vlan = Ether(dst="00:11:22:33:44:55", src="aa:bb:cc:dd:ee:ff") / \ Dot1Q(vlan=100) / \ IP(src="1.2.3.4", dst="5.6.7.8") / \ UDP(sport=12345, dport=54321) / Raw(b"vlan-test") sendp(pkt_vlan, iface="eth0")

4.2 用BPF_PROG_TEST_RUN做离线注入

如果不想在真实网卡上发包,eBPF 还提供了离线测试通道:bpftool prog run。它能把一个构造好的 skb 直接喂给某个 BPF 程序执行,适合验证“解析逻辑是否正确”,不需要真实协议栈参与。

bpftool prog run pinned /sys/fs/bpf/filter_ip \ data_in sample_pkt.bin \ data_out out.bin \ repeat 100

data_in是原始报文二进制文件,repeat是重复次数。跑完看返回值和data_out。这个方式特别适合自动化和 CI 集成,把用例矩阵变成脚本的一部分。

4.3 实测中的意外:用户态解析结构体没同步

还有一次让我们“崩”得莫名其妙的问题,和 BPF 本身无关:BPF 程序里struct event新增了一个字段,ringbuf 上报的数据变长了,但用户态程序还在用旧的sizeof(struct event)解析,直接把后续内存越界读爆了。这个属于版本同步问题,但和 BPF 事故混在一起,很容易被误判成“BPF 程序写崩了”。

建议:BPF 程序和用户态共享的结构体,严格定义在一个头文件里,编译时用同一个版本。结构体变更必须编译检查用户态代码,不能只改内核侧。

5. 写 socket filter 的几条工程化建议

事故修完,我把自己踩过的、团队踩过的坑整理成了一张内部检查清单。写得比较直白,每条都是真实教训。

5.1 每个 helper 返回值都必须检查

bpf_skb_load_bytes失败返回负值,此时目标栈内存不会被更新。如果你不检查,就会沿用栈上的旧值——在 BPF 里可能表现为 verifier 允许但你拿到的内容完全随机。轻则漏报误报,重则把坏数据喂给下游。不要觉得“多一个分支影响性能”,性能瓶颈从来不在这一条判断上。

正确姿势是“先判断再使用”:

ret = bpf_skb_load_bytes(skb, offset, &val, sizeof(val)); if (ret < 0) return 0; // 或者走错误统计分支

5.2 不要用ctx->data做指针算术

总有人觉得ctx->data就是报文起点,直接data + ETH_HLEN访问 IPv4 头最简单。但 socket filter 的 verifier 限制、非线性 skb、VLAN 偏移,每一个都是坑。老老实实用bpf_skb_load_bytes把需要的字节拷到栈上。如果追求极致性能,考虑用BPF_LD_ABS这类指令,但前提是你真的清楚边界语义,而且有测试覆盖。

5.3 字节序统一用 helper 转

bpf_ntohs、bpf_ntohl或__builtin_bswap16/32(取决于内核版本)都用起来。不要在代码里和“0x0008”裸比较。字节序问题不会导致崩溃,但会让你的过滤器在大小端不同的机器上行为完全不同,这是一个典型的“换台机器就出事”的隐患。

5.4 协议解析只取必要字节

IP 头加 TCP/UDP 头最多几十字节,单次 helper 调用就能读完。不要写循环逐字段读,也不要一次读取超大块(比如把整个 payload 拷到栈上)。栈总共 512 字节,报文可能远大于这个数。准确、克制的读取才是稳定的。

5.5 上线前跑一遍边界报文矩阵

开发环境跑通“普通 IPv4”不算完成。把 VLAN、IPv6、带 options、短包、ARP 至少都跑一遍。用bpftool prog run把离线用例写进 CI,每次改完 BPF 程序自动回归。这次事故如果当时有这套用例,根本不会流到线上。

5.6 观测手段首选 ringbuf,别用 bpf_trace_printk

排查现场临时用bpf_trace_printk没问题,但生产环境长期开着会拖慢收包路径。更好的方式是定义好struct event,通过bpf_ringbuf_output把关键字段(解析出的协议、偏移、源目地址、helper 返回值)打给用户态,用户态负责聚合和日志。这样 BPF 侧保持最小开销,排查时也有据可查。

另外,老内核上 helper 集可能不全,加载前用bpftool feature probe检查一下当前环境的 helper 可用性,别等上线了才发现bpf_ringbuf_output不存在。

回过头看,这次事故没有特别高深的技术门槛,所有问题都出在“对 sk_filter 执行上下文理解不精确”上。我后来把这套排查链路和测试矩阵做成了团队内部的 BPF 程序上线检查表,从那以后同类问题基本绝迹。如果你正在写 socket filter,或者准备在上线前给 BPF 程序做一次体检,希望这篇文章能帮你省掉一次线上“崩溃”的代价。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询