CVE-2026-98432 sits in the IPv4 TCP handshake code of the Linux kernel. It can be reached by anyone who can open a connection to a listening port, and Red Hat rates it Critical with a CVSS score of 9.8. RHEL 8, 9 and 10 are all affected, and so is anything built on the RHEL kernel. Fixed kernels shipped on 1 November, but a kernel fix only counts once the host has rebooted into it. That last step is where most fleets stall.
This post walks through the order I would do it in: put a cheap network-level mitigation on every unpatched host today, then patch and reboot in waves, and finish with a check that proves each host is actually running a fixed kernel. All of it with plain Ansible playbooks that run fine from the command line or as job templates in AAP.
What the bug is, in short
When a server accepts an IPv4 TCP connection whose SYN carries IP options (Record Route or Timestamp, for example), the kernel hands those options from the half-open request to the new socket without copying them. If a retransmitted SYN for the same request is being processed on another CPU at the same moment, the kernel can end up reading memory that has already been freed. The upstream fix is small: free the options only after an RCU grace period. The kernel CVE announcement and Akamai’s write-up have the details if you want them.
The reason this got loud is that Anthropic reported a working remote root exploit against servers with a multipath TCP (MPTCP) listener. It hasn’t been published. A lot of early advice therefore said “turn off MPTCP”. On RHEL that changes nothing, because MPTCP is already off by default, and Red Hat is explicit in its security bulletin that default systems are still vulnerable. Checking for MPTCP listeners with ss tells you whether you match one known exploit path. It doesn’t tell you whether you’re exposed.
Know which advisory you need
Red Hat shipped three advisories on 1 November, according to The Hacker News:
- RHEL 8.10: RHSA-2026:93414, kernel
4.18.0-553.182.1.el8_10 - RHEL 9.8: RHSA-2026:93412, kernel
5.14.0-687.64.1.el9_8 - RHEL 10.2: RHSA-2026:93413, kernel
6.12.0-211.72.1.el10_2
EUS streams get their own errata. RHEL 9.6 EUS, for instance, got RHSA-2026:94438 on 3 November, bundled with a handful of other kernel fixes. If you pin minor releases, check that the advisory for your stream is actually in the repositories or Satellite content views your hosts use. Hard-coding version strings into a playbook is fragile for exactly this reason, so the playbooks below ask dnf instead.
Step 1: mitigate every unpatched host today
Red Hat’s recommended interim measure is an nftables table that drops inbound IPv4 TCP packets with IP options, that is, any packet whose IP header is longer than the minimum five 32-bit words. Normal TCP options like MSS and SACK live in the TCP header, so ordinary traffic passes. It removes the input the bug needs. It is not a fix, and the bulletin says plainly that it reduces exposure without fully preventing exploitation.
Two properties matter for automation. The rule takes effect immediately for new and existing connections, with no service restarts. And it isn’t persistent: it vanishes on reboot. For a mitigation that should disappear once the host boots a fixed kernel, that’s exactly what you want.
The playbook asks dnf two questions per host. Is an advisory for this CVE still waiting in the repos? Is the host due a reboot to run the newest installed kernel? If either answer is yes, it loads the table.
---
- name: Interim nftables mitigation for CVE-2026-98432
hosts: rhel_servers
become: true
vars:
cve_id: CVE-2026-98432
cve_nft_file: /etc/nftables/cve-2026-98432.nft
tasks:
- name: Make sure needs-restarting is available
ansible.builtin.dnf:
name: yum-utils
state: present
- name: Check whether a fixed kernel is still waiting in the repos
ansible.builtin.command: dnf -q updateinfo list --cve {{ cve_id }}
register: cve_pending
changed_when: false
- name: Check whether the host needs a reboot to run its newest kernel
ansible.builtin.command: dnf needs-restarting -r
register: reboot_pending
changed_when: false
failed_when: reboot_pending.rc not in [0, 1]
- name: Protect hosts that are not running a fixed kernel yet
when: (cve_pending.stdout | trim | length > 0) or (reboot_pending.rc == 1)
block:
- name: Write the rule file (not included in nftables.conf, so not persistent)
ansible.builtin.copy:
dest: "{{ cve_nft_file }}"
owner: root
group: root
mode: "0600"
content: |
table inet cve_2026_98432 {
chain input {
type filter hook input priority -310; policy accept;
ip protocol tcp ip hdrlength > 5 counter drop
}
}
- name: See if the table is already loaded
ansible.builtin.command: nft list table inet cve_2026_98432
register: nft_table
changed_when: false
failed_when: false
- name: Validate the rule file
ansible.builtin.command: nft --check --file {{ cve_nft_file }}
changed_when: false
when: nft_table.rc != 0
- name: Load the table
ansible.builtin.command: nft --file {{ cve_nft_file }}
changed_when: true
when: nft_table.rc != 0
A few practical notes:
- The
counterkeyword is there for a reason. Runnft list table inet cve_2026_98432a day later. A counter that stays at zero tells you nothing legitimate needed IP options. One that climbs means someone or something is sending them, and you want to know which. - Test it on a non-production host first. Some old monitoring and network diagnostics use IPv4 options, and Red Hat warns the rule can break workloads that depend on them.
- The filter only sees traffic that passes through the host’s own network stack. Traffic between containers on the same host can bypass it, and OpenShift nodes have their own MachineConfig-based guidance linked from the bulletin.
- Since the rule is gone after a reboot, any host that reboots for an unrelated reason before it’s patched comes back unprotected. Running this playbook on a schedule until patching is done covers that gap, and it skips hosts that are already fine.
Step 2: patch and reboot in waves
Installing a kernel package is easy. The reboot is the part that needs care, because that’s where services drop and where a host occasionally doesn’t come back. Rolling it out in waves keeps the blast radius small: one canary host first, then 10%, then 30%, then the rest. With max_fail_percentage: 0 the play stops the moment one host fails, before the next wave starts.
---
- name: Patch and reboot for CVE-2026-98432 in waves
hosts: rhel_servers
become: true
serial:
- 1
- "10%"
- "30%"
- "100%"
max_fail_percentage: 0
vars:
cve_id: CVE-2026-98432
tasks:
- name: Make sure needs-restarting is available
ansible.builtin.dnf:
name: yum-utils
state: present
- name: Install the newest kernel from the enabled repos
ansible.builtin.dnf:
name: kernel
state: latest
- name: Check whether a reboot is needed
ansible.builtin.command: dnf needs-restarting -r
register: reboot_check
changed_when: false
failed_when: reboot_check.rc not in [0, 1]
- name: Reboot only the hosts that need it
when: reboot_check.rc == 1
block:
- name: Take the host out of rotation
ansible.builtin.include_tasks: tasks/drain.yml
- name: Reboot into the new kernel
ansible.builtin.reboot:
reboot_timeout: 900
msg: "Kernel update for {{ cve_id }}"
- name: Put the host back into rotation
ansible.builtin.include_tasks: tasks/undrain.yml
- name: Prove the host runs its newest installed kernel
ansible.builtin.command: dnf needs-restarting -r
register: post_check
changed_when: false
failed_when: post_check.rc != 0
- name: Prove no advisory for the CVE is still pending
ansible.builtin.command: dnf -q updateinfo list --cve {{ cve_id }}
register: still_open
changed_when: false
failed_when: still_open.stdout | trim | length > 0
The drain and undrain files are deliberately left as includes. What they do depends entirely on your environment: disabling a backend in HAProxy, calling a cloud load balancer API, moving a VIP, or stopping a clustered service cleanly. If a host has none of that, an empty task file is fine.
Because the kernel is an install-only package, state: latest installs the new kernel next to the old one rather than replacing it. The old kernel stays in the boot menu as a fallback, which is what you want if the new one misbehaves on some odd hardware.
In AAP I would split this into a workflow. One job template runs against a canary limit, an approval node pauses so someone looks at the canary’s services, and a second job template handles the remaining waves. Red Hat published a post on 3 November about orchestrating patch releases with approval gates, and the same pattern works for kernel updates even if you don’t need the full machinery.
Step 3: prove it, don’t assume it
The last two tasks in the patch play are the whole point. A host can have the fixed kernel installed and still be vulnerable, because it booted the old one. dnf needs-restarting -r returns 1 when the running kernel (or core libraries) is older than what’s installed, so requiring a 0 after the reboot catches hosts that came back on the wrong kernel. The updateinfo check catches hosts where the repos never offered the fix in the first place That happens with pinned content views and stale mirrors.
One caveat on that second check: if a host can’t reach its repositories at all, dnf may report nothing pending and the check passes for the wrong reason. In the patch play the kernel install task normally fails first in that situation, but keep it in mind if you reuse the check on its own.
If you want a fleet-wide report afterwards, gather ansible_facts.kernel across the inventory and compare it with the versions above. That is the list your security team will ask for anyway.
Clean up
Once a host runs a fixed kernel, the mitigation table is already gone, since it was never persisted. If you did add it to /etc/sysconfig/nftables.conf somewhere along the way, remove it there and delete the table with nft delete table inet cve_2026_98432. Leaving an old CVE filter around is how firewall rules nobody understands pile up.
The Red Hat CVE page and the bulletin are still marked as ongoing, so check them again before you close the change. The order holds even if details shift: cheap mitigation first, staged reboots second, and a check that looks at the running kernel instead of the package list.



Leave a Reply