尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
sk_filter读取IP地址崩溃复盘:BPF偏移与VLAN陷阱
那天下午内部群里突然甩进来一条消息“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以太网头 12IP 头内 saddr 偏移AF_INET / SOCK_STREAMTCP socket多数情况已到 L3/L4 交界锚点不稳定需要以实际抓包为准不能写死XDP 程序旁路参考L2 以太网头14 12tc 程序旁路参考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 idxlated 输出的是 eBPF 字节码翻译后的指令能看到每条指令对应的 C 代码行号前提是编译时保留了 BTF 和调试信息。我们很快发现一个可疑分支程序在读 IP 头之前只判断了“偏移 14 处是不是 0x0800IPv4”如果读到的前两字节不是 IPv4就直接返回 0 放行。看起来没问题但问题在于“偏移 14”在带 VLAN 的帧上读到的根本不是协议号而是 VLAN 头里的一部分。把现场报文的协议号打印出来之后确认了触发条件流量里混入了一批带 802.1Q 标签的报文程序按 14 字节偏移去读“以太网类型”读到的值落在了 VLAN 头内部于是走了错误的分支。坏地址、坏端口全部由此产生。2.3 三层错误叠加才最终酿成“崩溃”复盘下来这是一个三层错误叠加的典型事故第一层锚点判断错误。程序挂在 AF_PACKET 的 raw socket 上skb-data指向 L2 头代码却用了一个从 AF_INET socket 场景抄来的偏移逻辑。第二层VLAN 处理缺失。即使锚点没错写死 14 字节也无法应对 802.1Q 标签帧协议号读取直接错位。第三层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); // 错误三没有检查 retsaddr/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_PACKETskb-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 头内偏移 12daddr 偏移 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 拷到栈上原因有三层verifier 对 socket filter 的直接数据访问控制很严格。直接data offset解引用很容易触发invalid mem access拒绝加载或者需要在代码里维护复杂的data_end边界判断。报文可能不是线性存储的。内核 skb 的数据可能分片在多个 page 里ctx-data只覆盖线性数据区。bpf_skb_load_bytes内部会调用skb_copy_bits这样的函数把数据完整拷贝出来帮你处理非线性区。栈拷贝的开销在 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(dst00:11:22:33:44:55, srcaa:bb:cc:dd:ee:ff) / \ IP(src1.2.3.4, dst5.6.7.8) / \ UDP(sport12345, dport54321) / Raw(btest) sendp(pkt, ifaceeth0) # T02 带 VLAN 标签的 IPv4 pkt_vlan Ether(dst00:11:22:33:44:55, srcaa:bb:cc:dd:ee:ff) / \ Dot1Q(vlan100) / \ IP(src1.2.3.4, dst5.6.7.8) / \ UDP(sport12345, dport54321) / Raw(bvlan-test) sendp(pkt_vlan, ifaceeth0)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 100data_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 程序做一次体检希望这篇文章能帮你省掉一次线上“崩溃”的代价。
RELATED

相关推荐

PyTorch人脸表情识别实战:从CNN训练到OpenCV实时部署

PyTorch人脸表情识别实战:从CNN训练到OpenCV实时部署

简介:基于 PyTorch 的卷积神经网络人脸面部表情识别项目,面向深度学习和计算机视觉初学者及实战开发者,覆盖人脸检测、表情分类到模型训练评估完整流程。利用 PyTorch 动态图优势,结合数据增强与可视化工具,便于灵活调…

📅 2026/10/11 18:36:55
农作物病虫害识别毕设避坑指南:从数据清洗到模型训练全解析

农作物病虫害识别毕设避坑指南:从数据清洗到模型训练全解析

简介:面向高校毕业设计及课程项目的深度学习应用资料包,围绕常见农作物病虫害识别任务,提供从图像数据收集、视觉显著性处理、卷积神经网络构建到系统部署的完整方案,尤其适合计算机视觉、智慧农业方向的学生用于课题研究、代码复…

📅 2026/10/11 18:36:55
PyTorch实战:STGCN时空图卷积网络实现与调优

PyTorch实战:STGCN时空图卷积网络实现与调优

简介:基于PyTorch的STGCN时空图卷积网络实现代码,源自IJCAI 2018论文官方实现,面向从事人体行为分析、骨骼动作识别等方向的研究者与开发者,可用于视频监控、人机交互、医疗康复等场景的时空特征建模。压缩包共12个文件&#xff0…

📅 2026/10/11 18:36:55
MORE NEWS

更多资讯

📰

CNN+Transformer联合模型实现无参考图像清晰度评分

简介:本资源是一套面向计算机及相关专业在校学生、教师与工程师的图像质量评估实战项目,聚焦于清晰度等客观指标的自动化评分,适用于毕业设计、课程设计及AI方向大作业等场景。项目创新性地在CNN主干网络的中间层嵌入Transformer模块&#xf…

📰

Python景点数据分析系统:爬虫、数据库与可视化实战

简介:一套基于Python的热门景点数据分析与可视化系统项目实例,面向具备Python基础、希望掌握全栈式数据分析流程的研发人员与数据分析师,适用于文旅决策、景区运营优化和在线旅游平台推荐等场景。内容围绕完整项目闭环展开:从数据…

📰

光伏电站Python数据分析:分布、发电预测与能源管理一站式实践

简介:一套面向能源管理与光伏发电研究的MATLAB源码工具,专注计算分布式光伏在自发自用、统购统销、合同能源管理等不同运营模式下的综合效益,可供政策制定者、投资者、运营商及高校师生量化评估经济收益与节能减排潜力,适合需要快…

📰

MGRE+OSPF隧道组网:NHRP组播配置与典型排错实践

做Datacom方向认证的人,基本都会做到这样一个进阶实验:在通过非广播网络互联的多台路由器之间,用MGRE把分散站点拉成一张虚拟网,再在这张虚拟网上跑OSPF。听起来不难,实际配置下去你会发现,Hello包发不出去…

📰

传送带破损检测数据集:700张COCO格式图像+实例分割标注

简介:本资源是一套面向工业视觉检测领域的传送带皮带破损缺陷识别专用数据集,适用于计算机视觉方向的初学者与算法工程师开展目标检测模型训练与评估。数据集基于700张真实场景下的传送带图像构建,全部采用COCO标准JSON格式完成精细标注&…

📰

Claude Code 一周烧掉一半配额?我用逆向工程拆解 Agent 测试的缓存 TTL 盲区与 TaoToken 可观测性

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