Linux 数据包过滤器内幕
你们这些网络极客可能还记得我在 2001 年 6 月的 LJ 杂志上发表的文章“Linux 套接字过滤器:嗅探网络上的字节”,这篇文章是关于使用内置于 Linux 内核中的数据包过滤器的。在那篇文章中,我概述了数据包过滤器本身的功能;这一次,我将深入研究内核机制的深处,这些机制使过滤器能够工作,并分享一些关于 Linux 数据包处理内部机制的见解。
在前一篇文章中,提出了一些关于内核数据包处理的论点。值得简要回顾一下其中最重要的几点
数据包接收首先在网卡驱动程序级别处理,更准确地说是在中断服务例程中。服务例程查找接收帧内的协议类型,并将其适当地排队以供后续处理。
在接收和协议处理期间,如果机器拥塞,数据包可能会被丢弃。此外,当数据包向上层用户空间移动时,它们会丢失网络底层信息。
在套接字级别,就在到达用户空间之前,内核检查是否存在给定数据包的打开套接字。如果不存在,则丢弃该数据包。
然后,Linux 内核实现了一个通用协议,称为 PF_PACKET,它允许您创建一个套接字,该套接字直接从网卡驱动程序接收数据包。因此,任何其他协议的处理都会被跳过,并且可以接收任何数据包。
以太网卡通常只将发往自身的数据包传递给内核,丢弃所有其他数据包。然而,可以配置网卡,使其捕获流经网络的所有数据包,而与其 MAC 地址无关(混杂模式)。
最后,您可以将过滤器附加到套接字,以便只有与您的过滤器规则匹配的数据包才会被接受并传递到套接字。与 PF_PACKET 套接字结合使用,这种机制允许您从您的 LAN 高效地嗅探选定的数据包。
即使我们使用 PF_PACKET 套接字构建了嗅探器,Linux 套接字过滤器 (LSF) 也不仅限于这些。事实上,过滤器也可以用于普通的 TCP 和 UDP 套接字,以过滤掉不需要的数据包——当然,这种过滤器的使用不太常见。
在下文中,我有时会提到套接字或 sock 结构。就本文而言,这两种形式都表示相同的对象,后者对应于内核中前者的内部表示。实际上,内核同时保存着套接字结构和 sock 结构,但这二者之间的区别在这里并不重要。
另一个经常出现的数据结构是 sk_buff(socket buffer 的缩写,即套接字缓冲区),它表示内核内部的数据包。该结构的排列方式使得可以以相对廉价的方式向数据包数据添加和删除头部和尾部信息:实际上不需要复制数据,因为一切都只是通过移动指针来完成的。
在继续之前,可能有必要澄清可能的歧义。尽管名称相似,但 Linux 套接字过滤器与 2.3 早期版本引入内核的 Netfilter 框架的目的完全不同。即使 Netfilter 允许您将数据包带到用户空间并将其馈送到您的程序,但那里的重点是处理网络地址转换 (NAT)、数据包修改、连接跟踪、用于安全目的的数据包过滤等等。如果您只需要嗅探数据包并根据某些规则对其进行过滤,最直接的工具是 LSF。
现在我们将跟踪数据包从其进入计算机到在套接字级别交付到用户空间的整个过程。我们首先考虑普通(即非 PF_PACKET)套接字的常见情况。我们在链路层级别的分析基于以太网,因为这是最广泛和最具代表性的 LAN 技术。其他链路层技术的情况没有显着差异。
正如我们在上一篇文章中提到的,以太网卡硬连线了一个特定的链路层(或 MAC)地址,并且始终在其接口上监听数据包。当它看到一个 MAC 地址与其自身地址或链路层广播地址(对于以太网,即 FF:FF:FF:FF:FF:FF)匹配的数据包时,它开始将其读入内存。
数据包接收完成后,网卡生成中断请求。处理该请求的中断服务例程是网卡驱动程序本身,它在禁用中断的情况下运行,并且通常执行以下操作
分配一个新的 sk_buff 结构,定义在 include/linux/skbuff.h 中,它表示内核对数据包的视图。
将数据包数据从网卡缓冲区提取到新分配的 sk_buff 中,可能使用 DMA。
调用 netif_rx(),通用的网络接收处理程序。
当 netif_rx() 返回时,重新启用中断并终止服务例程。
netif_rx() 函数为下一步接收做准备;它将 sk_buff 放入当前 CPU 的传入数据包队列中,并标记 NET_RX 软中断(softirq,软中断将在下面解释)以通过 __cpu_raise_softirq() 调用执行。在此阶段,有两点值得注意。首先,如果队列已满,则数据包将被丢弃并永远丢失。其次,我们为每个 CPU 都有一个队列;结合新的延迟内核处理模型(软中断代替底半部),这允许在 SMP 机器中并发接收数据包。
如果您想查看实际的以太网驱动程序在运行,您可以参考位于 drivers/net/8390.c 中的简单 NE 2000 网卡 PCI 驱动程序;名为 ei_interrupt() 的中断服务例程调用 ei_receive(),而 ei_receive() 又执行以下过程
通过 dev_alloc_skb() 调用分配一个新的 sk_buff 结构。
从网卡缓冲区读取数据包(ei_block_input() 调用)并相应地设置 skb->protocol。
调用 netif_rx()。
对最多十个连续的数据包重复该过程。
位于 3c59x.c 中的 3COM 驱动程序提供了一个稍微复杂的示例,它使用 DMA 将数据包从网卡内存传输到 sk_buff。
让我们仔细看看 netif_rx() 函数。如前所述,此函数的作用是从网络驱动程序接收数据包,并将其排队以进行上层处理。它充当所有不同网卡驱动程序收集的数据包的单一汇集点,为上层协议处理提供输入。
由于此函数在中断上下文(即,其执行流程遵循中断服务路径)中运行,并且禁用了其他中断,因此它必须快速且简短。它不能执行长时间的检查或其他复杂的任务,因为当 netif_rx() 运行时,系统可能会丢失数据包。因此,此函数所做的基本上是从名为 softnet_data 的数组中选择数据包队列,该数组的索引基于当前正在运行的 CPU。然后,它检查队列的状态,识别五种可能的拥塞级别之一:NET_RX_SUCCESS(无拥塞)、NET_RX_CN_LOW、NET_RX_CN_MOD、NET_RX_CN_HIGH(分别为低、中和高拥塞)或 NET_RX_DROP(由于严重拥塞而丢弃数据包)。
如果达到严重拥塞级别,netif_rx() 将启动节流策略,允许队列恢复到非拥塞状态,从而避免因内核过载而导致服务中断。除其他好处外,这有助于避免可能的 DOS 攻击。
在正常情况下,数据包最终被排队 ( __skb_queue_tail()),并调用 __cpu_raise_softirq(cpuid, NET_IF_SOFTIRQ)。后一个函数的作用是调度一个软中断以供执行。
netif_rx() 函数终止,向调用者返回一个指示当前拥塞级别的值。此时,中断上下文处理完成,数据包已准备好由上层协议处理。此处理被延迟到稍后的时间,届时中断将被重新启用,并且执行时序将不再那么关键。从内核版本 2.2(基于底半部)到版本 2.4(基于软中断),延迟执行机制已发生根本变化。
详细解释底半部 (BH) 及其演变超出了本文的范围。但是,有些要点值得简要回顾一下。
首先,它们的设计基于这样的原则:内核在中断上下文中应尽可能少地执行计算。因此,当需要响应中断执行长时间操作时,相应的驱动程序将标记要执行的相应 BH,而实际上不执行任何复杂的操作。然后,在稍后的时间,内核将检查 BH 掩码以确定是否标记了一些 BH 以供执行,并在任何应用程序级任务之前执行它们。
BHs 工作得很好,但有一个重要的缺点:由于它们的结构,它们的执行严格地在 CPU 之间串行化。也就是说,同一个 BH 不能由多个 CPU 同时执行。这显然阻止了 SMP 机器上的任何形式的内核并行性,并严重影响了性能。 软中断 代表了 BHs 在 2.4 时代的演变,并且与 tasklet 一起,属于内核软件中断系列,这些代码片段可以在内核请求时执行,而没有严格的响应时间保证。
与 BHs 的主要区别在于,同一个软中断可以同时在多个 CPU 上运行。如果需要串行化,现在必须通过使用内核自旋锁显式获得。
软中断 的处理核心在 kernel/softirq.c 中的 do_softirq() 例程中执行。此函数检查一个位掩码,如果设置了与给定软中断对应的位,则它会调用相应的处理例程。在 NET_RX_SOFTIRQ 的情况下,这是我们此时感兴趣的,相关的函数是 net/core/dev.c 中的 net_rx_action()。 do_softirq() 函数可能会从内核内部的三个不同位置调用:kernel/irq.c 中的 do_IRQ(),它是通用的中断处理程序;kernel/entry.S 中的系统调用出口点;以及 kernel/sched.c 中的 schedule(),它是主进程调度函数。
换句话说,软中断的执行可能发生在硬件中断已被处理、应用程序级进程调用系统调用或新的进程被调度执行时。这样,软中断会得到足够频繁的刷新,以至于它们都不会等待太久才轮到它们。
对于旧式的底半部,触发机制也完全相同。
我们已经看到数据包通过网络接口进入,并排队等待稍后处理。然后,我们已经考虑了如何通过调用 net_rx_action() 函数来恢复此处理。现在是时候看看这个函数是做什么的了。基本上,它的操作非常简单:它只是从当前 CPU 的队列中出队第一个数据包 (sk_buff),并遍历数据包处理程序的两个列表,调用相关的处理函数。
关于这些列表以及它们是如何构建的,值得多说几句。这两个列表分别称为 ptype_all 和 ptype_base,分别包含通用数据包和特定数据包类型的协议处理程序。协议处理程序在内核启动时或创建特定套接字类型时注册自己,声明它们可以处理哪些协议类型;涉及的函数是 net/core/dev.c 中的 dev_add_pack(),它添加一个数据包类型结构(参见 include/linux/netdevice.h),其中包含指向在收到该类型的数据包时将调用的函数的指针。注册后,每个处理程序的结构要么放入 ptype_all 列表(对于 ETH_P_ALL 类型),要么哈希到 ptype_base 列表(对于其他 ETH_P_* 类型)。
因此,NET_RX 软中断所做的是按顺序调用每个注册的处理数据包协议类型的协议处理程序函数。通用处理程序(即 ptype_all 协议)首先被调用,而与数据包的协议无关;然后是特定的处理程序。正如我们将看到的,PF_PACKET 协议注册在两个列表之一中,具体取决于应用程序选择的套接字类型。另一方面,正常的 IP 处理程序注册在第二个列表中,并使用密钥 ETH_P_IP 进行哈希处理。
如果队列包含多个数据包,net_rx_action() 会在数据包上循环,直到已处理最大数量的数据包 (netdev_max_backlog) 或在此处花费的时间过长(时间限制为 1 个时钟节拍,即大多数内核上的 10 毫秒)。如果 net_rx_action() 中断循环并留下一个非空队列,则再次启用 NET_RX_SOFTIRQ,以便稍后恢复处理。
IP 协议接收函数,即 ip_rcv() (在 net/ipv4/ip_input.c 中),由在内核启动时注册的数据包类型结构指向 (ip_init(), 在 net/ipv4/ip_output.c 中)。显然,IP 的注册协议类型是 ETH_P_IP。
因此,每当类型为 ETH_P_IP 的数据包出队时,都会在软中断处理期间从 net_rx_action() 中调用 ip_rcv()。此函数对 IP 数据包执行所有初始检查,主要包括验证其完整性(IP 校验和、IP 头部字段和最小有效数据包长度)。如果数据包看起来正确,则调用 ip_rcv_finish()。作为旁注,对此函数的调用会通过 Netfilter 预路由控制点,这实际上是通过 NF_HOOK 宏实现的。
ip_rcv_finish(),仍在 ip_input.c 中,主要处理 IP 的路由功能。它检查数据包是否应转发到另一台机器,或者它是否发往本地主机。在前一种情况下,执行路由,并通过适当的接口发送数据包;否则,执行本地交付。所有的魔力都由 ip_route_input() 函数实现,该函数在 ip_rcv_finish() 的开头被调用,它通过在 skb->dst->input 中设置适当的函数指针来确定下一步处理步骤。对于本地绑定的数据包,此指针是 ip_local_deliver() 函数的地址。 ip_rcv_finish() 以调用 skb->dst->input() 结束。
此时,数据包肯定正在向上层协议移动。控制权传递给 ip_local_deliver();此函数仅处理 IP 分片重组(如果 IP 数据报被分片),然后转到 ip_local_deliver_finish() 函数。就在调用它之前,执行另一个 Netfilter 钩子 (ip-local-ip)。
后者是最后一个涉及 IP 级别处理的调用;ip_local_deliver_finish() 执行仍待完成的任务,以完成第 3 层的上层部分。IP 头部数据被修剪,以便数据包准备好传输到第 4 层协议。进行检查以评估数据包是否属于原始 IP 套接字,在这种情况下,将调用相应的处理程序 (raw_v4_input())。
原始 IP 是一种协议,允许应用程序直接伪造和接收自己的 IP 数据包,而无需进行实际的第 4 层处理。其主要用途是用于需要发送特定数据包以执行其任务的网络工具。此类工具的著名示例是 ping 和 traceroute,它们使用原始 IP 构建具有特定头部值的数据包。原始 IP 的另一个可能的应用是,例如,在用户级别实现自定义网络协议(例如 RSVP,资源预留协议)。原始 IP 可以被认为是 PF_PACKET 协议系列的等效标准,只是向上移动了一个开放系统互连 (OSI) 层。
但是,最常见的情况是,数据包将 направляться 到进一步的内核协议处理程序。为了确定它是哪个协议处理程序,检查 IP 头部内的协议字段。内核在此时使用的方法与 net_rx_action() 函数采用的方法非常相似;定义了一个哈希表,称为 inet_protos,其中包含所有已注册的后 IP 协议处理程序。哈希键当然是从 IP 头部的协议字段派生的。 inet_protos 哈希表在内核启动时由 inet_init() (在 net/ipv4/af_inet.c 中) 填充,它重复调用 inet_add_protocol() 以注册 TCP、UDP、ICMP 和 IGMP 处理程序(后者仅在启用多播时)。完整的协议表定义在 net/ipv4/protocol.c 中。
对于每个协议,都定义了一个处理程序函数:tcp_v4_rcv()、udp_rcv()、icmp_rcv() 和 igmp_rcv() 是与上述协议对应的显而易见的名称。因此,调用这些函数之一以继续进行数据包处理。该函数的返回值用于确定是否必须向发送者返回 ICMP 目标不可达消息。当上层协议不将数据包识别为属于现有套接字时,就会发生这种情况。正如您从上一篇文章中回忆的那样,嗅探网络数据的问题之一是拥有一个能够接收数据包的套接字,而与其端口/地址值无关。这里(以及刚刚提到的 *_rcv() 函数中)是该限制产生的地方。
至此,数据包已经走了一半以上的旅程。由于我们挚爱的杂志空间有限,我们将把数据包交给上层第 3 层协议,直到下个月。仍然有待探索的是第 4 层处理(TCP 和 UDP)、PF_PACKET 的处理,当然还有套接字过滤器钩子和实现。请耐心等待!