# CVE-2026-98432: check your Linux server and close public ports

> CVE-2026-98432 is a Linux IPv4 TCP use-after-free. Check your kernel, treat disabling MPTCP as a stop gap, then patch and close every public port on the VPS.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-11-02 | Topics: [Security](https://flaviocopes.com/tags/security/) | Canonical: https://flaviocopes.com/linux-tcp-cve-2026-98432/

On 1 November 2026, [Pieter Levels](https://x.com/levelsio/status/2116949634111115348) wrote that a VPS should keep every port closed. SSH stays on Tailscale. Web traffic arrives through a Cloudflare Tunnel. He called disabling MPTCP a stop gap, and closing public ports the better move.

Levels quoted [Ali Yahya's post](https://x.com/alive_/status/2116902936185889188), which says any unpatched Linux server with one TCP port exposed, and MPTCP active, is open to complete takeover, and he describes MPTCP as the default for many Go web servers. That post quotes [Tobi Lütke](https://x.com/tobi/status/2116638054899208685), who linked [a write-up of CVE-2026-98432](https://atlasum.net/blog/cve-2026-98432-linux-ipv4-tcp-uaf).

The CVE is real. The "MPTCP is required" part is the claim to be careful with. As of 1 November 2026, the kernel record describes a remote use-after-free in the IPv4 TCP handshake. The Go security team says one demonstrated exploit needs an MPTCP listener, and that the bug itself is not specific to MPTCP.

This post is the check, the stop gap, the patch, and the closed-port layout. The long versions of the tunnel and the tailnet already exist: [Cloudflare Tunnel](https://flaviocopes.com/cloudflare-tunnel/) and [a VPS with Tailscale and a tunnel](https://flaviocopes.com/secure-vps/). Trackers move. The status below is what the primary pages said on 1 November 2026, so read the tracker again on the day you patch.

## What is CVE-2026-98432?

[CVE-2026-98432](https://www.cve.org/CVERecord?id=CVE-2026-98432) is a use-after-free in the Linux kernel's IPv4 TCP handshake. [NVD](https://nvd.nist.gov/vuln/detail/CVE-2026-98432) published the record on 15 October 2026 and last modified it on 31 October 2026. NVD's status is still "Received". The description on the page is the kernel CNA text.

During a handshake, `tcp_v4_syn_recv_sock()` hands `ireq->ireq_opt` to the child socket without copying it. Another CPU can still be reading those options, under `rcu_read_lock()` only, while it builds a SYN-ACK for a retransmitted SYN. The child socket can be freed in the middle of that. `inet_sock_destruct()` frees the options with a plain `kfree()`, and `ip_options_build()` uses the freed memory.

The kernel text says this became possible with commit `079096f103fa` ("tcp/dccp: install syn_recv requests into ehash table"), which put request sockets on a path that no longer holds the listener lock. NVD marks kernels older than 4.4 unaffected, and 4.4 as affected.

A second path in the same text is request-socket migration, when `net.ipv4.tcp_migrate_req=1` or a `BPF_SK_REUSEPORT_SELECT_OR_MIGRATE` program clones a request and the clone shares `ireq_opt`. `tcp_v4_reqsk_destructor()` also used a plain `kfree()`.

The fix uses `kfree_rcu()` in `inet_sock_destruct()` and `tcp_v4_reqsk_destructor()`, so readers that already use RCU finish before the memory goes away. NVD links the stable commits, including [e3f33b0a1d89](https://git.kernel.org/stable/c/e3f33b0a1d89d738e2b913dab519226eaf76e15b). Debian's tracker notes that commit as 7.3-rc7. A separate calendar date for every stable backport was not in the kernel.org changelog pages checked for this post. The date to cite is NVD's publication on 15 October 2026, plus the version numbers in the next section.

The same kernel text says IPv6 is not affected by the first issue, because `tcp_v6_syn_recv_sock()` duplicates the options. It also says `tcp_v6_reqsk_destructor()` has the same migration issue with `ipv6_opt`, which only CALIPSO sets, and that this will be a separate patch. An IPv6-only listener is not a reason to skip the kernel update.

NVD's CVSS data is a secondary metric from the kernel CNA, not an NVD analysis of its own:

`CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H`

That is 9.8, Critical. Network, low complexity, no privileges, no user interaction, unchanged scope, high impact on confidentiality, integrity, and availability.

[Ubuntu's page](https://ubuntu.com/security/CVE-2026-98432) shows that same 9.8 Critical vector, and also a CVSS 4.0 score of 8.3 High:

`CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:L/VA:H/SC:N/SI:N/SA:N`

Ubuntu's own priority label is High. [Red Hat's CVE data](https://access.redhat.com/security/cve/CVE-2026-98432), fetched the same day, rates the threat Critical, with the 9.8 vector marked draft. Red Hat's statement says an unauthenticated remote attacker can cause kernel memory corruption or an immediate crash over ordinary IPv4 TCP, and that reliable arbitrary code needs precise heap control.

The [Go security note](https://www.openwall.com/lists/oss-security/2026/10/30/1), forwarded to oss-security on 29 October 2026, describes an exploit written up by [Anthropic](https://www.anthropic.com/research/discovering-vulnerabilities-with-claude) that reaches remote code execution against a server using MPTCP listeners. The Anthropic page is linked from that note. This post uses the Go note's description of the exploit, not a line-by-line read of the research write-up. Those three sources describe the same bug at different levels of confidence about code execution. The outcomes stay separate on purpose.

## Which kernels are fixed upstream?

NVD's affected data, from the kernel CNA, lists these as unaffected on each stable line. A version on that line is unaffected once it is at least the number in the first column, and at most the wildcard in the second.

| Unaffected from | Through |
| --- | --- |
| 5.10.272 | `5.10.*` |
| 5.15.223 | `5.15.*` |
| 6.1.190 | `6.1.*` |
| 6.6.159 | `6.6.*` |
| 6.12.113 | `6.12.*` |
| 6.18.56 | `6.18.*` |
| 7.2.10 | `7.2.*` |
| 7.3 | later versions, marked `original_commit_for_fix` |

Anything from 4.4 up to those points is in the affected range NVD published. Distro kernels do not use these numbers. `6.8.0-147` on Ubuntu is not "6.8, so it sits between 6.6 and 6.12". It is an Ubuntu package version. Compare it with Ubuntu's tracker, not with the upstream table alone.

NVD also lists the fixing commit hashes. The 6.12 one is [11a03ce3c4a6](https://git.kernel.org/stable/c/11a03ce3c4a62d84c10ccdfe47f8075006498568), the 6.1 one is [89b5b1eb7d63](https://git.kernel.org/stable/c/89b5b1eb7d633c33832b7ff2c02e19a144705036), and the rest are on the [NVD reference list](https://nvd.nist.gov/vuln/detail/CVE-2026-98432).

## Is MPTCP required?

The kernel CVE text does not mention MPTCP. The files it names are `net/ipv4/af_inet.c` and `net/ipv4/tcp_ipv4.c`.

The Go security team is explicit. The demonstrated exploit needs an MPTCP listener. The underlying bug is not specific to MPTCP. Systems with no MPTCP listeners can still be open to similar attacks. They call a per-program disable "a fragile mitigation", and they say the best fix is a patched kernel. Discussion of whether Go itself should change the default is on [issue 82717](https://go.dev/issue/82717). As of that 29 October note, they were not changing Go's default because of this bug.

So disabling MPTCP cuts off the path that write-up demonstrated. It leaves the use-after-free in the IPv4 handshake. Treat it as a stop gap while you patch, which is what Levels said, and keep going until the kernel is fixed and strangers cannot open a TCP connection to the machine.

### Go listeners and the default

[Go's GODEBUG history](https://go.dev/doc/godebug) says Go 1.24 added four values for `multipathtcp`:

| Value | Meaning |
| --- | --- |
| 0 | Disable MPTCP on dialers and listeners by default |
| 1 | Enable MPTCP on dialers and listeners by default |
| 2 | Enable MPTCP on listeners only by default |
| 3 | Enable MPTCP on dialers only by default |

For Go 1.24, the default is `multipathtcp=2`. Listeners use MPTCP when the operating system allows it. `0` is the pre-1.24 behavior.

That default follows the Go version in the main module. When a newer toolchain compiles a module whose `go` line is older, [the godebug rules](https://go.dev/doc/godebug) amend defaults back toward that older version. A repo that still says `go 1.22` does not pick up the 1.24 listener default just because the compiler is newer. A repo that says `go 1.24` or later does, unless a `godebug` line overrides it.

`net.ListenConfig.SetMultipathTCP` and `net.Dialer.SetMultipathTCP` override the GODEBUG setting. The Go security note says so, and the methods are documented on [ListenConfig](https://pkg.go.dev/net#ListenConfig.SetMultipathTCP) and [Dialer](https://pkg.go.dev/net#Dialer.SetMultipathTCP).

Yahya's "default for many Go web servers" matches a Go 1.24+ module on a kernel where MPTCP is enabled. It does not match every Go binary, and it is not what the kernel bug requires.

## How do you check whether you are exposed?

Four facts matter. The running kernel. Whether MPTCP is on. Which TCP ports are listening, and on which addresses. Whether a Go program is one of the listeners that asked for MPTCP.

Run the commands on the server. The output further down is from a Linux host checked on 1 November 2026. That host is Ubuntu 24.04.5 userspace with a custom kernel. It is not a stock cloud image, and it is not your VPS.

### Kernel version

```bash
uname -r
uname -v
```

On that host:

```text
6.12.94+
#1 SMP PREEMPT_DYNAMIC Tue Oct 27 12:38:43 UTC 2026
```

`6.12.94` is numerically below NVD's fixed line of `6.12.113`. The `+` means this build is not a kernel.org stable tarball and not a distro package. Treat that comparison as a hint until the package tracker agrees.

Then ask the package manager what it would install.

Ubuntu and Debian:

```bash
dpkg-query -W -f='${Package} ${Version}\n' 'linux-image*'
apt-cache policy linux-image-generic
```

The same host had no `linux-image*` package installed. `apt-cache policy linux-image-generic` showed a candidate of `6.8.0-147.147` from noble-updates and noble-security. Ubuntu's tracker, the same day, listed the 24.04 fix as work in progress in `6.8.0-149.149`. The running kernel and the archive package were two different things. Your server can look like that too, especially with a provider kernel, a cloud image, or a backports package. Read both numbers.

On RHEL and the clones that use `dnf`:

```bash
rpm -q kernel
uname -r
```

Match the package to the row on your distro's tracker. A reboot is what makes a newly installed kernel the running one. `uname -r` before the reboot still shows the old one.

### MPTCP

The kernel documents `net.mptcp.enabled` as a boolean, per network namespace. Sockets can be created when the value is 1. The documented default is 1. A distro can ship a different value, so read it.

```bash
sysctl net.mptcp.enabled
```

On that host:

```text
net.mptcp.enabled = 1
```

`ss` from iproute2 can show MPTCP sockets. On this host, `ss --help` lists `-M, --mptcp` as "display only MPTCP sockets". The man page was not installed on that host, so the help text is the source for the flag.

```bash
ss -Mlnp
```

Listening MPTCP sockets, numeric, with the process name. On that host the command printed only the header. Nothing on the machine was an MPTCP socket at that moment.

```text
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
```

A container has its own network namespace. `sysctl` on the host does not answer for the container. Run it inside the container too.

### Listening ports

```bash
ss -tlnp
```

`-t` is TCP, `-l` is listening, `-n` skips DNS, `-p` shows the process. On that host:

```text
State  Recv-Q Send-Q Local Address:Port  Peer Address:PortProcess
LISTEN 0      5          127.0.0.1:5901       0.0.0.0:*    users:(("Xtigervnc",pid=2208,fd=9))
LISTEN 0      128          0.0.0.0:26500      0.0.0.0:*
LISTEN 0      100          0.0.0.0:26058      0.0.0.0:*    users:(("python3",pid=2356,fd=3))
LISTEN 0      128          0.0.0.0:50052      0.0.0.0:*
LISTEN 0      5            0.0.0.0:2375       0.0.0.0:*
LISTEN 0      511                *:26054            *:*    users:(("node",pid=806,fd=26))
LISTEN 0      511                *:26053            *:*    users:(("node",pid=806,fd=25))
LISTEN 0      5              [::1]:5901          [::]:*    users:(("Xtigervnc",pid=2208,fd=10))
```

`127.0.0.1` and `[::1]` are loopback. `0.0.0.0` and `*` are every IPv4 or every address. Those are the listeners a public interface can reach, if the host firewall and the cloud firewall allow it. `ss` cannot see the cloud firewall. A port in this list can still be blocked upstream, and a port missing here is not open on this host.

From outside, scan the public address. That step is [later](#how-do-you-verify-nothing-is-open), after you still have a way back in.

### Is the Go binary using MPTCP?

`ss -Mlnp` is the direct check. If your server's process is on that list, the socket is MPTCP.

If you have the source, look at the toolchain and the module.

```bash
go version
```

On the host above, that printed `go version go1.22.2 linux/amd64`. Go 1.22 predates the listener default. A binary this toolchain builds keeps MPTCP off unless the code calls `SetMultipathTCP(true)`.

In a Go 1.24 or newer module, open `go.mod`. A `go 1.24` line (or later) plus no `godebug multipathtcp=0` means listeners default to MPTCP when the kernel allows it. `go list -f '{{.DefaultGODEBUG}}'` prints the defaults that differ from the base toolchain, which is how you see the effective `multipathtcp` value for that module.

## How do you disable MPTCP?

This stops new MPTCP sockets. It does not patch the use-after-free. Ordinary TCP keeps working. You lose multipath connections, which most web servers were not relying on. Restart the listeners after the sysctl, then run `ss -Mlnp` again. Whether sockets that were already open survive the change was not tested here. Check after the restart rather than assuming.

Write a file under `/etc/sysctl.d/` so a reboot keeps the setting:

```bash
echo 'net.mptcp.enabled=0' | sudo tee /etc/sysctl.d/90-disable-mptcp.conf
sudo sysctl -p /etc/sysctl.d/90-disable-mptcp.conf
sysctl net.mptcp.enabled
```

You want `net.mptcp.enabled = 0`.

The kernel doc says the value is per namespace. Set it in each container namespace you care about, or confirm the container inherited it. A Docker `--sysctl net.mptcp.enabled=0` flag was not tested on that host. Check Docker's sysctl documentation before you depend on that flag.

### Go, on top of the sysctl

The sysctl is the broad switch. The Go settings matter when a later config turns MPTCP back on, or when you cannot change the host sysctl yet. The Go team still calls the program-by-program switch fragile, because one forgotten listener is enough for the demonstrated exploit.

For one process, at runtime:

```bash
GODEBUG=multipathtcp=0 ./server
```

For every build of the main module, in `go.mod`:

```text
go 1.24

godebug multipathtcp=0
```

For one listener, in code. `SetMultipathTCP` overrides GODEBUG.

```go
package main

import (
	"context"
	"fmt"
	"net"
)

func main() {
	lc := net.ListenConfig{}
	lc.SetMultipathTCP(false)
	ln, err := lc.Listen(context.Background(), "tcp", "127.0.0.1:8080")
	if err != nil {
		panic(err)
	}
	defer ln.Close()
	fmt.Println("listening on", ln.Addr())
}
```

`false` here means this listener does not request MPTCP. The kernel still has to be patched.

### Red Hat's packet filter

[Red Hat's bulletin RHSB-2026-016](https://access.redhat.com/security/vulnerabilities/RHSB-2026-016) publishes a different stop gap. The page's public date is 30 October 2026, 16:03, and the bulletin status is Ongoing. Red Hat says filtering can reduce exposure, can disrupt real workloads, and is not a substitute for patching. The rule drops incoming IPv4 TCP packets that contain IP options. Red Hat says it cannot fully prevent the bug. Ordinary TCP options live in the TCP header and are not matched by this rule.

They tell you to test it before production. This is their rule, saved as `cve-2026-98432.nft`:

```nft
table inet cve_2026_98432 {
  chain input {
    type filter hook input priority -310; policy accept;
    ip protocol tcp ip hdrlength > 5 counter drop
  }
}
```

```bash
sudo nft --check --file cve-2026-98432.nft
sudo nft --file cve-2026-98432.nft
sudo nft list table inet cve_2026_98432
```

After the kernel is fixed, remove it:

```bash
sudo nft delete table inet cve_2026_98432
```

That table was not loaded on the host used for the `ss` output.

## How do you patch?

Find the fixed package on the tracker for the release you run. Install it. Reboot. Check `uname -r` again. If the tracker still says vulnerable, the reboot only proved you are running the newest package you could get, and that package is still the bug.

### Ubuntu

[Ubuntu's CVE page](https://ubuntu.com/security/CVE-2026-98432) was last updated 30 October 2026. Publication date on the page is 15 October 2026. That page did not list a USN. Ubuntu Livepatch has nothing to apply until a fixed kernel exists. There is no livepatch command to give for this CVE yet.

For the `linux` package, the page listed:

| Release | Status on the page |
| --- | --- |
| 26.10 | Vulnerable, work in progress in 7.3.0-10.10 |
| 26.04 LTS | Vulnerable, work in progress in 7.0.0-43.43 |
| 24.04 LTS | Vulnerable, work in progress in 6.8.0-149.149 |
| 22.04 LTS | Vulnerable, work in progress in 5.15.0-203.213 |
| 20.04, 18.04, 16.04 | Vulnerable |
| 14.04 | Not affected |

HWE, cloud, and OEM kernels have their own rows. `linux-hwe-6.8` on jammy was work in progress in `6.8.0-149.149~22.04.1`. Several other HWE kernels were Vulnerable with no fix version on the page. Use the tracker row for the package `uname -r` belongs to. The page is long because Ubuntu builds many kernels.

When a fixed package is in the archive, upgrade and reboot:

```bash
sudo apt update
apt-cache policy linux-image-generic
sudo apt upgrade
sudo reboot
```

`linux-image-generic` is the common metapackage on a desktop-style Ubuntu install. A cloud image or an HWE kernel uses a different name. If `apt-cache policy` does not match the kernel you are running, find the package with `dpkg -l 'linux-image*'` before you assume the upgrade touched it.

### Debian

[Debian's security tracker](https://security-tracker.debian.org/tracker/CVE-2026-98432), fetched 1 November 2026:

| Suite | Version on the page | Status |
| --- | --- | --- |
| bookworm | 6.1.176-1 | vulnerable |
| bookworm security | 6.1.190-1 | fixed, DLA-4841-1 |
| bookworm linux-6.12 security | 6.12.113-1~deb12u1 | fixed, DLA-4850-1 |
| trixie | 6.12.107-1 | vulnerable |
| trixie security | 6.12.113-1 | fixed, DSA-6568-1 |
| forky | 7.2.12-1 | fixed |
| sid | 7.2.14-1 | fixed |

The tracker's fixed-version table also lists unstable `linux` at `7.2.10-1`. Sid had already moved on to `7.2.14-1`. The note on the page points at [e3f33b0a1d89](https://git.kernel.org/linus/e3f33b0a1d89d738e2b913dab519226eaf76e15b) as 7.3-rc7.

On bookworm or trixie, install security updates and reboot:

```bash
sudo apt update
apt-cache policy linux-image-amd64
sudo apt upgrade
sudo reboot
uname -r
```

Then compare `uname -r` with the security-suite version, not with the base suite. A bookworm machine that is still on `6.1.176-1` has not taken DLA-4841-1.

### RHEL and the family

Red Hat's package state, from the CVE JSON on 1 November 2026:

| Product | Package | State |
| --- | --- | --- |
| RHEL 10, 9, 8 | kernel | Affected |
| RHEL 9 and 8 | kernel-rt | Affected |
| RHEL for NVIDIA 26 | kernel | Affected |
| OpenShift 4 | ose-rhel-coreos-8 and ose-rhel-coreos-9 | Affected |
| OpenShift 5 | ose-rhel-coreos-9 | Affected |
| RHEL 7 | kernel and kernel-rt | Not affected |
| RHEL 6 | kernel | Not affected |

`affected_release` in that JSON was empty. No fixed build was listed. Bugzilla is [2553390](https://bugzilla.redhat.com/show_bug.cgi?id=2553390). The bulletin tells you to apply kernel updates as they are released, and it does not mention kpatch. No live patch was listed there.

When Red Hat ships a fixed kernel:

```bash
sudo dnf update kernel
sudo reboot
uname -r
rpm -q kernel
```

Keep the previous kernel installed until the new one boots. `dnf` usually does that. If the new `uname -r` is still on the Affected side of the tracker, you rebooted into an unfixed package.

The AlmaLinux, Rocky Linux, Amazon Linux, SUSE, and Oracle trackers were not checked for this CVE. If you run one of those, use their page. A blog summary of their package versions is not a substitute for the tracker.

## How do you run a VPS with no public ports?

Patching closes the bug in the kernel. A public TCP listener is how a stranger reaches that bug, and the next one. The layout Levels described is the one to end on. Visitors hit Cloudflare. `cloudflared` on the VPS makes an outbound connection and carries the HTTP request to a program on localhost. You SSH over Tailscale. The public address accepts nothing new.

```mermaid
flowchart TB
  accTitle: A VPS with no public inbound ports
  accDescr: Visitors reach the app through Cloudflare and an outbound tunnel. An administrator reaches SSH through Tailscale. New connections to the public address are dropped.
  Browser[Browser] --> CF[Cloudflare]
  CF --> Tun[cloudflared]
  Tun --> App[Local app]
  Laptop[Laptop] --> TS[Tailscale]
  TS --> SSH[SSH]
  Pub[Public IP] --> Drop[Drop]
```

[The VPS guide](https://flaviocopes.com/secure-vps/) builds this on Ubuntu and says what to test before you touch a production box. The steps below are the short path, with the commands that were on the vendor pages on 1 November 2026. [Where to host](https://flaviocopes.com/where-to-host/) is the decision of whether a VPS is the right machine at all. The free [VPS course](https://flaviocopes.com/courses/vps/) and [Linux server security course](https://flaviocopes.com/courses/linux-server-security/) cover the day-to-day work around it.

### Keep a way in before you close port 22

Open the provider's web console and leave it logged in. On a Hetzner, DigitalOcean, or similar panel, that console is a keyboard on the VM that does not use your SSH port. If the firewall change is wrong, you fix it there.

Do the Tailscale SSH test in the next section before you delete the public SSH rule. Closing 22 first is how people lock themselves out.

### SSH over Tailscale

Install Tailscale from [their Linux instructions](https://tailscale.com/download), or review the install script and run it, which is the path in the [VPS guide](https://flaviocopes.com/secure-vps/):

```bash
curl -fsSL https://tailscale.com/install.sh -o /tmp/install-tailscale.sh
less /tmp/install-tailscale.sh
sudo sh /tmp/install-tailscale.sh
```

Bring the node up. Leave `--ssh` off until normal SSH over the tailnet works.

```bash
sudo tailscale up --hostname=vps-web
tailscale status
tailscale ip -4
```

From your laptop, on the same tailnet:

```bash
ssh user@100.x.y.z
```

Use the address `tailscale ip -4` printed. It is not the public IP.

[Tailscale SSH](https://tailscale.com/kb/1193/tailscale-ssh) is optional. `tailscale up --ssh` lets the tailnet policy decide who can get a shell, and Tailscale's docs describe it taking over port 22 for tailnet connections. Your existing `sshd` and `authorized_keys` stay in place for everyone else until you remove public SSH. Turn it on only after a normal `ssh` to the tailnet address already works.

Tailscale's [firewall page](https://tailscale.com/docs/reference/faq/firewall-ports) says a direct connection can fail and traffic still flows through DERP relays. Direct WireGuard uses UDP with a source port that defaults to 41641, and that port can be changed. Opening 41641/udp is how you help peer-to-peer. It is not required for Tailscale to function. `tailscale status` shows a connection as direct or relayed. The [tailnet course](https://flaviocopes.com/courses/tailscale/) is the longer explanation of that network. [SSH itself](https://flaviocopes.com/ssh-for-developers/) is unchanged. You are only changing which network can reach it.

### Web traffic through Cloudflare Tunnel

Install `cloudflared` from Cloudflare's apt repository, as in the [Tunnel guide](https://flaviocopes.com/cloudflare-tunnel/):

```bash
sudo mkdir -p --mode=0755 /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main" | sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt-get update && sudo apt-get install cloudflared
```

[Create the tunnel locally](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/local-management/create-local-tunnel/). On 1 November 2026 that page used a single `url:` field for one published application, not an ingress list.

```bash
cloudflared tunnel login
cloudflared tunnel create vps-web
cloudflared tunnel list
```

`tunnel login` writes a certificate under `~/.cloudflared/`. `tunnel create` writes a credentials JSON. Both are secrets. The create command prints a UUID.

`~/.cloudflared/config.yml` for one local app, with your UUID and your hostname's origin:

```yaml
url: http://localhost:8000
tunnel: <Tunnel-UUID>
credentials-file: /home/user/.cloudflared/<Tunnel-UUID>.json
```

Point `credentials-file` at the JSON `tunnel create` wrote. The origin stays on localhost. The public hostname never connects to port 8000 on the VPS address.

DNS, then run:

```bash
cloudflared tunnel route dns <UUID or NAME> app.example.com
cloudflared tunnel run <UUID or NAME>
```

`route dns` creates a CNAME to `<UUID>.cfargotunnel.com`. If the config file is not in the default directory, add `--config` and the path, which is what that page says.

More than one hostname still uses an ingress list, with a catch-all rule at the end. That form is the one walked through in the [Tunnel guide](https://flaviocopes.com/cloudflare-tunnel/), checked against `cloudflared` on 22 September 2026. Use it when `url:` is too small. A throwaway URL for an afternoon is a [quick tunnel](https://flaviocopes.com/cloudflare-quick-tunnels/), and it is the wrong tool for a server you keep.

When the foreground run works, install the service. Cloudflare's [Linux service page](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/local-management/as-a-service/linux/) says:

```bash
sudo cloudflared --config /home/user/.cloudflared/config.yml service install
sudo systemctl start cloudflared
sudo systemctl status cloudflared
```

The `--config` flag is there because `sudo` sets `HOME` to `/root`, and a config you wrote under `/home/user` would otherwise be invisible. Put your real home directory in the path.

The tunnel needs outbound access to Cloudflare. The VPS guide cites outbound port 7844 for that. You are not opening an inbound port for it.

### Firewall on the host

The provider firewall is the public boundary. A host firewall is the second layer, for the day the cloud rule is edited by mistake.

With ufw, from the provider console, after tailnet SSH works:

```bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow in on tailscale0 to any port 22 proto tcp
sudo ufw enable
sudo ufw status verbose
```

If the server uses IPv6, `/etc/default/ufw` should contain `IPV6=yes`. Do not run `ufw reset` on a box that already has rules you need.

Tailscale's default netfilter mode accepts tailnet traffic before ordinary ufw input rules. A ufw allow on port 22 is not your tailnet ACL. The [VPS guide](https://flaviocopes.com/secure-vps/) covers that, with a link to [Tailscale's netfilter modes](https://tailscale.com/docs/reference/netfilter-modes).

An nftables starting point, for a host that is not using ufw, is a drop policy on input, with established traffic, loopback, and SSH from the tailnet interface:

```nft
table inet filter {
  chain input {
    type filter hook input priority 0; policy drop;
    ct state established,related accept
    iifname "lo" accept
    iifname "tailscale0" tcp dport 22 accept
  }
}
```

Apply it from the provider console, with a working tailnet SSH session already open in another window. That table was not loaded on the check host. It is a starting shape, and it is a different table from Red Hat's CVE mitigation above. Output stays at the default accept, so the tunnel and Tailscale can still dial out. Add what you forgot before you disconnect the console.

### Docker publishes around ufw

Docker's [packet filtering documentation](https://docs.docker.com/engine/network/packet-filtering-firewalls/) describes published ports being handled in the nat table before the host firewall's input chain. `ufw deny` does not stop `-p 3000:3000`.

Publish to loopback when `cloudflared` runs on the host:

```yaml
services:
  app:
    ports:
      - "127.0.0.1:3000:3000"
```

If `cloudflared` is in another container, `127.0.0.1` is that container, not the app. Put them on one Docker network and point the tunnel at the service name. The VPS guide has that case too.

### The cloud firewall

After `ssh user@100.x.y.z` works from your laptop, and the site answers on the public hostname, delete inbound 22, 80, and 443 on the provider firewall. Leave outbound open. Do this in the provider panel, not only in ufw. A host firewall does not help a scanner that reaches a port Docker published, and a provider firewall in front of the VM does.

Then try the public IP from a network that is not your tailnet. The next section is that test.

## How do you verify nothing is open?

From another machine you control, one that is not joined to the tailnet:

```bash
nmap -Pn -sT -p- 203.0.113.10
```

`203.0.113.10` is the documentation address. Put the VPS public IP there. `-sT` is a TCP connect scan, so it does not need raw sockets. `-p-` is every TCP port. `-Pn` skips the ping. That ping is often dropped, and a skipped host looks like a clean result when it is not.

You want no open TCP ports. `filtered` means a firewall dropped or rejected the probe. `open` means something accepted it. An `open` line is a listener you still have to move or close.

UDP is a weaker scan. It is slow, it needs root for a useful result, and closed versus filtered is easy to misread.

```bash
sudo nmap -Pn -sU -p 41641 203.0.113.10
```

If that port is open, you left Tailscale's direct-connect port public. Tailscale still works without it. Close it if the goal is no public ports, and accept a relay when `tailscale status` says so.

Shodan and similar scanners are fine against an address you own. They are a second opinion, not the check you wait on. They can lag by days.

`ss -tlnp` on the server remains the list of what the kernel is willing to accept. Run both. The scan sees the path from the internet. `ss` sees the binds.

## FAQ

### Is my VPS vulnerable to CVE-2026-98432?

It is in range when the running kernel is at least 4.4 and older than the fixed version on its upstream line, or when your distro tracker still marks that package vulnerable. One public IPv4 TCP port is enough for a remote attacker to reach the handshake bug. MPTCP makes a demonstrated exploit path easier, and the kernel text does not require it. Read `uname -r`, then [Ubuntu](https://ubuntu.com/security/CVE-2026-98432), [Debian](https://security-tracker.debian.org/tracker/CVE-2026-98432), or [Red Hat](https://access.redhat.com/security/cve/CVE-2026-98432) for the package you boot.

### How do I disable MPTCP on Linux?

Set `net.mptcp.enabled=0` and store that line in `/etc/sysctl.d/90-disable-mptcp.conf`, then load it with `sysctl -p`. Restart listeners and confirm `ss -Mlnp` is empty. The kernel default documented for that sysctl is 1, per network namespace. On Go 1.24 or later, `GODEBUG=multipathtcp=0` or `godebug multipathtcp=0` in `go.mod` turns the listener default off, and `SetMultipathTCP(false)` does it for one socket.

### Does disabling MPTCP fix CVE-2026-98432?

It blocks the MPTCP listener path the Go security team described. The use-after-free is in IPv4 TCP option handling during the handshake, and they say hosts with no MPTCP listeners can still be vulnerable to similar attacks. Red Hat's IP-options filter has the same limit. Their bulletin says it cannot fully prevent the issue. Install the fixed kernel and reboot.

### Which Ubuntu and RHEL kernels are fixed?

As of the pages fetched on 1 November 2026, Ubuntu had not published a fixed `linux` package. The tracker showed work in progress, including `6.8.0-149.149` for 24.04 and `5.15.0-203.213` for 22.04, and no USN. Red Hat marked the RHEL 8, 9, and 10 kernels Affected, with no fixed release in the CVE JSON. Debian had shipped fixes in bookworm security (`6.1.190-1`) and trixie security (`6.12.113-1`). Check again on the day you patch. Those rows will change.

### How do I close every public port and still serve a site?

Serve the site through Cloudflare Tunnel, so `cloudflared` dials out and the app listens on localhost. SSH over Tailscale, and test `ssh` to the tailnet address before you remove public port 22. Default-deny incoming on ufw, allow SSH only on `tailscale0`, and delete inbound rules on the cloud firewall too. [The longer VPS guide](https://flaviocopes.com/secure-vps/) is the reboot and recovery checklist. Confirm with `ss -tlnp` and an `nmap` scan from outside the tailnet.
