mpeek: Finding Every TCP Door Before Patching CVE-2026-98432
I wrote a small scanner to answer one question the CVE-2026-98432 advisories kept dodging: which of my reachable endpoints actually need the kernel patch first? Here is how mpeek works, what it refuses to claim, and how it feeds my patch queue.
The advisories for CVE-2026-98432 all end the same way: update the kernel and reboot. True, and useless as a plan. A fleet is not patched in one motion. Something goes first, and the thing that goes first should be the thing an attacker can reach.
So I wrote mpeek. It is a single Python file that inventories reachable IPv4 TCP endpoints, checks on Linux whether an ordinary connection negotiated Multipath TCP, and turns the result into an ordered triage queue. I have been running it this week against my own networks to decide which hosts get rebooted tonight and which can wait for the maintenance window.
This is not an exploit, a vulnerability scanner, or a "you are safe" button. I want to be very clear about that up front, because the vulnerability itself has been widely misunderstood.
What the bug is, and what it is not
CVE-2026-98432 is a use-after-free in the Linux kernel's IPv4 TCP connection setup. It has been there since kernel 4.4. The kernel CVE team scored it 9.8. Anthropic found it with Claude, and their demonstrated exploit achieves remote root against a server with a Multipath TCP (MPTCP) listener. The exploit itself has not been published.
The MPTCP part is where the confusion started. Reading the headlines you could believe "no MPTCP, no problem." The Go Security Team's note on oss-security says the opposite: the bug is not specific to MPTCP, and per-program disabling is a fragile mitigation that assumes all viable exploits depend on MPTCP. Nobody has confirmed that assumption.
That same note explains why MPTCP suddenly matters to people who never configured it. Since Go 1.24, Go programs enable MPTCP by default on Linux listen sockets. If you run anything written in Go behind a raw TCP listener, you may have MPTCP exposure you never asked for.
AWS's bulletin adds the second thing that matters for triage. Exposure depends on whether the entry point forwards raw client packets to your Linux host or terminates the connection itself. An Application Load Balancer or a TLS listener terminates. A Network Load Balancer TCP listener, Global Accelerator, or PrivateLink forwards. Behind a forwarding entry point, your kernel is the one shaking hands with the internet.
So the real triage question has three parts. Which endpoints are reachable? Which of them negotiate MPTCP today? And for each one, is the kernel that actually answers the handshake patched?
What mpeek does
mpeek answers the first two parts with evidence and makes you supply the third.
It opens a normal TCP connection to each endpoint and port in scope, using the kernel's own MPTCP socket type on Linux. After the handshake it asks the kernel, through the standard socket API, whether this connection is MPTCP or fell back to plain TCP. Then it closes the connection. No banners, no HTTP request, no TLS handshake, no IP options, no malformed packets. From the target's point of view it is one connect and one close.
Every endpoint gets one of four labels:
observed: the client kernel confirms MPTCP negotiated on this connection.not_observed: MPTCP did not negotiate. The cause may be the client, the path, or the peer.unknown: timeout, refusal, error, or a slow handshake where retransmission fallback could hide negotiation.not_tested: you ran in explicit plain TCP mode, for example natively on macOS.
The outputs are a results.csv and results.jsonl with every attempted endpoint, a triage.csv ordered by next action, and a run.json that records vantage, scope, pinned targets, controls, timestamps, exclusions, and whether the run actually completed.
The inventory join is where it earns its keep
Scanning tells you what is reachable. It cannot tell you whether a kernel is patched. MPTCP exists on patched servers, and a plain TCP listener can sit on an unpatched kernel. So mpeek takes an optional inventory CSV with four required columns: ip, port, patch status, and patch evidence.
Status is unknown, unpatched, or fixed. Fixed and unpatched both require evidence, and the README is specific about what counts. Vendor advisory or package evidence for the running kernel, or an active livepatch, with an observation time. A new package waiting for a reboot is not evidence of a running fix. This is the mistake I most expect people to make this month, and the tool refuses to let you encode it.
With the inventory joined, each row in triage.csv gets a next action:
| Situation | Next action |
|---|---|
| Known unpatched, whether reachable or not | patch_running_kernel |
| Open, patch status unknown, MPTCP observed | verify_endpoint_kernel_urgent |
| Open, patch status unknown | verify_endpoint_kernel |
| Open, inventory says fixed | retain_inventory_fix_evidence |
| Not open | resolve_reachability_gap |
That ordering is deliberately an investigation queue, not a risk score. A confirmed unpatched TCP service always sorts above a "maybe MPTCP, maybe patched" candidate. Known unpatched endpoints that were refused, timed out, or never scanned still appear in the queue, because an unreachable host from this vantage is not a patched host.
In practice this is the file I have been working from. Sort, take the top, verify the kernel on the box, reboot, mark fixed with the package version and a timestamp, rerun.
Why it is so careful about saying no
The hard part of this tool was not the probe. It was making the negative results honest.
Firewall timeouts, a terminating proxy, a SYN proxy, load balancer pool rotation, and the client kernel's own fallback behaviour can all hide MPTCP capability. The Linux client can fall back to plain TCP after SYN retransmissions, and blackhole detection can temporarily suppress outgoing MPTCP for the whole network namespace. A run of not_observed results might mean your targets lack MPTCP. It might also mean your scanner gave up on MPTCP ten minutes ago.
mpeek handles this in a few ways:
- Local calibration. Before any remote probe,
--self-testopens one MPTCP listener and one plain TCP listener on loopback and confirms the kernel reports both correctly. If either control fails, MPTCP mode will not run. - Operator canary. With
--canary IP:PORTpointing at a known-good MPTCP listener you control, mpeek checks it before the scan, every 100 probes, and after. If the canary stops negotiating, the run stops with exit code 2 andrun.jsonis marked incomplete. - Kernel counters. It samples the kernel's MPTCP extension counters before, during, and after the scan. If the blackhole or SYN suppression counters climb, the run is stopped as inconclusive.
- Path manager preflight. MPTCP mode refuses to scan unless the in-kernel path manager is selected and
add_addr_acceptedis zero. Otherwise a peer could announce extra addresses and your scanner would quietly connect outside the approved scope.
None of this lets a negative result clear a host. The README repeats that more times than is elegant, and that is on purpose.
Scope is a hard boundary
Every target must fall inside a CIDR you pass with --allow. Hostnames resolve once, every resolved address is checked against the allowlist, and the actual connection uses the pinned literal IP. A DNS answer outside scope is recorded in run.json and never scanned. The tool does not discover additional addresses or follow MPTCP address announcements.
Rate limiting counts connection attempts, not packets, because the operating system can retransmit SYNs on its own. Defaults are conservative: five attempts per second, eight concurrent probes, two second connect timeout. A dry run writes the full plan without touching a target so you can read what it intends to do before it does it.
Ctrl-C keeps everything completed so far and marks the run incomplete. Output directories are timestamped and never overwritten.
How I am actually using it
Two vantages, every time. A public Linux VM to see what the internet sees, and an internal host to see the backends a terminating proxy hides. A scanner inside the VPC only proves reachability from the VPC.
The workflow in the quickstart is the one I follow:
python3 mpeek.py --self-test
python3 mpeek.py --targets-file targets.txt \
--allow 192.0.2.0/24 --ports 22,80,443,8443 \
--vantage approved-public-vm --dry-run --output plan
python3 mpeek.py --targets-file targets.txt \
--allow 192.0.2.0/24 --ports 22,80,443,8443 \
--vantage approved-public-vm --rate 5 \
--canary 192.0.2.50:443 --inventory inventory.csv \
--output audit-public
Then run.json first, triage.csv second. Exit code 3 means something needs review: an out-of-scope DNS answer, an inventory row that matched nothing, a probe error. Exit 0 is a complete run, and a complete run is still not a declaration that anything is safe.
The thing I did not expect was how much the inventory file would change my own behaviour. Writing "fixed" next to a host means writing down the running kernel version and when I checked. Several hosts I thought were done turned out to have the package installed and the old kernel still running. That is exactly the gap the AWS bulletin and every distro advisory warn about, and it is easy to miss when you are moving fast.
Limitations, plainly
- IPv4 only. IPv6 is deliberately excluded for this CVE-focused scan.
- MPTCP checks need Linux, ideally 5.16 or newer, with the
iptool from iproute2 for the read-only preflight. macOS only gets plain TCP reachability, and it says so on every row. - One handshake per endpoint. A load balancer may pick a different backend next time. Positive evidence describes the visible endpoint, not every kernel behind it.
- It trusts your inventory. It does not log into hosts or authenticate anything.
- It does not inspect your logging or detection pipeline. Keep that on the same tracker as the patch queue.
Get it
mpeek is MIT licensed, needs Python 3.9 or newer, has no dependencies, and does not need root. Version 1.1.0 is the initial public release candidate. Tests run with python3 -m unittest -v and only ever touch loopback.
Use it against networks you are authorised to scan. Then patch the running kernel, reboot, and check that the kernel that comes back is the one you think it is.
Sources: Go Security Team on oss-security, AWS Security Bulletin 2026-160, Red Hat RHSB-2026-016, Linux MPTCP socket API.