跳到主要内容

4.7 重组:把碎掉的镜子拼回来

上一节我们像切菜一样把大包切成了碎片,这很爽,但也留下了一个巨大的烂摊子。

想象一下你是接收端的内核。你刚才看到的不是一个个整齐划一的数据包,而是一堆乱七八糟飞来的片段:有的头先到,有的尾先到,有的中间隔着几百毫秒。甚至,这一堆碎片里可能混杂着黑客故意抛进来的「恶意碎片」——它们就像是为了迷惑你而存在的错误拼图碎片。

如果你是内核,你怎么知道这些碎片属于同一个包?万一中间丢了一片,你要等多久才能放弃?如果有人发了一堆首尾重叠的碎片来搞你,你该怎么防?

这就是本节的主题——重组。它是 ip_fragment() 的逆运算,也是网络栈里最棘手、最容易出安全漏洞的地方之一。

检查碎片:ip_is_fragment()

重组不是免费午餐,它是昂贵的 CPU 和内存操作。所以内核在动真格之前,会先做一件轻量级的事情:检查这个包到底是不是碎片

这个过程发生在 ip_local_deliver() 里——当包已经通过了路由查找,被确认为是要发给本地机器的时候。

int ip_local_deliver(struct sk_buff *skb)
{
/*
* Reassemble IP fragments.
*/
if (ip_is_fragment(ip_hdr(skb))) {
if (ip_defrag(skb, IP_DEFRAG_LOCAL_DELIVER))
return 0;
}

return NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_IN, skb, skb->dev, NULL,
ip_local_deliver_finish);
}

这里调用的 ip_is_fragment() 是一个极其精简的判断逻辑。它盯着 IPv4 头部的 frag_off 字段看。你可能会想:「这还不简单?看 MF 标志位就行了。」

直觉又错了。

如果是最后一个碎片呢?它的 MF 位是 0,表示后面没东西了,但它依然是个碎片。如果是中间的碎片呢?MF 是 1,偏移量也不是 0。只有原始包(或者根本没分片的包)才是 MF 为 0 且偏移量为 0。

所以内核的判断逻辑是这样的:

static inline bool ip_is_fragment(const struct iphdr *iph)
{
return (iph->frag_off & htons(IP_MF | IP_OFFSET)) != 0;
}

只要 frag_off 字段里,MF 标志位或者偏移量任何一项不为 0,它就是碎片。

这意味着:

  1. 第一片:偏移量是 0,但 MF 是 1 → 返回 true。
  2. 中间片:偏移量不是 0,MF 是 1 → 返回 true。
  3. 最后一片:偏移量不是 0,但 MF 是 0 → 返回 true。

任何满足这三个条件之一的,都会被扔进 ip_defrag() 的绞肉机里。

寻找归属:四维定位

一旦确认为碎片,内核就要给它找家了。

你不能简单地拿个全局链表把所有碎片往里扔——那样效率太低。内核使用的是哈希表,而哈希值的计算依赖于一个四维坐标。这个坐标必须在所有碎片中完全一致,才能证明它们是「一家人」:

  1. ** Identification (id)**:我们在上一节分片时提到过,这是同一个包被切分后共享的身份证号。
  2. 源地址 (saddr):谁发的。
  3. 目的地址 (daddr):发给谁。
  4. 协议:是 TCP 还是 UDP(这决定了上层协议)。

内核用这个四元组调用哈希函数 ipqhashfn,在全局的哈希表里查找对应的 ipq(IP queue)结构体。这个结构体就是这一家人在内核里的临时户籍档案:

struct ipq {
struct inet_frag_queue q;
u32 user; // 谁在用这个队列(本地?防火墙?)
__be32 saddr; // 源 IP
__be32 daddr; // 目的 IP
__be16 id; // 身份证 ID
u8 protocol; // 协议号
u8 ecn; // 显式拥塞通知支持
int iif; // 入站接口索引
unsigned int rid; // 路由 ID
struct inet_peer *peer; // 对端信息
};

