MPTCP 握手全解剖:一条连接是如何"长出"多条腿的

1 阅读21分钟

MPTCP 深度解析系列(2/20)


一辆测试车停在停车场。工程师打开笔记本,把抓包工具接到 T-Box 的网口上,运行了一条命令:

tcpdump -i eth0 -nn -X 'tcp[tcpflags] & tcp-syn != 0'

屏幕上开始滚动数据包。第一行 SYN 包的十六进制输出里,藏着几个陌生的字节:

0x0040:  1e0c 0001 0000 0000 1a2b 3c4d 5e6f ...

1e = 十进制 30,这是 MPTCP 的 TCP Option 类型号。后面跟着长度、子类型、密钥……

工程师盯着这串字节,脑子里转了个问题:这里面到底发生了什么?

这篇文章,就是那个问题的完整答案。

我们将跟着这辆 T-Box,经历四幕:连接建立(MP_CAPABLE)→ 新路径加入(MP_JOIN)→ 地址通告(ADD_ADDR)→ 中间设备的破坏与应对。每一幕,我们都会深入到字节的级别。


一、地基:TCP Options 空间,MPTCP 的"巴掌大的地盘"

在拆解任何握手细节之前,得先搞清楚 MPTCP 是在多么拥挤的空间里工作的。

40 字节,仅此而已

TCP 头部有一个固定的 20 字节结构(源端口、目的端口、序列号、确认号……),剩下的空间留给"Options"字段。TCP 头部长度字段(Data Offset)用 4 bit 表示,最大值是 15 个 32 bit 单位,也就是 60 字节。减去固定的 20 字节,Options 空间上限是 40 字节

听起来不算少。但这 40 字节在一个 SYN 包里早就被"预约"得差不多了:

MSS(Maximum Segment Size):       4 字节
Window Scale(RFC 7323):          3 字节
SACK Permitted(RFC 2018):        2 字节
Timestamps(RFC 7323):           10 字节
────────────────────────────────────
以上合计:                         19 字节
剩余给 MPTCP:                 最多 21 字节

而 MPTCP 的 MP_CAPABLE 选项在 SYN 包里需要 12 字节,在第三个 ACK 包里需要 20 字节。这就意味着,设计 MPTCP 选项格式的工程师们,从第一天起就知道自己在玩一个极限压缩游戏。

MPTCP 选项的通用格式

所有 MPTCP 选项都共用一个框架:

+──────+────────+──────────────+──────────────+
│ Kind │ Length │ SubType(4b)  │   Data...    │
│  301 byte │ + Flags(4b)  │              │
+──────+────────+──────────────+──────────────+
  • Kind = 30:这是 IANA 分配给 MPTCP 的选项类型号,固定不变
  • SubType(4 bit):区分具体功能

完整的 SubType 列表:

SubType名称作用
0MP_CAPABLE连接建立时的能力协商
1MP_JOIN加入新子流
2DSS数据序列信号(每个数据包携带)
3ADD_ADDR通告新的可用地址
4REMOVE_ADDR撤销某个地址
5MP_PRIO子流优先级调整
6MP_FAIL通知数据不一致
7MP_FASTCLOSE快速关闭整个连接
8MP_TCPRST携带原因码的重置(v1 新增)

这张表就是 MPTCP 在 TCP 头部能做到的所有事情。记住这张表,后面每一个机制你都能找到对应的 SubType。

打个比方:这 40 字节的 Options 空间,就像名片的背面——你要在这巴掌大的地方,印上公司名、职位、手机号、微信、邮箱、二维码……每多一样就要挤掉别的内容。MPTCP 的工程师们在这里玩的,是把每一 bit 都压榨干净的极限设计艺术。

实战:用以下命令抓 SYN 包,找 1e(十六进制 30)这个字节,就是 MPTCP 选项的起点:

tcpdump -i any 'tcp[tcpflags] & tcp-syn != 0' -X -nn

二、MP_CAPABLE:第一次握手的秘密

三次握手,每一步都有戏

03-mp-capable-handshake.png

T-Box 向云端服务器发出第一个 SYN 包。在普通 TCP 的三次握手之上,MPTCP 悄悄多做了三件事。

第一步:SYN 包(T-Box 发出)

