diff --git a/README.md b/README.md index aa44680..a72bfcf 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,42 @@ 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. 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 +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/tasks/cve_2026_98432.yml b/roles/hardening/tasks/cve_2026_98432.yml new file mode 100644 index 0000000..5e9b851 --- /dev/null +++ b/roles/hardening/tasks/cve_2026_98432.yml @@ -0,0 +1,173 @@ +--- +# 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 + register: cve_2026_98432_ruleset_file + when: cve_2026_98432_mode != 'off' + +- 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' + +# 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) + +# 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')) + +- 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 + +# 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: + - >- + (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 is not the + ruleset on disk in 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..263cc6c --- /dev/null +++ b/roles/hardening/templates/cve-2026-98432.nft.j2 @@ -0,0 +1,38 @@ +#!/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: 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; + + # 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" +{% 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 %} + } +}