这里有个很有意思的设计细节:IPv4 的重组逻辑实际上是与 IPv6 共享的。你看这个 struct ipq,它内部嵌入了一个 struct inet_frag_queue q。这个通用结构体和相关的底层方法(比如 inet_frag_findinet_frag_evictor)并不是 IPv4 专用的,IPv6 也在用。这说明分片和重组的痛苦是协议无关的,内核设计者把这部分痛苦抽象成了一套通用框架。

重组的入口:ip_defrag()

当我们真正进入 ip_defrag() 时,第一件事不是干活,而是打扫卫生

int ip_defrag(struct sk_buff *skb, u32 user)
{
struct ipq *qp;
struct net *net;

net = skb->dev ? dev_net(skb->dev) : dev_net(skb_dst(skb)->dev);
IP_INC_STATS_BH(net, IPSTATS_MIB_REASMREQDS);

/* Start by cleaning up the memory. */
ip_evictor(net);

ip_evictor(net) 这一行至关重要。它会在开始新的重组任务前,检查内存压力。如果系统里的碎片队列太多,占用的内存超过了阈值,它就会无情地踢掉一些最老的队列(inet_frag_evictor)。这意味着如果网络太拥塞或者内存太紧,你的包可能会在重组前就被扫地出门。

清理完卫生后,就是经典的「查找或创建」逻辑:

/* Lookup (or create) queue header */
if ((qp = ip_find(net, ip_hdr(skb), user)) != NULL) {
int ret;

spin_lock(&qp->q.lock);
ret = ip_frag_queue(qp, skb);
spin_unlock(&qp->q.lock);
ipq_put(qp);
return ret;
}

IP_INC_STATS_BH(net, IPSTATS_MIB_REASMFAILS);
kfree_skb(skb);
return -ENOMEM;

ip_find() 会根据我们刚才说的四维坐标去哈希表里找。如果找到了,返回现有的 qp;如果没找到,它会创建一个新的 ipq 结构体并初始化。如果连创建都失败了(内存耗尽),那就只能更新失败计数 IPSTATS_MIB_REASMFAILS 并扔掉包。

插入碎片:乱序与重叠的艺术

拿到队列 qp 之后,重头戏来了——ip_frag_queue()。这个函数的任务是把刚到来的 SKB 插入到队列的正确位置上。

注意,碎片到达的顺序是完全不可控的。你可能先收到了第三片,然后才收到第一片。所以 ipq->q.fragments 这个链表必须按偏移量严格排序。

在插入之前,内核还得解决一个非常棘手的问题:重叠

这种情况可能发生:比如同一个包的第一片发了一次,路由器觉得丢了,源端又重传了一次第一片(或者中间有攻击者故意发重叠包)。内核必须精确地处理这种重叠,丢弃多余的数据,防止数据错乱或者被利用来进行缓冲区溢出攻击。这部分逻辑在源码里非常冗长,全是边界检查,我们这里重点看它如何确定位置和插入逻辑。

首先,内核计算当前碎片的结束位置:

/* Determine the position of this fragment. */
end = offset + skb->len - ihl;
err = -EINVAL;

/* Is this the final fragment? */
if ((flags & IP_MF) == 0) {
/* If we already have some bits beyond end
* or have different end, the segment is corrupted.
*/
if (end < qp->q.len ||
((qp->q.last_in & INET_FRAG_LAST_IN) && end != qp->q.len))
goto err;
qp->q.last_in |= INET_FRAG_LAST_IN;
qp->q.len = end;
} else {
...
}

这里有个关键变量 qp->q.len,它记录的是整个原始包的总长度

  • 当我们收到一个 MF 为 0 的包(最后一片),我们就知道这个包的总长度是 end
  • 如果我们之前收到的碎片暗示的总长度和现在矛盾,或者已经有更远的碎片存在,那说明数据包坏了,直接丢掉。

接下来是寻找插入位置的链表遍历。这是一段教科书级的链表插入逻辑:

prev = NULL;
for (next = qp->q.fragments; next != NULL; next = next->next) {
if (FRAG_CB(next)->offset >= offset)
break; /* bingo! */
prev = next;
}

FRAG_CB 是一个宏,用来从 SKB 的控制块(cb)里拿取存储在该 SKB 内部的偏移量信息。它是个很小的辅助宏,但极其关键:

#define FRAG_CB(skb) ((struct ipfrag_skb_cb *)((skb)->cb))

找到位置(prevnext)之后,就可以动手插入链表了:

FRAG_CB(skb)->offset = offset;
/* Insert this fragment in the chain of fragments. */
skb->next = next;
if (!next)
qp->q.fragments_tail = skb;
if (prev)
prev->next = skb;
else
qp->q.fragments = skb;
...
qp->q.meat += skb->len;

这里的 qp->q.meat 是个很形象的变量名(中文叫「肉」)。它记录的是当前已经收集到的有效数据的总长度

每当有一个新碎片成功插入,meat 就增加一点。

大团圆:ip_frag_reasm()

什么时候才算拼完了?

两个条件必须同时满足:

  1. 收到了最后一片INET_FRAG_LAST_IN 标志位已置位,意味着我们确切知道总长 len)。
  2. 收集到的有效数据长度等于总长度qp->q.meat == qp->q.len)。