TCP Options 中的 MP_CAPABLE:
┌──────┬────────┬──────────────────────┐
│  30  │   12   │ SubType=0 | Flags    │
│      │        │ C=1 H=0 ...          │
├──────┴────────┴──────────────────────┤
│         Sender Key(64 bit)          │
│         T-Box 随机生成,唯一标识本端   │
└──────────────────────────────────────┘

这里的 Sender Key 是 T-Box 启动时随机生成的 64 bit 密钥,简称 Key_A。它不是用来加密数据的——它是用来派生连接的"身份证号"和安全基础材料的。

Flags 字段里有几个能力协商位:

  • C(Checksum):表示本端要求计算 MPTCP 数据校验和
  • H:算法标识位,v1 中 H=0 代表使用 HMAC-SHA256(v0 用 SHA1)

第二步:SYN-ACK(服务端回应)

┌──────┬────────┬──────────────────────┐
│  30  │   12   │ SubType=0 | Flags    │
├──────┴────────┴──────────────────────┤
│         Sender Key(64 bit)          │
│         服务端随机生成,简称 Key_B    │
└──────────────────────────────────────┘

服务端生成自己的 Key_B,放在同样的位置发回来。此时,T-Box 已经同时持有了 Key_A(自己的)和 Key_B(服务端的)。

第三步:第三个 ACK(v1 的重大改进)

v0 的第三个 ACK 只是一个普通的 ACK,没有额外动作。v1 在这里做了一个重要优化:

┌──────┬────────┬──────────────────────┐
│  3020   │ SubType=0 | Flags    │
├──────┴────────┴──────────────────────┤
│         Key_A(64 bit)              │
│         Key_B(64 bit)              │
│         Data Length(可选)          │
└──────────────────────────────────────┘

v1 允许在第三个 ACK 中直接携带应用数据(Data Length > 0)。这意味着连接建立完成的那一刻,第一批业务数据就已经在路上了,比 v0 节省了一个 RTT。

对于 T-Box 这种云控场景,100ms 的端到端延迟预算里,一个 RTT 可能就是 20–50ms——每一毫秒都不能浪费。

两把密钥生什么用?

握手结束后,双方各自执行同样的计算:

H  = HMAC-SHA256(Key_A || Key_B)
Token  = H 的前 32 bit    ← 这条连接的"身份证号",后续 MP_JOIN 用
IDSN_A = H 的后 64 bit    ← T-Box 的 Data Sequence Number 初始值

H2 = HMAC-SHA256(Key_B || Key_A)
IDSN_B = H2 的后 64 bit   ← 服务端的 DSN 初始值

这两个派生值非常关键:

Token(32 bit) 是整条 MPTCP 连接的唯一标识符。后续每次 MP_JOIN 都要带上它,让服务端知道"这条新的 TCP 连接是要加入哪个 MPTCP 会话的"。

IDSN(Initial Data Sequence Number) 是数据序列号的随机起点。这是 v1 相对 v0 最重要的安全改进。

v0 的安全漏洞——为什么 DSN 不能从 0 开始

v0(RFC 6824)的数据序列号从固定值 0 开始。听起来无害,但存在一个实际攻击面:

攻击者如果能嗅探到一条 MPTCP 连接的存在(比如通过旁信道),就可以预测 DSN 的值,然后构造一个携带伪造 DSS 选项的 TCP 包,注入到连接中。这就是数据注入攻击(TCP data injection)的 MPTCP 变种。

v1 的修复很优雅:IDSN 基于 HMAC-SHA256 随机生成。攻击者不知道 Key_A 和 Key_B,就无法预测 IDSN,更无法伪造有效的 DSN。

打个比方:v0 的 DSN 就像银行支票的流水号从 0001 开始——知道你今天用了 0100,就能猜到明天用 0101。v1 改成了随机的 8 位密码,猜不出来。

三种"中道崩殂"的场景

不是每次 MP_CAPABLE 握手都能成功。有三种常见的失败路径:

场景 A:服务端不支持 MPTCP

  • T-Box 发 SYN+MP_CAPABLE,服务端的 SYN-ACK 里没有 MP_CAPABLE
  • T-Box 静默回退到普通 TCP,连接照样建立
  • 应用程序无感知,只是失去了多路径能力

