4.4 当 IP 选项在包里醒来
上一节我们看着组播包在内核里走完了它的一生,从 ip_rcv 进来,要么本机接收,要么继续转发。那条路径很清晰,就像高速公路上没有障碍物一样。
但现实中的网络世界没那么干净。
IPv4 头部里藏着一种古老且时常让人头疼的机制:IP 选项(IP Options)。虽然现在的互联网流量里,带 IP 选项的包少之又少——因为它们会拖慢路由器的转发速度——但在某些特定场景下,比如你想追踪数据包走过的路径(ping -R)或者做些精确的时间戳记录,它们就会冒出来。
更麻烦的是,处理 IP 选项需要完全不同的代码路径。一旦遇到带选项的包,内核不能像往常那样快速转发,必须停下来解析、处理,甚至修改头部。
既然上一节的结尾提到了它们,我们就在这个章节里彻底解决掉这个历史包袱。
4.4.1 常见的 IP 选项类型
先别急着看内核代码,我们先看看“敌军”有哪些。RFC 791 定义了一大堆 IP 选项,虽然现在很多已经成了“化石”,但内核依然要能处理它们。
你可以把这些选项想象成贴在包裹上的快递面单备注:大部分包裹不需要备注,直接贴单发货;但有些特殊包裹,发货人会在备注栏里写上“必须经过中转站 A”、“请记录到达时间”或者“严禁中转”。
面单大小是有限的(IPv4 头部最多 60 字节),备注自然也有上限。下面是几种最常见的备注类型:
记录路由
这是最直观的一个:让路上的路由器把自己的 IP 地址填进去。
ping -R 用的就是这个。如果你在命令行里敲了这行命令,发出的 ICMP 请求包里就会带一个 IPOPT_RR 选项。每个经过的路由器(如果它支持且没因为安全策略禁用)都会把它的出口 IP 地址填进这个选项的缓冲区里。
听着很美好?现实是残酷的。
IPv4 头部总共最多只有 40 字节给选项用(因为头部最长 60 字节,固定 20 字节),扣掉选项本身的元数据(类型、长度、指针),一个 IP 地址占 4 字节,所以你最多只能记录 9 个 IP 地址。如果你的网络跳数超过 9 个,后面的路由器想填也填不下了——这时候它们通常就直接转发,不再理会这个选项。
而且,出于安全原因(防止攻击者探测网络拓扑),很多现代路由器默认会直接丢弃或忽略带 Record Route 的包。所以如果你兴冲冲地试了一下 ping -R,结果发现啥都没记录下来,别惊讶,连 man ping 里都特意写了这行字:“Many hosts ignore or discard this option.”
Stream ID (IPOPT_SID)
这是给老式 SATNET 网络用的,用来携带一个 16 位的流标识符。现在基本看不到了,你可以把它当成“古董”,但在代码里它依然占有一席之地。
严格源记录路由 (IPOPT_SSRR) 与 宽松源记录路由 (IPOPT_LSRR)
这两个是“强制导航”选项。
- SSRR (Strict):数据包必须严格按照列表里的地址顺序传输,中间绝不允许经过列表之外的路由器。
- LSRR (Loose):数据包必须经过列表里的所有路由器,但中间允许经过其他未列出的路由器。
这听起来很强,但也正是由于安全问题(攻击者可以利用这招进行 IP 欺骗或绕过防火墙),现在的网络设备大多也会直接禁用这两个选项。
路由器警告 (IPOPT_RA)
这个选项像是给包插了一面小旗子,告诉路上的路由器:“喂,别瞎转发,停下来仔细看看我的内容!”
这通常用于 RSVP(资源预留协议)或组播协议。一旦路由器看到 IPOPT_RA,就知道这个包可能包含它需要处理的控制信息,不能直接扔给快速转发路径。
4.4.2 内核怎么表示这些选项?
到了内核层面,Linux 不会直接拿着原始的 IPv4 头部字节流去比对。那样太慢也太容易出错。它会把这些零散的字节解析成一个结构体:struct ip_options。
你可以把这个结构体理解为把“面单备注”翻译成内部工作单。快递员看不懂潦草的手写备注,但看不懂标准化的内部工单。
这个结构体定义在 include/net/inet_sock.h 里:
struct ip_options {
__be32 faddr; /* 保存的第一跳地址 */
__be32 nexthop; /* LSRR/SSRR 的下一跳地址 */
unsigned char optlen; /* 选项总长度,不超过 40 字节 */
unsigned char srr; /* 源路由选项 (SRR) 的偏移量 */
unsigned char rr; /* 记录路由 (RR) 的偏移量 */
unsigned char ts; /* 时间戳 选项的偏移量 */
/* 下面是一堆标志位,打包成一个 unsigned char 和几个 bit field */
unsigned char is_strictroute:1, /* 是否使用了严格源路由 (SSRR) */
srr_is_hit:1, /* 目的地址是否命中本机 (SRR 用) */
is_changed:1, /* IP 头是否被修改过 (校验和需重算) */
rr_needaddr:1, /* 是否需要记录 RR 地址 */
ts_needtime:1, /* 是否需要记录时间戳 */
ts_needaddr:1; /* 是否需要记录时间戳对应的地址 */
unsigned char router_alert; /* 路由器警告选项的值 */
unsigned char cipso; /* CIPSO 安全选项 */
unsigned char __pad2;
unsigned char __data[0]; /* 柔性数组,存放从用户空间来的原始数据 */
};
这里有几个字段需要特别留心,因为它们直接关系到后面的代码逻辑:
is_strictroute:如果是IPOPT_SSRR,这个位就是 1;如果是IPOPT_LSRR,它就是 0。这是区分严格和宽松模式的关键。rr_needaddr:这是一个“待办事项”标志。当内核解析到一个IPOPT_RR选项,并且发现还有空间记录地址时,它会把这位置 1。等到转发或发送时,看到这个标志就知道“哦,我得把当前接口的 IP 填进去”。is_changed:这是一个非常重要的脏位。一旦我们修改了 IP 选项里的任何内容(比如填入了一个新地址),IPv4 头部的校验和就失效了。这个位告诉内核:“别忘了我动过手脚,快去重算校验和!”
4.4.3 解析选项:ip_options_compile()
现在让我们进入接收路径的核心。当一个带着 IP 选项的数据包到达时,ip_rcv 最终会调用 ip_rcv_options,而后者的大脑就是 ip_options_compile()。
顾名思义,这个函数就像一个编译器:它读入原始的、字节级的 IP 选项流(源代码),然后“编译”成内核好懂的 struct ip_options 对象(目标代码)。
它的调用场景主要有两种:
- 接收路径:解析收到的数据包选项。此时
skb不为空,选项数据从skb里取。 - 发送路径:处理用户通过
setsockopt()设置的选项。此时skb为 NULL,选项数据从opt->__data里取。
我们主要看接收路径。
初始化:从哪里开始读?
代码的第一件事是确定“光标”的位置。
int ip_options_compile(struct net *net, struct ip_options *opt, struct sk_buff *skb)
{
...
unsigned char *optptr;
unsigned char *iph;
if (skb != NULL) {
/* 接收路径:选项紧跟在固定 20 字节的 IP 头部后面 */
optptr = (unsigned char *)&(ip_hdr(skb)[1]);
} else {
/* 发送路径:选项从用户空间拷贝到了 __data 里 */
optptr = opt->__data;
}
/* 倒推 IP 头部位置(通用写法) */
iph = optptr - sizeof(struct iphdr);
...
}
注意这里的一个细节:&(ip_hdr(skb)[1])。这个语法有点像指针算术游戏。ip_hdr(skb) 返回的是 struct iphdr *。加 1 意味着指针向后移动了 sizeof(struct iphdr)(通常是 20)字节。这正是选项开始的地方。
循环解析:处理单字节选项
接下来就是在一个 for 循环里一个个过选项。每个选项的第一个字节是类型码(Type)。
最简单的类型是单字节选项:IPOPT_END(结束) 和 IPOPT_NOOP(空操作)。
IPOPT_NOOP:就是个填充物,遇到它就直接跳过,指针往后挪一位。IPOPT_END:这标志着选项列表的结束。根据 RFC 规定,IPOPT_END后面不能有任何有效选项。但为了安全,内核会把后面剩余的空间全部填满IPOPT_END,防止脏数据被误解析。同时,因为修改了头部,opt->is_changed会被置 1。
for (l = opt->optlen; l > 0; ) {
switch (*optptr) {
case IPOPT_END:
/* 把后面剩下的字节全填成 END,并标记已修改 */
for (optptr++, l--; l>0; optptr++, l--) {
if (*optptr != IPOPT_END) {
*optptr = IPOPT_END;
opt->is_changed = 1;
}
}
goto eol; /* 跳出循环 */
case IPOPT_NOOP:
l--;
optptr++;
continue; /* 继续下一个 */
}
...
}
循环解析:处理多字节选项
除了上面两个,其他选项都是多字节的。它们至少包含:类型(1字节)+ 长度(1字节)+ 数据。
这里有个经典的踩坑点:如果选项声称的长度比剩下的空间还大,或者长度本身小于 2,那就是个坏包。内核会设置 pp_ptr(指向出错位置)并跳转到 error 处理,发送 ICMP "Parameter Problem" 报文回去。
/* 读取选项长度(第二个字节) */
optlen = optptr[1];
/* 长度合法性检查:必须 >= 2 且不能超过剩余空间 */
if (optlen < 2 || optlen > l) {
pp_ptr = optptr;
goto error;
}
switch (*optptr) {
...
}
案例:处理 Record Route (IPOPT_RR)
终于到了重头戏。让我们看看内核是怎么处理 ping -R 发出的那个 Record Route 选项的。
结构回顾:RR 选项的结构是 [Type (1) | Len (1) | Ptr (1) | IP1 (4) | IP2 (4) | ...]。
- Ptr:这是一个偏移量指针,指向下一个可以填入 IP 地址的位置。
代码逻辑如下:
- 长度检查:选项至少得有 3 个字节(Type, Len, Ptr)。
- 指针检查:Ptr 的值至少得是 4(因为前 3 个字节是头部,数据区从第 4 个字节开始)。
- 溢出检查:
Ptr + 3不能超过总长度(否则指针指到天外去了)。 - 填入地址:如果还有空间,内核就把当前出口地址(通过
spec_dst_fill获取)拷贝到Ptr指向的位置。注意:memcpy的目标地址计算用了optptr[optptr[2]-1],这里的-1是因为选项里的 Ptr 是从 1 开始计数的(指向 Type 字段为 1),而 C 语言数组下标是从 0 开始的。 - 推进指针:填完之后,Ptr 要加 4,指向下一个空位。
- 设置标志:
opt->rr_needaddr = 1,表示下次还得继续处理(如果在转发路径上)。
case IPOPT_RR:
/* 检查最小长度 */
if (optlen < 3) {
pp_ptr = optptr + 1;
goto error;
}
/* 检查指针有效性 (Ptr >= 4) */
if (optptr[2] < 4) {
pp_ptr = optptr + 2;
goto error;
}
if (optptr[2] <= optlen) {
/* 检查是否有空间填入 4 字节地址 (Ptr + 3 > Len) */
if (optptr[2] + 3 > optlen) {
pp_ptr = optptr + 2;
goto error;
}
if (rt) { /* 如果路由项存在 */
spec_dst_fill(&spec_dst, skb); /* 获取出口地址 */
/* 填入地址:注意指针计算的细节 */
memcpy(&optptr[optptr[2]-1], &spec_dst, 4);
opt->is_changed = 1; /* 改了头部,校验和失效 */
}
/* 指针后移 4 字节 */
optptr[2] += 4;
/* 设置标志:告诉转发路径还得干活 */
opt->rr_needaddr = 1;
}
/* 记录 RR 选项在 IP 头中的偏移量 */
opt->rr = optptr - iph;
break;
案例:处理 Timestamp (IPOPT_TIMESTAMP)
时间戳选项比 RR 更复杂一点,因为它有几种模式。
它的结构多了一个字节:[Type | Len | Ptr | Flags (Overflow:4 | Flag:4)]。
- Overflow:高 4 位,记录因为空间不足导致无法记录的次数。
- Flag:低 4 位,定义了三种模式:
- 0 (IPOPT_TS_TSONLY):只记时间戳,不记 IP。每跳占 4 字节。
- 1 (IPOPT_TS_TSANDADDR):既记 IP 又记时间。每跳占 8 字节。
- 3 (IPOPT_TS_PRESPEC):只有当 IP 地址匹配预置列表时才记时间。
这里的逻辑也是各种检查边界,然后根据 Flag 决定是只写 4 字节的时间,还是 8 字节的“地址+时间”。
case IPOPT_TIMESTAMP:
/* ... 省略类似的长度和指针检查 ... */
/* 提取 Flag (低 4 位) */
switch (optptr[3] & 0xF) {
case IPOPT_TS_TSONLY:
if (skb)
timeptr = &optptr[optptr[2]-1];
opt->ts_needtime = 1;
optptr[2] += 4; /* 指针挪 4 字节 */
break;
case IPOPT_TS_TSANDADDR:
/* 检查空间是否足够 (8 字节) */
if (optptr[2] + 7 > optptr[1]) { ... }
if (rt) {
spec_dst_fill(&spec_dst, skb);
/* 先填 IP */
memcpy(&optptr[optptr[2]-1], &spec_dst, 4);
/* timeptr 指向 4 字节后的位置 */
timeptr = &optptr[optptr[2]+3];
}
opt->ts_needaddr = 1;
opt->ts_needtime = 1;
optptr[2] += 8; /* 指针挪 8 字节 */
break;
case IPOPT_TS_PRESPEC:
/* ... 预置地址逻辑 ... */
optptr[2] += 8;
break;
}
...
安全检查:源路由禁用
解析完所有选项后,内核会检查一个全局策略:是否允许源路由。
因为 SSRR 和 LSRR 带有巨大的安全风险,管理员通常会通过 sysctl net.ipv4.conf.all.accept_source_route 把它关掉。如果在解析时发现带了 SRR 选项,且系统配置不允许,那这个包就会被直接丢弃。
if (unlikely(opt->srr)) {
struct in_device *in_dev = __in_dev_get_rcu(dev);
if (in_dev) {
if (!IN_DEV_SOURCE_ROUTE(in_dev)) {
/* 策略禁止,丢弃 */
goto drop;
}
}
/* 处理源路由逻辑 */
if (ip_options_rcv_srr(skb))
goto drop;
}
4.4.4 转发路径与分片的冲突
你以为解析完就完事了?不,还有一个麻烦的地方:分片。
假设一个带 Record Route 选项的大包(比如 4000 字节)经过路由器,需要分片。IP 头部(包含选项)会被复制到每一个分片里。
但是!有些选项是不应该复制到所有分片的。比如 Record Route,你只想记录路由器的列表,如果每个分片都记录一次,不仅浪费空间,还会导致逻辑混乱(谁有空帮每个分片都盖一次章?)。
RFC 规定,选项类型字节的最高位(Copied Flag)决定了这一点:
- 1:必须复制到所有分片。
- 0:只复制到第一个分片。
不幸的是,IPOPT_RR 和 IPOPT_TIMESTAMP 的 Copied Flag 都是 0。这意味着只有第一个分片带着完整的选项,后面的分片不仅不带这些选项,连原来的位置都得用 IPOPT_NOOP 填充,保持头部对齐。
这就是 ip_options_fragment() 干的事。
它只处理第一个分片(被 ip_fragment 调用)。它会遍历选项列表,把那些 Copied Flag = 0 的选项全部挖掉,换成 IPOPT_NOOP。
void ip_options_fragment(struct sk_buff *skb)
{
unsigned char *optptr = skb_network_header(skb) + sizeof(struct iphdr);
struct ip_options *opt = &(IPCB(skb)->opt);
int l = opt->optlen;
while (l > 0) {
switch (*optptr) {
case IPOPT_END:
return;
case IPOPT_NOOP:
l--;
optptr++;
continue;
}
/* 检查该选项的 Copied Flag (最高位) */
if (!IPOPT_COPIED(*optptr)) {
/* 如果不用复制,就把它 memset 成 NOOP */
memset(optptr, IPOPT_NOOP, optptr[1]);
}
l -= optptr[1];
optptr += optptr[1];
}
/* 既然选项都被抹掉了,相关的标志也得清零 */
opt->ts = 0;
opt->rr = 0;
opt->rr_needaddr = 0;
/* ... */
}
这就是为什么你在抓包时,经常看到除了第一个分片外,后面的分片头部里全是 NOP (0x01)。
4.4.5 构建与发送:反其道而行之
前面讲的都是接收路径(解析)。如果是发送路径呢?比如你自己写了个 Raw Socket,想亲手构造一个带 Record Route 的包发给对面,这时候内核得把你的 struct ip_options “编译”回二进制流塞进 IP 头里。
这就是 ip_options_build() 干的事。它基本上是 ip_options_compile 的逆向操作:
- 把
opt->__data里的内容memcpy到 IP 头部选项区。 - 如果是源路由(SRR),把目的地址填进去。
- 如果是 RR 或 TS 且需要填数据,它负责填入当前的源地址或时间戳。
代码就不逐行贴了,逻辑很直白。值得注意的是,它也会调用 ip_send_check() 来重新计算校验和——因为修改头部是家常便饭。
至此,我们就把 IP 选项在内核里的来龙去脉理清楚了。
从接收时的编译解析(ip_options_compile),到转发时的严格检查(Source Route),再到分片时的部分丢弃(ip_options_fragment),最后是发送时的逆向构建(ip_options_build)。这是一条完整的链路。
虽然 IP 选项在现代网络中已属异类,甚至被视为性能杀手,但作为系统工程师,你得知道当它们出现时,内核是如何小心翼翼地处理这些“历史遗留问题”的。毕竟,你永远不知道什么时候某个古老的协议或者某个执着的网络工程师会扔给你一个带选项的包。
下一节,我们将离开这些复杂的选项处理逻辑,进入 IPv4 的最后一段旅程:发送路径。那时候,我们不再是被动接收,而是主动构造数据包,把它们推向网络。