Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ip>`. 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
Expand Down
14 changes: 14 additions & 0 deletions group_vars/ipfs_nodes/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
173 changes: 173 additions & 0 deletions roles/hardening/tasks/cve_2026_98432.yml
Original file line number Diff line number Diff line change
@@ -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'
8 changes: 8 additions & 0 deletions roles/hardening/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <box>.
- 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
# ---------------------------------------------------------------------------
Expand Down
22 changes: 22 additions & 0 deletions roles/hardening/templates/cve-2026-98432-filter.service.j2
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions roles/hardening/templates/cve-2026-98432.nft.j2
Original file line number Diff line number Diff line change
@@ -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 %}
}
}
Loading