#!/usr/bin/env bash # cve-2026-98432.sh: interim mitigation for CVE-2026-98432 on Ubuntu # https://github.com/lowerpower/cve-2026-98432-mitigation # # CVE-2026-98432 is a use-after-free in the Linux IPv4 TCP handshake (kernels # 4.4 and later), triggered by IPv4 options on an incoming SYN. Until a fixed # kernel is installed and running, this script: # 1. drops inbound TCP packets that carry IPv4 options, for every listener on # the host and its containers, before the kernel's TCP code sees them # 2. turns MPTCP off for new sockets (net.mptcp.enabled=0) # Both persist across reboots. Normal TCP traffic never carries IPv4 options. # # Usage: sudo bash cve-2026-98432.sh report, apply, self-test # sudo bash cve-2026-98432.sh --check report only, changes nothing # sudo bash cve-2026-98432.sh --undo remove, once the fixed kernel runs # sudo bash cve-2026-98432.sh --undo --force remove even if it doesn't # # The last line is always "STATUS: ...". Exit codes: 0 protected, # 2 not protected, 1 error. set -u VERSION=1.0.0 CVE="CVE-2026-98432" NFT_FILE=/etc/nftables-cve-2026-98432.nft UNIT_NAME=cve-2026-98432-mitigation.service UNIT=/etc/systemd/system/$UNIT_NAME SYSCTL_FILE=/etc/sysctl.d/90-cve-2026-98432.conf MPTCP=/proc/sys/net/mptcp/enabled usage() { cat </dev/null 2>&1; } drops() { nft list table ip cve_2026_98432 2>/dev/null | grep -oE 'packets [0-9]+' | awk '{print $2}'; } # MPTCP listeners on the host, and the ones inside containers that can be # reached from outside the container (loopback and Docker's internal DNS are # skipped, as are containers that share the host's or another container's network) list_mptcp() { local out c pid name net n=0 out=$(ss -MlnpH 2>/dev/null | awk '{print $4, $6}') [ -n "$out" ] && { n=1; echo "$out" | sed 's/^/ host /'; } if command -v docker >/dev/null && docker info >/dev/null 2>&1; then for c in $(docker ps -q); do net=$(docker inspect -f '{{.HostConfig.NetworkMode}}' "$c" 2>/dev/null) case "$net" in host|container:*) continue ;; esac pid=$(docker inspect -f '{{.State.Pid}}' "$c" 2>/dev/null) [ -n "$pid" ] && [ "$pid" != 0 ] || continue name=$(docker inspect -f '{{.Name}}' "$c" 2>/dev/null | sed 's#^/##') out=$(nsenter -t "$pid" -n ss -MlnH 2>/dev/null | awk '{print $4}' | grep -vE '^(127\.|\[::1\])' | tr '\n' ' ') [ -n "$out" ] && { n=1; echo " container $name: $out"; } done fi [ $n -eq 0 ] && echo " none" } report() { local f pid gv n=0 echo "== $(hostname): $(. /etc/os-release; echo "$PRETTY_NAME"), kernel $KERNEL [cve-2026-98432.sh $VERSION]" case "$KSTATE" in fixed) echo " running kernel HAS the fix: the mitigation can be removed (--undo)" ;; unfixed) echo " running kernel does NOT have the fix" ;; unknown) echo " no Ubuntu changelog found for this kernel, so the fix can't be checked here (try: pro fix $CVE)" ;; esac for f in /usr/share/doc/linux-modules-[0-9]*/changelog.Debian.gz; do [ "$KSTATE" != fixed ] && [ -f "$f" ] || continue case "$f" in *"/linux-modules-$KERNEL/"*) continue ;; esac zgrep -q "$CVE" "$f" 2>/dev/null && echo " fixed kernel installed, reboot to run it: $(basename "$(dirname "$f")" | sed 's/^linux-modules-//')" done if [ -r "$MPTCP" ]; then echo " net.mptcp.enabled = $(cat "$MPTCP")" else echo " net.mptcp.enabled: kernel has no MPTCP"; fi if rule_loaded; then echo " option-drop rule: loaded, $(drops) packets dropped" else echo " option-drop rule: not loaded"; fi echo " MPTCP listeners:" list_mptcp echo " Go programs on public TCP listeners (Go 1.24+ opens MPTCP by default):" if ! command -v strings >/dev/null; then echo " not checked (install binutils for this check)" else for pid in $(ss -tlnpH | grep -vE ' (127\.|\[::1\])' | grep -oE 'pid=[0-9]+' | cut -d= -f2 | sort -u); do gv=$(strings -n 6 "/proc/$pid/exe" 2>/dev/null | grep -m1 -oE '^go1\.[0-9]+(\.[0-9]+)?$') [ -n "$gv" ] && { n=1; echo " $(cat "/proc/$pid/comm") (pid $pid, $gv)"; } done [ $n -eq 0 ] && echo " none" fi } status() { if [ "$KSTATE" = fixed ]; then echo "STATUS: protected (running kernel has the fix)"; return 0 elif rule_loaded; then echo "STATUS: protected (interim mitigation active; kernel fix still needed)"; return 0 elif [ "$KSTATE" = unknown ]; then echo "STATUS: NOT protected (kernel fix not confirmed, mitigation not applied)"; return 2 else echo "STATUS: NOT protected (kernel not fixed, mitigation not applied)"; return 2 fi } KSTATE=$(kernel_state) case "$MODE" in --check) report; echo; status; exit $? ;; --undo) if [ "$KSTATE" != fixed ] && [ "$FORCE" != --force ]; then report; echo echo "Not removing: the running kernel doesn't have the fix, so this would leave" echo "the host exposed. Reboot into a fixed kernel first, or use --undo --force." echo; status; exit 1 fi prev=$(sed -n 's/^# previous net.mptcp.enabled = //p' "$SYSCTL_FILE" 2>/dev/null) systemctl disable --now "$UNIT_NAME" 2>/dev/null nft delete table ip cve_2026_98432 2>/dev/null rm -f "$UNIT" "$NFT_FILE" "$SYSCTL_FILE" systemctl daemon-reload [ -w "$MPTCP" ] && sysctl -q -w net.mptcp.enabled="${prev:-1}" echo "Mitigation removed (net.mptcp.enabled restored to ${prev:-1})." echo; report; echo; status; exit $? ;; esac # apply report echo if [ "$KSTATE" = fixed ]; then echo "Nothing to apply: the running kernel already has the fix." echo; status; exit $? fi echo "== Applying" if ! command -v nft >/dev/null; then echo " installing nftables" apt-get install -y -qq nftables >/dev/null 2>&1 \ || { apt-get update -qq >/dev/null 2>&1 && apt-get install -y -qq nftables >/dev/null 2>&1; } \ || { echo " could not install nftables; nothing changed"; exit 1; } fi cat > "$NFT_FILE" <<'EOF' table ip cve_2026_98432 { chain prerouting { type filter hook prerouting priority raw; policy accept; ip protocol tcp ip hdrlength != 5 counter drop comment "CVE-2026-98432 interim: drop TCP with IPv4 options" } } EOF if ! nft -c -f "$NFT_FILE" 2>/dev/null; then rm -f "$NFT_FILE" echo " this nftables ($(nft --version 2>/dev/null)) can't load the rule; nothing changed" exit 1 fi cat > "$UNIT" </dev/null; exec /usr/sbin/nft -f $NFT_FILE' ExecStop=/usr/sbin/nft delete table ip cve_2026_98432 [Install] WantedBy=multi-user.target EOF systemctl daemon-reload systemctl enable -q "$UNIT_NAME" systemctl restart "$UNIT_NAME" echo " option-drop rule: $(systemctl is-active "$UNIT_NAME") / $(systemctl is-enabled "$UNIT_NAME")" if [ -r "$MPTCP" ]; then # Keep the value from before the first apply, so --undo can put it back. if [ -f "$SYSCTL_FILE" ]; then prev=$(sed -n 's/^# previous net.mptcp.enabled = //p' "$SYSCTL_FILE"); prev=${prev:-1} else prev=$(cat "$MPTCP") fi printf '# %s interim mitigation, applied %s by cve-2026-98432.sh %s\n# Remove with: sudo bash cve-2026-98432.sh --undo (once a fixed kernel is running)\n# previous net.mptcp.enabled = %s\nnet.mptcp.enabled = 0\n' \ "$CVE" "$(date +%F)" "$VERSION" "$prev" > "$SYSCTL_FILE" sysctl -q -w net.mptcp.enabled=0 echo " net.mptcp.enabled = $(cat "$MPTCP")" else echo " kernel has no MPTCP; nothing to turn off" fi echo echo "== Testing" port=$(ss -tlnH | awk '{print $4}' | grep -E '^(0\.0\.0\.0|127\.0\.0\.1|\*):' | sed 's/.*://' | sort -n | grep -m1 -xE '80|443' \ || ss -tlnH | awk '{print $4}' | grep -E '^(0\.0\.0\.0|127\.0\.0\.1|\*):' | sed 's/.*://' | head -1) if [ -z "$port" ] || ! command -v python3 >/dev/null; then echo " no IPv4 listener or python3 to test against; skipped" else before=$(drops) python3 - "$port" <<'EOF' import socket, sys port = int(sys.argv[1]) RR = b'\x07\x07\x04\x00\x00\x00\x00\x01' # IPv4 Record Route option + NOP pad for label, opts in (("plain SYN", None), ("SYN with an IPv4 option", RR)): s = socket.socket(); s.settimeout(4) if opts: s.setsockopt(socket.IPPROTO_IP, socket.IP_OPTIONS, opts) try: s.connect(("127.0.0.1", port)); r = "connected" except Exception as e: r = "blocked (%s)" % type(e).__name__ finally: s.close() print(" %-26s -> 127.0.0.1:%d %s" % (label, port, r)) EOF echo " packets dropped by the rule during the test: $(( $(drops) - before ))" echo " expected: plain SYN connected, SYN with an IPv4 option blocked" fi echo echo "== MPTCP listeners still open (opened before the change; restart those services to drop MPTCP):" list_mptcp echo status