Viktar Dubiniuk

CVE-2026-98432 stopgap for nftables and iptables

  • #linux
  • #nftables
  • #iptables
  • #kernel

Drop inbound IPv4 TCP that carries IP options before conntrack, persist the rule with systemd, and prove it with one crafted SYN.

On a distribution that no longer ships kernel updates, the rule below is the only fix, not a stopgap until a package arrives.

CVE-2026-98432 is a use-after-free in the IPv4 TCP handshake, present since 4.4 (079096f103fa). tcp_v4_syn_recv_sock() hands ireq->ireq_opt to the child socket without copying it. Another CPU can still be inside tcp_v4_send_synack() for a retransmitted SYN, reading that blob under rcu_read_lock(), while inet_sock_destruct() frees it with kfree(). Request-socket migration has the same shape: the clone shares ireq_opt, and tcp_v4_reqsk_destructor() frees it with kfree() too. Readers already use RCU. The fix is kfree_rcu() in both destructors.

The mainline fix is e3f33b0a1d89 (“ipv4: free inet_opt and ireq_opt after an RCU grace period”), in 7.3. The stable backports are 5.10.272, 5.15.223, 6.1.190, 6.6.159, 6.12.113, 6.18.56, and 7.2.10. Boot 7.3 or one of those and delete the rule below. Until then, drop the packets that carry the options. The published RCE demo used an MPTCP listener; the use-after-free does not require MPTCP. net.mptcp.enabled=0 removes that listener shape and leaves the bug in place.

The trigger is an IPv4 header longer than 20 bytes on a TCP packet. IHL is that length in 32-bit words: 5 means no options, 6–15 means options are present. TCP options (MSS, timestamps, SACK, window scale) sit in the TCP header and leave IHL at 5. The match is TCP and IHL, so IGMP, ICMP, and UDP with options pass. IPv6 is out of scope here: tcp_v6_syn_recv_sock() copies its options. The drop covers options that arrive on the SYN. With NetLabel/CIPSO (SELinux or Smack) the kernel can attach IP options to the request itself; that path was not checked against this bug.

Both rules hook prerouting, so they see locally delivered packets and forwarded packets, including traffic that Docker DNATs afterwards (nat/PREROUTING is -100). Outbound connections leave through the output hook and are not matched. The vulnerable path is the host that receives the SYN.

nftables

The rule lives in its own table. firewalld, ufw, and Docker write other tables and do not delete this one. Debian and Ubuntu’s nftables.service does: the stock /etc/nftables.conf starts with flush ruleset, and a later start of that unit deletes inet cve_guard with no error. After=nftables.service only orders the boot. PartOf=nftables.service restarts this unit on systemctl restart nftables, so the table is loaded again after the flush. systemctl reload nftables does not propagate; that needs ReloadPropagatedFrom=nftables.service. Priority -310 runs before the raw hook (NF_IP_PRI_RAW is -300), so the packet is gone before conntrack and before the handshake code.

/etc/nft-cve-guard.conf:

table inet cve_guard
delete table inet cve_guard
table inet cve_guard {
    chain prerouting {
        type filter hook prerouting priority -310; policy accept;
        ip protocol tcp ip hdrlength 6-15 counter drop
    }
}

The leading table / delete table pair is what makes nft -f safe to repeat. A bare delete fails when the table is absent, and nft -f stops at the first error, so a clean boot would never install the rule. inet with an ip match is IPv4 only. hdrlength is IHL. Reloading the file replaces the table; it does not stack a second chain, and the counter goes back to zero.

/etc/systemd/system/cve-guard.service:

[Unit]
Description=CVE-2026-98432 stopgap (drop TCP with IP options)
Wants=network-pre.target
Before=network-pre.target
After=nftables.service
PartOf=nftables.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/sbin/nft -f /etc/nft-cve-guard.conf
ExecStop=-/usr/sbin/nft delete table inet cve_guard

[Install]
WantedBy=multi-user.target

Wants= and Before= on network-pre.target install the table before addresses come up. RemainAfterExit=yes keeps the oneshot active so ExecStop runs. The leading - ignores a failed nft delete table: after a manual nft flush ruleset, or when the table is already gone, systemctl stop would otherwise end failed. On systemctl restart nftables this unit stops first, so the table is still there; the - is for the manual case.

sudo systemctl daemon-reload
sudo systemctl enable --now cve-guard.service
systemctl is-active cve-guard
nft list table inet cve_guard

iptables

Same match, in the raw table: prerouting at -300, still before conntrack (-200) and before filter (0). xt_u32 takes the first header byte and keeps the low nibble.

/usr/local/sbin/cve-guard.sh:

#!/bin/sh
# CVE-2026-98432 stopgap: drop TCP packets with IP options
case "$1" in
  start)
    modprobe xt_u32 2>/dev/null
    iptables -t raw -C PREROUTING -p tcp -m u32 --u32 "0>>24&0xF=6:15" -j DROP 2>/dev/null || \
    iptables -t raw -I PREROUTING 1 -p tcp -m u32 --u32 "0>>24&0xF=6:15" -j DROP
    ;;
  stop)
    while iptables -t raw -C PREROUTING -p tcp -m u32 --u32 "0>>24&0xF=6:15" -j DROP 2>/dev/null; do
      iptables -t raw -D PREROUTING -p tcp -m u32 --u32 "0>>24&0xF=6:15" -j DROP
    done
    ;;
  *) echo "usage: $0 start|stop"; exit 1 ;;
esac

0>>24&0xF=6:15 is four bytes at offset 0, shifted to the first byte, masked to IHL, inclusive range 6 through 15. Quote it. The shell will eat &.

