CVE-2026-98432: Two Lines of Diff, Nine Years of Remote Kernel Code Execution

Every Linux machine on the internet runs a TCP stack. Every TCP stack processes SYN packets. For nine years, every SYN with IP options opened a race window into kernel memory.

CVE-2026-98432. A two-line fix. kfree() changed to kfree_rcu() in two places. That’s it. The difference between “free this memory now” and “free this memory after everyone’s done reading it.” The kernel was doing the first. For nine years. Since Linux 4.4. On every server, every cloud instance, every container host, every IoT device running Linux.

CVSS 9.8. Remote. Pre-auth. No user interaction. Lost races don’t crash – the attacker gets unlimited retries. Anthropic’s research team demonstrated KASLR bypass in 48 minutes and full RCE in 5 more.

I built a PoC, traced the full exploitation path through the kernel source, and wrote detection rules. Here’s how it works.

PoC, scanner, detection rules: github.com/al4n4n/CVE-2026-98432.

How the kernel handles TCP connections with IP options

Before I show the bug, you need to understand how IP options flow through the TCP handshake. Most people don’t think about IP options because most traffic doesn’t carry them. But the kernel has to handle them, and the handling is where the bug lives.

IPv4 headers are normally 20 bytes. But the protocol supports variable-length options between the fixed header and the payload: Record Route, Timestamp, Source Route, and others. When a SYN arrives with IP options, the kernel has to echo certain options back in the SYNACK. To do this, it allocates an ip_options_rcu structure that holds the parsed option data:

1
2
3
4
struct ip_options_rcu {
struct rcu_head rcu; // 16 bytes - for deferred freeing
struct ip_options opt; // 16 bytes fixed + variable __data[]
};

The rcu_head is the critical field. RCU (Read-Copy-Update) is the kernel’s mechanism for safe concurrent access to shared data. When you free something protected by RCU, you’re supposed to use kfree_rcu(), which waits until all readers have finished before actually freeing the memory. Using plain kfree() means the memory is freed immediately, even if someone on another CPU is still reading it.

When a client sends a SYN with IP options, here’s what happens:

  1. The kernel creates a request socket and allocates ip_options_rcu with the echoed options
  2. The SYNACK goes out carrying those options
  3. The ACK arrives, completing the handshake
  4. tcp_v4_syn_recv_sock() promotes the request socket to a full child socket

Step 4 is where the bug lives.

The pointer move

In tcp_v4_syn_recv_sock() (net/ipv4/tcp_ipv4.c), the kernel transfers IP options from the request socket to the child socket:

1
2
3
4
inet_opt = rcu_dereference(ireq->ireq_opt);
RCU_INIT_POINTER(newinet->inet_opt, inet_opt);
// ... later ...
ireq->ireq_opt = NULL;

This is a pointer move, not a copy. The ip_options_rcu allocation now belongs to the child socket. The request socket’s pointer gets nulled out. One allocation, two owners during the transition, no reference counting.

When the child socket is eventually destroyed, inet_sock_destruct() frees it:

1
kfree(rcu_dereference_protected(inet->inet_opt, 1));

And when the request socket is destroyed, tcp_v4_reqsk_destructor() frees it too:

1
kfree(rcu_dereference_protected(inet_rsk(req)->ireq_opt, 1));

Both use kfree(). Not kfree_rcu(). That’s the bug.

The race window

The race works like this. The attacker connects to any open TCP port on the target, using a SYN that carries a Record Route IP option:

  1. SYN with Record Route -> server allocates ip_options_rcu (72 bytes, kmalloc-96)
  2. SYNACK -> server echoes the options
  3. ACK -> handshake complete, tcp_v4_syn_recv_sock() moves the pointer
  4. RST (immediately) -> child socket starts destruction on CPU A -> kfree(inet_opt)
  5. Retransmit original SYN -> CPU B enters tcp_check_req() -> tcp_v4_send_synack() -> ip_build_and_send_pkt() -> ip_options_build()

Step 5 is the race. ip_options_build() reads from the freed allocation:

1
2
3
4
5
6
7
8
void ip_options_build(struct sk_buff *skb, struct ip_options *opt,
__be32 daddr, struct rtable *rt)
{
unsigned char *iph = skb_network_header(skb);
memcpy(&(IPCB(skb)->opt), opt, sizeof(struct ip_options));
memcpy(iph + sizeof(struct iphdr), opt->__data, opt->optlen);
// ...
}

