跳到主要内容

10.7 NAT Traversal in IPsec

上一节我们还在享受 xfrm_lookup() 带来的「确定感」——策略匹配了,状态找到了,加密完成了,包发出去了。一切看起来都很美好。

但现实世界喜欢在这种时候给你一巴掌。

想象一下:你在家里搭建了一个 IPsec VPN,连接到公司的服务器。在家里,你是 NAT 之后的一台内网机(比如 192.168.1.5),你的路由器有一个公网 IP。当你兴致勃勃地试图建立隧道时,你发现隧道起不来,或者起来了之后马上就断。

为什么?因为在中间那个负责「翻译」地址的 NAT 设备眼里,你的 ESP 包简直就是天书。


NAT 设备为什么不放过 ESP

让我们站在 NAT 设备的视角看问题。NAT 的本职工作很单纯:改写源/目的 IP 地址(有时还有端口),然后修正 TCP 或 UDP 头部的校验和。

这里的关键词是修正校验和

传输层(TCP/UDP)计算校验和时,会把 IP 头部的源地址和目的地址也算进去(这叫 Pseudo-Header)。所以,一旦 NAT 改了 IP 地址,它必须同步更新 TCP/UDP 的校验和,否则接收方算出来的数对不上,直接把包扔了。

在传输模式下,ESP 会把整个 TCP/UDP 头部加密。

这就尴尬了。

NAT 设备看着一个 ESP 包:

  1. 它想改 IP 地址。
  2. 它想更新 TCP/UDP 校验和。
  3. 但是 TCP/UDP 头被加密了,它看不懂,更没法算校验和。

结果:NAT 设备只能做两件事:要么把包原样转发(但这通常不行,因为内网 IP 出不去),要么——绝大多数情况——直接把包丢弃。这就导致 IPsec 在 NAT 环境下彻底失效。

但也不是所有协议都有这个问题。比如 SCTP,它的校验和就不覆盖 IP 头部,所以它对 NAT 的变化是「免疫」的。可惜,我们日常用的主要是 TCP 和 UDP。

为了解决这个问题,IETF 搞了一个标准(RFC 3948),名字起得很直白——UDP Encapsulation of IPsec ESP Packets。这就是我们常说的 NAT-T(NAT Traversal)。

它的核心思想很简单:既然你不认 ESP,那我就把它伪装成你最爱的 UDP。


NAT-T 的游戏规则

在深入代码之前,先划几个重点,免得你踩坑:

  1. 只救 ESP,不救 AH:AH 协议本身会对 IP 头部做完整性校验,一旦 NAT 改了 IP 地址,AH 校验就会失败。所以 NAT-T 只对 ESP 有效。如果你还在用 AH,在 NAT 环境下基本无解。
  2. 拒绝手动密钥:NAT-T 无法配合手动密钥使用。它必须依赖 IKEv1 或 IKEv2 的协商机制。为什么?因为 NAT-T 的开启需要双方握手确认,这个过程是 IKE 协议的一部分。
  3. 软件层面的妥协:虽然 NAT-T 是 IPsec 的事,但其实很多 P2P 应用(尤其是 VoIP)也要处理类似的问题。像 STUN、TURN、ICE 这些协议,都是为了解决「在内网里怎么建立连接」的问题。甚至 strongSwan 还实现了类似 TURN 的中继服务,让两个都在 NAT 后面的客户端能通过第三方建立连接。

⚙️ NAT-T 的运作模式

NAT-T 的目标很明确:在 IP 头部和 ESP 头部之间,硬塞进去一个 UDP 头部。

这样,NAT 设备看到的就是一个标准的 UDP 数据包,它能改 IP,能改端口,能算 UDP 校验和,大家皆大欢喜。

1. 开启与协商

这一步是用户空间的活,但内核得配合。

在旧一点的软件里(比如 Openswan),你得在 /etc/ipsec.conf 里显式地写一句 nat_traversal=yes 才能激活这个功能。而在 strongSwan 的 IKEv2 守护进程 Charon 里,NAT-T 是默认开启且不能关闭的——作者显然认为现在的网络环境里,NAT 是常态,不支持 NAT-T 就是自绝于人民。