场景 B:中间设备剥离了 MP_CAPABLE

  • T-Box 发 SYN+MP_CAPABLE,防火墙把 Kind=30 的选项删掉,服务端收到的是普通 SYN
  • 服务端回 SYN-ACK,没有 MP_CAPABLE → 结果与场景 A 相同

场景 C:中间设备篡改了 MP_CAPABLE(最危险)

  • 某些"智能"代理设备会修改 TCP Options 内容,导致双方的 Key 对不上
  • 这种情况 MPTCP 握手会失败,但 TCP 连接不会断——会触发 MP_FASTCLOSE 后回退

三种场景的共同结论:TCP 连接本身从不中断,只是多路径能力的开关被关上了。 这就是 MPTCP 优雅降级设计的价值所在。

v0 vs v1 对比:

维度RFC 6824(v0)RFC 8684(v1)
安全算法HMAC-SHA1HMAC-SHA256
第三个 ACK仅 Key,不含数据可携带应用数据(少一个 RTT)
IDSN从 0 开始,可预测基于 HMAC 随机生成
版本协商Flags 中携带版本号,支持三级回退
连接关闭机制不完善新增 MP_FASTCLOSE、MP_TCPRST

02-mp-capable-bytes.png 图 1:MP_CAPABLE 握手的字节布局,以及 Key_A/Key_B 派生 Token 和 IDSN 的过程

实战:用 Wireshark 过滤 MPTCP 握手:

# Wireshark 显示过滤器
mptcp.subtype == 0

# tcpdump:找十六进制输出里的 1e 0c(Kind=30, Len=12)
tcpdump -i eth0 -nn -X 'tcp[tcpflags] & tcp-syn != 0'

三、MP_JOIN:长出第二条腿

01-tcp-vs-mptcp.png

02-mptcp-architecture.png

为什么 MP_JOIN 不是一条普通 TCP 连接?

T-Box 刚刚通过 4G 建立了第一条子流,MPTCP 连接跑起来了。这时车辆换了地方,5G 信号出现了。T-Box 想把 5G 这条路也用上——它需要建立第二条子流,把它"插入"已有的 MPTCP 连接。

问题来了:为什么不直接建一条普通 TCP 连接,然后"声称"它属于这个 MPTCP 会话?

答案很简单:没有认证机制,任何人都可以伪造

攻击者如果能嗅探到你的 MPTCP Token(这个值在 MP_CAPABLE 完成后就固定了),就可以向你的服务器发一个伪造的 MP_JOIN SYN,把一条恶意连接"插入"你的 MPTCP 会话,然后注入数据或者劫持流量。

所以 MP_JOIN 需要同时解决两个问题:

  1. 身份绑定:证明"这条新 TCP 连接,是 Token=XXXX 那个 MPTCP 会话的合法成员"
  2. 双向认证:客户端验证服务端(防止连接到仿冒服务器),服务端验证客户端(防止伪造子流)

这套双向认证机制,就是 MP_JOIN 三次握手的核心。

MP_JOIN 三次握手的字节布局

第一步:SYN(T-Box 发起新子流)

┌──────┬────────┬──────────────────────────┐
│  3012   │ SubType=1 | Flags | B    │
├──────┴────────┴──────────────────────────┤
│  Address_ID(1 byte)                     │
│  Token(32 bit)← 连接身份证号            │
│  Nonce_A(32 bit,随机数)               │
└──────────────────────────────────────────┘

这里有三个关键字段:

  • Token:从 MP_CAPABLE 派生的 32 bit 标识,服务端用它找到对应的 MPTCP 会话
  • Address_ID:标识这条子流来自哪个网络接口。4G=1,5G=2——是逻辑编号,不是 IP 地址
  • Nonce_A:客户端随机生成的 32 bit 随机数,用于 HMAC 计算,防重放攻击

第二步:SYN-ACK(服务端回应)

┌──────┬────────┬──────────────────────────┐
│  3016   │ SubType=1 | Flags        │
├──────┴────────┴──────────────────────────┤
│  Address_ID(1 byte,服务端接口标识)     │
│  Truncated_HMAC_B(64 bit)              │
│  Nonce_B(32 bit,服务端随机数)         │
└──────────────────────────────────────────┘

服务端在这一步证明自己的身份:Truncated_HMAC_B 是用 Key_B 计算出的截断 HMAC,让客户端能验证"这确实是我最初握手的那个服务端"。

