CVE-2026-98432 and the IPv4 options nobody sends

On 15 October the Linux kernel CVE team published CVE-2026-98432, a use-after-free in the code that completes an IPv4 TCP handshake. The fix went into mainline earlier in October and has been backported to the stable branches from 5.10 upwards. When we checked the distributions on 1 November, Debian had fixed kernels for bookworm and trixie in its security archive. Ubuntu’s tracker showed fixes in progress for 22.04, 24.04, 26.04 and 26.10, and none released. Red Hat rates the bug Critical at 9.8 and points to a mitigation bulletin, RHSB-2026-016, without listing a fixed kernel.

According to Anthropic, one of its internal research models found the bug and wrote a working remote root exploit for servers with a Multipath TCP listener. We have not seen that exploit and have not tried to reproduce it. A remotely reachable memory-safety bug in the TCP handshake, present since Linux 4.4 in January 2016, was enough for us to act before the vendor kernels arrived.

Why it’s serious

  • It needs nothing unusual. Any Linux server with an IPv4 TCP port open is affected, on default settings. That covers every web server and every SSH daemon, on kernels going back to 2016.
  • It’s remote and needs no login or user action. The kernel CVE team and Red Hat score it 9.8. Red Hat calls that preliminary, and some vendors score it 8.1 because the race is hard to win.
  • The fix commit is public and explains the bug precisely. Anyone capable now has a map.
  • Anthropic says its model wrote a working remote root exploit. We haven’t seen it, and as far as the reports say it isn’t public.

What goes wrong

A TCP SYN can carry IPv4 options in its IP header, such as Record Route or Timestamp. Very little on the modern internet sends them. The kernel still honours them when they arrive. When a listener receives a SYN with options, the kernel saves them on the request socket in ireq->ireq_opt so the SYN-ACK can go back with matching options.

When the handshake completes, tcp_v4_syn_recv_sock() creates the child socket and hands ireq_opt to it as newinet->inet_opt. It moves the pointer and does not copy the data. From then on the child owns that memory. When the child is destroyed, inet_sock_destruct() frees it with a plain kfree().

The request socket can still be in use at that moment. If the client retransmits its SYN, another CPU may process the retransmission for the same request socket and build a fresh SYN-ACK from tcp_check_req(). That path reads ireq_opt under rcu_read_lock() only, and ip_build_and_send_pkt() and ip_options_build() then read opt->optlen twice.

Before 2016 the listener lock serialised all of this. Commit 079096f103fa (“tcp/dccp: install syn_recv requests into ehash table”) made request processing lockless to help busy listeners scale. Since then, nothing stops the child from being freed while a SYN-ACK is still being built from the same options. Because TCP child sockets are not SOCK_RCU_FREE, the memory goes back to the allocator at once. ip_options_build() can then read it after something else has reused it.

The upstream fix is e3f33b0a1d89 (“ipv4: free inet_opt and ireq_opt after an RCU grace period”) by Eric Dumazet. It changes kfree() to kfree_rcu() in inet_sock_destruct() and tcp_v4_reqsk_destructor(). The readers already used RCU, and other code that replaces inet_opt already freed it after a grace period. These two destructors were the exceptions.

The same commit closes a second path through request socket migration. With net.ipv4.tcp_migrate_req=1, or a BPF program of type BPF_SK_REUSEPORT_SELECT_OR_MIGRATE, reqsk_timer_handler() clones a request socket and the clone shares the original’s ireq_opt. Both are off by default. IPv6 escapes the main issue because tcp_v6_syn_recv_sock() duplicates the options. A similar migration problem with CALIPSO options on IPv6 is due in a separate patch.

The race window is narrow. An attacker who loses it gets one failed handshake and can try again for as long as the port is open.

The fixed upstream versions are 5.10.272, 5.15.223, 6.1.190, 6.6.159, 6.12.113, 6.18.56, 7.2.10 and 7.3.

Why dropping packets with IP options works

Every step above needs ireq_opt to be set. Unless the host uses NetLabel CIPSO labelling, the kernel sets it only when the SYN carried IP options. If a SYN with options never reaches the TCP stack, the request socket holds no options and the child has none to free.

Ordinary traffic has no need for IP options. Browsers, mail servers, SSH clients and monitoring probes send plain 20-byte IPv4 headers. Many networks already discard packets that carry options. In our tests, a SYN with Record Route did not survive the public internet between two UK hosting providers in either direction. We did not count on that, because an attacker may have a path that keeps them.

The first part of our mitigation is a small nftables table that drops IPv4 TCP packets whose header is longer than five 32-bit words:

code
destroy table ip rodmena_ipopts
table ip rodmena_ipopts {
	chain prerouting {
		type filter hook prerouting priority raw; policy accept;
		ip hdrlength != 5 meta l4proto tcp counter drop
	}
}

The table stands apart from anything ufw, Docker or a hand-written ruleset manages, and reloading those leaves it alone. A prerouting chain at raw priority runs before connection tracking and before the routing decision. It also sees traffic on the loopback interface. That covers a local user who sets IP_OPTIONS on an ordinary socket and connects to a service on the same machine. The rule matches TCP only. IGMP uses the Router Alert option and plays no part in this bug.