当这两个条件达成,内核知道拼图完成了,是时候把它们粘成一个完整的 SKB 了。

if (qp->q.last_in == (INET_FRAG_FIRST_IN | INET_FRAG_LAST_IN) &&
qp->q.meat == qp->q.len) {
unsigned long orefdst = skb->_skb_refdst;

skb->_skb_refdst = 0UL;
err = ip_frag_reasm(qp, prev, dev);
skb->_skb_refdst = orefdst;
return err;
}

进入 ip_frag_reasm() 后,内核面临一个最大的挑战:内存分配

它需要一个新的 buffer 来存放完整的 IP 数据包。这个 buffer 的大小是 ihlen + qp->q.len

/* Allocate a new buffer for the datagram. */
ihlen = ip_hdrlen(head);
len = ihlen + qp->q.len;

err = -E2BIG;
if (len > 65535)
goto out_oversize;
...

这里有一个硬性检查:len > 65535。IPv4 的总长度字段只有 16 位,最大值就是 65535。如果碎片拼起来超过这个数,那一定是哪里出了问题(可能是恶意碎片),必须丢掉。

如果长度合法,内核就会把所有碎片的 skb_copy_bits 到这个新的大 SKB 里,调整 IP 头部,清除所有分片标志,把它变成一个看起来从未被切割过的完整包。

至此,那个被打碎的镜子,终于被拼回了原样,被送往传输层继续它的旅程。


重组的阴暗面:定时炸弹

既然我们提到了 ip_defrag(),就不得不提一个让无数网络工程师和内核开发者头疼的机制:超时

重组不是无限期的。如果有人发给你第一个碎片,然后发个呆,不发剩下的,你的内核就要一直占用内存守着这个残缺的包吗?当然不。

每个 ipq 队列都有一个定时器。如果在这个时间内(默认是 30 秒,可以在 /proc/sys/net/ipv4/ipfrag_time 里调)重组没完成,ip_expire() 方法就会被触发。它会发送一个 ICMP Time Exceeded 消息告诉对端,并清空整个队列。

这个机制的存在,也是为了防止一种经典的 DoS 攻击:Teardrop 攻击。攻击者发送一堆精心构造的、首尾严重重叠的碎片,让内核在计算重叠时陷入死循环或者崩溃。虽然现在的内核已经修补了大部分重叠计算的漏洞,但这种超时机制依然是抵御资源耗尽攻击的最后一道防线。