From c8d91b21cca79797e22fb16aa21daa42615406d1 Mon Sep 17 00:00:00 2001 From: Takwa Date: Fri, 30 Oct 2026 13:27:57 +0100 Subject: [PATCH 1/2] Block the CVE-2026-98432 trigger at the host firewall Until the boxes run a fixed kernel, an nft table of its own at prerouting/raw drops IPv4 TCP carrying IP options (the bug needs a SYN with them) and strips the MPTCP option from SYNs. Raw priority is ahead of conntrack and kube-proxy, so pods are covered as well as the host. It is loaded by its own oneshot unit, not nftables.service: Ubuntu's /etc/nftables.conf starts with `flush ruleset`, which would wipe the rules k3s, ufw and fail2ban keep in nf_tables. Loading it touches no other table and restarts nothing, so there is no needle warm-up. cve_2026_98432_mode starts at `count`, which only counts, so what `enforce` would drop can be seen before it drops anything. Enforce one box first through host_vars, then the rest. Co-Authored-By: Claude Opus 5.5 --- README.md | 31 +++++ group_vars/ipfs_nodes/main.yml | 14 ++ roles/hardening/handlers/main.yml | 9 ++ roles/hardening/tasks/cve_2026_98432.yml | 122 ++++++++++++++++++ roles/hardening/tasks/main.yml | 8 ++ .../cve-2026-98432-filter.service.j2 | 22 ++++ .../hardening/templates/cve-2026-98432.nft.j2 | 28 ++++ 7 files changed, 234 insertions(+) create mode 100644 roles/hardening/tasks/cve_2026_98432.yml create mode 100644 roles/hardening/templates/cve-2026-98432-filter.service.j2 create mode 100644 roles/hardening/templates/cve-2026-98432.nft.j2 diff --git a/README.md b/README.md index aa44680..92b8f4b 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,37 @@ enables ufw with a default-deny inbound policy, and configures fail2ban. fail2ban is kept mainly to cut log noise: with password authentication disabled there are few credential guesses left for it to ban. +**CVE-2026-98432.** Until the boxes run a kernel with the fix, an nft table of +its own, `inet cve_2026_98432`, drops IPv4 TCP that carries IP options (the +bug's trigger is a SYN with them) and strips the MPTCP option from SYNs, so +MPTCP clients fall back to plain TCP. It hooks prerouting at raw priority, +ahead of conntrack and kube-proxy, so it covers pods as well as the host. It +is loaded by `cve-2026-98432-filter.service`, not `nftables.service`: Ubuntu's +`/etc/nftables.conf` starts with `flush ruleset`, which would wipe the rules +k3s, ufw and fail2ban keep in nf_tables. + +`cve_2026_98432_mode` in `group_vars` picks what it does: `count` matches and +counts only, `enforce` drops and strips, `off` removes it. Loading it changes +no other table and restarts nothing, so there is no needle warm-up. Roll it +out as: + +1. `count` everywhere, then read the counters for an hour or so: + `sudo nft list table inet cve_2026_98432`. Anything counted is an attack + attempt or traffic that `enforce` would drop; know which before going on. +2. `enforce` on one box first (`cve_2026_98432_mode: enforce` in its + `host_vars`), then the group default. To prove it from outside, use a port + anyone can reach (22, or a needle libp2p port), not 443, which only + Cloudflare can reach: + `sudo nping --tcp -p 22 --ip-options R `. Before `enforce` it should get + a SYN-ACK; if it gets nothing even then, something on the path already + drops IP options and the test proves nothing. After, no SYN-ACK, and the + drop counter on the box goes up. + +To watch for attempts on a box: +`sudo tcpdump -ni any 'tcp[tcpflags] & tcp-syn != 0 and ip[0] & 0xf != 5'`. +Undo instantly with `sudo systemctl stop cve-2026-98432-filter`, which deletes +the table; set `off` to remove it for good once the fixed kernel is in. + The provider's unused **`ubuntu` account (uid 1000, in `sudo`) is removed**. Several container images run as uid 1000, including k3s's own metrics-server, and a container escape as that uid should not land in a diff --git a/group_vars/ipfs_nodes/main.yml b/group_vars/ipfs_nodes/main.yml index a7a9dba..e9326c4 100644 --- a/group_vars/ipfs_nodes/main.yml +++ b/group_vars/ipfs_nodes/main.yml @@ -49,6 +49,20 @@ firewall_allowed_tcp: - "{{ ssh_port }}" firewall_allowed_udp: [] +# CVE-2026-98432: a remote bug in the kernel's TCP handshake, triggered by a SYN +# that carries IPv4 options. Until the boxes run a fixed kernel, an nft table +# of its own drops IPv4 TCP with IP options, and strips the MPTCP option from +# SYNs (roles/hardening/tasks/cve_2026_98432.yml). Nothing legitimate sends +# IP options on TCP. +# +# count match and count, change nothing: what to run first, to see what +# enforce would have hit (sudo nft list table inet cve_2026_98432) +# enforce drop and strip +# off remove the table and its unit +# +# Override per box in host_vars to enforce one box before the others. +cve_2026_98432_mode: count + # --------------------------------------------------------------------------- # Rollout mode # --------------------------------------------------------------------------- diff --git a/roles/hardening/handlers/main.yml b/roles/hardening/handlers/main.yml index 489c4c6..446ec8d 100644 --- a/roles/hardening/handlers/main.yml +++ b/roles/hardening/handlers/main.yml @@ -29,3 +29,12 @@ ansible.builtin.systemd: name: fail2ban state: restarted + +# A reload re-runs `nft -f`, which replaces the table in one transaction. +- name: Reload the CVE-2026-98432 filter + ansible.builtin.systemd: + name: cve-2026-98432-filter + state: reloaded + daemon_reload: true + # As the start task: a dry run on a box without the unit has nothing to ask. + when: not (ansible_check_mode and cve_2026_98432_unit_file is changed) diff --git a/roles/hardening/tasks/cve_2026_98432.yml b/roles/hardening/tasks/cve_2026_98432.yml new file mode 100644 index 0000000..861e8e1 --- /dev/null +++ b/roles/hardening/tasks/cve_2026_98432.yml @@ -0,0 +1,122 @@ +--- +# CVE-2026-98432 mitigation, until the boxes run a fixed kernel: an nft table +# of its own at prerouting/raw (templates/cve-2026-98432.nft.j2), loaded at +# boot by its own oneshot unit. See cve_2026_98432_mode in group_vars. +# +# Loading the table is one atomic nft transaction that touches no other table: +# k3s, ufw, fail2ban and the workloads are not restarted, so there is no needle +# warm-up to wait for. What can hurt is the rule matching real traffic, which +# is what `count` mode is for. + +- name: Check cve_2026_98432_mode is one the role knows + ansible.builtin.assert: + that: cve_2026_98432_mode in ['count', 'enforce', 'off'] + fail_msg: "cve_2026_98432_mode is {{ cve_2026_98432_mode }}; use count, enforce or off." + quiet: true + +- name: Install nftables + ansible.builtin.apt: + name: nftables + state: present + when: cve_2026_98432_mode != 'off' + +- name: Create the directory for the CVE-2026-98432 ruleset + ansible.builtin.file: + path: "{{ cve_2026_98432_ruleset | dirname }}" + state: directory + mode: "0755" + when: cve_2026_98432_mode != 'off' + +# validate runs `nft -c`, which hands the whole transaction to the kernel and +# aborts it: a rule the kernel or this nft cannot do fails here, on the box, +# before anything is loaded. +- name: Write the CVE-2026-98432 ruleset + ansible.builtin.template: + src: cve-2026-98432.nft.j2 + dest: "{{ cve_2026_98432_ruleset }}" + mode: "0644" + validate: /usr/sbin/nft -c -f %s + when: cve_2026_98432_mode != 'off' + notify: Reload the CVE-2026-98432 filter + +- name: Install the CVE-2026-98432 filter unit + ansible.builtin.template: + src: cve-2026-98432-filter.service.j2 + dest: /etc/systemd/system/cve-2026-98432-filter.service + mode: "0644" + register: cve_2026_98432_unit_file + when: cve_2026_98432_mode != 'off' + notify: Reload the CVE-2026-98432 filter + +# A dry run on a box without the unit cannot ask systemd about it: the file +# above was only diffed, not written. +- name: Enable and start the CVE-2026-98432 filter + ansible.builtin.systemd: + name: cve-2026-98432-filter + enabled: true + state: started + daemon_reload: true + when: + - cve_2026_98432_mode != 'off' + - not (ansible_check_mode and cve_2026_98432_unit_file is changed) + +- name: Load the CVE-2026-98432 ruleset now + ansible.builtin.meta: flush_handlers + +# Read the live table, not the file: this is what the kernel is doing. +- name: Read the loaded CVE-2026-98432 table + ansible.builtin.command: + argv: [nft, list, table, inet, cve_2026_98432] + register: cve_2026_98432_table + changed_when: false + when: + - cve_2026_98432_mode != 'off' + - not ansible_check_mode + +- name: Check the loaded table is in the configured mode + vars: + rules: "{{ cve_2026_98432_table.stdout_lines | select('search', 'comment \"cve-2026-98432') | list }}" + ansible.builtin.assert: + that: + - rules | length == 2 + - (rules | select('search', ' drop ') | list | length == 1) == (cve_2026_98432_mode == 'enforce') + - (rules | select('search', 'reset tcp option') | list | length == 1) == (cve_2026_98432_mode == 'enforce') + fail_msg: >- + {{ inventory_hostname }}: the live cve_2026_98432 table does not match + mode {{ cve_2026_98432_mode }}: {{ rules }} + quiet: true + when: + - cve_2026_98432_mode != 'off' + - not ansible_check_mode + +- name: Report the CVE-2026-98432 counters + ansible.builtin.debug: + msg: "{{ cve_2026_98432_table.stdout_lines | select('search', 'counter') | map('trim') | list }}" + when: + - cve_2026_98432_mode != 'off' + - not ansible_check_mode + +- name: Look for an installed CVE-2026-98432 filter unit + ansible.builtin.stat: + path: /etc/systemd/system/cve-2026-98432-filter.service + register: cve_2026_98432_unit + when: cve_2026_98432_mode == 'off' + +# off: stopping the unit runs its ExecStop, which deletes the table. +- name: Stop and disable the CVE-2026-98432 filter + ansible.builtin.systemd: + name: cve-2026-98432-filter + enabled: false + state: stopped + when: + - cve_2026_98432_mode == 'off' + - cve_2026_98432_unit.stat.exists + +- name: Remove the CVE-2026-98432 filter files + ansible.builtin.file: + path: "{{ item }}" + state: absent + loop: + - /etc/systemd/system/cve-2026-98432-filter.service + - "{{ cve_2026_98432_ruleset }}" + when: cve_2026_98432_mode == 'off' diff --git a/roles/hardening/tasks/main.yml b/roles/hardening/tasks/main.yml index 2ef6eb4..69a594e 100644 --- a/roles/hardening/tasks/main.yml +++ b/roles/hardening/tasks/main.yml @@ -221,6 +221,14 @@ enabled: true state: started +# Its own table and unit beside ufw, never through ufw or nftables.conf. +# Tagged so it can go out on its own: site.yml --tags cve_2026_98432 -l . +- name: Mitigate CVE-2026-98432 at the host firewall + ansible.builtin.import_tasks: cve_2026_98432.yml + vars: + cve_2026_98432_ruleset: /etc/nftables.d/cve-2026-98432.nft + tags: [cve_2026_98432] + # --------------------------------------------------------------------------- # 4. SSH lockdown - last, and only after the gate above passed # --------------------------------------------------------------------------- diff --git a/roles/hardening/templates/cve-2026-98432-filter.service.j2 b/roles/hardening/templates/cve-2026-98432-filter.service.j2 new file mode 100644 index 0000000..eda5e7d --- /dev/null +++ b/roles/hardening/templates/cve-2026-98432-filter.service.j2 @@ -0,0 +1,22 @@ +# Managed by Ansible (roles/hardening/tasks/cve_2026_98432.yml). +# +# Ordered like Debian's nftables.service, so the rules are in place before the +# network comes up, but loading only this table: nftables.service itself would +# load /etc/nftables.conf, whose `flush ruleset` wipes k3s, ufw and fail2ban. +[Unit] +Description=Drop the CVE-2026-98432 trigger (TCP with IPv4 options) +Wants=network-pre.target +Before=network-pre.target shutdown.target +Conflicts=shutdown.target +DefaultDependencies=no + +[Service] +Type=oneshot +RemainAfterExit=yes +StandardInput=null +ExecStart=/usr/sbin/nft -f {{ cve_2026_98432_ruleset }} +ExecReload=/usr/sbin/nft -f {{ cve_2026_98432_ruleset }} +ExecStop=-/usr/sbin/nft delete table inet cve_2026_98432 + +[Install] +WantedBy=sysinit.target diff --git a/roles/hardening/templates/cve-2026-98432.nft.j2 b/roles/hardening/templates/cve-2026-98432.nft.j2 new file mode 100644 index 0000000..09e4f94 --- /dev/null +++ b/roles/hardening/templates/cve-2026-98432.nft.j2 @@ -0,0 +1,28 @@ +#!/usr/sbin/nft -f +# Managed by Ansible (roles/hardening/tasks/cve_2026_98432.yml), mode +# {{ cve_2026_98432_mode }}. Loaded at boot by cve-2026-98432-filter.service. +# +# This file touches only its own table. Never put `flush ruleset` here: k3s, +# ufw and fail2ban keep their rules in nf_tables too, and it would wipe them. +# +# The first two lines make a reload atomic and idempotent: the table is +# created if missing, then deleted and redefined in the same transaction. +table inet cve_2026_98432 +delete table inet cve_2026_98432 + +table inet cve_2026_98432 { + # priority raw: before conntrack and before kube-proxy's DNAT, so this + # covers the host, hostNetwork pods and every other pod alike. + chain pre { + type filter hook prerouting priority raw; policy accept; + + # The trigger. Any TCP with IP options, not only SYNs: SYN-ACKs to the + # boxes' own outbound connections are covered too, at no cost. + meta nfproto ipv4 meta l4proto tcp ip hdrlength != 5 counter {{ 'drop ' if cve_2026_98432_mode == 'enforce' }}comment "cve-2026-98432 ipv4 options" + + # Second layer: no MPTCP handshakes, so clients fall back to plain TCP. + # SYNs only, so MPTCP connections already open when this loads are left + # alone rather than broken mid-flow. + tcp flags & syn == syn tcp option mptcp exists counter {{ 'reset tcp option mptcp ' if cve_2026_98432_mode == 'enforce' }}comment "cve-2026-98432 mptcp" + } +} From a657246ca80754ceeda6911ac76acacf55211218 Mon Sep 17 00:00:00 2001 From: Takwa Date: Fri, 30 Oct 2026 13:46:29 +0100 Subject: [PATCH 2/2] Reload from live state, and drop SYNs with a second MPTCP option Review of #19 found two problems, both reproduced here. The reload was a handler notified by the template tasks. A run that wrote the ruleset and died before loading it left the old mode live, and every later run found the file already right and never reloaded: an interrupted count -> enforce stayed counting, an interrupted rollback kept dropping. The reload is now decided from the kernel: the file is loaded into a throwaway network namespace and listed, and the table is reloaded when the live listing (counters blanked) differs or is missing. The handler is gone. The assert compares the same listings. `reset tcp option mptcp` blanks only the first matching option (nft_exthdr_tcp_strip_eval returns after one), so a SYN with two kept the second. Reproduced on sing-1's kernel in a throwaway namespace; in enforce mode a SYN still carrying an MPTCP option after the strip is now dropped. Same test after the fix: plain SYN arrives, one option arrives stripped, two options and IP options are dropped. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 +- roles/hardening/handlers/main.yml | 9 --- roles/hardening/tasks/cve_2026_98432.yml | 73 ++++++++++++++++--- .../hardening/templates/cve-2026-98432.nft.j2 | 14 +++- 4 files changed, 80 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 92b8f4b..a72bfcf 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,12 @@ disabled there are few credential guesses left for it to ban. **CVE-2026-98432.** Until the boxes run a kernel with the fix, an nft table of its own, `inet cve_2026_98432`, drops IPv4 TCP that carries IP options (the bug's trigger is a SYN with them) and strips the MPTCP option from SYNs, so -MPTCP clients fall back to plain TCP. It hooks prerouting at raw priority, +MPTCP clients fall back to plain TCP. nft strips only the first matching +option, so a SYN still carrying one after that (built with two) is dropped. +Whether to reload is decided from the live table, compared with the file +loaded into a throwaway network namespace, so a run interrupted between +writing the file and loading it is put right by the next run. It hooks +prerouting at raw priority, ahead of conntrack and kube-proxy, so it covers pods as well as the host. It is loaded by `cve-2026-98432-filter.service`, not `nftables.service`: Ubuntu's `/etc/nftables.conf` starts with `flush ruleset`, which would wipe the rules diff --git a/roles/hardening/handlers/main.yml b/roles/hardening/handlers/main.yml index 446ec8d..489c4c6 100644 --- a/roles/hardening/handlers/main.yml +++ b/roles/hardening/handlers/main.yml @@ -29,12 +29,3 @@ ansible.builtin.systemd: name: fail2ban state: restarted - -# A reload re-runs `nft -f`, which replaces the table in one transaction. -- name: Reload the CVE-2026-98432 filter - ansible.builtin.systemd: - name: cve-2026-98432-filter - state: reloaded - daemon_reload: true - # As the start task: a dry run on a box without the unit has nothing to ask. - when: not (ansible_check_mode and cve_2026_98432_unit_file is changed) diff --git a/roles/hardening/tasks/cve_2026_98432.yml b/roles/hardening/tasks/cve_2026_98432.yml index 861e8e1..5e9b851 100644 --- a/roles/hardening/tasks/cve_2026_98432.yml +++ b/roles/hardening/tasks/cve_2026_98432.yml @@ -36,8 +36,8 @@ dest: "{{ cve_2026_98432_ruleset }}" mode: "0644" validate: /usr/sbin/nft -c -f %s + register: cve_2026_98432_ruleset_file when: cve_2026_98432_mode != 'off' - notify: Reload the CVE-2026-98432 filter - name: Install the CVE-2026-98432 filter unit ansible.builtin.template: @@ -46,7 +46,6 @@ mode: "0644" register: cve_2026_98432_unit_file when: cve_2026_98432_mode != 'off' - notify: Reload the CVE-2026-98432 filter # A dry run on a box without the unit cannot ask systemd about it: the file # above was only diffed, not written. @@ -60,10 +59,56 @@ - cve_2026_98432_mode != 'off' - not (ansible_check_mode and cve_2026_98432_unit_file is changed) -- name: Load the CVE-2026-98432 ruleset now - ansible.builtin.meta: flush_handlers +# Whether to reload is decided from what the kernel holds, not from whether a +# file changed on this run: a run that wrote the file and then died before +# loading it would otherwise leave the old mode live, and every later run, +# finding the file already right, would leave it there. +# +# The file is loaded into a throwaway network namespace and listed, so the +# comparison is between two listings in nft's own format. Counters are +# blanked: they are the one part that differs between equal tables. +- name: List the CVE-2026-98432 ruleset on disk as nft would load it + ansible.builtin.command: + argv: + - unshare + - --net + - sh + - -c + - nft -f "$1" && nft list table inet cve_2026_98432 + - sh + - "{{ cve_2026_98432_ruleset }}" + register: cve_2026_98432_desired + changed_when: false + check_mode: false + when: + - cve_2026_98432_mode != 'off' + - not (ansible_check_mode and cve_2026_98432_ruleset_file is changed) + +- name: Read the live CVE-2026-98432 table + ansible.builtin.command: + argv: [nft, list, table, inet, cve_2026_98432] + register: cve_2026_98432_live + changed_when: false + failed_when: false + check_mode: false + when: cve_2026_98432_mode != 'off' + +# A reload re-runs `nft -f`, which replaces the table in one transaction. It +# zeroes the counters, so it only runs when the live table is wrong. +- name: Reload the CVE-2026-98432 filter when the live table is not the ruleset + ansible.builtin.systemd: + name: cve-2026-98432-filter + state: reloaded + daemon_reload: true + when: + - cve_2026_98432_mode != 'off' + - not (ansible_check_mode and cve_2026_98432_unit_file is changed) + - >- + cve_2026_98432_ruleset_file is changed + or cve_2026_98432_live.rc != 0 + or (cve_2026_98432_live.stdout | regex_replace('counter packets [0-9]+ bytes [0-9]+', 'counter')) + != (cve_2026_98432_desired.stdout | regex_replace('counter packets [0-9]+ bytes [0-9]+', 'counter')) -# Read the live table, not the file: this is what the kernel is doing. - name: Read the loaded CVE-2026-98432 table ansible.builtin.command: argv: [nft, list, table, inet, cve_2026_98432] @@ -73,17 +118,23 @@ - cve_2026_98432_mode != 'off' - not ansible_check_mode -- name: Check the loaded table is in the configured mode +# The listing check catches a load that did not happen; the mode check catches +# a template that renders the wrong rules for the mode. +- name: Check the loaded table is the ruleset, in the configured mode vars: rules: "{{ cve_2026_98432_table.stdout_lines | select('search', 'comment \"cve-2026-98432') | list }}" + enforce: "{{ cve_2026_98432_mode == 'enforce' }}" ansible.builtin.assert: that: - - rules | length == 2 - - (rules | select('search', ' drop ') | list | length == 1) == (cve_2026_98432_mode == 'enforce') - - (rules | select('search', 'reset tcp option') | list | length == 1) == (cve_2026_98432_mode == 'enforce') + - >- + (cve_2026_98432_table.stdout | regex_replace('counter packets [0-9]+ bytes [0-9]+', 'counter')) + == (cve_2026_98432_desired.stdout | regex_replace('counter packets [0-9]+ bytes [0-9]+', 'counter')) + - rules | length == (3 if enforce | bool else 2) + - (rules | select('search', ' drop ') | list | length) == (2 if enforce | bool else 0) + - (rules | select('search', 'reset tcp option mptcp') | list | length) == (1 if enforce | bool else 0) fail_msg: >- - {{ inventory_hostname }}: the live cve_2026_98432 table does not match - mode {{ cve_2026_98432_mode }}: {{ rules }} + {{ inventory_hostname }}: the live cve_2026_98432 table is not the + ruleset on disk in mode {{ cve_2026_98432_mode }}: {{ rules }} quiet: true when: - cve_2026_98432_mode != 'off' diff --git a/roles/hardening/templates/cve-2026-98432.nft.j2 b/roles/hardening/templates/cve-2026-98432.nft.j2 index 09e4f94..263cc6c 100644 --- a/roles/hardening/templates/cve-2026-98432.nft.j2 +++ b/roles/hardening/templates/cve-2026-98432.nft.j2 @@ -11,8 +11,10 @@ table inet cve_2026_98432 delete table inet cve_2026_98432 table inet cve_2026_98432 { - # priority raw: before conntrack and before kube-proxy's DNAT, so this - # covers the host, hostNetwork pods and every other pod alike. + # priority raw: after defragmentation, before conntrack and kube-proxy's + # DNAT. Traffic from outside meets it before it is delivered to the host, + # a hostNetwork pod, or forwarded to a pod; pod-to-pod traffic on cni0 + # meets it too, since k3s sets bridge-nf-call-iptables. chain pre { type filter hook prerouting priority raw; policy accept; @@ -24,5 +26,13 @@ table inet cve_2026_98432 { # SYNs only, so MPTCP connections already open when this loads are left # alone rather than broken mid-flow. tcp flags & syn == syn tcp option mptcp exists counter {{ 'reset tcp option mptcp ' if cve_2026_98432_mode == 'enforce' }}comment "cve-2026-98432 mptcp" +{% if cve_2026_98432_mode == 'enforce' %} + + # `reset tcp option` blanks only the FIRST matching option + # (nft_exthdr_tcp_strip_eval returns after one), so a SYN built with two + # would keep the second. A client sends one; anything still carrying + # one here was built to get past the rule above. + tcp flags & syn == syn tcp option mptcp exists counter drop comment "cve-2026-98432 mptcp repeated" +{% endif %} } }