
手机从 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 利用](https://www.anthropic.com/research/discovering-vulnerabilities-with-claude)打的是一个 MPTCP 监听端口。关掉 MPTCP 能去掉这条利用路径，底层的 TCP 问题还在。

<!--more-->

## 一条连接，多条路径

MPTCP 是 TCP 的扩展，协议由 [RFC 8684](https://www.rfc-editor.org/rfc/rfc8684.html)（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，形成第二条子流。

```mermaid
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 的用例说明](https://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 说明](https://mptcp.dev/load-balancer.html)。

MPTCP 不会自动生效。应用要显式创建 MPTCP socket，或者用 `mptcpize`（LD_PRELOAD）、eBPF 的 `mptcpify` 强制已有程序使用，这两个工具都只用于 Linux。它要求两端都实现，中间设备可能剥离 TCP options；也不是所有 TCP socket option 都支持，例如 Linux 上的 MPTCP 目前不兼容 KTLS。

## Linux 里怎么用

Linux 从 5.6 开始支持 MPTCP，内核文档在 [networking/mptcp](https://www.kernel.org/doc/html/latest/networking/mptcp.html)。应用通过 `IPPROTO_MPTCP`（值为 262）创建 socket：

```c
int fd = socket(AF_INET, SOCK_STREAM, IPPROTO_MPTCP);
```

如果内核不支持，`socket()` 会返回 `EINVAL`（5.6 之前）、`EPROTONOSUPPORT`（未编译 MPTCP）或 `ENOPROTOOPT`（被 `net.mptcp.enabled=0` 关闭）。这个 socket 是 MPTCP 元 socket，内核为它创建第一条 TCP 子流，后续子流由 path manager 创建。

`net.mptcp` 下有几个常用的 sysctl：

```bash
sysctl net.mptcp.enabled
sysctl net.mptcp.pm_type
sysctl net.mptcp.scheduler
sysctl net.mptcp.allow_join_initial_addr_port
```

这里的 `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` 配置：

```bash
# 服务端：通告 10.0.2.2，允许两条额外子流，接受两个 ADD_ADDR
ip mptcp limits set subflow 2 add_addr_accepted 2
ip mptcp endpoint add 10.0.2.2 dev eth2 signal

# 客户端：把 10.0.2.1 作为新子流的源地址
ip mptcp limits set subflow 2 add_addr_accepted 2
ip mptcp endpoint add 10.0.2.1 dev eth2 subflow
```

这些参数有的控制整条连接，有的只描述一个地址：

- `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)](https://man7.org/linux/man-pages/man8/ip-mptcp.8.html)。

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 文档](https://go.dev/doc/godebug)。

看 MPTCP 状态要分两层：

```bash
ss -Mani                          # MPTCP 元 socket
ss -tani | grep tcp-ulp-mptcp     # 子流，标记为 tcp-ulp-mptcp
nstat -asz | grep MPTcpExt        # 协议计数器
ip -ts mptcp monitor              # 子流和地址事件
ss -Ml                            # MPTCP listener
```

`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)`](https://man7.org/linux/man-pages/man8/ss.8.html)、[`nstat(8)`](https://man7.org/linux/man-pages/man8/nstat.8.html) 和 [`ip(8)`](https://man7.org/linux/man-pages/man8/ip.8.html)。括号中的英文用于理解和助记，不代表所有短选项都有同名长选项。

### 两条子流的实验

在一台 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 是：

```text
ESTAB 0 0 10.0.1.1:... 10.0.1.2:5201
    subflows:1 add_addr_accepted:1 subflows_max:2 ... subflows_total:2
```

`subflows_total:2` 表示一条初始子流加一条额外子流。`ss -tani` 里能看到两条 `ESTAB`，都带 `tcp-ulp-mptcp` 和各自的 token/id；一条来自 10.0.1.1，另一条来自 10.0.2.1。

传 500 MiB 数据，在传输中途把第二条路径 `c2` down 掉：

```text
T=1  path1 bytes_acked 122.7 MB
     path2 bytes_acked  74.8 MB
T=2  ip -n c link set c2 down
T=3  path1 bytes_acked 336.9 MB
     path2 bytes_acked 154.1 MB（有 unacked/lost，等待重传）
client: read 500.0 MiB
```

- `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](https://github.com/sgInnora/innora-cve-research/blob/main/CVE-2026-98432/writeup.md)。

[Ubuntu 的 CVE 页面记录 CVSS 为 9.8](https://ubuntu.com/security/CVE-2026-98432)，描述为可导致远程代码执行；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()`](https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/net/ipv4/tcp_ipv4.c?h=v6.12.112#n1777) 把 `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()`](https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/net/ipv4/af_inet.c?h=v6.12.112#n158) 用普通 `kfree()` 释放 `inet_opt`，没有等 RCU 宽限期。

```text
CPU0：ACK 完成握手                         CPU1：重传 SYN
─────────────────────────────            ─────────────────────────────
tcp_v4_syn_recv_sock()
  inet_opt = ireq->ireq_opt
  （指针移交，不拷贝）
  inet_ehash_nolisten()
    reqsk 从 ehash 摘除
                                           tcp_check_req()
                                             已在 ehash 中查到 reqsk
                                             持有引用
                                           tcp_v4_send_synack()
                                             inet_csk_route_req()
                                               rcu_dereference(ireq_opt)
子套接字被销毁                              ip_options_build()
  inet_sock_destruct()                        读取 opt->optlen
    kfree(inet_opt)  ← 无 RCU 宽限          memcpy(opt->__data, optlen)
```

[`ip_options_build()`](https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/net/ipv4/ip_options.c?h=v6.12.112#n50) 先 `memcpy` 整个 `struct ip_options` 到 `IPCB(skb)->opt`，再 `memcpy(iph + 20, opt->__data, opt->optlen)`。两次读之间，对象可能已经被回收；如果回收者能控制新对象的内容，第二次读到的 `optlen` 和 `__data` 就是攻击者的数据。`optlen` 变大可以越界读，`srr` 被控制可以越界写。

上游修复只改了两处 `kfree`：把 [`inet_sock_destruct()`](https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/net/ipv4/af_inet.c?h=v6.12.113#n158) 和 [`tcp_v4_reqsk_destructor()`](https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/net/ipv4/tcp_ipv4.c?h=v6.12.113#n1222) 里的 `kfree` 改成 `kfree_rcu`，等所有 RCU 读者退出后再释放。提交见 [`e3f33b0a1d89`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/patch/?id=e3f33b0a1d89d738e2b913dab519226eaf76e15b)。同一个修复还覆盖了 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 安全公告](https://www.openwall.com/lists/oss-security/2026/10/30/1)也说，演示的利用需要目标暴露 MPTCP listener，但底层 bug 不是 MPTCP 特有的。

公开利用选 MPTCP listener，和子套接字的销毁时机有关。MPTCP listener 在内核里是一个元 socket 加一个 TCP 子流 listener。收到 SYN 时，`icsk_af_ops->syn_recv_sock` 指向 [`subflow_syn_recv_sock()`](https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/net/mptcp/subflow.c?h=v6.12.112#n816)，它先调用底层的 [`tcp_v4_syn_recv_sock()`](https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/net/ipv4/tcp_ipv4.c?h=v6.12.112#n1740)，也就是前面说的 `ireq_opt` 移交点。然后它检查 MPTCP 选项：

```c
fallback_is_fatal = tcp_rsk(req)->is_mptcp && subflow_req->mp_join;

if (child && *own_req) {
    if (!ctx || fallback) {
        if (fallback_is_fatal) {
            subflow_add_reset_reason(skb, MPTCP_RST_EMPTCP);
            goto dispose_child;
        }
        goto fallback;
    }
}
```

走到 `dispose_child`，子套接字会被立即关闭：

```c
dispose_child:
    mptcp_subflow_drop_ctx(child);
    tcp_rsk(req)->drop_req = true;
    inet_csk_prepare_for_destroy_sock(child);
    tcp_done(child);
    ...
    return child;
```

`tcp_done()` 把子套接字置为 CLOSE；`drop_req` 让 `tcp_check_req()` 走 [`if (own_req && rsk_drop_req(req))`](https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/net/ipv4/tcp_minisocks.c?h=v6.12.112#n870)，丢掉 reqsk 并返回子套接字；`tcp_v4_rcv()` 随后调用 `tcp_child_process()`，最后 [`sock_put(child)`](https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/net/ipv4/tcp_minisocks.c?h=v6.12.112#n951) 触发 `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 目录](https://github.com/sgInnora/innora-cve-research/tree/main/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](https://github.com/sgInnora/innora-cve-research/tree/main/CVE-2026-98432)，我没有逐个对照 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` 看：

```bash
ss -Ml
# LISTEN 0 2 10.0.1.2:5201 0.0.0.0:*
```

`ss -ltn` 会把它显示成普通 TCP，不看 `-M` 容易漏。其他语言或框架要按各自的创建方式检查：C 里看是否用了 `IPPROTO_MPTCP`，已有二进制看是否被 `mptcpize` 或 eBPF 强制。

如果确认不需要 MPTCP，可以关掉 Go 的默认值：

```bash
GODEBUG=multipathtcp=0 ./server
```

或者写在 `go.mod` 里：

```text
godebug multipathtcp=0
```

也可以在单个 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 规则是：

```nft
table inet cve_2026_98432 {
  chain input {
    type filter hook input priority -310; policy accept;
    ip protocol tcp ip hdrlength > 5 counter drop
  }
}
```

它挡的是带 IPv4 header options 的 TCP 报文，也就是 `ireq_opt` 非空的主要来源。普通 TCP options（timestamp、window scale）在 TCP 头里，不受影响。这条规则来自 [Red Hat 的 RHSB-2026-016](https://access.redhat.com/security/vulnerabilities/RHSB-2026-016)，部署前要按自己的流量测试，它不是补丁的替代品。

规则还有几个覆盖不到的地方。SELinux 或 Smack 的 NetLabel CIPSO 映射也可能让 `ireq_opt` 非空，只按 IP options 过滤不一定覆盖。容器有自己的 network namespace，host 上的规则不一定管到容器内部，要在对应 netns 或 CNI 层处理。转发、NodePort、桥接流量的 hook 点和 host input 不同，不能只在一处加规则就认为全覆盖。

容器共享宿主内核，所以宿主内核必须打补丁；VM 有独立的 guest 内核，宿主机的 Livepatch 不会补 guest。对多租户集群，这两类边界要分开评估。

