"CVE-2026-98432: a 9.8-rated TCP race, measured"
On 15 October the Linux kernel CVE team published CVE-2026-98432, scored 9.8: a use-after-free in the IPv4 TCP handshake, reachable remotely, that Anthropic reports an unreleased Claude model turned into a zero-click exploit — arbitrary commands as root against servers with a Multipath TCP listener, no credentials, 53 minutes from first probe to shell in their proof of concept. The fix is two lines. The interesting part is not the patch; it is how wide the race actually is. We built the measurement tooling and ran it against a vulnerable host.
The bug in one paragraph
When a SYN arrives with IPv4 source-route / record-route / timestamp options, the kernel saves them (struct ip_options_rcu) on the request socket. When the handshake completes, tcp_v4_syn_recv_sock() hands that pointer — not a copy — to the child socket. Since commit 079096f103fa (4.4, "install syn_recv requests into ehash table") request sockets are handled without the listener lock, so while one CPU is building a SYN-ACK for a retransmitted SYN and reading those options, another CPU can be completing the handshake and freeing the child — and inet_sock_destruct() freed inet_opt with a plain kfree(), with no RCU deferral and no SOCK_RCU_FREE on the child. A second, independent trigger exists via request-socket migration (net.ipv4.tcp_migrate_req=1, or a BPF_SK_REUSEPORT_SELECT_OR_MIGRATE program): inet_reqsk_clone() makes the old request socket and its clone share one options object, and freeing the clone kfree()s it under the same reader. The fix (e3f33b0a1d89, by Eric Dumazet, reported by Xinyang Ge of Anthropic) is exactly what the readers already assumed: kfree() becomes kfree_rcu() in the two destructors.
sequenceDiagram
participant A as CPU 1 — retransmitted SYN
participant O as options object
participant B as CPU 2 — child freed
A->>O: deref ireq->ireq_opt in tcp_check_req()
Note over A: build SYN-ACK:<br/>memcpy(iph+20, opt->__data, opt->optlen)
B->>O: kfree(inet_opt) in inet_sock_destruct()
Note over A: reads the option object — freed
The reads happen in ip_build_and_send_pkt() / ip_options_build(), under rcu_read_lock() only. IPv6 is unaffected on the first path (tcp_v6_syn_recv_sock() copies the options); the IPv6 migration path has the same shape for CALIPSO-set options and the fix commit says a separate patch will follow — that patch is not out as of today, so tcp_v6_reqsk_destructor() is still race-prone in combination with CALIPSO.
What the exploit does, per Anthropic
Anthropic's exploit code is not public; the first wave of community releases (30 October) are race triggers, reuse oracles and mitigation tooling — none claim code execution. From the disclosure, the chain is:
- Remote KASLR leak. Win the race so the freed options object is re-taken by attacker-controlled data, and the kernel reads a length field the attacker influences (
opt->optlen), copies more bytes than it should into the SYN-ACK, and sends them back to the attacker. One leaked pointer in the reply and the kernel layout is known. Took 48 minutes in their PoC. - Control-flow hijack. Win the race again, overflow a buffer through the same object, overwrite a callback pointer with a payload staged in kernel memory via an ordinary protocol feature; the payload chains existing kernel functions into a root shell. Five more minutes.
The property that makes this practical: a lost race fails the handshake, not the kernel — so retries are free. Anthropic's scenario is an MPTCP listener, which matters beyond their lab because Go 1.24 and later enable MPTCP by default on listen sockets; a plain net.Listen in a current Go service is already an MPTCP listener.
Why the vendors split 9.8 vs 8.1
| Evaluator | Score | Attack complexity |
|---|---|---|
| kernel CVE team | 9.8 | Low — the attacker induces both sides of the race |
| Red Hat, Oracle, SUSE, Amazon | 8.1 | High — the race is hard to win in practice |
Both positions are defensible, and our measurements are the kind of data this argument should be had over. The kernel team is right that both sides are attacker-induced; the vendors are right that "win a ~1 µs window against a causally correlated free, at scale" is not a low bar. Our reading: on a single ordinary server, remote code execution is a datacenter-scale problem (which is also where request-socket migration and MPTCP listeners live), but remote denial of service is much cheaper — see the length-violation primitive below.
An independent reproduction published 30 October (offline-ant/tcp2kaslr) settles two of the open questions. Built on 6.18.55 from the release tarball, running under KASAN in QEMU, the UAF fired on a plain IPPROTO_TCP listener — twice, including once with net.mptcp.enabled=0. So MPTCP is not a precondition, and it does not make the race easier either: 0 events in 10.46M MPTCP attempts versus 0 in 8.77M plain, bounding any difference far below the hype. And it puts a number on the difficulty vendors were gesturing at: on a stock (non-KASAN) kernel, ~2 events in 48.3M attempts — order 4e-8 per attempt — and bursty, both events inside the same five-second window, for reasons nobody can yet name. That number is also a best case for the attacker: their harness forces the reader and the free onto two pinned CPUs, and the victim closes the accepted child instantly. A production server that does real work between accept() and close(), behind a NIC that hashes one flow to one CPU, is strictly harder. Two field notes from their work deserve wider circulation. SYN cookies silently take a host off the vulnerable path — cookie-mode requests never allocate the options object — so a fast enough flood stops racing against the real thing at exactly the moment the attacker thinks it is racing hardest. And the right way to ask "is this kernel fixed?" is at the binary level: disassemble the two free sites in the running kernel and check whether they call kfree or kvfree_call_rcu (their check-free-sites.py does exactly this). Version strings lie about backports; instructions do not.
The lab: measuring a race you cannot see
Target: Ubuntu 20.04, 5.4.0-216-generic, 4 CPUs, SLUB with SLAB_FREELIST_RANDOM/HARDENED, no KASAN/KFENCE — a kernel Ubuntu marks Vulnerable with the end-of-life qualifier, i.e. it will never receive a fix. Two structural facts decided the approach, both measured with ftrace rather than assumed:
- No MPTCP on this kernel config, so the advisory's flavour of the race is out of reach; only plain TCP applies.
- Single RX queue: every packet of a flow is processed on the same CPU. A remote attacker's SYN-retransmit and ACK can never run the reader and the free on different CPUs on this NIC. The remote shape is structurally unreachable here — which is why the same race was presumably found at datacentre scale, on many-queue hosts.
So the tooling drives both racing packets locally, with CPU-pinned threads: a reader thread spamming retransmitted SYNs, a generator thread emitting the ACK/RST that frees, and a spray thread re-allocating from kmalloc-64 (where the 52-byte option objects live — one of the busiest caches in the kernel, and one an unprivileged process can spray directly via setsockopt(IP_OPTIONS), add_key() or setxattr()) with a content marker, then sniffing SYN-ACKs for foreign option bytes.
The numbers
Sustained trigger: 1,300–2,600 request-sock lifecycles/s, ~2,500–9,000 reader builds/s, ~3,700 frees/s. A 335 s soak moved 18.6 M packets and 468k SYN-ACK exchanges with no crash.
Instrumenting the real path with kprobes (pep-uafprobe.sh, 20 s run):
| Metric | Value |
|---|---|
Reader builds (ip_build_and_send_pkt entry/return) |
183k |
Frees (tcp_v4_reqsk_destructor + inet_sock_destruct) |
75k |
| Frees landing inside a reader build, other CPU | 1,407 (~1.9 %) |
| Frees within 17 µs before a build (upper bound for the heap-exposure shape) | 14,718 (~20 %) |
Occupancy is not identity. A custom module (pep_uaf.c) then matched the same object by pointer between a build's entry and return:
| Metric | Value |
|---|---|
| Destructor calls confirmed (by content) to free an intact options object | 46,841 / 46,841 (100 %) |
ip_options_build() invocations |
348,607 |
| Free of the object currently being read | 0 |
| Same, across a 12-phase sweep X = 0…1600 µs (~280k frees, 2.2 M builds) | 0 |
Zero — but an informative zero. With a 100 %-validated freeing side, it rules out a broken detector and points at the timing: when the free is caused by a packet for the same request sock, it lands outside its own reader's read, every time. The free causally follows its own SYN-ACK retransmit; the race needs the two events decorrelated, which is what request-socket migration, many-queue RSS, and the event rates of a hyperscaler provide. This is the vendor "AC: High" position with a mechanism behind it.
Rather than wait for luck, pep_force.ko removes it: at the entry of ip_build_and_send_pkt() — whose 5th argument is the options object — it kfree()s the object (what the buggy free does) and immediately re-allocates from the same cache with attacker-chosen bytes (what an exploit would do). The SYN-ACK then carries the reclaimer's bytes, deterministically: the read primitive is real and remotely visible.
That module also produced the most instructive failure of the project. The first version hooked ip_options_build() and hung the box — because that function has three callers: the request-sock SYN-ACK path, __ip_queue_xmit() with a live socket's inet_opt, and __ip_make_skb() with cork->opt, a pointer inside an sk_buff, not a slab object at all. Freeing the latter two is instant corruption. Hooks must be placed where the object's ownership is provable, not where the read happens.
Primitives once the object is re-taken
- Remote heap read —
memcpy(iph+20, opt->__data, opt->optlen)copies up to 40 bytes of another owner's slab data into a SYN-ACK returned to the attacker. No privileges needed, and the shape is exactly Anthropic's stage 1. - Length violation → DoS —
opt->optlenalso drivesskb_push(20 + optlen)while the SYN-ACK skb only has 188 bytes of headroom;optlen > 168movesskb->databelowskb->head.SLAB_FREELIST_RANDOM/HARDENEDwrites into a freed-but-not-yet-reused object land at a random 8-byte-aligned offset, which analysis puts at ~1/8 probability of presentingoptlen ≥ 0x80— a remote oops trigger with no content control at all. This is why "the most likely impact is a crash" (Red Hat) should not be read as comforting. - Bounded write —
ip_rt_get_source()writes 4 bytes atiph + opt->rr(andts/srr/cipso), offsets derived from the option bytes themselves. With the object re-taken viasetsockopt(IP_OPTIONS), the write target becomes attacker-influenced — a cross-object 4-byte write once the offset exceeds the buffer. Full code execution still needs the leak → layout → write chain against KASLR and hardened SLUB.
What defenders should do
- Patch. Fixed upstream in 5.10.272, 5.15.223, 6.1.190, 6.6.159, 6.12.113, 6.18.56, 7.2.10, 7.3. Debian, Oracle, Amazon (kernel6.12/6.18) and most of SUSE shipped fixes within two weeks. As of 30 October, Ubuntu and RHEL have released no fixed kernel, and everything between the introduction (4.4, January 2016) and the branches that went end-of-life never receives it: Ubuntu lists focal, bionic and xenial simply as Vulnerable. Check
uname -ron every host — containers share the host kernel, so a container platform is one host kernel decision, not a per-image one. - Interim mitigation exists while you wait for kernels. Independent implementations (notably Akamai's validation-and-mitigation script) converge on the same shape: drop inbound IPv4 TCP that carries IP options at the prerouting hook (
ip hdrlength > 5 meta l4proto tcp dropin its own nftables table — which also covers DNAT-ed container traffic, since the containers share the host's netfilter), setnet.ipv4.tcp_migrate_req=0, and optionallynet.mptcp.enabled=0. Know its edges: XDP, Cilium datapath and macvlan/ipvlan containers bypass host netfilter entirely, aBPF_SK_REUSEPORT_SELECT_OR_MIGRATEprogram keeps the migration variant reachable, and CIPSO deployments genuinely break under the drop rule — CIPSO is an IPv4 option. For the RHEL family, community kpatch livepatches for AlmaLinux/Rocky 8–10 now exist, which is a path that does not wait on Red Hat. - Inventory Go services. Anything built with Go ≥ 1.24 listens with MPTCP by default.
GODEBUG=multipathtcp=0(orgodebug multipathtcp=0ingo.mod) is the stopgap; the Go team themselves call per-program mitigation fragile and recommend the kernel fix. And with tcp2kaslr's result above, be clear about what it buys: it removes the configuration Anthropic exploited, not the exposure — plain listeners are equally vulnerable. - Watch IPv6 + CALIPSO. The migration-path equivalent in
tcp_v6_reqsk_destructor()is explicitly deferred to a follow-up patch that is not out yet. - Detection is weak but improving. A lost race leaves no trace beyond a failed connection; heavy probing looks like many short-lived IPv4 connection attempts carrying Record-Route or Timestamp IP options with retransmits to the same port. The community toolkits now ship Suricata rules for that shape, and the marker-echo detector they use (unique token per attempt in the SYN's IP options; a SYN-ACK echoing another attempt's token is a confirmed read of a freed object) is directly reusable as a hunting signature. Treat matches as a lead, not proof — nothing has been reported exploited in the wild, and the CVE is not in CISA's KEV as of today.
Takeaway
A 9.8 on a race is a statement about a distribution of outcomes, not about your one server. What our measurement adds is the shape of that distribution: the window is ~1 µs wide and causally correlated against a single attacker on a single-queue host — which makes remote code execution a scale problem — while the freed-object length field makes a crash look like a coin flip. Patch what can be patched, count your EOL kernels and Go binaries first, and treat the score debate as answered in both directions: 9.8 is real somewhere, and it is not the number that describes your fleet's risk.
Sources
- Upstream fix,
e3f33b0a1d89— Eric Dumazet, reported by Xinyang Ge (Anthropic), 2026-10-01. - Anthropic: Discovering and exploiting vulnerabilities in critical, real-world software with Claude, 2026-10-27, chapter 5.
- CVE-2026-98432 record (kernel CNA, 9.8) and Ubuntu status page (focal: Vulnerable, no fix).
- Ostorlab deep dive and The Hacker News for the vendor scoring split and distribution status tables.
- Go issue 82717 — MPTCP-by-default exposure and mitigation guidance.
- offline-ant/tcp2kaslr — independent KASAN reproduction on plain TCP,
ANALYSIS.mdwith the rate measurements and harness caveats,check-free-sites.pybinary patch check. - akamai/cve-2026-98432-validation-mitigation — patch-state/exposure check with interim mitigation and exact rollback; H1z3-br0/CVE-2026-98432 — race trigger with marker-echo oracle, KASLR resolver, Suricata rules.
- Lab measurements: our
tcppeeperreproduction toolkit (remoteAF_PACKETdriver, local pinned-thread trigger, kprobe window probe, identity-exact detector module, forced-interleaving module), run against5.4.0-216-generic.