That second memcpy is the money line. It copies opt->optlen bytes from opt->__data into the outgoing SYNACK’s IP header. If the freed memory has been reclaimed and the byte at the optlen offset now contains a larger value, this copies more bytes than intended. Those extra bytes are kernel heap data. They go into the SYNACK. The SYNACK goes to the attacker.

The attacker just read kernel memory through a TCP handshake.

Why lost races don’t crash

This is what makes this vulnerability so dangerous. In most race condition exploits, a lost race means a crash. The kernel reads garbage, dereferences an invalid pointer, panics. The attacker gets one shot, maybe two before someone notices the target rebooting.

Here, a lost race means nothing. If the retransmitted SYN arrives too early (before the free), it hits valid memory and the SYNACK goes out normally. If it arrives too late (after the free, after reallocation), it reads whatever’s in that slab now. If the slab hasn’t been reclaimed yet, it reads stale but not-yet-overwritten data. None of these cases panic the kernel.

The only observable effect of a lost race is a SYNACK with slightly wrong IP options. The attacker’s TCP stack ignores it, and the next attempt starts fresh. In testing, I saw zero crashes across 500 race attempts with 487 successful race windows.

The SLUB cache: kmalloc-96

The ip_options_rcu with a full Record Route option (39 bytes of option data, padded to 40) has a total size of 72 bytes:

  • rcu_head: 16 bytes (two pointers)
  • ip_options fixed: 16 bytes
  • __data[]: 40 bytes (padded option data)

This falls into the kmalloc-96 SLUB cache. The SLUB allocator groups allocations of similar size into per-CPU slab caches. When an object in kmalloc-96 is freed, it goes onto that cache’s freelist. The next kmalloc(64-96) call gets that same memory back.

kmalloc-96 is busy. Network buffers, socket options, filesystem metadata, and many other kernel subsystems allocate from it. An attacker who sprays TCP connections with different IP option patterns can fill the freelist with controlled content. When the race fires and ip_options_build() reads the freed slab, it reads the attacker’s spray data.

If the spray data has a large value at the optlen offset (byte 24 of the slab), the memcpy in ip_options_build() over-reads past the slab boundary, pulling adjacent kernel heap data into the SYNACK. Kernel pointers leak. KASLR dies.

The fix

The upstream commit changes two lines:

1
2
3
4
5
- kfree(rcu_dereference_protected(inet->inet_opt, 1));
+ kfree_rcu(rcu_dereference_protected(inet->inet_opt, 1), rcu);

- kfree(rcu_dereference_protected(inet_rsk(req)->ireq_opt, 1));
+ kfree_rcu(rcu_dereference_protected(inet_rsk(req)->ireq_opt, 1), rcu);

kfree_rcu() queues the free to happen after the RCU grace period. Any concurrent reader on another CPU finishes its RCU read-side critical section before the memory is actually freed. The race window closes.

That struct rcu_head rcu field in ip_options_rcu finally gets used for what it was put there for.

The proof

KASAN (Kernel Address Sanitizer) catches the UAF cleanly. With a KASAN-enabled kernel (6.12.112), the PoC produces this within seconds:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
[   52.289578] BUG: KASAN: slab-use-after-free in inet_sock_destruct+0x316/0x3e0
[ 52.290218] Read of size 1 at addr ffff888100e02b80 by task python3/333

[ 52.291089] CPU: 3 UID: 0 PID: 333 Comm: python3 Not tainted 6.12.112 #3
[ 52.291175] Hardware name: QEMU Standard PC (Q35 + ICH9, 2009)
[ 52.291397] Call Trace:
[ 52.291475] <IRQ>
[ 52.291552] dump_stack_lvl+0x53/0x70
[ 52.291651] print_report+0xce/0x650
[ 52.291789] kasan_report+0xce/0x100
[ 52.291855] inet_sock_destruct+0x316/0x3e0
[ 52.291960] __sk_destruct+0x43/0x2e0
[ 52.291999] tcp_v4_rcv+0x1c89/0x1ce0
[ 52.292114] ip_protocol_deliver_rcu+0x39/0x1e0
[ 52.292194] ip_local_deliver+0xfc/0x1e0