-C makes start idempotent. Insert at position 1 so the rule wins against anything else already in raw/PREROUTING. The stop loop deletes every copy, including ones added by hand. chmod +x the script.

/etc/systemd/system/cve-guard.service:

[Unit]
Description=CVE-2026-98432 stopgap (drop TCP with IP options)
After=network-pre.target
After=netfilter-persistent.service
Before=network.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/cve-guard.sh start
ExecStop=/usr/local/sbin/cve-guard.sh stop

[Install]
WantedBy=multi-user.target

After=netfilter-persistent.service matters when netfilter-persistent, or another iptables-restore, loads saved rules at boot and would otherwise wipe the drop. If that unit is not installed, the ordering line does nothing.

Same systemctl enable --now. Check with iptables -t raw -vnL PREROUTING.

Pick one implementation per host. Two units with the same name will overwrite each other.

Prove the counter moves

A normal SYN does not increment the counter. IHL stays 5. The probe is a raw IPv4 TCP SYN with four NOP options (type 1, one byte each): header length 24, IHL 6. Send it to 127.0.0.1. Delivery via loopback crosses prerouting, so the counter on the same host moves. Nothing has to listen on the port. The drop happens before TCP. The probe needs CAP_NET_RAW.

tcp-cve-test.go:

package main

import (
	"encoding/binary"
	"flag"
	"fmt"
	"log"
	"net"

	"golang.org/x/net/ipv4"
)

func csum(b []byte) uint16 {
	var s uint32
	for i := 0; i+1 < len(b); i += 2 {
		s += uint32(binary.BigEndian.Uint16(b[i:]))
	}
	if len(b)%2 == 1 {
		s += uint32(b[len(b)-1]) << 8
	}
	for s>>16 != 0 {
		s = (s & 0xffff) + (s >> 16)
	}
	return ^uint16(s)
}

func main() {
	ipStr := flag.String("ip", "127.0.0.1", "destination IPv4 address")
	port := flag.Int("port", 80, "destination port")
	flag.Parse()

	dst := net.ParseIP(*ipStr).To4()
	if dst == nil {
		log.Fatalf("bad IPv4 address: %s", *ipStr)
	}

	c, err := net.Dial("udp", fmt.Sprintf("%s:%d", dst, *port))
	if err != nil {
		log.Fatal(err)
	}
	src := c.LocalAddr().(*net.UDPAddr).IP.To4()
	c.Close()

	tcp := []byte{
		0xD4, 0x31, byte(*port >> 8), byte(*port & 0xff),
		0, 0, 0, 1,
		0, 0, 0, 0,
		0x50, 0x02, 0x72, 0x10,
		0, 0, 0, 0,
	}
	pseudo := append(append(append([]byte{}, src...), dst...), 0, 6, 0, byte(len(tcp)))
	binary.BigEndian.PutUint16(tcp[16:], csum(append(pseudo, tcp...)))

	conn, err := net.ListenPacket("ip4:tcp", "0.0.0.0")
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()
	raw, err := ipv4.NewRawConn(conn)
	if err != nil {
		log.Fatal(err)
	}

	h := &ipv4.Header{
		Version:  4,
		Len:      24,
		TotalLen: 24 + len(tcp),
		TTL:      64,
		Protocol: 6,
		Src:      src,
		Dst:      dst,
		Options:  []byte{1, 1, 1, 1}, // 4× NOP
	}
	if err := raw.WriteTo(h, tcp, nil); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("sent to %s:%d\n", dst, *port)
}
go mod init tcp-cve-test
go get golang.org/x/net@v0.62.0
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o tcp-cve-test .

CGO_ENABLED=0 produces a static binary. GOOS and GOARCH fix it to linux/amd64, so the host you copy it to needs neither a Go toolchain nor the libc of the build machine.

check.sh reads whichever counter is installed. An iptables u32 rule in raw/PREROUTING wins; otherwise it reads packets from inet cve_guard. Run it as root: the probe needs CAP_NET_RAW, and reading the raw table does too.

#!/bin/sh
set -eu
bin=${1:-./tcp-cve-test}

counter() {
  if command -v iptables >/dev/null 2>&1 \
      && iptables -t raw -S PREROUTING 2>/dev/null | grep -q u32; then
    iptables -t raw -vnxL PREROUTING | awk '/u32/ {print $1; exit}'
  elif command -v nft >/dev/null 2>&1 \
      && nft list table inet cve_guard >/dev/null 2>&1; then
    nft list table inet cve_guard | grep -o 'packets [0-9]*' | awk '{print $2; exit}'
  else
    echo NONE
  fi
}

before=$(counter)
if [ "$before" = "NONE" ] || [ -z "$before" ]; then
  echo "FAIL: no cve-guard rule" >&2
  exit 2
fi
"$bin" -ip 127.0.0.1 -port 80 >/dev/null
after=$(counter)
if [ "$after" -gt "$before" ]; then
  echo "OK: $before -> $after"
else
  echo "FAIL: counter did not grow ($before -> $after)" >&2
  exit 1
fi
sudo ./check.sh ./tcp-cve-test

OK means the counter grew by at least one. Exit 2 means neither rule is loaded. A flat counter means the rule is not on the path, or the probe left with IHL 5. tcpdump on lo can still show the SYN after a successful drop: the ingress tap is before prerouting. The counter is the check.

Traffic that really does send IP options on TCP — record route, timestamp, strict or loose source route, CIPSO — is dropped. Ordinary HTTP and HTTPS do not.

Remove it after the kernel update

Once uname -r is a fixed build:

sudo systemctl disable --now cve-guard.service

ExecStop deletes the nft table or the iptables rule. nft list table inet cve_guard has to fail. iptables -t raw -S PREROUTING has to print no u32 drop.

← All posts