第三步:第三个 ACK(客户端完成认证)

┌──────┬────────┬──────────────────────────┐
│  30  │   24   │ SubType=1 | Flags        │
├──────┴────────┴──────────────────────────┤
│  HMAC_A(160 bit = 20 字节)             │
└──────────────────────────────────────────┘

客户端在这一步证明自己的身份:HMAC_A 是用 Key_A 计算出的完整 HMAC(20 字节),让服务端能验证"这条新子流确实来自知道 Key_A 的客户端"。

HMAC 计算的完整公式

这是整个 MPTCP 握手里最容易搞混的地方。把它写清楚:

# 服务端计算 Truncated_HMAC_B(放入 SYN-ACK):
HMAC_B_full = HMAC-SHA256(
    key  = Key_B,
    msg  = Nonce_A || Nonce_B    ← 先 A 后 B(64 bit 拼接)
)
Truncated_HMAC_B = HMAC_B_full 的前 8 字节(64 bit)

# 客户端计算 HMAC_A(放入第三个 ACK):
HMAC_A = HMAC-SHA256(
    key  = Key_A,
    msg  = Nonce_B || Nonce_A    ← 注意:先 B 后 A,顺序反过来了!
)
HMAC_A_20 = HMAC_A 的前 20 字节(160 bit)

注意 Nonce_A || Nonce_BNonce_B || Nonce_A 的顺序是反的——这是故意的,确保两个 HMAC 用不同的输入,防止一方推算出另一方的 HMAC。

用 Python 可以直接验证:

import hmac, hashlib, os

# 模拟握手材料(实际来自抓包)
Key_A  = bytes.fromhex('1a2b3c4d5e6f7890')  # T-Box 的 Key
Key_B  = bytes.fromhex('abcdef0123456789')  # 服务端的 Key
Nonce_A = bytes.fromhex('11223344')
Nonce_B = bytes.fromhex('aabbccdd')

# 服务端计算 Truncated_HMAC_B
hmac_b = hmac.new(Key_B, Nonce_A + Nonce_B, hashlib.sha256).digest()
truncated_hmac_b = hmac_b[:8]  # 前 64 bit
print(f"Truncated_HMAC_B: {truncated_hmac_b.hex()}")

# 客户端计算 HMAC_A(注意 Nonce 顺序反了)
hmac_a = hmac.new(Key_A, Nonce_B + Nonce_A, hashlib.sha256).digest()
hmac_a_20 = hmac_a[:20]  # 前 160 bit
print(f"HMAC_A: {hmac_a_20.hex()}")

把这段代码跑一遍,再对着 Wireshark 里的 MP_JOIN 抓包,你就能亲眼验证每一个字节从哪来、到哪去。

类比:酒店换房卡系统

整个 MP_JOIN 认证机制,用一个日常场景来理解:

你入住了一家酒店(MP_CAPABLE 握手),前台给了你一张主卡(Key_A),并登记了你的房间号(Token = 1506)。

第二天,你的朋友来找你,想帮你拿快递进房间(MP_JOIN,新子流)。但你不能直接把主卡给他——酒店不允许。

正确流程是:

  1. 朋友去前台,报出房间号 1506(SYN 里的 Token)
  2. 前台查到房间号,用你的入住信息做一个临时验证码给朋友(SYN-ACK 里的 Truncated_HMAC_B)
  3. 朋友把临时验证码发给你,你用主卡确认这确实是我们酒店的前台给的(第三个 ACK 里的 HMAC_A)
  4. 验证通过,朋友拿到了进房间的权限

全程没有泄露主卡(Key_A 和 Key_B 始终在双方内存里,不出现在网络包里);前台(服务端)和你(客户端)互相验证了身份;朋友(新的 5G 子流)只是载体,无法单独伪造权限。

Address_ID 的妙用:穿越 NAT 的钥匙

每个网络接口被分配一个 1 字节的 Address_ID(0–255),由 Path Manager 在本地管理。这个 ID 是双方约定的逻辑标识,不是 IP 地址。

为什么不直接用 IP 地址?因为 NAT。

T-Box 的 4G 接口在运营商侧的 IP 是 10.x.x.x(内网),经过 CGNAT 后变成了某个公网 IP,云端服务器实际看到的是这个公网 IP。如果子流的标识绑定在 IP 上,T-Box 和云端根本对不上号。

