Send the right packets to a Linux server, and in Anthropic’s proof of concept you have a root shell fifty-three minutes later. No password, no login, no one clicking anything.
That is CVE-2026-98432, a use-after-free in the Linux kernel’s IPv4 TCP handshake, rated 9.8 out of 10. On 27 October, Anthropic showed the exploit working against servers with a Multipath TCP (MPTCP) listener. The fix is two lines, and it is already in every maintained stable kernel. Your distribution’s kernel is another matter: as I write this, Ubuntu’s fixed kernels are still “work in progress” on every supported release.
That gap, between “the bug is public” and “my kernel is fixed”, is where the danger is, and it is what this post is about: how to detect anyone trying the bug, how to block it before the patch arrives, why doing that with eBPF earlier in the stack beats a netfilter rule in some setups (and where it does not), the other mitigations that matter, and how to patch Ubuntu the moment the update lands.
📖 Terms in this post
ireq_opt, a copy of the SYN’s IP options. (request_sock.h)sk_buff). It can pass, drop or redirect. (XDP)sk_buff (tc-bpf); netfilter is what nftables and iptables program. (Netfilter hooks)Point-in-time (2 November 2026). Ubuntu’s status comes from its CVE tracker as last updated on 30 October; check it again before you rely on anything here. I tested on a Linux 7.2: both eBPF programs pass the verifier; the BPF LSM block and the bpftrace tripwire work end to end over loopback; the XDP program detects and drops live traffic in native mode on a veth pair (testing); all four nftables and iptables rules below drop SYNs that carry IP options and let ordinary connections through; and every Go setting below was checked against a real listener with
ss. Try them on a test host in detect mode before you put them on anything that matters.
When a SYN arrives with IPv4 options, the kernel copies those options into the request socket (ireq->ireq_opt). When the handshake completes, tcp_v4_syn_recv_sock() hands that copy to the new child socket without making its own copy. Meanwhile, on another CPU, a retransmitted SYN for the same connection can make the kernel send another SYN-ACK, which reads ireq_opt under RCU protection only. Child sockets free their options with a plain kfree(), not after an RCU grace period. If the child is torn down at the wrong moment, the SYN-ACK path reads freed memory in ip_options_build(). (CVE record)
A few properties matter for everything that follows:
ireq_opt is only filled in when the SYN carries options; otherwise it stays empty. (tcp_v4_save_options) That is why every network mitigation below is “drop IPv4 TCP packets with IP options”.[::] without IPV6_V6ONLY, though, also accepts IPv4 clients, and the kernel hands those connections straight to the vulnerable IPv4 code. (tcp_ipv6.c) “It listens on IPv6” is no protection. Those packets are still IPv4 on the wire, so every mitigation below covers them.net.ipv4.tcp_migrate_req=1, or a reuseport BPF program that migrates). (CVE record)The upstream fix changes kfree() to kfree_rcu() in two destructors, so readers already inside an RCU section finish before the memory goes. The structure involved already had the rcu_head that needs. (af_inet.c · tcp_ipv4.c · inet_sock.h)
The bug lives in the middle packet of the handshake, the SYN-ACK: the only one of the three that the server sends. Here is what a Linux server does with it, step by step, from the current kernel source.
The shaded step is where the bug lives: every SYN-ACK, including one triggered by a retransmitted SYN, is built from the request socket’s copy of the options.
tcp_v4_send_synack(), which hands ireq_opt to ip_build_and_send_pkt() inside an RCU read section. (tcp_ipv4.c) That is the detail that matters: if the client’s SYN carried IP options, the server echoes them back in its SYN-ACK. In an ordinary handshake there are no options, ireq_opt is empty, and the SYN-ACK has a plain 20-byte IP header.net.ipv4.tcp_synack_retries times (5 by default). (ip-sysctl)tcp_check_req() recognises a “pure retransmitted SYN” for the same request and sends a fresh SYN-ACK at once, subject to a rate limit, through the same code, reading ireq_opt again. (tcp_minisocks.c)tcp_v4_syn_recv_sock() creates the real socket for the connection, and the request socket goes away. From then on, nothing on the server refers to the SYN-ACK.Where the bug fits. Steps 3 and 4 can happen at the same moment on two different CPUs. In step 4, the new socket takes over the request socket’s options memory rather than copying it: ireq_opt simply becomes the new socket’s inet_opt. If that new socket is closed quickly, the memory is freed straight away with kfree(). Meanwhile, on the other CPU, step 3 may still be building a SYN-ACK from that same memory. It is now reading memory that has been freed, and that an attacker may already have refilled. The fix frees it with kfree_rcu() instead, which waits until every reader that started before the free, including that SYN-ACK, has finished.
This is also why tcp_v4_send_synack() makes a good tripwire, below: every SYN-ACK built for a request that carries options passes through it.
What each defence in this post does to that SYN-ACK:
security_inet_conn_request(), before tcp_v4_send_synack() ever runs, so the SYN-ACK is never built.ireq_opt is empty, the SYN-ACK goes out as usual, and the bug is out of reach.Every mitigation is a choice of where on a SYN’s path through the kernel to act. In order, for a packet arriving on a NIC:
| # | Point in the receive path | Who can act there | Can block? |
|---|---|---|---|
| 1 | NIC driver, before an sk_buff exists |
XDP (native mode) | ✅ |
| 2 |
sk_buff built; generic XDP for drivers without native support |
XDP (generic mode) | ✅ |
| 3 | Device ingress |
tc (clsact), nftables netdev ingress
|
✅ |
| 4 | IP layer, before routing | nftables/iptables prerouting (priorities: defrag −400, raw −300, conntrack −200) | ✅ |
| 5 | After routing: local delivery, or forward to a container | nftables/iptables input (Red Hat’s rule, −310) or forward | ✅ |
| 6 | TCP: request socket created, SYN’s options saved |
BPF LSM inet_conn_request
|
✅ |
| 7 | SYN-ACK sent (the racing reader) | kprobe on tcp_v4_send_synack
|
❌ observe only |
| 8 | Final ACK: child socket created (the racing writer) | kprobe on tcp_v4_syn_recv_sock
|
❌ observe only |
Points 1–5 filter packets. Point 6 refuses connection requests from inside the TCP code. Points 7 and 8 are where the bug actually happens, and they are where you can see it, but not stop it.
Red Hat’s official mitigation is an nftables rule at the input hook (point 5). It works, and if it fits your setup, use it. Moving the same check to XDP (point 1) buys you five things.
sk_buff, runs conntrack or walks any rules. An attacker racing this bug sends handshakes for tens of minutes. Dropping them at the driver costs almost nothing.It is not free, and it is not always better.
sk_buff exists. It still runs before netfilter and still covers every namespace, but point 2 above mostly goes away. Common virtual NICs support native mode: virtio-net since 4.10 and Hyper-V’s netvsc since 5.6.netdev ingress chain (point 3) runs on the device before routing and before namespaces, close to tc. If the namespace problem is all you care about, that is the low-effort fix; see the other mitigations below.The BPF LSM program (point 6) is a different trade-off again, covered in its own section below.
Kernel versions decide two separate things: whether you have the bug at all, and which of the mitigations below your kernel can run.
Every kernel from 4.4 onwards, where commit 079096f103fa introduced the race. The fix is in these stable releases and everything after them on each branch:
| Branch | Fixed in |
|---|---|
| 5.10 | 5.10.272 |
| 5.15 | 5.15.223 |
| 6.1 | 6.1.190 |
| 6.6 | 6.6.159 |
| 6.12 | 6.12.113 |
| 6.18 | 6.18.56 |
| 7.2 | 7.2.10 |
| mainline | 7.3 |
(CVE record) Branches that are no longer maintained upstream, such as 4.19 and 5.4, have no upstream fix at all. On those, you depend entirely on your distribution backporting it. Distribution kernels also carry their own version numbers, so for Ubuntu, go by the package versions below, not the upstream numbers.
| Mitigation | Minimum kernel | Also needs |
|---|---|---|
nftables rules (inet input or prerouting) |
3.14 | nftables |
nftables netdev ingress rule |
4.2 | nftables |
iptables u32 rule |
any affected kernel | iptables with the u32 match |
XDP guard, generic mode (xdpgeneric) |
4.12 | clang, bpftool |
XDP guard, native mode (xdpdrv) |
4.8, if the driver supports it: virtio-net 4.10, ixgbe 4.12, i40e 4.13, ice 5.5, Hyper-V netvsc 5.6, veth 4.19 | clang, bpftool |
| BPF LSM block | 5.7 |
CONFIG_BPF_LSM=y; bpf in the active LSM list; kernel BTF (/sys/kernel/btf/vmlinux, 5.4+, CONFIG_DEBUG_INFO_BTF=y); a bpftool from kernel 6.2 or later for autoattach
|
| bpftrace tripwire | 4.1 for kprobes | kernel BTF (5.4+) for the struct casts, or kernel headers |
net.mptcp.enabled=0 |
5.6 | none. Older kernels have no MPTCP, so no MPTCP listeners, but they still have the bug |
net.ipv4.tcp_migrate_req=0 |
5.14 | none. Older kernels cannot migrate request sockets, so that second trigger does not exist there |
Sources: the feature list in BCC’s kernel-versions.md (kprobes, XDP, generic XDP, driver support, LRU hash maps, BTF, bpf_probe_read_kernel, BPF LSM), and the introducing commits for the rest: veth native XDP 948d4f214fde (4.19), BTF in sysfs 341dfcf8d78e (5.4), MPTCP f870fa0b5768 (5.6), tcp_migrate_req f9ac779f881c (5.14), bpftool autoattach 19526e701ea0 (6.2), nftables inet 1d49144c0aaa (3.14) and netdev ed6c4136f157 (4.2).
With each release’s original (GA) kernel:
| Ubuntu | Kernel | nftables / iptables | XDP guard | BPF LSM | Tripwire | MPTCP sysctl | tcp_migrate_req |
|---|---|---|---|---|---|---|---|
| 26.10 | 7.3 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 26.04 LTS | 7.0 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 24.04 LTS | 6.8 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 22.04 LTS | 5.15 | ✅ | ✅ | ✅, but the stock bpftool (5.15) lacks autoattach
|
✅ | ✅ | ✅ |
| 20.04 LTS | 5.4 | ✅ | ✅ | ❌ needs 5.7 | ✅ if kernel BTF is present | no MPTCP | no migration |
| 18.04 LTS | 4.15 | ✅ | ✅ | ❌ | needs kernel headers | no MPTCP | no migration |
| 16.04 LTS | 4.4 | ✅ | ❌ generic XDP needs 4.12, virtio-net native 4.10 | ❌ | needs kernel headers | no MPTCP | no migration |
HWE kernels move a release up the table: 20.04 with its 5.15 HWE kernel behaves like 22.04, for example. Check what you are actually running:
uname -r # kernel version
grep -E 'CONFIG_BPF_LSM|CONFIG_DEBUG_INFO_BTF=' /boot/config-$(uname -r)
cat /sys/kernel/security/lsm # is "bpf" active?
ls /sys/kernel/btf/vmlinux # kernel BTF present?
bpftool prog help 2>&1 | grep -c autoattach # 1 means your bpftool has autoattach
This program counts IPv4 TCP packets that carry IP options per source address, and, once you switch it to enforce mode, drops them. It matches the same packets as Red Hat’s nftables rule. It keeps its per-source counts in an LRU hash map and its mode in a one-slot array map, and builds with libbpf’s headers.
// SPDX-License-Identifier: GPL-2.0
// XDP guard for CVE-2026-98432: count, and optionally drop, IPv4 TCP
// packets that carry IP options (IHL > 5), before the kernel allocates
// an skb for them. Mode 0 = detect (count and pass), 1 = enforce (drop).
#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/in.h>
#include <linux/ip.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>
struct vlan_hdr {
__be16 tci;
__be16 proto;
};
/* Packets with IP options, per source address. */
struct {
__uint(type, BPF_MAP_TYPE_LRU_HASH);
__uint(max_entries, 16384);
__type(key, __u32); /* IPv4 source, network byte order */
__type(value, __u64); /* packets seen */
} ipopt_src SEC(".maps");
/* Single slot: 0 = detect, 1 = enforce. */
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, 1);
__type(key, __u32);
__type(value, __u32);
} ipopt_mode SEC(".maps");
SEC("xdp")
int ipopts_guard(struct xdp_md *ctx)
{
void *data = (void *)(long)ctx->data;
void *end = (void *)(long)ctx->data_end;
struct ethhdr *eth = data;
struct iphdr *ip;
__u16 proto;
__u32 key = 0, src, *mode;
__u64 one = 1, *seen;
if ((void *)(eth + 1) > end)
return XDP_PASS;
proto = eth->h_proto;
ip = (void *)(eth + 1);
/* Unwrap one 802.1Q / 802.1ad tag. */
if (proto == bpf_htons(ETH_P_8021Q) || proto == bpf_htons(ETH_P_8021AD)) {
struct vlan_hdr *vh = (void *)(eth + 1);
if ((void *)(vh + 1) > end)
return XDP_PASS;
proto = vh->proto;
ip = (void *)(vh + 1);
}
if (proto != bpf_htons(ETH_P_IP) || (void *)(ip + 1) > end)
return XDP_PASS;
if (ip->ihl <= 5 || ip->protocol != IPPROTO_TCP)
return XDP_PASS;
src = ip->saddr;
seen = bpf_map_lookup_elem(&ipopt_src, &src);
if (seen)
__sync_fetch_and_add(seen, 1);
else
bpf_map_update_elem(&ipopt_src, &src, &one, BPF_ANY);
mode = bpf_map_lookup_elem(&ipopt_mode, &key);
return (mode && *mode) ? XDP_DROP : XDP_PASS;
}
char LICENSE[] SEC("license") = "GPL";
Build, load and attach it. It starts in detect mode, because a fresh array map is all zeroes.
sudo apt install clang libbpf-dev linux-tools-$(uname -r) # clang, headers, bpftool
clang -O2 -g -target bpf -c ipopts_guard.bpf.c -o ipopts_guard.bpf.o
sudo bpftool prog load ipopts_guard.bpf.o /sys/fs/bpf/ipopts_guard \
pinmaps /sys/fs/bpf/ipopts_maps
sudo bpftool net attach xdp pinned /sys/fs/bpf/ipopts_guard dev eth0 # use xdpgeneric if the driver refuses
sudo bpftool net show dev eth0 # confirm it is attached
Watch it for a while, then switch to enforce:
# Who has sent IPv4 TCP packets with options (keys are source addresses, network byte order).
sudo bpftool map dump pinned /sys/fs/bpf/ipopts_maps/ipopt_src
# Enforce: drop them from now on.
sudo bpftool map update pinned /sys/fs/bpf/ipopts_maps/ipopt_mode key 0 0 0 0 value 1 0 0 0
Remove it once the kernel is patched:
sudo bpftool net detach xdp dev eth0
sudo rm -r /sys/fs/bpf/ipopts_guard /sys/fs/bpf/ipopts_maps
An XDP attachment does not survive a reboot. Since the reboot you are waiting for is the one that installs the fixed kernel, that is usually what you want; if not, wrap the load and attach commands in a small systemd unit.
The XDP program filters packets on one NIC. This one refuses the connection request itself, from inside the kernel’s TCP code, in every network namespace at once.
The hook is security_inet_conn_request(). In the IPv4 handshake, the kernel saves the SYN’s options into the request socket and then immediately calls this hook. If it returns an error, the request is dropped: no request socket is kept, no SYN-ACK is sent, and the race never starts. (tcp_v4_route_req · tcp_conn_request) The same hook runs for SYN-cookie connections and for MPTCP subflows, which reuse the TCP request path. (syncookies.c · subflow.c)
// SPDX-License-Identifier: GPL-2.0
// BPF LSM for CVE-2026-98432: refuse TCP connection requests whose SYN
// carries IPv4 options. Runs in security_inet_conn_request(), after the
// kernel has parsed the SYN and before it sends a SYN-ACK, in every
// network namespace.
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_endian.h>
#define ETH_P_IP 0x0800
#define EPERM 1
__u64 refused = 0; /* requests refused so far */
SEC("lsm/inet_conn_request")
int BPF_PROG(no_ipopts_req, const struct sock *sk, struct sk_buff *skb,
struct request_sock *req, int ret)
{
unsigned char *head;
__u16 nh;
__u8 ver_ihl;
if (ret) /* an earlier LSM already refused it */
return ret;
if (BPF_CORE_READ(skb, protocol) != bpf_htons(ETH_P_IP))
return 0;
head = BPF_CORE_READ(skb, head);
nh = BPF_CORE_READ(skb, network_header);
if (bpf_probe_read_kernel(&ver_ihl, sizeof(ver_ihl), head + nh))
return 0;
if ((ver_ihl & 0x0f) <= 5)
return 0;
__sync_fetch_and_add(&refused, 1);
return -EPERM;
}
char LICENSE[] SEC("license") = "GPL";
BPF LSM programs only run if bpf is in the kernel’s list of active security modules. Check first:
cat /sys/kernel/security/lsm
If bpf is missing, add it to the end of that list on the kernel command line (for example lsm=landlock,lockdown,yama,integrity,apparmor,bpf, using your current list plus ,bpf) via GRUB_CMDLINE_LINUX in /etc/default/grub, run sudo update-grub, and reboot. Then generate vmlinux.h from the kernel’s BTF type information, build, and load with bpftool prog loadall ... autoattach:
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
clang -O2 -g -target bpf -D__TARGET_ARCH_x86 -I. -c no_ipopts_req.bpf.c -o no_ipopts_req.bpf.o
sudo bpftool prog loadall no_ipopts_req.bpf.o /sys/fs/bpf/no_ipopts autoattach
sudo bpftool prog show name no_ipopts_req # note the map id of its .bss
sudo bpftool map dump id <that-id> # "refused" counter
autoattach arrived in the bpftool that ships with kernel 6.2. On an older distribution, such as Ubuntu 22.04 whose linux-tools bpftool comes from 5.15, either install a newer bpftool from the standalone releases or write a small libbpf loader. The kernel itself only needs 5.7.
To remove it, delete the pinned directory: sudo rm -r /sys/fs/bpf/no_ipopts.
What this buys you over XDP and netfilter:
BPF_CORE_READ, bpf_probe_read_kernel), so the same object file works across kernel versions.And what it costs:
bpf is already active, it is the strongest stopgap here.ireq_opt at this hook. (syncookies.c) On systems that use labelled networking (for example SELinux with NetLabel/CIPSO), the kernel can attach options to a connection whose SYN had none. No packet filter sees that. This is my reading of why Red Hat says filtering “reduces exposure to this vulnerability but cannot fully prevent it”. (Red Hat) Default Ubuntu, with AppArmor, does not do this.A kprobe on tcp_v4_syn_recv_sock sees the exact moment the bug happens, so it is tempting to make it refuse. It cannot. kprobe and fentry programs observe; the helpers that let a probe change a function’s result (bpf_override_return, and fmod_ret programs) only work on functions on the kernel’s error-injection allowlist. (Error injection) The TCP handshake functions are not on it. On my Linux 7.2 kernel, sudo cat /sys/kernel/debug/error_injection/list names 1,150 functions, almost all of them system calls. None of the handshake functions this post touches (tcp_v4_syn_recv_sock, tcp_v4_send_synack, tcp_conn_request, tcp_check_req, inet_csk_route_req and the rest) is among them. The only networking entries are socket system-call entry points such as socket and setsockopt, and overriding those cannot help: the kernel completes the TCP handshake in its packet-processing path before any application calls accept(). The LSM hook above is the supported way to say no at that layer.
Even if you block at the edge, you want to know if someone is trying. There are three levels of visibility, from cheapest to most specific.
counter, so sudo nft list table inet cve_2026_98432 shows how many packets matched. It tells you how many, not who or whether they reached TCP.ipopt_src gives you packets with IP options per source address, without dropping anything.tcp_v4_send_synack() is one of the two functions the CVE description names as reading ireq_opt in the race. (NVD) It runs for every SYN-ACK, including the ones triggered by retransmitted SYNs. If it fires for a request that carries IP options, a SYN with options reached the vulnerable code. If the same source keeps showing up, it is retransmitting to win the race.Here it is as a bpftrace one-liner (save
it as tripwire.bt):
// tripwire.bt
// CVE-2026-98432 tripwire: a SYN-ACK is being sent for a connection
// request whose SYN carried IPv4 options. tcp_v4_send_synack() is one of
// the two sides of the race; a source that repeats is retrying it.
kprobe:tcp_v4_send_synack
{
$ireq = (struct inet_request_sock *)arg3; // 4th argument: req
if ($ireq->ireq_opt != 0) {
$src = ntop($ireq->req.__req_common.skc_daddr);
@synack_with_ipopts[$src] = count();
printf("%s SYN-ACK to %s for a SYN with IPv4 options\n",
strftime("%H:%M:%S", nsecs), $src);
}
}
On a normal server this prints nothing: legitimate clients almost never send IP options, and many networks drop them on the way. (RFC 7126) Leave it running in a tmux session until you have patched, or feed its output to your alerting.
Two notes. The tripwire only fires for packets that got past your filters, so run it alongside the XDP program in detect mode, or after you have enforced, as a check that nothing slips through. And tcp_v4_send_synack is a static function. The kernel calls it through a function pointer, so the compiler cannot inline it away, but confirm with grep -w tcp_v4_send_synack /proc/kallsyms before relying on it.
Five kinds of check back this post, each in its own subsection below. All of them ran on a Linux 7.2.
Tests 2 and 3 need a way to send a SYN that carries an IP option. hping3’s --rroute flag adds the record-route option, which makes the IPv4 header longer than 20 bytes, exactly what the bug needs. Tests 4 and 5 run inside a throwaway user and network namespace (unshare -rn), which gives you real CAP_NET_ADMIN over a private network stack without root, so the rules go into the actual kernel and nothing on the host changes.
The quickest check, and the first one to run on any new kernel. It loads each program, which runs the verifier, then removes it without attaching anything. No output means success:
sudo bpftrace --dry-run tripwire.bt
sudo bpftool prog load ipopts_guard.bpf.o /sys/fs/bpf/t_xdp && sudo rm /sys/fs/bpf/t_xdp
sudo bpftool prog loadall no_ipopts_req.bpf.o /sys/fs/bpf/t_lsm && sudo rm -r /sys/fs/bpf/t_lsm
Both work inside the TCP code rather than on a particular interface, so one machine and loopback are enough. Run the tripwire in one terminal:
sudo bpftrace tripwire.bt # the bpftrace script above, saved to a file
And the test in another:
nc -l 8080 & # a listener
sudo hping3 -S -p 8080 -c 3 --rroute 127.0.0.1 # 1. SYN with IP options, no block
sudo bpftool prog loadall no_ipopts_req.bpf.o /sys/fs/bpf/t_lsm autoattach
sudo hping3 -S -p 8080 -c 3 --rroute 127.0.0.1 # 2. SYN with IP options, blocked
sudo hping3 -S -p 8080 -c 3 127.0.0.1 # 3. ordinary SYN, program still loaded
sudo rm -r /sys/fs/bpf/t_lsm; kill %1 # clean up
What I saw on a Linux 7.2, trimmed:
# 1. SYN with IP options, no block: the kernel answers with SYN-ACKs
len=84 ip=127.0.0.1 ttl=64 DF id=0 sport=8080 flags=SA seq=0 win=65495 rtt=0.9 ms
len=84 ip=127.0.0.1 ttl=64 DF id=0 sport=8080 flags=SA seq=1 win=65495 rtt=0.9 ms
len=84 ip=127.0.0.1 ttl=64 DF id=0 sport=8080 flags=SA seq=2 win=65495 rtt=0.8 ms
3 packets tramitted, 3 packets received, 0% packet loss
# 2. With the BPF LSM loaded: the request is refused, no SYN-ACK is sent
3 packets tramitted, 0 packets received, 100% packet loss
# 3. Ordinary SYN with the program still loaded: unaffected
len=44 ip=127.0.0.1 ttl=64 DF id=0 sport=8080 flags=SA seq=0 win=65495 rtt=0.4 ms
len=44 ip=127.0.0.1 ttl=64 DF id=0 sport=8080 flags=SA seq=1 win=65495 rtt=0.3 ms
len=44 ip=127.0.0.1 ttl=64 DF id=0 sport=8080 flags=SA seq=2 win=65495 rtt=0.3 ms
3 packets tramitted, 3 packets received, 0% packet loss
(tramitted is hping3’s own spelling.) Meanwhile the tripwire reported the three SYN-ACKs from step 1, and nothing for steps 2 and 3:
Attached 1 probe
06:53:54 SYN-ACK to 127.0.0.1 for a SYN with IPv4 options
06:53:55 SYN-ACK to 127.0.0.1 for a SYN with IPv4 options
06:53:56 SYN-ACK to 127.0.0.1 for a SYN with IPv4 options
That is the whole story in miniature: the replies in step 1 are 84 bytes instead of 44 because the kernel echoes the options back, which is the copy of ireq_opt the bug is about; the LSM stops the request before any SYN-ACK exists; and ordinary connections never notice.
This looks as if it needs two machines, because loopback traffic never reaches a NIC’s XDP hook. It does not: a veth pair between your main network namespace and a throwaway one behaves like a cable between two hosts, and the veth driver supports native XDP, so the program runs on a real driver hook. This uses exactly the commands from the XDP section above:
# A veth "cable": veth-host stays here, veth-peer goes into namespace "client".
sudo ip netns add client
sudo ip link add veth-host type veth peer name veth-peer
sudo ip link set veth-peer netns client
sudo ip addr add 10.99.0.1/24 dev veth-host && sudo ip link set veth-host up
sudo ip -n client addr add 10.99.0.2/24 dev veth-peer && sudo ip -n client link set veth-peer up
nc -l 8080 & # listener on the host side
sudo bpftool prog load ipopts_guard.bpf.o /sys/fs/bpf/ipopts_guard pinmaps /sys/fs/bpf/ipopts_maps
sudo bpftool net attach xdpdrv pinned /sys/fs/bpf/ipopts_guard dev veth-host # native mode
sudo ip netns exec client hping3 -S -p 8080 -c 3 --rroute 10.99.0.1 # 1. detect mode, SYN with options
sudo bpftool map dump pinned /sys/fs/bpf/ipopts_maps/ipopt_src
sudo bpftool map update pinned /sys/fs/bpf/ipopts_maps/ipopt_mode key 0 0 0 0 value 1 0 0 0
sudo ip netns exec client hping3 -S -p 8080 -c 3 --rroute 10.99.0.1 # 2. enforce mode, SYN with options
sudo ip netns exec client hping3 -S -p 8080 -c 3 10.99.0.1 # 3. enforce mode, ordinary SYN
sudo bpftool map dump pinned /sys/fs/bpf/ipopts_maps/ipopt_src
sudo bpftool net detach xdpdrv dev veth-host # clean up
sudo rm -r /sys/fs/bpf/ipopts_guard /sys/fs/bpf/ipopts_maps
sudo ip link del veth-host; sudo ip netns del client; kill %1
What I saw, trimmed:
$ sudo bpftool net show dev veth-host
xdp:
veth-host(20) driver id 758
# 1. Detect mode: SYNs with options pass and are counted
len=84 ip=10.99.0.1 ttl=64 DF id=0 sport=8080 flags=SA seq=0 win=64240 rtt=0.5 ms
len=84 ip=10.99.0.1 ttl=64 DF id=0 sport=8080 flags=SA seq=1 win=64240 rtt=0.4 ms
len=84 ip=10.99.0.1 ttl=64 DF id=0 sport=8080 flags=SA seq=2 win=64240 rtt=0.3 ms
3 packets tramitted, 3 packets received, 0% packet loss
[{ "key": 33579786, "value": 6 }]
# 2. Enforce mode: SYNs with options are dropped at the driver
3 packets tramitted, 0 packets received, 100% packet loss
# 3. Enforce mode: ordinary SYNs are untouched
len=44 ip=10.99.0.1 ttl=64 DF id=0 sport=8080 flags=SA seq=0 win=64240 rtt=0.7 ms
len=44 ip=10.99.0.1 ttl=64 DF id=0 sport=8080 flags=SA seq=1 win=64240 rtt=0.6 ms
len=44 ip=10.99.0.1 ttl=64 DF id=0 sport=8080 flags=SA seq=2 win=64240 rtt=0.5 ms
3 packets tramitted, 3 packets received, 0% packet loss
[{ "key": 33579786, "value": 9 }]
driver in the bpftool net show output confirms native mode, not generic. The map key is the source address as bpftool prints a 32-bit integer on a little-endian machine: 33579786 is 0x0200630a, whose bytes in memory are 0a 63 00 02, or 10.99.0.2.
The counts are worth a second look, because they are 6 and 9, not 3 and 6. Step 1 counted six packets for three SYNs. The other three are the client kernel’s own RSTs: hping3 sends from a raw socket, so when the SYN-ACK arrives the client kernel has no socket for it and answers with a reset, and tcp_v4_send_reset() echoes the IP options of the packet it is answering. (tcp_ipv4.c) Those RSTs carry options too, so the program counts them. Step 2 added three more: in enforce mode the program counts a packet before it drops it. Step 3 added nothing, because ordinary SYNs have no options. In production, expect the map to count every option-bearing packet from a source, not only its SYNs.
Each rule is loaded into a private namespace, then a small Python client tries to connect to a local listener twice: once with IP options on its SYN, set with setsockopt(IP_OPTIONS) (the same record-route option hping3 uses), and once without. Save the three nftables rules from Other mitigations as redhat.nft, pre.nft and ingress.nft (with device "lo", since a new namespace only has loopback), and the client as conntest.py:
import socket, sys, threading
# Record-route option: type 7, length 39, pointer 4, nine empty slots, then EOL: 40 bytes.
RR = bytes([7, 39, 4]) + bytes(36) + b"\x00"
srv = socket.socket()
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 8080))
srv.listen(16)
threading.Thread(target=lambda: [srv.accept()[0].close() for _ in iter(int, 1)], daemon=True).start()
def attempt(with_options):
c = socket.socket()
c.settimeout(2)
if with_options:
c.setsockopt(socket.IPPROTO_IP, socket.IP_OPTIONS, RR)
try:
c.connect(("127.0.0.1", 8080))
return "connected"
except OSError as e:
return f"blocked ({type(e).__name__})"
finally:
c.close()
print(f"{sys.argv[1]:<20} with IP options: {attempt(True):<24} without: {attempt(False)}")
Then, with no root needed:
unshare -rn sh -c '
ip link set lo up
python3 conntest.py "no rule"
for f in redhat.nft pre.nft ingress.nft; do
nft --file $f; python3 conntest.py $f; nft flush ruleset
done
iptables -t raw -I PREROUTING -p tcp -m u32 --u32 "0>>24&0xF=6:15" -j DROP
python3 conntest.py "iptables u32"'
What I saw:
no rule with IP options: connected without: connected
redhat.nft with IP options: blocked (TimeoutError) without: connected
pre.nft with IP options: blocked (TimeoutError) without: connected
ingress.nft with IP options: blocked (TimeoutError) without: connected
iptables u32 with IP options: blocked (TimeoutError) without: connected
Every rule drops the SYN that carries options, so the connection times out, and lets the ordinary one through. Each rule’s counter read 2 afterwards: the original SYN and one retransmission before the client gave up.
First, net.mptcp.enabled=0, again in a private namespace. A new namespace starts with MPTCP enabled, which is exactly the situation inside a container. Turning it off makes an MPTCP-capable listener fall back to plain TCP, and it keeps accepting connections. The output below is from a Go listener (MPTCP on by default) started in the namespace before and after the change; mptcp= and tcp= count matching lines in ss -Mln and ss -tln:
net.mptcp.enabled in new netns: 1
enabled=1 (namespace default) mptcp=1 tcp=1
set net.mptcp.enabled=0: ok
enabled=0 mptcp=0 tcp=1
connect to fallback listener: ok
host netns still: net.mptcp.enabled=1
An MPTCP listener shows up in ss -t as well as ss -M, because it holds an ordinary TCP listening socket underneath. That is why the check for MPTCP is ss -Mln and not ss -Mlnt.
Then the Go settings. A minimal Go 1.27 server listens with net.ListenConfig, and ss -Mln shows whether its port is an MPTCP listener. It runs once per setting from the Go section above, including deliberately conflicting ones to check which wins:
default (go 1.27 in go.mod) MPTCP listener
GODEBUG=multipathtcp=0 env plain TCP
//go:debug multipathtcp=0 plain TCP
go.mod: godebug multipathtcp=0 plain TCP
SetMultipathTCP(false) plain TCP
go.mod says go 1.23 (new toolchain) plain TCP
env GODEBUG=2 overrides //go:debug=0 MPTCP listener
SetMultipathTCP(false) beats GODEBUG=2 plain TCP
And go list -f '{{.DefaultGODEBUG}}' reported multipathtcp=0 for the go.mod, //go:debug and go 1.23 modules, and nothing for the default one, exactly as it should.
None of these need eBPF, and most are worth doing regardless.
Red Hat’s nftables rule. The official stopgap, dropping IPv4 TCP packets with IP options at the input hook. It does not touch TCP options, which is where MSS, window scaling, SACK, timestamps and MPTCP itself are signalled, so ordinary connections, and MPTCP connections, carry on as before:
table inet cve_2026_98432 {
chain input {
type filter hook input priority -310; policy accept;
ip protocol tcp ip hdrlength > 5 counter drop
}
}
Load it with nft:
sudo nft --check --file cve-2026-98432.nft && sudo nft --file cve-2026-98432.nft
sudo nft delete table inet cve_2026_98432 # once patched
(Red Hat)
The same rule, earlier, if you run containers. The input hook misses traffic forwarded to containers. Two variants cover it. These are my adaptations, not Red Hat’s. Prerouting, which still runs in the host namespace but before the forward-or-deliver decision:
table inet cve_2026_98432_pre {
chain prerouting {
type filter hook prerouting priority -310; policy accept;
ip protocol tcp ip hdrlength > 5 counter drop
}
}
Or the netdev ingress hook, on the device itself, which is the nftables equivalent of tc:
table netdev cve_2026_98432_ingress {
chain ingress {
type filter hook ingress device "eth0" priority -500; policy accept;
ip protocol tcp ip hdrlength > 5 counter drop
}
}
iptables, if that is what you have. There is no built-in header-length match, but the u32 match can read the IHL nibble:
sudo iptables -t raw -I PREROUTING -p tcp -m u32 --u32 "0>>24&0xF=6:15" -j DROP
Shrink who can reach your listeners. The bug is pre-authentication, so the most effective mitigation is not letting strangers complete a handshake at all: allowlist source addresses for SSH and admin ports, and put public services behind a proxy or CDN so only its addresses can reach the origin. Red Hat and others list restricting access to trusted networks as the main interim step. (BleepingComputer)
Turn MPTCP off where you do not use it. It removes the demonstrated exploit path, though not the bug. The same goes for kernels built without MPTCP at all (no net.mptcp.enabled sysctl): they cannot have an MPTCP listener, but the use-after-free is in the ordinary IPv4 TCP code, so they still need the fix.
List MPTCP listeners with ss, then switch MPTCP off and make it persist with a sysctl.d file:
ss -Mln # MPTCP listening sockets only; adding -t would list plain TCP too
sudo sysctl -w net.mptcp.enabled=0 # this network namespace only
echo 'net.mptcp.enabled = 0' | sudo tee /etc/sysctl.d/90-no-mptcp.conf
net.mptcp.enabled is per network namespace, so a container has its own copy, set to 1 by default. (MPTCP sysctl) In Docker Compose, use the service’s sysctls key:
services:
app:
sysctls:
net.mptcp.enabled: 0
Go services deserve their own check. Since Go 1.24, net.Listen and ListenConfig use MPTCP by default on Linux when the kernel supports it, so a Go service can be an MPTCP listener without anyone deciding that. (Go 1.24 release notes) The behaviour is controlled by the multipathtcp GODEBUG setting: 0 turns MPTCP off for listeners and dialers, which is the pre-1.24 behaviour; 2, the Go 1.24 default, turns it on for listeners only. (GODEBUG)
Which default a binary gets depends on the go line in its main module’s go.mod, not just the toolchain that built it: a program whose go.mod says go 1.23 keeps MPTCP off even when built with a newer Go. To see what a main package will default to:
go list -f '{{.DefaultGODEBUG}}' ./cmd/myservice # only differences from the toolchain default are shown
There are four ways to turn it off, from quickest to most permanent.
At run time, no rebuild. Set the environment variable for the service, for example with a systemd Environment= override. It takes precedence over everything compiled in:
# systemctl edit myservice
[Service]
Environment=GODEBUG=multipathtcp=0
In Docker Compose, add GODEBUG: "multipathtcp=0" under the service’s environment:. If the service already sets GODEBUG, append with a comma (GODEBUG=foo=1,multipathtcp=0) rather than replacing it.
In the source of the main package (Go 1.21 or later), a //go:debug directive above the package line, compiled into the binary:
//go:debug multipathtcp=0
package main
In go.mod (Go 1.23 or later), a godebug line in the main module, which applies to every binary built from it:
godebug multipathtcp=0
Per listener, in code. ListenConfig.SetMultipathTCP(false) overrides both the system default and any GODEBUG setting for that listener. Use it when only some listeners should avoid MPTCP:
var lc net.ListenConfig
lc.SetMultipathTCP(false)
ln, err := lc.Listen(ctx, "tcp", ":8080")
(net.ListenConfig) To check what a live connection is actually using, (*net.TCPConn).MultipathTCP() reports whether it negotiated MPTCP.
Either way, confirm from the outside with ss -Mln: the service’s port should no longer be listed. (Leave out -t: an MPTCP listener also holds an ordinary TCP listening socket, so ss -Mlnt lists every listener and tells you nothing.) And keep the limits of this in mind. Turning MPTCP off in Go, or with net.mptcp.enabled=0, makes the listener fall back to plain TCP. That removes the path Anthropic demonstrated, but the bug is in the TCP handshake itself, so the listener is still vulnerable until the kernel is patched. Pair it with dropping SYNs that carry IP options.
The Go security team says the same. Its advisory on this CVE lists the environment variable, the go.mod setting and the per-socket calls as ways to turn MPTCP off, but calls disabling it program by program a fragile mitigation: one listener you missed leaves the system exposed, and it assumes every workable exploit needs MPTCP. It recommends upgrading the kernel, and says changing Go’s MPTCP default would be premature; the discussion continues in issue 82717. (Go security team)
Keep request migration off. Make sure net.ipv4.tcp_migrate_req is 0, the default, and that nothing attaches a reuseport BPF program that migrates requests. That closes the second trigger. (ip-sysctl)
This is the actual fix; everything above is a bridge to it.
Check where you stand with the Ubuntu Pro client and, if you use it, Livepatch:
uname -r # running kernel
pro fix CVE-2026-98432 # Canonical's answer for this machine (Ubuntu Pro client)
canonical-livepatch status # if you use Livepatch
As of Ubuntu’s 30 October update, fixes were in progress in these kernels, so treat these versions or later as fixed once they are published:
| Release | Fixed kernel (in progress) |
|---|---|
| 26.10 | 7.3.0-10.10 |
| 26.04 LTS | 7.0.0-43.43 |
| 24.04 LTS | 6.8.0-149.149 |
| 22.04 LTS | 5.15.0-203.213 |
| 20.04, 18.04, 16.04 | Listed as vulnerable, no fix yet |
Cloud and HWE kernels (linux-aws, linux-azure, linux-gcp, linux-hwe-* and others) have their own version numbers on the same page. (Ubuntu CVE tracker)
When the update is published:
sudo apt update
sudo apt full-upgrade # pulls in the new linux-image-* packages
sudo reboot
uname -r # confirm you booted the fixed version
full-upgrade matters: a new kernel ABI comes as new package names, which plain apt upgrade can hold back. And the fix does nothing until you reboot into it, unless a livepatch covers it.
Livepatch. The fix is small and local, two kfree calls becoming kfree_rcu, which makes it a reasonable livepatch candidate in principle. As of 2 November, though, Canonical had not published a Livepatch notice for this CVE, so plan for a reboot. Building your own livepatch with kpatch against Ubuntu’s kernel source is possible, but it is a production kernel change made by you, outside the vendor’s testing; only worth it if a reboot is truly impossible.
Then undo the stopgaps. Detach the XDP program, delete the pinned BPF LSM directory, delete the nftables tables, and stop the tripwire. Keep MPTCP off and source allowlists in place if you did not need them anyway.
Security advice that nobody ran is a liability, so here is exactly what was tested. Everything below ran on a Linux 7.2. The packet-filter and MPTCP tests ran inside a throwaway user and network namespace (unshare -rn), which gives real CAP_NET_ADMIN over a private network stack without root, so the rules were loaded into the actual kernel rather than just syntax-checked.
Tested, and working as described:
| Approach | How it was tested | Result |
|---|---|---|
| BPF LSM block |
hping3 --rroute over loopback, before and after loading |
✅ SYNs with IP options refused; ordinary SYNs still answered |
| bpftrace tripwire | Run alongside the hping3 test |
✅ Fired once per SYN-ACK for a SYN with options; silent otherwise |
| XDP and BPF LSM programs | Loaded with bpftool, not attached |
✅ Both pass the verifier |
| XDP program, live | Native mode (xdpdrv) on a veth pair, hping3 --rroute from the other namespace; every command from the XDP section, including the enforce flip and removal |
✅ Detect mode counts and passes; enforce mode drops SYNs with options at the driver; ordinary SYNs untouched |
| Red Hat’s nftables rule (input hook) | Loaded in a private namespace; TCP connects with and without IP options (setsockopt(IP_OPTIONS), record-route) |
✅ Connections with options time out; ordinary ones succeed |
| nftables prerouting variant | Same | ✅ Same |
nftables netdev ingress variant |
Same, attached to lo instead of eth0
|
✅ Same |
iptables u32 rule |
Same | ✅ Same |
net.mptcp.enabled=0 |
Go listener in a private namespace, checked with ss -Mln
|
✅ Listener falls back to plain TCP and still accepts connections; the setting is per namespace, and a new namespace starts at 1 |
| Go defaults and opt-outs | Go 1.27 listener checked with ss -Mln under each setting |
✅ Default is MPTCP; the environment variable, //go:debug, go.mod godebug, and SetMultipathTCP(false) each give plain TCP; a module with go 1.23 stays plain TCP even when built with Go 1.27 |
| Go precedence | Same, with conflicting settings | ✅ The environment variable overrides //go:debug; SetMultipathTCP(false) overrides the environment variable |
go list -f '{{.DefaultGODEBUG}}' |
Run against each test module | ✅ Reports multipathtcp=0 exactly where expected |
| “A kprobe cannot block” | Read /sys/kernel/debug/error_injection/list and searched it for the TCP handshake functions |
✅ None of them listed (1,150 functions, no TCP handshake code); only socket system-call entry points from networking |
Not tested; based on documentation or reading the kernel source:
refused counter, and adding bpf to the LSM list through GRUB.sysctls: and the GODEBUG environment entry). The behaviour underneath them was verified, but not through Docker itself.net.ipv4.tcp_migrate_req, from the kernel documentation and the CVE text only.netdev variant if you run containers, or the XDP program if you want per-source visibility and coverage of every namespace behind the NIC.bpf is already an active LSM: load the BPF LSM program for an in-kernel block across all namespaces.tcp_v4_send_synack tripwire and watch for repeating sources.net.mptcp.enabled=0 on the host and in containers that do not need MPTCP, and GODEBUG=multipathtcp=0 (or a //go:debug or go.mod setting) for Go services; keep tcp_migrate_req at 0.apt update && apt full-upgrade && reboot, confirm with uname -r, then remove the stopgaps.net/ipv4/tcp_ipv4.c, net/ipv4/tcp_input.c, net/ipv4/syncookies.c, net/ipv4/tcp_minisocks.c, net/ipv6/tcp_ipv6.c, net/mptcp/subflow.c, net/ipv4/af_inet.c, include/net/tcp.h, include/net/inet_sock.h.net package documentation.