引子
本文将要描述一个我在某些linux版本以及libpcap(unix/linux平台下网络数据捕获包)中发现的一个非常险恶的bug。
一个有意思的现象
一位客户向我们报告,在一些安装了Debian Lenny的机器上,处于主动备份模式下的网卡不能检测到发送的数据包,边界流量检测器没有任何图形显示。我在公司里找了几台与客户硬件配置一样的机器,开始对问题展开调查。
首先,我从自己的笔记本ping目标机器。接着,我在目标机器上使用tcpdump嗅探(sniff 窃听网络上流经的数据包)绑定接口收到的ICMP包。
% sudo tcpdump -i bond0 dst 172.16.209.136 and proto 1 12:57:26.275660 IP 172.16.209.1 > 172.16.209.136: ICMP echo request, id 62831, seq 54, length 64 12:57:27.275731 IP 172.16.209.1 > 172.16.209.136: ICMP echo request, id 62831, seq 55, length 64 ^C 2 packets captured 2 packets received by filter 0 packets dropped by kernel |
看来一切正常,是时候开始监听eth0了。eth0绑定的是活跃物理网卡:
% sudo tcpdump -i eth0 dst 172.16.209.136 and proto 1 ^C 0 packets captured 2 packets received by filter 0 packets dropped by kernel |
结果,bond0嗅探的结果显示有ICMP输入,但是eth0绑定的物理网卡没有任何数据包输入。这就难怪我们的监测工具没有输入流量显示,因为测量仪没有检测到数据包!