绑定在 Address_ID 上就不一样了:T-Box 内部知道"Address_ID=1 对应我的 4G 接口",服务端知道"Address_ID=1 对应那个从 IP=x.x.x.x 来的子流"——即使 NAT 改变了 IP,双方的逻辑映射不变。

这一个 byte 的设计,是 MPTCP 能在真实运营商网络上工作的关键之一。

实战

# 查看当前子流的 Address_ID 分配
ss -Mtnpi

# 示例输出片段:
# mptcp seq:... subflows:2 add_addr_signal:0
# src 10.0.0.1:443 dst 192.168.1.100:54321 uid:1001

四、ADD_ADDR:告知更多地址

场景:云端有备用 IP,想让 T-Box 来连

T-Box 已经建立了 4G 和 5G 两条子流,数据在并行流动。这时,云端服务器发现自己的灾备机房刚刚启动,有一个新的 IP 地址可用,想让 T-Box 过来建第三条子流……

这就是 ADD_ADDR 的场景。

ADD_ADDR 的字节格式(v1)

┌──────┬────────┬──────────────────────────────┐
│  30  │  N     │ SubType=3 | IPver | E        │
├──────┴────────┴──────────────────────────────┤
│  Address_ID(1 byte)                         │
│  IP 地址(4 byte for IPv4,16 byte for IPv6) │
│  Port(2 byte,可选)                         │
│  Truncated_HMAC(8 byte,v1 新增)            │
└──────────────────────────────────────────────┘

几个字段值得特别说明:

E 标志(Echo)E=0 表示"我在通告自己的地址",E=1 表示"我在回显你之前通告给我的地址"(用于地址确认,v1 新增,现在很少用)。

Truncated_HMAC(v1 新增):这是 v1 修复 v0 安全漏洞的核心改动。

v0 的 ADD_ADDR 漏洞

v0 的 ADD_ADDR 没有任何认证——任何人都可以向 T-Box 发送一个伪造的 ADD_ADDR,把里面的 IP 地址换成攻击者控制的服务器。T-Box 收到后,会乖乖地向这个 IP 发起 MP_JOIN……这个 MP_JOIN 里包含了 Token、Nonce 等信息,攻击者可以分析甚至劫持连接。

这是一种 SSRF(Server-Side Request Forgery)的变体。

v1 的修复:ADD_ADDR 里加入了 Truncated_HMAC,用 Key_B 派生计算,验证通告来自合法的对端。T-Box 收到 ADD_ADDR 后,先验证 HMAC,通过才去建子流。

谁发 ADD_ADDR?谁去连?

这是部署时经常搞反的问题。

原则:有公网 IP 的一侧发 ADD_ADDR,另一侧去发起 MP_JOIN。

在 T-Box 场景下:

  • T-Box 在 CGNAT 后面,它的"地址"在运营商侧是私有 IP,没有意义
  • 云端服务器通常有公网 IP
  • 因此:服务端发 ADD_ADDR,T-Box 收到后决定是否发起 MP_JOIN

Linux 内核控制这个行为的参数:

# T-Box 侧:最多接受几个 ADD_ADDR 并主动去建子流
ip mptcp limits set add_addr_accepted 2

# 最多允许几条子流并行
ip mptcp limits set subflows 4

# 查看当前配置
ip mptcp limits show

REMOVE_ADDR:极简的地址撤销

当某个地址不可用时(比如服务端的备用机房下线),发送方会用 REMOVE_ADDR 通知对方:

┌──────┬────────┬──────────────────────────┐
│  30  │   4    │ SubType=4                │
├──────┴────────┴──────────────────────────┤
│  Address_ID(1 byte)                     │
└──────────────────────────────────────────┘

注意:只有 4 字节,只携带 Address_ID,没有 IP 地址。原因同前:NAT 后面的 IP 可能已经变了,Address_ID 才是不变的逻辑标识。

收到 REMOVE_ADDR 后,该 Address_ID 对应的所有子流会被关闭。

Path Manager 的角色(埋钩子)

ADD_ADDR 的收发决策和 MP_JOIN 的发起,都由 Linux 内核的 Path Manager(路径管理器) 负责。内核默认实现是 pm_netlink,通过 netlink 接口接受用户空间的配置。