The second part is two sysctls:

code
net.mptcp.enabled = 0
net.ipv4.tcp_migrate_req = 0

Turning MPTCP off removes the listener type the reported exploit used. With it off, socket(AF_INET, SOCK_STREAM, IPPROTO_MPTCP) fails with “Protocol not available”. Go has enabled MPTCP on listeners by default since 1.24 where the kernel supports it, and it falls back to plain TCP when the kernel refuses. The migration sysctl is already 0 by default. We set it explicitly because a later change to it would otherwise go unnoticed.

Making it survive a reboot

On Debian and Ubuntu the packaged nftables.service loads /etc/nftables.conf, and the stock file begins with flush ruleset. Enabling that service to carry one table would wipe ufw’s rules and Docker’s NAT at every boot. We load the table from its own oneshot unit instead:

code
[Unit]
Description=Drop IPv4 TCP packets carrying IP options (CVE-2026-98432)
DefaultDependencies=no
After=local-fs.target
Before=network-pre.target
Wants=network-pre.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/sbin/nft -f /etc/rodmena/ipopts-drop.nft
ExecStop=/usr/sbin/nft destroy table ip rodmena_ipopts

[Install]
WantedBy=multi-user.target

Ordering it before network-pre.target puts the table in place before networking is configured. systemd-sysctl applies the two sysctls at boot from a file in /etc/sysctl.d/.

Testing it in both directions

Watching an attack fail tells you very little about a filter. The probe might have been broken, or something on the path might have dropped the packet first. We wanted to see the rule match the packets we care about, and to see ordinary connections still get through.

The probe is a short Python script that runs as an ordinary user. It opens a listener on 127.0.0.1 and connects once with a plain socket. Then it connects again after adding a Record Route option with setsockopt(IPPROTO_IP, IP_OPTIONS):

python
def rr_option():
    return bytes([7, 11, 4]) + bytes(8) + bytes([0])

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.IPPROTO_IP, socket.IP_OPTIONS, rr_option())
s.settimeout(4.0)
s.connect((host, port))

The bytes are option type 7 (Record Route), a length of 11, a pointer of 4 and two empty address slots, padded to 12 bytes.

We ran the test in three steps on each server:

  1. Load the table with counter and no drop. Both connections succeed and the counter rises by 6 packets. This shows the probe really puts options on the wire and the rule matches them.
  2. Change the rule to counter drop. The plain connection still succeeds. The connection with options times out, and the counter shows 4 packets, the SYN and its three retransmissions.
  3. Install the unit, restart it, and run the probe again against the persistent version.

Before step 2 we armed an automatic undo with systemd-run --on-active=300 /usr/sbin/nft destroy table ip rodmena_ipopts. Then we opened a fresh SSH session from outside. We cancelled the timer and made the change permanent only after that login worked. We also stopped and started the unit to check that it removes and restores the table, and confirmed that public HTTPS, mail and monitoring scrapes carried on as before.

Where the mitigation stops

Netfilter tables belong to a network namespace, and so does net.mptcp.enabled. A namespace created after the change starts with MPTCP enabled and no rules. We checked this inside a namespace made with unshare -n, which is what Docker and runc do for each container. The sysctl read 1, nft list tables printed nothing, and the probe connected with its options intact.

This leaves two ways around the host table.

The first is an unprivileged user who creates a namespace of their own. Current Ubuntu ships with kernel.apparmor_restrict_unprivileged_userns=1, which blocks this, and our attempt at unshare -rn as an ordinary user failed when it tried to write the uid map. Where that restriction is missing, kernel.unprivileged_userns_clone=0 on Debian or user.max_user_namespaces=0 does the same job, provided nothing you run depends on user namespaces.

The second way is through a container. When code in a container connects to a service in the same container, the traffic stays on the container’s own loopback and never passes through the host table. On a host that runs untrusted or third-party containers, these steps help until the kernel arrives:

  • Start containers with --sysctl net.mptcp.enabled=0. We confirmed with Docker that a default container reads 1 and a container started with the flag reads 0.
  • Drop CAP_NET_RAW, which Docker grants by default. Without it, code in the container cannot inject crafted SYNs of its own.
  • Add a seccomp rule that refuses setsockopt with level IPPROTO_IP and option IP_OPTIONS, which is how an ordinary TCP socket gets options onto its packets.

These steps shrink what a container can reach until the fixed kernel is installed.

What we are waiting for

The real fix is a kernel that contains e3f33b0a1d89 or its stable backport. When the distribution kernels ship we will install them, reboot inside planned maintenance windows and check the running version on each server. The nftables table will stay after that, since our servers have no use for IP options.

If you run Linux servers, these four commands tell you where you stand:

code
sysctl net.mptcp.enabled net.ipv4.tcp_migrate_req
ss -H -lMn
uname -r
nft list tables

After mitigation the first should print 0 for both values. The second lists MPTCP listeners and should print nothing. The third tells you whether your kernel is at or above a fixed stable version. The fourth shows whether a table like the one above is loaded.

References