这是什么原因呢?
设备无关层
为了调试这个问题,我首先开始检查网络协议栈的设备无关层,跟踪pcap负责处理输入数据包的相关代码。设备驱动通过调用设备无关层的netif_receive_skb函数处理从网络捕获的一组数据。
查看位于net/core/dev.c文件中的netif_receive_skb函数(简洁起见这里只摘取重要部分):
1 2 3 4 5 6 7 8 9 10 | int netif_receive_skb(struct sk_buff *skb) { /* ... */ orig_dev = skb_bond(skb); if (!orig_dev) return NET_RX_DROP; /* ... */ |
skb_bond函数会判断skb是否属于bond。如果是,那么函数要确保skb来自bond上的一个活跃设备。这个检查是为了防止高层协议为某个bond重复进行配置分配。如果skb通过了这些检查,skb的dev指针会被赋值为指向bond的设备指针,同时netif_receive_skb函数会返回原设备指针。
从概念上理解,你可以把上述代码等价于如下伪代码:
起始状态:1 2 3 4 5 6 7 | orig_dev = NULL skb->dev = "eth0" orig_dev = skb->dev if skb->dev 是bond的一部分: if skb->dev 是bond上的活跃设备 skb->dev = bond |
1 2 | orig_dev = "eth0" skb->dev = bond |
因此,看上去skb消息被当成发自一个bond中的设备而不是物理设备。
如果我们继续跟踪netif_receive_skb函数,我们会看到代码将skb转给了pcap处理:
1 2 3 4 5 6 7 8 | list_for_each_entry_rcu(ptype, &ptype_all, list) { if (!ptype->dev || ptype->dev == skb->dev) { if (pt_prev) ret = deliver_skb(skb, pt_prev, orig_dev); pt_prev = ptype; } } |
这段代码对存储了所有pcap条目信息的列表ptype_all进行遍历, 判断pcap条目的设备结构能否匹配skb所属设备。
循环中对设备的检查非常有意思:
1 | if (!ptype->dev || ptype->dev == skb->dev) |
如果你试图嗅探来自eth0的数据包,但是由于eth0已经是bond的一部分,因而这个检查一定会失败。原因是skb->dev已经被重写为bond设备的dev结构了。
这就是为什么tcpdump和其他的测量工具嗅探bond相关的物理设备时看不到发送的数据包了!
只要简单地把if语句修改成:
1 | if (!ptype->dev || ptype->dev == skb->dev || ptype->dev == orig_dev) { |
因为加入了orig_dev检查,这下pcap就能够处理被dev指针被修改过的skb了。
让我们来测试一下这个修改。
有意思的现象,第二轮观察
接下来,重新构建并安装修改后的内核(顺便说一下,有一份非常有用的文档),重新ping目标机器并开始嗅探发向物理设备的数据包:
1 2 3 4 5 | % sudo tcpdump -i eth0 dst 172.16.209.136 and proto 1 ^C 0 packets captured 2 packets received by filter 0 packets dropped by kernel |
什么情况?
为什么修改以后还是没有看到发送的数据包?
libpcap
让我们快速检查一下内核中负责处理AF_PACKET地址家族的libpcap接口。
AF_PACKET 在内核中是单独实现的一个地址家族,相关的代码位于net/packet/af_packet.c。libpcap通过调用socket系统调用建立一个socket,调用的第一个参数被设置为PACKET。libpcap接下来会使用bind系统调用把这个socket绑定想要嗅探的设备上。
现在有两种方法可以从内核中拿到数据包:
• “以前的方法”: 对每个数据包的文件描述符调用recvfrom函数。在老版本的内核上,只有这一个函数可用。
• “新方法”:调用poll函数会通知libpcap有一组数据包到达,在内核与libpcap的共享内存中等待读取。比起“老办法”这种方法效率更高(使用的系统调用更少),在最近大多数的内核包括Debian Lenny都支持这种办法。
结果是,尽管Debian Lenny的内核支持“新方法”实现的AF_PACKET,但是相应的libpcap却不支持。这就意味着tcpdump(依赖于libpcap)只能逐次逐个地从内核中取得数据包。
更新版本的libpcap默认使用“新方法”从内核读取数据包。因为Lenny支持这种用法,我试着构建了一个更新版本的libpcap并修改了tcpdump。在修改过的Lenny内核上测试这个修改,我看到当我在bond上的物理设备进行嗅探时,数据包从RX路径流出。如果我把新的libpcap修改成使用“以前的方法”搜集数据包,没有数据包从RX路径流出。
这意味着在使用“以前的方法”时,要么AF_PACKET有bug,要么多版本的libpcap有bug。
if语句
经过数小时痛苦地阅读代码,我找了一条if语句可以控制libpcap使用“以前的方法”读取数据包。
1 2 3 4 5 | // From pcap_read_packet in pcap-linux.c: if (handle->md.ifindex != -1 && from.sll_ifindex != handle->md.ifindex) return 0; |
这条if语句会进行索引判断,对比从内核中读取数据包使用的索引号是否和用户通知libpcap监控网络设备使用的索引号相同。如果索引号匹配失败,pcap_read_packet函数会直接返回,不再调用libpcap提供的回调函数。
这段代码是为了防止内核中可能出现的竞争情况,比如虽然已经创建了socket但还没有来得及绑定到一个特定设备上,这种情况下AF_PACKET会在调用socket和bind中间把所有的数据包存储到队列中。
然而,当数据包发向作为bond设备一部分的物理设备时这个检查会失败。
用户向libpcap请求对这个物理设备监控,但正如我们上面看到的,当有数据包到来时内核会用netif_receive_skb中的bond设备指针覆盖dev结构。这样就会造成bond设备的索引和物理设备的索引不一致。
这条if语句就是为什么发送的数据包在修改了内核以后仍然无法被类似tcpdump或者边界流量测量器捕获。
这个检查在从内核读取数据包的“新方法”中不存在,因为支持新mmap方法的内核不会产生上述代码需要防止的竞争条件。因此,把一个更新版本的libpcap链接到tcpdump上(内核已经过修改)就能看到发送给bond上物理设备的数据包。
这个检查在上仍然存在。
总结
计算机能够正常运行真是个奇迹。
英文原文: Joe Damato 编译:在线 – 唐尤华