去年有个同事抱着一段 eBPF 代码来找我说在 socket filter 里想读一下 IP 地址结果一加载就报错换了一台老一点的内核机器直接触发 oops网络栈都跟着受影响。我扫了一眼代码第一反应是“这里的坑你全踩了”。这个问题在 eBPF 开发者里非常典型尤其是刚上手 socket 路径编程的人十有八九会掉进同一个坑里。标题里的“sk_filter 读取 IP 地址崩溃”说白了就是你在__sk_buff上挂了一个 BPF 程序想从数据包里把 IP 头、源地址、目的地址抠出来做统计或过滤结果程序要么加载时被 verifier 拒掉要么运行时不规范的内存访问把内核搞崩。这篇文章就拿我自己调试过的案例把崩溃的类型、根因、正确写法、排查工具全部过一遍。适合两类人看一类是想在 socket 路径上做流量分析但刚接触 eBPF 的开发者另一类是已经在写 XDP / tc 程序遇到 verifier 报错不知道怎么下手的运维或内核爱好者。1. 先搞懂“崩溃”到底是什么1.1 sk_filter 挂在哪个位置sk_filter 其实是一类 BPF 程序的挂载点它运行在 socket 的接收路径上。你可以理解为每个 socket 收包的时候内核都会先把这个包交给挂在上面的 BPF 程序“过目”程序可以决定放行、丢弃或者只是看一眼然后记录点信息。这个位置很有用。比如你想统计某个源 IP 的连接数、想对特定端口的流量做采样甚至想做一层简单的用户态 ACK 过滤都可以在 socket filter 上实现。它比 iptables 灵活比 XDP 简单适合做一些“业务相关”的包处理逻辑。但它也是最容易写崩的位置因为程序跑在内核上下文里包指针的操作一旦越界边界检查没做好后果直接作用在整台机器上。1.2 这里的“崩溃”有两种形态我在实际项目里看到的“崩溃”要先分清楚是哪一种加载期崩溃你加载程序时内核的 verifier 发现程序访问包的方式不安全直接拒绝加载用户态工具报错有些情况下加载程序本身的用户态逻辑还会因为 fd 处理不当而 segfault。这是“软崩溃”其实算是个保护机制真正的问题被拦在外面。运行期崩溃程序侥幸通过 verifier 检查但运行中访问了非法内存导致内核 oops 甚至 panic。这个就比较严重了整个网络栈可能被打断机器直接失去响应。sk_filter 读 IP 地址这个场景95% 的“崩溃”是第一种verifier 报出invalid access to packet。但我在内核 4.14 等老版本上见过第二种确实能搞挂整机。后面我会把两种的根因都拆开你会发现其实问题出在同一个地方——对包边界的理解和检查方式不对。2. 崩溃现场一段必炸的示例代码2.1 看起来没毛病的初版代码为了还原现场我写一个非常典型的错误版本。这段代码的思路是拿到 IP 头后先判断是不是 TCP是的话就再读一下目的端口判断是不是发给 80 端口同时打印出源 IP#include linux/bpf.h #include linux/pkt_cls.h #include linux/if_ether.h #include linux/ip.h #include linux/tcp.h #include bpf/bpf_helpers.h SEC(socket) int read_ip_and_port(struct __sk_buff *skb) { void *data (void *)(long)skb-data; void *data_end (void *)(long)skb-data_end; struct ethhdr *eth data; struct iphdr *ip (void *)eth sizeof(struct ethhdr); struct tcphdr *tcp; if (eth-h_proto ! bpf_htons(ETH_P_IP)) return 0; if (ip-protocol IPPROTO_TCP) { tcp (void *)ip ip-ihl * 4; if ((void *)tcp sizeof(struct tcphdr) data_end) return 0; if (tcp-dest bpf_htons(80)) bpf_printk(hit port 80, src%x\n, ip-saddr); } return 0; } char _license[] SEC(license) GPL;编译命令clang -O2 -g -target bpf -D__TARGET_ARCH_x86 -c read_ip.c -o read_ip.o加载我用 bpftoolbpftool prog load read_ip.o /sys/fs/bpf/read_ip type sock结果加载直接失败。报错长这样libbpf: prog read_ip_and_port: failed to load: -13 libbpf: -- BEGIN PROG LOAD LOG -- 0: (bf) r6 r1 ... 25: (7b) *(u64 *)(r10 -8) r3 26: (bf) r1 r10 27: (07) r1 -8 28: (85) call bpf_trace_printk#6 29: (b7) r0 0 30: (95) exit invalid access to packet, off14 size2, R1(id0,off0,r0) R1 offset is outside of the packet -- END PROG LOAD LOG --off14 size2意思是程序尝试在包数据起始地址偏移 14 字节的位置读 2 个字节而 verifier 判断这个偏移已经在包范围之外了。如果你是因为这个报错进来的恭喜你已经和它见过面了。问题就出在我代码里的访问顺序。2.2 verifier 报错到底在说什么verifier 检查程序时会模拟每条指令的执行路径跟踪每一个寄存器的值的可能范围。对于包指针它知道一个“合法范围”就是[data, data_end]。每次你从包指针上做加法、做间接访问它都会检查结果是否落在合法范围里。我的错误在于访问eth-h_proto之前只做了“包是 IP 包”的判断但没有先检查包长度是否至少包含完整的 ethhdr。verifier 在执行到偏移 14 的访问时看到的寄存器状态是“这个指针的合法范围是 0 到某个大数”偏移 14 可能越界所以直接判定非法访问。这就好比你在快递驿站取件单子上写“货架 A 第 14 格”但你还没确认这个驿站到底有没有 A 货架就直接伸手进去拿管理员当然要喊停。这个案例里最反直觉的地方是verifier 不会因为你后面写了(void *)tcp sizeof(struct tcphdr) data_end这种检查就自动把前面所有访问都当作安全的。它按指令顺序逐条跟踪前面没过后面再弥补也没用。3. 拆解根因四大高频崩溃点3.1 坑一忘了以太网头或者说链路层头很多人刚写 socket filter 时下意识把skb-data当成 IP 头起点。这是因为抓包工具比如 tcpdump默认显示的就是 IP 层往上的内容容易形成思维惯性。但实际上skb-data指向的是链路层头对以太网接口来说就是struct ethhdr14 字节。没有先跳过这 14 字节你会读到以太网头的 type 字段当成 IP 版本号源 IP 地址读出来是 MAC 地址的一部分。更危险的是如果你不检查包长度就做data 14 20这样的偏移碰到长度不足的包比如 ARP、IPv4 分片中的短包指针运算结果直接跑到data_end外面。不过这里有个细节要想清楚不是所有接口都有以太网头。你如果跑在 loopback 上skb-data指向的就是 IP 头本身如果你跑在 PPPoE、GRE 这类隧道接口上链路层头结构又不完全是 ethhdr。所以严谨的做法是通过skb-protocol判断网络层协议结合skb-mac_header计算偏移量。但对以太网接口这种最普遍的场景直接用 ethhdr 没问题关键是第一步就要做长度检查。3.2 坑二边界检查的顺序颠倒这是我见过最多的问题也是上面示例代码的核心致命伤。正确的逻辑链条应该是先确认整个包长度足以放下 ethhdr再确认足以放下 iphdr再确认足以放下你要读的 tcp 头只有每一步都站得住才去做访问很多人图省事上来就写一句if ((void *)ip sizeof(struct iphdr) data_end) return 0;然后直接访问eth-h_proto。这在 verifier 眼里是无效的因为它执行到eth-h_proto这句时还看不到你后面的长度检查。verifier 按指令流顺序模拟前面没确认后面确认没用。这个要用“顺序”的思维去理解。你写的是 C 代码但 verifier 看的是指令流它不会做“以后面代码推断前面变量安全”这种逆向推理。所有访问包的指针运算都必须在访问之前完成边界约束约束必须覆盖到访问的最远端点。3.3 坑三动态偏移让 verifier 无法确认边界再看我示例里这一句tcp (void *)ip ip-ihl * 4;ip-ihl是 IP 头长度字段是个变量取值范围是 5 到 15通常都是 5单位是 4 字节。verifier 看到你用一个变量做偏移而这个变量的范围虽然存在但它不知道这个范围具体是多少。它只知道你从包里读了一个 8 位值乘了 4这个值的可能范围是 0 到 255乘 4 之后是 0 到 1020。于是它认为tcp指针的偏移范围“太宽”sizeof(struct tcphdr)是 20 字节加上最大偏移 1020很可能超出包边界。所以它拒绝了你。解决办法有两个思路把偏移“收敛”到确定范围先对ip-ihl做校验比如if (ip-ihl 5) return 0;让 verifier 知道这个值至少不会小于 5。但这还不够上限还是不确定。更稳妥的做法用bpf_skb_load_bytes这类辅助函数来读取它内部由内核帮你做边界校验verifier 对这种辅助函数的安全机制更熟悉不再需要你手工保证边界。我个人的习惯是判断协议用直接指针访问读可变偏移处的字段尽量换用bpf_skb_load_bytes。3.4 坑四辅助函数选错我见过有人一上来就用bpf_probe_read_kernel去读skb-data里的 IP 头。这个函数是用来读内核内存的比如未知的内核结构体字段、通过指针间接访问数据它的语义是“可能会 page fault我来兜底”。但包数据这种情况比较特殊包的数据其实可以通过直接指针访问skb-data和skb-data_end之间的区域在 verifier 的模型里是“已经被证明安全的可读范围”你不需要也不应该用bpf_probe_read_kernel再去读一遍。用错函数会带来两个问题某些内核版本上verifier 会因为你在 socket filter 上下文里使用了不匹配的辅助函数直接拒绝加载报unknown func之类的错误。就算加载成功bpf_probe_read_kernel的读取方式对包数据的处理也不是最优路径性能上差不少。在 sk_filter 场景下读包内数据就用三种方式直接指针访问配合长度检查、bpf_skb_load_bytes、bpf_skb_load_bytes_relative。其他的一律别碰。3.5 附加载环境的权限坑除了代码逻辑崩溃还有一个常见来源是权限。从内核 4.15 开始多数发行版默认禁止非特权用户加载 BPF 程序你在容器里跑、或者普通用户跑bpftool会直接拿到operation not permitted的报错。这个不是代码问题但很多人被卡在这里还以为是程序写错了。确认方式很简单id # 如果当前用户不是 root用 sudo 试试 sudo bpftool prog load read_ip.o /sys/fs/bpf/read_ip type sock或者检查内核参数sysctl kernel.unprivileged_bpf_disabled如果结果是 2说明连开关都改不了必须 root 才能加载。4. 正确写法与完整复现过程4.1 修复后的代码修复的核心原则是三个先查长度、再做偏移、最后访问。我把初版代码改成了这样#include linux/bpf.h #include linux/if_ether.h #include linux/ip.h #include linux/tcp.h #include bpf/bpf_helpers.h SEC(socket) int read_ip_fixed(struct __sk_buff *skb) { void *data (void *)(long)skb-data; void *data_end (void *)(long)skb-data_end; struct ethhdr *eth; struct iphdr *ip; struct tcphdr *tcp; if (data sizeof(struct ethhdr) data_end) return 0; eth data; if (eth-h_proto ! bpf_htons(ETH_P_IP)) return 0; if ((void *)eth sizeof(struct ethhdr) sizeof(struct iphdr) data_end) return 0; ip (void *)eth sizeof(struct ethhdr); if (ip-protocol IPPROTO_TCP) { if ((void *)ip sizeof(struct iphdr) sizeof(struct tcphdr) data_end) return 0; tcp (void *)ip sizeof(struct iphdr); if (tcp-dest bpf_htons(80)) { bpf_printk(src%x dest%x\n, ip-saddr, ip-daddr); } } return 0; } char _license[] SEC(license) GPL;这个版本的思路变化处理eth-h_proto之前先保证整个包至少有一个 ethhdr 的长度。处理 IP 头之前再保证 ethhdr iphdr 两部分都在包内。处理 TCP 头之前再保证多一个 tcphdr。我没再使用ip-ihl * 4这种动态偏移固定取sizeof(struct iphdr)。常规 IP 包没选项字段时就是这个大小如果真遇到带选项的包用bpf_skb_load_bytes去读更稳。4.2 边界的计算方式这里的data sizeof(struct ethhdr) sizeof(struct iphdr) data_end可能有人会觉得绕。其实verifier 的检查逻辑是一个指针的偏移范围不能超过 data_end而你访问的最远端点必须也在范围内。你访问的最远端点就是ip-saddr这个字段它位于 IP 头的第 12 到第 16 字节偏移处也就是整个包数据起点的偏移14 12到14 16。我检查的是整个 iphdr 都在包内远端点检查到了偏移 34覆盖了 saddr 的偏移 26所以是安全的。有人会问我只需要读源地址为什么不只检查 30 字节可以但没必要。检查多一点反而让 verifier 更好判断代码逻辑也更清晰。这里的原则是“宁可多检查不可少检查”。4.3 编译、加载与验证全流程修复后重新编译加载clang -O2 -g -target bpf -D__TARGET_ARCH_x86 -c read_ip_fixed.c -o read_ip_fixed.o sudo bpftool prog load read_ip_fixed.o /sys/fs/bpf/read_ip_fixed type sock加载成功后你需要把程序 attach 到一个具体的 socket 上。bpftool 对 socket filter 的 attach 支持有限我一般写一个极简的用户态程序来做#include stdio.h #include stdlib.h #include unistd.h #include sys/socket.h #include netinet/in.h #include linux/bpf.h #include bpf/libbpf.h int main(int argc, char **argv) { struct bpf_object *obj; struct bpf_program *prog; struct bpf_link *link; int sock, err; if (argc 3) { fprintf(stderr, usage: %s prog.o prog_name\n, argv[0]); return 1; } obj bpf_object__open_file(argv[1], NULL); if (!obj) { fprintf(stderr, open obj failed\n); return 1; } err bpf_object__load(obj); if (err) { fprintf(stderr, load obj failed: %d\n, err); return 1; } prog bpf_object__find_program_by_name(obj, argv[2]); if (!prog) { fprintf(stderr, find prog failed\n); return 1; } sock socket(AF_INET, SOCK_DGRAM, 0); if (sock 0) { perror(socket); return 1; } link bpf_program__attach_socket_filter(prog, sock); if (!link) { fprintf(stderr, attach failed\n); return 1; } printf(attached, sock%d\n, sock); sleep(60); bpf_link__destroy(link); close(sock); bpf_object__close(obj); return 0; }编译用户态程序gcc -O2 -o attach attach.c -lbpf -lelf -lz分开两个终端一个跑 attach 程序一个跑sudo cat /sys/kernel/debug/tracing/trace_pipe然后从另一个 shell 随便访问一个 80 端口的服务比如curl http://example.com就能在 trace_pipe 里看到类似这样的输出attach-1234 [001] .... 12345.678901: 0: src0a000001 destc00002010a000001就是 10.0.0.1 的十六进制表示。4.4 更适合落地的做法用 map 把 IP 数据传回用户态bpf_printk方便是方便但有几个限制只能打日志不适合生产环境格式串解析有开销在部分内核版本上%pI4这类格式化修饰符不可用还得自己转成十六进制再打印。我在实际项目里更推荐把 IP 地址写入 BPF map用户态程序读出后再格式化。定义一个数组 mapstruct { __uint(type, BPF_MAP_TYPE_ARRAY); __uint(max_entries, 8); __type(key, __u32); __type(value, __u32); } ip_map SEC(.maps);内核侧__u32 key 0; __u32 ip ip-saddr; bpf_map_update_elem(ip_map, key, ip, BPF_ANY);用户态侧用bpf_map__lookup_elem读出来再用inet_ntop转成字符串。这样做的好处是不用管bpf_printk的格式串限制性能开销更小也能直接和监控系统对接。5. 常见问题速查与独家调试经验5.1 问题速查表我整理了 sk_filter 场景下最常踩的坑按报错信息、根因、解法整理成表报错或现象根因解决办法invalid access to packet, off14 size2访问 eth 头之前没做长度检查先判断data sizeof(struct ethhdr) data_end再访问invalid access to packet, off0 size2, R1...包指针在尾部更新后重新计算偏移出错检查指针变量的更新逻辑或改用bpf_skb_load_bytesunknown func bpf_probe_read_kernel#113在 socket filter 上下文用了不匹配的辅助函数使用bpf_skb_load_bytes或直接指针访问permission denied/operation not permitted非特权用户加载 BPF 程序用 root 加载检查kernel.unprivileged_bpf_disabledprocessed X insns, stack depth Y后无明确错误verifier 深度或复杂度超过限制简化程序逻辑拆分成多个 BPF 程序程序加载成功但无输出trace_pipe 没开、权限不足、或者包根本没有到达这个 sock检查trace_pipe是否可读确认 attach 到了正确的 socket加载成功但运行一段时间后网络连接卡死程序执行时间过长或死循环风险减少循环次数严格限制指令复杂度用 map 存统计而非每次都打日志5.2 调试三板斧之一verifier 日志是宝藏很多人看到 verifier 那几百行日志就头大直接放弃。其实你只需要看最后 10 行特别是包含invalid或R寄存器状态的那几行。verifier 日志的格式是R1(id0,off0,r0)其中r表示这个寄存器从包指针算起的最大合法偏移。如果日志里r0说明 verifier 认为这个指针到目前为止还没被证明有任何合法偏移那你之后的任何访问都会触发报错。报错里的off和size才是关键offN表示你要访问的偏移量sizeN表示你访问的字节数。看到这个数字先自己算一遍N size 是否大于你当前已经确认的包长度这是最快的定位方式。5.3 调试三板斧之二bpftool dump xlated加载失败后把指令拆开看bpftool prog dump xlated name read_ip_fixed能达到两个目的一是确认你的 C 代码被编译成了哪些指令二是看 verifier 在哪个指令处开始拒绝。比如最常见的*(u16 *)(r1 14)这种指令一眼就能看出你在偏移 14 处访问了 2 字节。如果指令流里出现了r1 r2这种动态加偏移的指令不用看日志也知道是动态偏移的坑。我调试过很多回最后发现一个规律verifier 拒绝的位置通常就是你第一条没有边界检查的访问指令。你只要把它之前缺少的长度检查补上后面的问题大部分都能迎刃而解。5.4 调试三板斧之三临时打印法是双刃剑加bpf_printk确实方便但有两个坑生产环境不能开日志刷屏能把 trace 缓冲区塞满影响系统整体 perf在循环里打日志每 100 个包打一次日志都算多的会直接放大程序的开销。我自己的做法是先在 map 里累加一个计数器用户态程序每隔几秒读一次。调通逻辑之后再决定要不要换成真正的日志输出。这样既能确认程序被调用到了又不会因为打日志把性能搞坏。5.5 老内核和国产系统环境的额外提醒我在排查这个案例时还遇到过一种“环境崩溃”就是程序在新内核上写得没问题但一放到老内核或者某些国产系统上就报错。主因通常是下面几个辅助函数缺失bpf_probe_read_kernel是 5.2 才有的bpf_skb_load_bytes_relative在某些老版本里也没有。如果你的程序要兼容 4.x 内核先查一下目标内核的辅助函数支持列表。verifier 能力差异老内核的 verifier 对动态偏移、指针算术的容忍度更低同样的逻辑5.10 内核可能放行4.14 内核直接拒绝。JIT 未开启某些系统默认没开 BPF JIT所有程序走解释执行性能损失明显对 socket filter 这种每个包都会触发的程序影响会被放大。我的建议是开发环境至少用 5.10 以上的内核先把逻辑调对再回头做老内核兼容。不要一上来就在生产环境的老内核上反复试错那样既慢又容易把线上系统搞出问题。最后一件事写这个案例时我又想起自己第一次在 socket filter 里读 IP 地址的场景折腾了一个通宵最后发现只是因为忘了在访问 ethhdr 之前做长度检查。现在回头看eBPF 的 verifier 看着严格其实保护了所有人。它的逻辑很简单你不能访问没证明安全的区域你不能依赖没确认过的边界。理解了这一点sk_filter 乃至 XDP、tc 上的大多数 verifier 报错都能一眼看穿。如果你也在写类似程序把“先查长度、再做偏移、最后访问”这句话贴在显示器上能帮你省下不少通宵的时间。