协商流程大致是这样的:

  • 第一步:确认能力 在 IKEv1 的主模式阶段,双方会交换 ISAKMP 消息。如果支持 NAT-T,会在 Vendor ID 字段里带上特定的标记(IKEv1)。在 IKEv2 里,这已经是标准的一部分了,不用特意声明。

  • 第二步:探测 NAT 双方互发 NAT-D(NAT Discovery)载荷。这其实是一个探测包:我知道我自己的 IP,我知道我看到的你的 IP,如果这两个值不匹配,或者中间经过的 IP 地址发生了变化,我们就知道中间有 NAT 设备在「搞鬼」。

一旦确认路径上有 NAT,双方就会立刻切换到 NAT-T 模式。

2. UDP 封装结构

切换模式后,原本长这样的 ESP 包:

[ IP Header ] [ ESP Header ] [ Encrypted Data ] [ ESP Trailer ] [ ICV ]

变成了这样:

[ IP Header ] [ UDP Header ] [ ESP Header ] [ Encrypted Data ] [ ESP Trailer ] [ ICV ]

关键细节

  • 端口号:UDP 头部的源端口和目的端口都是 4500。这是 IANA 规定的 NAT-T 专用端口。
  • 保活机制:NAT 映射是有超时时间的。如果长时间没有流量,NAT 设备会把映射关系删掉。为了防止这一点,NAT-T 要求每隔 20 秒发一次 Keep-Alive 包。
    • 保活包长什么样:它就是一个发送到 UDP 4500 端口的包,载荷只有一个字节,值为 0xFF。收到这个包,内核就知道这是在「刷存在」,不需要解密,也不需要往上层递送。

3. 内核态的接收路径(xfrm4_udp_encap_rcv

当这个「穿了马甲」的 UDP 包到达接收方内核时,会发生什么?

我们以前看 IPsec 接收路径时,数据包是直接从 IP 层(ip_protocol 注册的回调)进入 ESP 处理逻辑的。但现在不行了,因为 IP 层看到的是 UDP 协议,内核会把它当成普通 UDP 包交给 UDP 协议栈处理。

UDP 协议栈收到包后,会检查这个包是否是「封装」包。

net/ipv4/xfrm4_input.c 里,有一个专门处理这种情况的函数:xfrm4_udp_encap_rcv()

它的逻辑其实很直白(B 模式):

  1. 拦截:UDP 协议栈在处理数据之前,先调用这个函数。
  2. 剥离马甲:函数检查 UDP 端口是否为 4500,然后检查 UDP 载荷。
    • 如果载荷是 0xFF(Keep-Alive),直接丢弃,任务完成。
    • 如果是封装的 ESP 包,它会调用 xfrm4_rcv_encap()
  3. 移交 ESPxfrm4_rcv_encap() 会把 UDP 头部剥离,把指向 ESP 头部的指针传给正常的 IPsec 接收路径(xfrm_input())。

对于内核来说,剥离 UDP 头之后的包,和正常的 ESP 包没有任何区别。剩下的解密、验证、重组流程,全部复用我们上一节讲的逻辑。


回到那个「翻译官」的类比

你可以把 NAT-T 想象成给一份加密文件套上一个标准的快递信封。邮局(NAT)只看信封上的地址(IP 和 UDP 端口),它不管信封里装的是啥,只要地址对,它就负责转运。只有当信封到了目的地,收件人(内核)才会撕开信封,拿出里面的加密文件去处理。

这个信封(UDP 头)存在唯一的目的,就是为了骗过路上的邮局。这虽然有点「曲线救国」,但在现有 IPv4 地址枯竭、NAT 无处不在的现实下,这是最实用的解法。


走到这里,IPsec 的核心机制——从底层的 XFRM 框架,到加密算法的集成,再到策略与状态的博弈,以及最后为了穿过 NAT 而不得不做的 UDP 封装——我们就都串起来了。

这不仅仅是一堆协议和代码,这是一整套为了在不安全的网络上建立安全通道而演化出来的复杂工程。哪怕像 NAT 这样「破坏」互联网端到端原则的设计,IPsec 也能通过加一层封装来适配它。这种妥协与适应,或许才是工程世界的常态。