MPTCP 能做什么:多路径、子流与 CVE-2026-98432 的监听端口
最后更新:
手机从 Wi-Fi 走到 5G 覆盖边缘,正在跑的 SSH 会断。TCP 连接绑在源地址、源端口、目的地址、目的端口上,源地址一变,原来的连接就失效;应用要么重连,要么在传输层解决。
MPTCP 把这件事放到传输层解决。它仍然是 TCP,只是在 TCP 选项里加了一套多路径协商:一条 MPTCP 连接可以有多条普通 TCP 子流,每条子流走一条路径。应用读写的是一个 socket,看到的是同一条可靠字节流。
2026 年 10 月,MPTCP 因为另一个原因被提起。CVE-2026-98432 是 Linux IPv4 TCP 的 use-after-free,和 MPTCP 协议本身无关;但 Anthropic 公开的远程 root 利用打的是一个 MPTCP 监听端口。关掉 MPTCP 能去掉这条利用路径,底层的 TCP 问题还在。
一条连接,多条路径
MPTCP 是 TCP 的扩展,协议由 RFC 8684(MPTCPv1,2020 年)定义,废弃了 RFC 6824。它不改变 TCP 的可靠字节流语义,只是把一条字节流拆到多条路径上传输。
MPTCP 的选项和状态使用下面这些术语:
| 概念 | 作用 |
|---|---|
| MPTCP 连接 | 应用看到的 socket,由双方的 key/token 标识 |
| 子流 | 一条普通 TCP 连接,承载 MPTCP 数据的一部分 |
| DSN | MPTCP 层的数据序列号,用于跨子流重组 |
| DSS | 把子流的 TCP 序列号映射到 DSN |
| MP_CAPABLE | 初始握手,交换 key,确认双方支持 MPTCP |
| MP_JOIN | 为已有连接增加一条子流 |
| ADD_ADDR / REMOVE_ADDR | 通告或撤销可用地址 |
| MP_PRIO | 调整子流优先级,例如标记 backup |
| MP_FAIL | 子流级故障,进入恢复流程 |
| MP_FASTCLOSE | 快速关闭整条 MPTCP 连接 |
初始子流仍然是一次 TCP 三次握手,只是 SYN、SYN/ACK、ACK 里带了 MP_CAPABLE。双方各有一个 key,token 是 key 的哈希,后续 MP_JOIN 用它标识连接,不需要在网络上暴露 key。连接建立后,服务端可以用 ADD_ADDR 通告自己的另一个地址,客户端据此发起 MP_JOIN,形成第二条子流。
sequenceDiagram
autonumber
participant C as 客户端
participant S as 服务端
C->>S: SYN + MP_CAPABLE(key_C)
S->>C: SYN/ACK + MP_CAPABLE(key_S)
C->>S: ACK + MP_CAPABLE
Note over C,S: 双方由 key 计算 token,初始子流建立
S->>C: ADD_ADDR(10.0.2.2)
C->>S: MP_JOIN(token_S, nonce_C),新子流
S->>C: SYN/ACK + MP_JOIN(HMAC)
C->>S: ACK + MP_JOIN(HMAC)
Note over C,S: 两条子流属于同一条 MPTCP 连接
C-->>S: 数据走子流 1
C-->>S: 数据走子流 2
Note over C,S: 子流 1 失效,未确认数据在子流 2 重传
每条子流是独立的 TCP 连接,有自己的拥塞控制和重传。MPTCP 层用 DSN 把子流上的数据拼回一条字节流;一条子流丢包,可以在另一条子流上重传。Path Manager 负责子流的创建、删除和地址通告,Packet Scheduler 决定下一个报文走哪条子流。
对端或中间设备不支持 MPTCP 时,SYN/ACK 里没有 MP_CAPABLE,连接会降级成普通 TCP,应用不会收到错误。服务端可以先打开 MPTCP,客户端愿意用再用。
MPTCP 的几种用法
最常见的用法是换网。手机同时有 Wi-Fi 和蜂窝时,MPTCP 把两条路径都握在手里;Wi-Fi 消失后,未确认的数据从蜂窝重传,应用不需要重连。Apple 从 2013 年起就在智能手机上主要用它做这类切换,具体场景写在 mptcp.dev 的用例说明里。对 SSH、视频、游戏这类长连接,这比断线重连更接近用户想要的行为。
两条路径也可以同时用。固网加移动、专线加 5G,都可以让一条连接同时从两条路径发送数据。它不能简单当成链路捆绑:MPTCP 按连接调度,受接收窗口、拥塞控制和路径 RTT 差异影响。两条路径差距太大时,调度器未必能把带宽直接相加,所以它适合两条路径都能稳定到达对端的场景。
只需要备用路径时,可以给 endpoint 加 backup。默认调度器会优先使用非 backup 子流,只有其他子流不可用时才把数据放到 backup 上。服务端也可以通告多个地址,让客户端决定走哪条。这里的“选路”是连接内的子流选择,不是替换系统的路由表。
服务端通常是收益较小的一方。机房链路稳定、带宽充足,MPTCP 的主要价值在客户端侧;但服务端打开 MPTCP 后,客户端才有机会用。普通 TCP 客户端连接 MPTCP listener 时,内核在 accept() 时给应用一个普通 TCP socket,额外开销很小。麻烦的是 L4 负载均衡:ADD_ADDR 通告的地址和端口必须能把后续子流送回同一台后端,否则第二条子流可能落到另一台机器。常见做法是服务端设置 net.mptcp.allow_join_initial_addr_port=0,并用 signal endpoint 通告每台后端专属的地址或端口,负载均衡器按这个地址和端口转发。细节见 mptcp.dev 的 load balancer 说明。
MPTCP 不会自动生效。应用要显式创建 MPTCP socket,或者用 mptcpize(LD_PRELOAD)、eBPF 的 mptcpify 强制已有程序使用,这两个工具都只用于 Linux。它要求两端都实现,中间设备可能剥离 TCP options;也不是所有 TCP socket option 都支持,例如 Linux 上的 MPTCP 目前不兼容 KTLS。
Linux 里怎么用
Linux 从 5.6 开始支持 MPTCP,内核文档在 networking/mptcp。应用通过 IPPROTO_MPTCP(值为 262)创建 socket:
| |
如果内核不支持,socket() 会返回 EINVAL(5.6 之前)、EPROTONOSUPPORT(未编译 MPTCP)或 ENOPROTOOPT(被 net.mptcp.enabled=0 关闭)。这个 socket 是 MPTCP 元 socket,内核为它创建第一条 TCP 子流,后续子流由 path manager 创建。
net.mptcp 下有几个常用的 sysctl:
| |
这里的 sysctl <键名> 只读取当前值,没有修改配置。各键含义是:
enabled(启用):MPTCP 开关,按 network namespace(网络命名空间,netns)分别生效,默认 1。pm_type(Path Manager type,路径管理器类型):0 表示内核 path manager,1 表示交给用户态 daemon。scheduler(调度器):选择发送数据使用的 MPTCP 调度器。在 6.12.113 上只有default,/proc/sys/net/mptcp/available_schedulers可以看到内核实际加载了哪些。allow_join_initial_addr_port:是否允许对端向初始子流的地址和端口发起 MP_JOIN,默认 1。
路径用 ip mptcp 配置:
| |
这些参数有的控制整条连接,有的只描述一个地址:
limits set(设置上限):修改当前网络命名空间的 MPTCP 连接限制。subflow 2:在limits set中表示每条连接最多允许 2 条额外子流,不包含初始子流。add_addr_accepted 2:允许接受对端通过 ADD_ADDR 通告的 2 个额外地址。endpoint add <地址>(添加端点):把地址加入本机 MPTCP path manager 的端点配置。dev eth2(device,设备):把这个端点关联到网卡eth2。signal(通告):用 ADD_ADDR 向对端通告这个端点。subflow(子流):在endpoint add中表示使用这个本地地址发起额外子流,区别于前面的数量上限。backup(备用):把使用该端点的子流标为备用。fullmesh(全互连):与subflow配合,向已知的各个对端地址建立子流。
完整语义见 ip-mptcp(8)。
Go 1.21 开始支持 MPTCP,net.Dialer.SetMultipathTCP 控制拨号;Go 1.24 起监听默认开启 MPTCP(GODEBUG=multipathtcp=2,只影响 listener)。可以用 GODEBUG=multipathtcp=0 或 go.mod 里的 godebug multipathtcp=0 关闭默认值,也可以用 net.ListenConfig.SetMultipathTCP(false) 按 listener 覆盖。取值和默认行为见 Go 的 GODEBUG 文档。
看 MPTCP 状态要分两层:
| |
ss 的短选项可以合写,-Mani 对应 -M -a -n -i,-tani 对应 -t -a -n -i:
-M/--mptcp:选择 MPTCP 元 socket。-t/--tcp:选择 TCP socket,用来检查子流。-a/--all(all,全部):同时显示监听和非监听 socket。-n/--numeric(numeric,数字形式):用数字显示端口,不转换成服务名。-i/--info(information,信息):显示内部连接信息。-l/--listening(listening,监听中):只显示 listener。
不加 -a 或 -l 时,ss -M 默认看已建立连接。只看 ss -ltn 会把 MPTCP listener 显示成普通 TCP,容易漏掉。其余两条观察命令的参数是:
nstat -a(absolute,绝对值助记):输出累计计数,不计算相对上次查询的增量。nstat -s:不更新查询历史,避免影响下次增量统计。nstat -z/--zeros(zero,零):连值为 0 的计数器也显示。-asz是这三个选项的合写。ip -ts/-tshort(short timestamp,短时间戳):为监控输出添加简短时间戳;-ts是一个选项的缩写,不是-t -s。mptcp monitor:持续观察 MPTCP 地址和子流事件。| grep ...:把输出交给grep,只保留包含指定文本的行。
参数细节见 ss(8)、nstat(8) 和 ip(8)。括号中的英文用于理解和助记,不代表所有短选项都有同名长选项。
两条子流的实验
在一台 6.12.113 的 Linux 上,用两个 network namespace 和两对 veth 建了两条路径:c1/s1 是 10.0.1.0/24,c2/s2 是 10.0.2.0/24。服务端在 10.0.1.2:5201 监听 MPTCP,通告 10.0.2.2;客户端从 10.0.1.1 连过去,并把 10.0.2.1 配成 subflow endpoint。
连接建立后,客户端看到的元 socket 是:
| |
subflows_total:2 表示一条初始子流加一条额外子流。ss -tani 里能看到两条 ESTAB,都带 tcp-ulp-mptcp 和各自的 token/id;一条来自 10.0.1.1,另一条来自 10.0.2.1。
传 500 MiB 数据,在传输中途把第二条路径 c2 down 掉:
| |
ip -n c/-netns c(network namespace):在名为c的网络命名空间中执行操作;这里的-n与ss -n含义不同。link set c2 down:把该命名空间中的网卡c2设为关闭状态,用来模拟路径中断。
两条子流都在承载数据;第二条路径断开后,连接没有断,客户端仍然读完 500 MiB。这些数字来自同一台机器上的 veth,不能当作真实带宽,只能说明调度器会把数据分到两条子流,以及一条路径消失后 MPTCP 能在另一条上继续。
如果客户端连的是普通 TCP listener,MPTCP 会降级:服务端 accept() 出来的是普通 TCP socket,ss -tani 里没有 tcp-ulp-mptcp;客户端 nstat 里的 MPCapableFallbackSYNACK 增加。应用代码不需要处理这个差异。
CVE-2026-98432
2026 年 10 月,Linux IPv4 TCP 的 ireq_opt 生命周期问题被公开为 CVE-2026-98432。
| 时间 | 事件 |
|---|---|
| 2026-10-01 | 修复补丁发出,提交 e3f33b0a1d89 |
| 2026-10-05 | 合入上游 |
| 2026-10-15 | CVE 公开,稳定分支回移 |
| 2026-10-27 | Anthropic 公开利用细节 |
| 2026-10-28 | Innora 发布独立复现 |
CVE 公开日期取自 Ubuntu 页面,Anthropic 的日期是其文章发布日期;补丁发出、合入和 Innora 复现的日期来自 Innora 的 writeup。
Ubuntu 的 CVE 页面记录 CVSS 为 9.8,描述为可导致远程代码执行;Red Hat 把它标为 Critical。按 Ubuntu 的描述,问题始于提交 079096f103fa(tcp/dccp: install syn_recv requests into ehash table),Innora 的 writeup 把它对应到 Linux 4.4;此后所有未打补丁的 IPv4 TCP 内核都受影响。触发需要让 ireq_opt 非空。最常见的是 SYN 带 IPv4 options;SELinux 或 Smack 的 CIPSO 映射也能走到同一条路径。
ireq_opt 的生命周期
SYN 如果带 IPv4 options(IHL > 5),tcp_v4_save_options() 会分配一个 ip_options_rcu:大小是 sizeof(struct ip_options_rcu) + optlen。40 字节 options 时是 32 + 40 = 72 字节,落到 kmalloc-96。这个对象归请求套接字 reqsk 的 ireq_opt 所有。
ACK 完成握手时,tcp_v4_syn_recv_sock() 把 ireq->ireq_opt 直接赋给子套接字的 newinet->inet_opt。这是指针移交,不拷贝;子套接字随后被 inet_ehash_nolisten() 放进 ehash,reqsk 从 ehash 摘除,如果 own_req 为真,再把 ireq->ireq_opt 置 NULL。
问题在另一个 CPU。自 4.4 起,reqsk 在 ehash 里可以被无 listener 锁地查到。一个重传的 SYN 命中同一个 reqsk,会走 tcp_check_req() → tcp_v4_send_synack() → inet_csk_route_req() / ip_options_build(),只在 rcu_read_lock() 下读 ireq->ireq_opt。与此同时,ACK 路径上的子套接字可能已经被销毁,inet_sock_destruct() 用普通 kfree() 释放 inet_opt,没有等 RCU 宽限期。
| |
ip_options_build() 先 memcpy 整个 struct ip_options 到 IPCB(skb)->opt,再 memcpy(iph + 20, opt->__data, opt->optlen)。两次读之间,对象可能已经被回收;如果回收者能控制新对象的内容,第二次读到的 optlen 和 __data 就是攻击者的数据。optlen 变大可以越界读,srr 被控制可以越界写。
上游修复只改了两处 kfree:把 inet_sock_destruct() 和 tcp_v4_reqsk_destructor() 里的 kfree 改成 kfree_rcu,等所有 RCU 读者退出后再释放。提交见 e3f33b0a1d89。同一个修复还覆盖了 reqsk 迁移路径(net.ipv4.tcp_migrate_req=1 或 BPF reuseport),那是另一种共享 ireq_opt 的方式。IPv6 的主路径不受影响,因为 tcp_v6_syn_recv_sock() 会复制 options;IPv6 的 CALIPSO 迁移问题另行处理。
MPTCP 监听的释放时机
CVE-2026-98432 不是 MPTCP 协议漏洞。Red Hat 的公告明确写了 “Multipath TCP is not a required condition for this flaw”,默认关闭 MPTCP 的 OpenShift 节点仍然受影响。Go 安全公告也说,演示的利用需要目标暴露 MPTCP listener,但底层 bug 不是 MPTCP 特有的。
公开利用选 MPTCP listener,和子套接字的销毁时机有关。MPTCP listener 在内核里是一个元 socket 加一个 TCP 子流 listener。收到 SYN 时,icsk_af_ops->syn_recv_sock 指向 subflow_syn_recv_sock(),它先调用底层的 tcp_v4_syn_recv_sock(),也就是前面说的 ireq_opt 移交点。然后它检查 MPTCP 选项:
| |
走到 dispose_child,子套接字会被立即关闭:
| |
tcp_done() 把子套接字置为 CLOSE;drop_req 让 tcp_check_req() 走 if (own_req && rsk_drop_req(req)),丢掉 reqsk 并返回子套接字;tcp_v4_rcv() 随后调用 tcp_child_process(),最后 sock_put(child) 触发 inet_sock_destruct(),把 inet_opt 释放掉。整个过程都在 ACK 的处理路径里,不需要等应用 accept(),也不需要额外的 RST。
MP_JOIN 可以走到这条路径:攻击者先正常建立一条 MPTCP 连接,从 SYN/ACK 里拿到服务端的 key,算出 token;再向 listener 的初始地址和端口发一个带 IPv4 options 和 MP_JOIN 的 SYN(allow_join_initial_addr_port 默认是 1),然后回一个不带有效 MPJ_ACK 的 ACK。subflow_req->mp_join 为真,fallback_is_fatal 为真,子套接字走 dispose_child。这时另一个 CPU 上的重传 SYN 如果已经查到 reqsk 并持有引用,就可能在 ip_options_build() 里读到刚被释放的 inet_opt。
普通 TCP 没有这条由 MPTCP 选项错误直接触发的 dispose 路径。子套接字建好后进入 accept 队列,要等应用关闭或 RST 才会释放;重传 SYN 的 SYNACK 构造通常早就结束了。攻击者也可以用 RST 提前释放,但 RST 要等子套接字进入 ehash 或 accept 队列之后才能命中,释放时机比 MPTCP 的 dispose 晚,时序窗口更难对上。
Anthropic 的文章没有展开这层原因,上面的路径是我根据 6.12 源码推出来的。它能解释公开利用为什么选 MPTCP;普通 TCP listener 同样受影响,只是需要不同的触发方式。
利用链和复现边界
Anthropic 的公开描述把利用分成两段:第一段用越界读把内核指针带回用户态,破 KASLR;第二段用越界写覆盖回调指针,触发一条已经布置在内核里的 payload,最后执行 root 命令。文章给的数字是 48 分钟拿到内核指针、再 5 分钟拿到 shell;输掉竞态只会让这次握手失败,不会崩系统,所以可以一直重试。文章里没有附利用代码。
Innora 的仓库把原语拆得更细:kmalloc-96 回收、TOCTOU、越界读、越界写、把 skb_shared_info->nr_frags 改成 8、在 frags 遍历里解引用攻击者给的 page 指针。每一步都有 trace 级实验,仓库在 sgInnora/innora-cve-research 的 CVE-2026-98432 目录。
这套仓库没有走到完整的远程利用。他们的实验跑在 QEMU 里的 6.12.112 arm64 插桩内核上,插桩补丁在 inet_opt 移交窗口里提前 kfree 并做 spray。writeup 也写明,在他们这套触发结构里,SYNACK 读取发生在释放之前,只要 reqsk 指针还有效,子套接字的销毁就排不到前面;在未插桩的内核上,要靠拉长构建窗口才可能重叠,每轮命中的概率很低。所以它证明的是漏洞和利用原语。公开的第三方 PoC 大多也是 proof-only:验证 UAF 或内存泄漏 oracle,不包含 root 链;在非隔离环境跑竞态触发可能直接把内核打崩。我没有在真实环境跑利用,CVE 部分依据公开文章、修复提交和 6.12.112 源码。
补丁、检查和临时措施
先打补丁
带 e3f33b0a1d89 的稳定版本有:
| 分支 | 修复版本 |
|---|---|
| 6.12 | 6.12.113 |
| 6.6 | 6.6.159 |
| 6.1 | 6.1.190 |
| 5.15 | 5.15.223 |
| 5.10 | 5.10.272 |
| 6.18 | 6.18.56 |
这张表取自 Innora 仓库的 README,我没有逐个对照 kernel.org 的变更日志;Ubuntu 和 Red Hat 只列各自的包版本。截至 2026 年 11 月初,7.2.10 和 7.3 也已包含修复。发行版内核可能用不同的版本号回移,先看厂商的 CVE tracker,不要只比对 uname -r 的主版本。升级内核后需要重启;不要假设 Livepatch 一定覆盖这个函数,是否覆盖以厂商公告为准。
uname -r(kernel release,内核发行版本):打印当前正在运行的内核版本,不是已经安装但尚未启动的内核版本。
检查 MPTCP 监听
如果服务是 Go 1.24 及以上编译、跑在支持 MPTCP 的 Linux 上,监听默认会尝试 MPTCP(multipathtcp=2)。用 ss -Ml 看:
| |
ss -ltn 会把它显示成普通 TCP,不看 -M 容易漏。其他语言或框架要按各自的创建方式检查:C 里看是否用了 IPPROTO_MPTCP,已有二进制看是否被 mptcpize 或 eBPF 强制。
如果确认不需要 MPTCP,可以关掉 Go 的默认值:
| |
或者写在 go.mod 里:
| |
也可以在单个 listener 上用 net.ListenConfig.SetMultipathTCP(false) 覆盖。改完要重启服务;已经在运行的 MPTCP listener 不会因为环境变量变化而改变。
临时措施覆盖不到的地方
net.mptcp.enabled=0 能阻止新的 MPTCP socket,但它是 per-netns 的,只影响新 socket;已有的 listener 和连接不变,要重启服务才生效。它移除的是公开利用走过的那条 MPTCP 路径,不是底层 IPv4 TCP 的 UAF。
net.mptcp.allow_join_initial_addr_port=0 会拒绝向初始地址和端口的 MP_JOIN,能收窄上面那条 MP_JOIN 路线,但同样不是修复;如果服务依赖这个行为做多路径接入,还会破坏正常功能。
更直接的临时措施是过滤带 IPv4 options 的 TCP 报文。Red Hat 公告给的 nftables 规则是:
| |
它挡的是带 IPv4 header options 的 TCP 报文,也就是 ireq_opt 非空的主要来源。普通 TCP options(timestamp、window scale)在 TCP 头里,不受影响。这条规则来自 Red Hat 的 RHSB-2026-016,部署前要按自己的流量测试,它不是补丁的替代品。
规则还有几个覆盖不到的地方。SELinux 或 Smack 的 NetLabel CIPSO 映射也可能让 ireq_opt 非空,只按 IP options 过滤不一定覆盖。容器有自己的 network namespace,host 上的规则不一定管到容器内部,要在对应 netns 或 CNI 层处理。转发、NodePort、桥接流量的 hook 点和 host input 不同,不能只在一处加规则就认为全覆盖。
容器共享宿主内核,所以宿主内核必须打补丁;VM 有独立的 guest 内核,宿主机的 Livepatch 不会补 guest。对多租户集群,这两类边界要分开评估。