6.7 路由器的「悄悄话」:ICMPv4 Redirect
上一节我们聊了 FIB 那精密的静态结构。你可以把它想象成一张挂在墙上的、画得一丝不苟的地图。
但现实网络是动态的。有时,路由器会发现手里那张「地图」不够精准,或者更糟——它发现旁边的邻居正在走弯路。
这时候,路由器不会干看着。它会敲敲邻居的肩膀,发一条消息:「嘿,别送给我了,直接走那边更快。」
这就是 ICMPv4 Redirect(重定向) 机制存在的意义。
场景设定:一次尴尬的「绕路」
在深入代码之前,我们先建立一个直观的场景。这比干巴巴的定义要好理解得多。
想象一个简单的局域网(192.168.2.0/24)。三台机器连在同一个交换机上:
- AMD 服务器:192.168.2.200(发送方)
- Windows 服务器:192.168.2.10(默认网关)
- 笔记本电脑:192.168.2.7(最终目标)
正常情况下,AMD 服务器访问笔记本(ping 192.168.2.7),应该直接通过二层 ARP 把包发过去。
但假设管理员在 AMD 服务器上配了一条非常奇怪的命令:
ip route add 192.168.2.7 via 192.168.2.10
这条命令告诉内核:「去 192.168.2.7 的包,必须先送给 192.168.2.10(Windows 服务器)。」
于是,尴尬的事情发生了:
- AMD 服务器把发给笔记本的包,发给了 Windows 服务器。
- Windows 服务器收到包,查路由表,发现:「哎?这目标地址就在我这根网线上啊,我自己能发,为什么要我中转?」
- Windows 服务器意识到这是一条次优路径。
ICMP Redirect 就是为了解决这种尴尬而设计的。
Windows 服务器会把包转发给笔记本(保证业务不中断),但同时,它会给 AMD 服务器回一个 ICMP Redirect 消息:
- 类型:ICMP Redirect (Type 5)
- 代码:
ICMP_REDIR_HOST(Redirect Host) —— 意思是「针对这个特定主机,改道」 - 新网关地址:192.168.2.7(告诉 AMD:「下次直接发这就行了」)
发起重定向:内核的判定逻辑
内核并不是一遇到转发就发 Redirect,那样网络会被这种消息淹没。它有一套严格的判定标准。
这个过程分两步走:
- 定罪阶段:在路由查找输入路径 (
__mkroute_input) 中打上一个标记。 - 宣判阶段:在转发路径 (
ip_forward) 中真正发送消息。
第一步:打标记 (__mkroute_input)
当数据包到达路由器需要进行转发时,内核会调用 fib_lookup 找到路由结果,然后进入 __mkroute_input 构建路由缓存项。在这里,内核会灵魂拷问:「这包是不是走冤枉路了?」
来看这个关键的 if 判断:
/* net/ipv4/route.c */
static int __mkroute_input(struct sk_buff *skb,
const struct fib_result *res,
struct in_device *in_dev,
__be32 daddr, __be32 saddr, u32 tos)
{
struct rtable *rth;
struct in_device *out_dev;
unsigned int flags = 0;
/* ... 省略部分代码 ... */
/*
* 核心判断:必须同时满足以下所有条件,才会设置 RTCF_DOREDIRECT 标志
* 1. out_dev == in_dev: 进接口和出接口是同一个(这是最明显的“绕路”特征)
* 2. IN_DEV_TX_REDIRECTS(out_dev): sysctl 开关允许发送重定向
* 3. 必须满足以下之一:
* a. IN_DEV_SHARED_MEDIA: 这是一个共享介质(比如以太网)
* b. inet_addr_onlink(...): 源地址和下一跳网关在同一个子网内
*/
if (out_dev == in_dev && err && IN_DEV_TX_REDIRECTS(out_dev) &&
(IN_DEV_SHARED_MEDIA(out_dev) ||
inet_addr_onlink(out_dev, saddr, FIB_RES_GW(*res)))) {
flags |= RTCF_DOREDIRECT; /* 好了,标记一下,准备发 Redirect */
/* 既然要发重定向,说明路由马上要变了,这包的缓存先别加 */
do_cache = false;
}
/* 将标记写入路由表项 */
rth->rt_flags = flags;
/* ... */
}
为什么要这么苛刻?
- 进接口 = 出接口:如果包从 eth0 进来,又从 eth0 出去,说明源主机和目的主机在同一个网段,它本该直接发,为什么要经过我?这符合「次优路由」的定义。
- Shared Media:如果是点对点链路(比如 PPP),那么进接口肯定等于出接口,但那是正常的,不能发 Redirect。
- On-link Check:确保新的网关建议是合理的,不会把主机指到一个不可达的地方。
第二步:发消息 (ip_forward)
标记打好后,数据包进入转发流程 ip_forward()。在这里,内核会最后一次确认,然后发出「整改通知」。
/* net/ipv4/ip_forward.c */
int ip_forward(struct sk_buff *skb)
{
struct iphdr *iph = ip_hdr(skb);
struct rtable *rt = skb_rtable(skb);
struct ip_options *opt = &(IPCB(skb)->opt);
/*
* 检查是否允许发送重定向:
* 1. rt->rt_flags & RTCF_DOREDIRECT: 刚才在 __mkroute_input 里打的标记
* 2. !opt->srr: IP 选项里没有 Strict Source Route (源站选路),如果用户强制指定路径,别瞎掺和
* 3. !skb_sec_path(skb): 这不是个 IPsec 包 (IPsec 解包后可能导致 iface 相同,但那是假象)
*/
if (rt->rt_flags & RTCF_DOREDIRECT && !opt->srr && !skb_sec_path(skb))
ip_rt_send_redirect(skb);
/* ... 继续转发数据包 ... */
}
最后,在 ip_rt_send_redirect() 中,内核构造 ICMP 包并发出:
/* net/ipv4/route.c */
void ip_rt_send_redirect(struct sk_buff *skb)
{
/* ... */
/*
* 发送 ICMP Redirect 消息
* ICMP_REDIR_HOST: 代码,表示针对主机重定向
* rt_nexthop(...): 这是从路由项里取出的“更优的下一跳”地址
* 在我们的例子里,这个值就是 192.168.2.7
*/
icmp_send(skb, ICMP_REDIRECT, ICMP_REDIR_HOST,
rt_nexthop(rt, ip_hdr(skb)->daddr));
}
接收重定向:听话与防骗
现在场景换过来。AMD 服务器收到了 Windows 服务器发来的 ICMP Redirect。它会怎么做?
它不会立刻修改主路由表(FIB)——那样太危险了,随便谁发个包就能改我的路由表,网络还得了?
它会做两件事:
- 严格的安检:这消息靠谱吗?
- 建立“例外”:在 FIB 之上建立一条临时的、针对特定流的 Nexthop Exception。
处理逻辑集中在 __ip_do_redirect() 方法中。
1. 安检逻辑
/* net/ipv4/route.c */
static void __ip_do_redirect(struct rtable *rt, struct sk_buff *skb,
struct flowi4 *fl4, bool kill_route)
{
__be32 new_gw = icmp_hdr(skb)->un.gateway; /* ICMP 建议的新网关 */
__be32 old_gw = ip_hdr(skb)->saddr; /* 原来的网关(发消息给我的那个) */
struct net_device *dev = skb->dev;
struct in_device *in_dev;
/* ... */
/* 第一关:消息里的“旧网关”必须跟我现在的路由网关一致 */
if (rt->rt_gateway != old_gw)
return;
in_dev = __in_dev_get_rcu(dev);
if (!in_dev)
return;
/* 第二关:各种变态过滤条件 */
if (new_gw == old_gw || /* 新旧一样?耍我呢? */
!IN_DEV_RX_REDIRECTS(in_dev) || /* sysctl 允许接收重定向吗? */
ipv4_is_multicast(new_gw) || /* 新网关不能是组播 */
ipv4_is_lbcast(new_gw) || /* 不能是受限广播 */
ipv4_is_zeronet(new_gw)) /* 不能是 0.0.0.0 */
goto reject_redirect;
/* 第三关:如果是非共享介质,必须严格检查新网关是否在链路上 */
if (!IN_DEV_SHARED_MEDIA(in_dev)) {
if (!inet_addr_onlink(in_dev, new_gw, old_gw))
goto reject_redirect;
/* secure_redirects 检查:新网关本身得是一个合法的默认路由候选者 */
if (IN_DEV_SEC_REDIRECTS(in_dev) && ip_fib_check_default(new_gw, dev))
goto reject_redirect;
} else {
/* 共享介质下的额外检查 */
if (inet_addr_type(net, new_gw) != RTN_UNICAST)
goto reject_redirect;
}
/* ... 通过安检,开始干活 ... */
这一大堆 goto reject_redirect 是为了防止攻击者伪造 ICMP Redirect 包把流量劫持走。历史上很多网络攻击就是靠乱发 ICMP Redirect 搞定老旧系统的。
2. 建立 FIB Nexthop Exception
一旦通过安检,内核就会执行核心操作——更新或创建一个 FIB nexthop exception。
还记得我们在本章开头提过的 FIB 架构吗?fib_info 里存了 nexthop 信息。直接改 fib_info 会影响全局,所以内核在这里用了 Exception 机制。
/* ... 接上文 __ip_do_redirect ... */
/* 去邻居子系统找一下这个新网关的邻居项 */
n = ipv4_neigh_lookup(&rt->dst, NULL, &new_gw);
if (n) {
/* 如果邻居状态不是有效的(NUD_VALID),先发个 ARP 问问 */
if (!(n->nud_state & NUD_VALID)) {
neigh_event_send(n, NULL);
} else {
/* 如果是合法的,再次查 FIB 表(为什么?为了拿到 fib_nh 指针) */
if (fib_lookup(net, fl4, &res) == 0) {
struct fib_nh *nh = &FIB_RES_NH(res);
/*
* 关键操作:更新/创建 FNHE (FIB Nexthop Exception)
* fl4->daddr: 目标地址 (192.168.2.7)
* new_gw: 新的网关 (192.168.2.7)
* 后面两个 0 是关于 PMTU 和其他指标的
*/
update_or_create_fnhe(nh, fl4->daddr, new_gw, 0, 0);
}
/* 如果需要,把旧的路由缓存标记为过时 */
if (kill_route)
rt->dst.obsolete = DST_OBSOLETE_KILL;
/* 发送邻居更新通知 */
call_netevent_notifiers(NETEVENT_NEIGH_UPDATE, n);
}
neigh_release(n);
}
return;
reject_redirect:
/* ... 报错或日志 ... */
这里发生了什么?
内核并没有改全局的 FIB 表。它针对的是「去往 192.168.2.7」这一条特定的流,在 fib_nh 上面挂了一个 Exception:「以后去 192.168.2.7,别走网关了,直接发 192.168.2.7。」
这就体现了 Linux 路由设计的精妙之处:
- 全局还是按规矩走 FIB(
via 192.168.2.10)。 - 局部(经过 Redirect 教育后的流)走 Exception,直接发包。
历史的回响:IPv4 Routing Cache 的消亡
既然提到了 Redirect 和路由缓存,我们不得不花点篇幅聊聊一个已经被移除的机制——IPv4 Routing Cache。
如果你翻阅旧版本文档(内核 3.6 以前),你会发现整个路由子系统是围绕着“路由缓存”设计的。
那个时代是这样的:
- Routing Cache:一个巨大的 Hash 表,它是查找的第一站。Key 是。
- FIB Tables:只有在 Cache Miss 时才会去查这里,查到后再塞回 Cache。
为什么移除了它? 这听起来很美好——快取快用。但有一个致命缺陷:DoS 攻击。
因为每个唯一的都会生成一个缓存条目。攻击者只需要向随机 IP 地址发包(比如 1.2.3.4, 1.2.3.5...),内核就会疯狂地创建 Cache 条目,直到内存耗尽。
虽然它有垃圾回收机制(GC),但在高强度攻击下,GC 本身也会把 CPU 吃满。
转折点:FIB TRIE (LC-trie) 为了干掉 Cache,内核必须让 FIB 表本身的查找足够快。解决方案就是引入 FIB TRIE(一种基于最长前缀匹配的树状结构)。
FIB TRIE 的查找复杂度是 O(length of address),在大规模路由表下比 Hash 表更稳,且没有 Hash 碰撞的烦恼。当 FIB TRIE 足够快时,那个臃肿且不安全的 Cache 层就成了累赘。
于是,在 2012 年左右的内核 3.6 中,ip_rt_cache 彻底退场。
对于“考古”的意义
虽然现代内核没有它了,但在工业界(比如 RHEL 6 或更老的嵌入式系统),你依然可能遇到这些代码。如果你在老内核的日志里看到 rt_intern_hash 或者 ip_route_input_slow,别惊慌,那就是旧时代的遗迹。
本章回响
路由器不是只会默默转发的哑巴。
通过 ICMP Redirect,路由器能够告诉主机「有一条更近的路」。但这并不简单,内核需要小心翼翼地平衡信任与安全:既要听话地优化路径(创建 FNHE),又要防止被恶意消息欺骗(Strict Checks)。
这一章,我们从 fib_lookup 的逻辑,一路讲到 FIB 表的 TRIE 结构,再到 Redirect 的动态修正。我们看到了内核是如何把一张静态的路由表,变成一个动态、高效且安全的转发系统的。
下一章,我们将把目光投向更远的地方——多路径路由。当一条路不够走,或者为了负载均衡时,内核是如何同时在多条路上起舞的?