FIELD NOTES · LINUX SECURITY

CVE-2026-98432: what I run until the fixed kernel ships

A post in my feed said a 9.8 Linux kernel bug was out, and to go patch it. On Ubuntu I could not: the fixed kernels were still pending. Networking is not my home ground, so I learned as I went, took it slow and kept asking questions until the workaround made sense. These are my notes: what the bug is, the one rule I run meanwhile, how I tested it, and what it does not cover. All files are linked at the end.

Sandeep Shelke · Stavanger ·

In short

  • CVE-2026-98432 is a use-after-free in the Linux kernel's IPv4 TCP code. It starts with a TCP SYN that carries IP options.
  • Ubuntu rates it 9.8. When I checked on 31 Oct 2026, Ubuntu's fixed kernels for 22.04, 24.04, 26.04 and 26.10 were still pending, and 20.04 and older had no fix version listed.
  • IP options make the IPv4 header longer than 20 bytes. None of my use cases need them, so one nft rule drops every IPv4 TCP packet that carries them, before TCP sees it.
  • I loaded it with a 5-minute timer that undoes it, checked SSH from a new session, and rebooted twice. Both times the rule was up about 6 seconds into boot, before networking and SSH.
  • It is a workaround, not a fix. When the fixed kernel runs, it goes.

The bug

When a SYN with IP options arrives, the kernel keeps the options with the half-open connection. When the connection is accepted, the new socket gets the same options block instead of a copy. A retransmitted SYN for the same connection can be handled at the same moment on another CPU, which reads the options to build a SYN-ACK. If the new socket is freed while that read runs, the other CPU reads freed memory.

FIG. 02HOW IT GOES WRONG
Two CPUs and one options blockCPU 1 receives a SYN with IP options and keeps them with the half-open connection. The accepted socket gets the same options block without a copy. CPU 2 handles a retransmitted SYN and reads the block to build a SYN-ACK. CPU 1 frees the socket and the block with kfree while CPU 2 is still reading: a use-after-free. The fix frees the block with kfree_rcu, only after every reader is done.CPU 1CPU 2SYN with optionskept by the kernelSocket acceptedsame block, no copySocket freedkfree() while CPU 2 readsOptions blockRetransmitted SYNsame connectionBuilds a SYN-ACKreads the optionsReads freed memoryuse-after-freefreesreadsFix: kfree() becomes kfree_rcu(), freed only after every reader is doneTwo CPUs and one options blockCPU 1: SYN with optionskept with the connectionCPU 1: socket acceptedsame options block, no copyCPU 2: retransmitted SYNreads the block for a SYN-ACKCPU 1: socket freedkfree() while CPU 2 readsCPU 2: still readinguse-after-freeFix: kfree_rcu(), free only afterevery reader is done
Two owners point at one options block. One CPU frees it while the other is still reading it. The fix waits until every reader is done before freeing.

The upstream fix, "ipv4: free inet_opt and ireq_opt after an RCU grace period", changes kfree() to kfree_rcu() in two places, so the block is freed only after every reader is done. A second path goes through request-socket migration, which is off unless net.ipv4.tcp_migrate_req is 1 or a reuseport BPF program asks for it.

Anyone who can reach an open IPv4 TCP port on the host can send such a SYN. No login is needed. IPv6 is not affected by the main issue.

How serious

  • Ubuntu: CVSS 3.1 9.8, priority high. CVSS 4.0: 8.3.
  • Red Hat's bug report lists it as urgent.
  • In the public review of the fix I found no crash trace and no reproducer, and it does not say how the bug was found. An AI review bot found that the first version of the fix missed the migration path, and the fix's author sent a second version that covers both.
  • A comment on Red Hat's bug report, a question from another user, says remote root was shown against a server with an MPTCP listener. It cites a research post I could not find, so I treat it as unverified.

So I did not panic and I did not wait. Proportionate, not paranoid.

Am I exposed?

I wrote a small read-only script that answers that for one Ubuntu host. It looks for the CVE in the running kernel's changelog, for an installed fixed kernel that is not running yet, for open IPv4 TCP ports, and, as root, for the rule below. It prints ports only, no addresses.