但默认的 Path Manager 不够聪明:它不知道 5G 信号强度、不知道当前拥塞状态、不知道应该"什么时候激活备用子流才划算"。如果你想要精细控制(比如:4G 延迟超过 80ms 才启动 5G 子流;或者两条链路同时可用时优先用 5G 做主路径),需要写自定义 Path Manager。

这个话题在系列第 6 篇会深入到 net/mptcp/pm_netlink.c 的源码层面。

实战

# 查看 MPTCP 端点配置
ip mptcp endpoint show

# 监控 MPTCP 事件(ADD_ADDR / REMOVE_ADDR 实时可见)
ip monitor mptcp

# 示例输出:
# [MPTCP_EVENT_ANNOUNCE] msk=0x... addr=10.10.1.1 id=2 flags=...

五、中间设备的破坏与工程应对

理论很美好,现实里有运营商。

T-Box 跑在公网上,数据包会经过运营商的 CGNAT、防火墙、DPI 设备。这些设备大多数是在 MPTCP 诞生之前就设计的,它们不认识 Kind=30 的 Options,处理方式五花八门。

三种破坏模式和诊断方法

模式一:TCP Options 剥离

这是最常见的情况。防火墙或 DPI 设备把它不认识的 TCP Options 直接删掉——"不认识的东西,删了保险"。

  • 症状:T-Box 发的 SYN 里有 MP_CAPABLE,但抓到的 SYN-ACK 里没有 → MPTCP 自动回退普通 TCP

  • 诊断

    nstat -az | grep MPTCPMPCapableFallbackACK
    # 这个计数器增长 = 发生了回退
    

模式二:MP_JOIN SYN 被防火墙拦截

最坑的情况之一。有些防火墙配置了"只允许出站连接"的策略——它认识第一条子流的 SYN(因为是 T-Box 主动发起的),但 MP_JOIN 的 SYN 如果是从服务端向 T-Box 发起的(服务端主动建子流),就会被当作"非法入站连接"丢弃。

  • 症状:第一条子流建立成功,MP_JOIN 的 SYN 始终收不到 SYN-ACK

  • 诊断

    ss -Mtnpi | grep "subflows=1"
    # 只有 1 条子流,但预期有 2 条
    

模式三:CGNAT 的 HMAC 联动破坏

这是技术含量最高的破坏方式。CGNAT 在改写 IP 地址和端口时,会同步更新 TCP Checksum(这是正确的)。但 CGNAT 不懂 MPTCP,它不会更新 MP_JOIN 选项里的 HMAC——因为 HMAC 的计算输入里没有包含源 IP/Port(这是 MPTCP 设计时有意为之的)。

理论上这应该是安全的(HMAC 不依赖 IP),但某些 CGNAT 实现会把 TCP Options 整块修改,导致 HMAC 字节被破坏。

  • 症状:MP_JOIN 握手完成了,但数据传输阶段出现大量 RST 或 HMAC 验证失败

  • 诊断

    nstat -az | grep MPTCPMPJoinAckHMACFailure
    # HMAC 校验失败计数增长
    

中国运营商的现实(给 T-Box 工程师的实战总结)

基于实际测试经验:

接入类型MPTCP 兼容性建议
移动/联通/电信 公网 4G不稳定,部分省份 CGNAT 会剥离 Options用探测脚本先验证,做好回退预案
联通/电信 企业专线 APN(to B)较好,通常透传 TCP Options推荐用于生产环境
5G SA 独立组网(试商用)理论上支持 ATSSS,MPTCP 最友好稳定性待验证,持续跟进
WiFi + 4G 双链路(室内)通常 WiFi 侧无问题,4G 侧看运营商参考上述 4G 建议

落地前的标准检查流程

# 第一步:全量 MPTCP 诊断
nstat -az | grep -i mptcp

# 第二步:查看连接状态和子流数量
ss -Mtnpi

# 第三步:查看实时 MPTCP 事件
ip monitor mptcp

# 第四步:如果怀疑 Options 被剥离,抓 SYN 包对比
tcpdump -i eth0 -nn -X 'tcp[tcpflags] & tcp-syn != 0' | head -100