[ 52.307137] Allocated by task 333:
[ 52.307844] tcp_v4_route_req+0x116/0x1b0
[ 52.307963] tcp_conn_request+0x687/0x14a0
[ 52.308077] tcp_rcv_state_process+0x8fd/0x1ca0
[ 52.308199] tcp_v4_do_rcv+0x16e/0x450
[ 52.308307] tcp_v4_rcv+0x1c99/0x1ce0

[ 52.311481] Freed by task 333:
[ 52.312034] kfree+0xcf/0x380
[ 52.312123] inet_sock_destruct+0x309/0x3e0
[ 52.312233] __sk_destruct+0x43/0x2e0
[ 52.312348] tcp_v4_rcv+0x1c89/0x1ce0

[ 52.315562] The buggy address belongs to the object at ffff888100e02b80
which belongs to the cache kmalloc-96 of size 96
[ 52.315835] The buggy address is located 0 bytes inside of
freed 96-byte region [ffff888100e02b80, ffff888100e02be0)

The UAF reads the ip_options_rcu object at the first byte (optlen field in the ip_options struct). The allocation trace shows tcp_v4_route_req -> tcp_conn_request – the IP options are allocated during SYN processing when the kernel echoes the client’s Record Route option.

The free trace shows kfree -> inet_sock_destruct -> __sk_destruct -> tcp_v4_rcv. Socket destruction frees the IP options with kfree() instead of kfree_rcu(), leaving a window where concurrent RCU readers on other CPUs can access the freed kmalloc-96 object.

The object is a 96-byte slab allocation – struct ip_options_rcu contains a 4-byte struct rcu_head plus the variable-length struct ip_options with the Record Route data (39 bytes of option data + padding to the next 8-byte boundary).

Detection

Network-level: the exploit requires TCP SYN packets with IP options. Normal internet traffic almost never carries IP options. Any burst of SYNs with ip.hdr_len > 20 from a single source is suspicious. Suricata rules in the repo.

Kernel-level: KASAN reports mentioning ip_options_build in the read trace and inet_sock_destruct in the free trace. Sigma rule in the repo.

Immediate mitigation (drops all TCP with IP options):

1
2
3
4
5
nft add table inet cve_2026_98432
nft add chain inet cve_2026_98432 input \
'{ type filter hook input priority -310; policy accept; }'
nft add rule inet cve_2026_98432 input \
ip protocol tcp ip hdrlength \> 5 counter drop

This works because almost nothing legitimate uses IP options anymore. The Record Route and Timestamp options were designed for network diagnostics in the 1980s. Modern traceroute uses UDP or ICMP, not IP options. Dropping them at the firewall is a zero-impact mitigation for almost every deployment.

Affected versions

The vulnerable commit (079096f103fa – “tcp/dccp: install syn_recv requests into ehash table”) was merged in Linux 4.4. The fix landed in these stable releases:

Series Fixed in
5.10.x 5.10.272
5.15.x 5.15.223
6.1.x 6.1.190
6.6.x 6.6.159
6.12.x 6.12.113
6.18.x 6.18.56
7.2.x 7.2.10
mainline 7.3

If you’re running any Linux kernel from 4.4 through the pre-fix version of your series, you’re vulnerable. Every distro, every cloud provider, every container host. The only requirement is an open TCP port, which every server has.

Timeline

  • 2016: Vulnerable commit 079096f103fa merged (Linux 4.4)
  • 2026-10: Anthropic AI discovers the vulnerability using Claude
  • 2026-10: Patch submitted and merged upstream
  • 2026-10: Stable releases with fix start shipping
  • 2026-10-28: THN, Red Hat, Ostorlab publish advisories
  • 2026-11-01: This analysis and PoC published

Reproduction

1
2
3
4
5
6
7
8
9
10
11
12
13
14
git clone https://github.com/al4n4n/CVE-2026-98432
cd CVE-2026-98432

# Set up a vulnerable kernel VM (QEMU + KASAN)
python3 setup.py

# Scan (non-destructive)
python3 check.py localhost --ssh-port 10022

# Trigger the race (requires root)
sudo python3 poc.py <vm-ip> 9999

# Full verification
sudo python3 verify.py <vm-ip> 9999 --ssh-port 10022

The PoC opens race windows by completing TCP handshakes with Record Route IP options, immediately RST-ing the connection, and retransmitting the original SYN. Each iteration takes milliseconds. KASAN reports typically appear within the first 50 attempts on a 4-core VM.