shell
sudo bash check-cve-2026-98432.sh; echo "exit $?"
TABLEEXIT CODES OF THE CHECK SCRIPT
Exit codes of the check script
ExitVerdict
0PATCHED: the running kernel's changelog lists the CVE
1EXPOSED: no fix found. Also used when no ports are open (low remote exposure)
2FIX INSTALLED, NOT ACTIVE: reboot into it
3MITIGATED: no fix yet, the drop rule is loaded

Without root it cannot read the firewall, so a mitigated host still says EXPOSED, with a note. The part that checks for the rule, shortened:

shell
if [ "$(id -u)" = 0 ]; then
  if command -v nft >/dev/null 2>&1 && NFT="$(nft list table inet cve98432 2>/dev/null)"; then
    if echo "$NFT" | grep -q 'hook prerouting' && echo "$NFT" | grep -q 'hdrlength' && echo "$NFT" | grep -q 'drop'; then
      MIT_STATE=active
    else
      MIT_STATE=broken
    fi
  else
    MIT_STATE=absent
  fi
fi

It says MITIGATED only when all three parts are found. If nft prints something else on another version, it falls back to EXPOSED, not to a false MITIGATED.

The rule

FIG. 03WHERE THE RULE SITS
Where the rule sitsAn IPv4 TCP packet meets the rule in the prerouting hook at priority -310. If its header is longer than 20 bytes it carries IP options and is dropped and counted. Otherwise it goes on to connection tracking, the firewall and TCP.IPv4 TCPpacket arrivesprerouting -310header over 20 bytes?conntrackfirewallTCPdroppedand countednoyes: IP optionsWhere the rule sitsIPv4 TCPpacket arrivesprerouting -310over 20 bytes? then dropped, countedconntrack, firewallTCP
The rule runs in the prerouting hook at priority -310, before connection tracking, the rest of the firewall and TCP. A packet with IP options is dropped there and counted.

The rule file, /etc/nftables-cve98432.nft:

nft
table inet cve98432
flush table inet cve98432
table inet cve98432 {
  chain pre {
    type filter hook prerouting priority -310; policy accept;
    ip protocol tcp ip hdrlength gt 5 counter drop
  }
}
  • ip hdrlength gt 5 is the length field from Fig. 01: more than 5 words means the packet carries IP options.
  • ip protocol tcp keeps it to IPv4 TCP. Everything else passes.
  • The first two lines make the file safe to load more than once.

Load it safely

FIG. 04GOING SLOW
Going slow, four stepsStep 1, a namespace test on my own machine, 28 of 28 checks. Step 2, live on one machine with a 5-minute undo timer and a new SSH session. Step 3, two reboots, the rule up at about 6 seconds, before networking. Step 4, the other machines, with the same timer and the same checks.1 · Namespace testown machine28 of 28 checks2 · Live, one machine5-minute undo timernew SSH session3 · Two rebootsrule up at about 6 sbefore networking4 · Other machinessame timersame checksGoing slow, four steps1 · Namespace testown machine · 28 of 28 checks2 · Live, one machine5-minute undo timer · new SSH session3 · Two rebootsrule up at about 6 s · before networking4 · Other machinessame timer · same checks
Each step can be undone before the next one starts.

Before anything, check whether nftables.service is enabled. Its /etc/nftables.conf usually starts with flush ruleset, which wipes this table when it loads.

shell
systemctl is-enabled nftables.service

If it is enabled, it can load after this rule at boot and wipe it. The unit below orders itself after nftables.service, so the rule loads last. If you restart nftables.service later, restart this unit too: sudo systemctl restart cve98432-nft.service.

Then load the rule with a safety undo. If anything breaks, the timer deletes the table after 5 minutes.

shell
sudo install -m 0644 rules.nft /etc/nftables-cve98432.nft
sudo systemd-run --unit=cve98432-revert --on-active=5min nft delete table inet cve98432
sudo nft -f /etc/nftables-cve98432.nft

From a new SSH session, check that you can still log in and that the services you need still answer. Then keep it:

shell
sudo systemctl stop cve98432-revert.timer
sudo nft list table inet cve98432

The listing shows the counter. Every dropped packet adds to it.

Keep it across reboots

The unit /etc/systemd/system/cve98432-nft.service loads the rule before networking starts, and deletes the table when it stops:

systemd unit
[Unit]
Description=CVE-2026-98432 interim mitigation (drop IPv4 TCP with IP options)
DefaultDependencies=no
After=nftables.service
Before=network-pre.target
Wants=network-pre.target