# 关注以下内核计数器含义:
# MPTCPMPCapableSYNRxMismatch  → MP_CAPABLE 被中间设备修改
# MPTCPMPJoinSynRx             → 收到 MP_JOIN SYN(服务端侧用)
# MPTCPMPJoinAckHMACFailure    → HMAC 校验失败,中间设备篡改
# MPTCPMPCapableFallbackACK    → 客户端侧回退到普通 TCP
# MPTCPMPCapableFallbackSYNACK → 服务端侧回退到普通 TCP

关于中间设备的兼容性问题,以及 MPTCP 在中国运营商网络环境下的完整应对方案——包括 NAT 穿越技巧和 DPI 识别规避——我们会在系列第 4 篇专门展开讲。


六、完整流程:一图看懂四幕

把四章的内容串成一张时序图:

T-Box(4G 接口)                         云端服务器
    │                                       │
    │──SYN [MP_CAPABLE Key_A]──────────────>│  ① T-Box 通告能力,带上自己的密钥
    │<──SYN-ACK [MP_CAPABLE Key_B]──────────│  ② 服务端回应,带上自己的密钥
    │──ACK [MP_CAPABLE Key_A+B + 初始数据]─>│  ③ v1:连接建立同时发数据(省 1 RTT)
    │                                       │
    │<══════ 4G 子流正常传输数据 ══════════>│
    │                                       │
    │──SYN [MP_JOIN Token,Nonce_A]─────────>│  ④ 5G 接口发起第二条子流
    │<──SYN-ACK [MP_JOIN HMAC_B,Nonce_B]────│  ⑤ 服务端用 Key_B 证明身份
    │──ACK [MP_JOIN HMAC_A]────────────────>│  ⑥ T-Box 用 Key_A 证明身份,双向认证完成
    │                                       │
    │<══════ 4G + 5G 双路并行 ════════════>│
    │                                       │
    │<──ADD_ADDR [AddrID=2, IP=y.y.y.y]─────│  ⑦ 服务端通告备用地址(含 HMAC 认证)
    │                                       │
    │──SYN [MP_JOIN Token,Nonce_C]─────────>│  ⑧ T-Box 主动连备用地址(第三条子流)
    │<──SYN-ACK [MP_JOIN HMAC_D,Nonce_D]────│
    │──ACK [MP_JOIN HMAC_C]────────────────>│  ⑨ 第三条子流建立完成
    │                                       │
    │<══════ 4G + 5G + 备用,三路并行 ═════>│

这九步,就是一条 MPTCP 连接从无到有、从一条路变成三条路的完整生命历程。


写在最后

回到停车场里那台 T-Box,屏幕上的 1e 0c 00 01 1a 2b 3c 4d 5e 6f 78 90 现在你能读懂了:

  • 1e = Kind=30,这是 MPTCP
  • 0c = Len=12,选项总长度 12 字节
  • 00 = SubType=0(高 4 bit),Flags(低 4 bit)
  • 01 = Flags 字节
  • 后面 8 字节 = Sender Key(Key_A),T-Box 的随机密钥

每一个字节背后,都是 IETF 工作组十年工程经验的结晶:40 字节的 Options 空间压榨到极致、HMAC 双向互认保安全、Address_ID 抽象掉 NAT 地址的变化……

这套机制不完美——中间设备的破坏让 MPTCP 在某些运营商网络上举步维艰。但它在真实互联网上运行了十年,撑住了苹果数亿台设备的流量,也正在撑起云控车联网的生死线。

下一篇预告:「MPTCP vs MPQUIC:下一代多路径传输谁更有前途?」——我们暂别字节级的细节,抬头看看另一个挑战者的设计哲学,以及内核态 vs 用户态的这场根本性取舍,最终会走向何方。


参考资料

  1. RFC 8684: TCP Extensions for Multipath Operation with Multiple Addresses, IETF, 2020
  2. RFC 6824: TCP Extensions for Multipath Operation with Multiple Addresses, IETF, 2013
  3. RFC 8041: Use Cases and Operational Experience with Multipath TCP, IETF, 2017
  4. Linux kernel source: net/mptcp/options.c(MP_CAPABLE/MP_JOIN 解析实现)
  5. Linux kernel source: net/mptcp/crypto.c(HMAC 计算实现)
  6. mptcp.dev: 官方测试工具与诊断指南
  7. Bagnulo et al., "Multipath TCP Security Analysis", RFC 6182 Appendix, 2011