[Service]
Type=oneshot
ExecStart=/usr/sbin/nft -f /etc/nftables-cve98432.nft
ExecStop=-/usr/sbin/nft delete table inet cve98432
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target
shell
sudo install -m 0644 cve98432-nft.service /etc/systemd/system/
sudo nft -c -f /etc/nftables-cve98432.nft && echo OK
sudo systemctl daemon-reload
sudo systemctl enable --now cve98432-nft.service
sudo bash check-cve-2026-98432.sh; echo "exit $?"    # expect MITIGATED, exit 3

I added After=nftables.service after my boot tests. It only changes the order on hosts where nftables.service is enabled.

To undo it at any time: sudo systemctl disable --now cve98432-nft.service. The stop deletes the table.

What I checked

Before touching a real machine, I tested the rule in a throw-away network namespace: unshare -rn gives a private network with only loopback, and everything in it disappears when the test ends. The test sends a SYN with IP options from Python, one connection per case:

python
IP_OPTIONS = getattr(socket, "IP_OPTIONS", 4)
RR = bytes([7, 7, 4, 0, 0, 0, 0, 0])  # Record Route, 1 slot, padded to 8 bytes
TS = bytes([68, 12, 5, 0] + [0] * 8)  # Timestamp, 2 slots, 12 bytes

c = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
c.settimeout(3.0)
c.setsockopt(socket.IPPROTO_IP, IP_OPTIONS, RR)
c.connect(("127.0.0.1", 5000))  # times out when the rule drops the SYN
shell
timeout 90 unshare -rn python3 test_cve98432.py

If your host blocks unprivileged user namespaces, sudo unshare -n python3 test_cve98432.py does the same.

  1. Without the rule, a SYN with a Record Route or Timestamp option is accepted, and the new socket holds the options. That is the hand-off the bug is about.
  2. With the rule, both are dropped and plain connects still work. 28 of 28 checks passed, and again after I upgraded the machine to Ubuntu 26.04.
  3. An IPv4 SYN with options to a socket that accepts both IPv6 and IPv4 is dropped too.
  4. Loading /etc/nftables.conf wipes the table, and the options SYN is accepted again. Reloading the rule file restores the drop.
  5. Live with the 5-minute timer: a new SSH session worked, a test connect with IP options timed out, and the counter went up by 3.
  6. Two reboots on one machine: the unit was active about 6 seconds into boot, before networking and SSH. After each boot the table was loaded, the options connect was dropped, and SSH worked.
  7. Then I put it on the other machines I run, with the same timer and the same checks.

What it does not cover

  • It is not a fix. The kernel bug stays until a fixed kernel runs.
  • It drops every IPv4 TCP packet with IP options, also legitimate ones. Normal traffic rarely uses them, but I have not measured it on every network. Watch the counter.
  • It also drops forwarded IPv4 TCP packets with IP options, so on a router or a Docker or VM host it affects that traffic too. I have not tested it on a router.
  • Options the host adds itself are not seen. NetLabel can add CIPSO options through SELinux or Smack. None of my machines runs either.
  • The IPv6 issue with CALIPSO options has its own patch. The rule matches IPv4 only.
  • The race itself was not tested. The tests show that the trigger is dropped.
  • A normal user cannot see the rule. sudo nft list table inet cve98432 can.
  • If nft fails at boot, the host boots without it. systemctl is-active cve98432-nft shows it.

Remove it later

Remove it once a fixed kernel is installed and running. Ubuntu has announced these versions, still marked work in progress when I checked: 24.04: 6.8.0-149, 22.04: 5.15.0-203, 26.04: 7.0.0-43, 26.10: 7.3.0-10. The changelog check in the script decides, not the version number:

  1. Before the reboot, the check script should say FIX INSTALLED, NOT ACTIVE, exit 2.
  2. After the reboot, as root, it should say PATCHED, exit 0.
  3. sudo systemctl disable --now cve98432-nft.service
  4. sudo rm /etc/nftables-cve98432.nft /etc/systemd/system/cve98432-nft.service && sudo systemctl daemon-reload

Do not skip understanding what it is doing. It is one line, and I only trusted it once I knew what each part does.

Files

Shared as they are, for your own judgement. Read them before you run them.

Contact

Tell me which data and which work you have in mind.

Stavanger, Norway

Remote across EU and India time zones, on-site when needed

I read every message myself and reply within 24 hours. Your details are used only to reply to this message.