Skip to content

Block the CVE-2026-98432 trigger at the host firewall - #19

Merged
TakGN merged 2 commits into
mainfrom
cve-2026-98432-filter
Oct 30, 2026
Merged

TakGN merged 2 commits into
mainfrom
cve-2026-98432-filter

Conversation

@TakGN

@TakGN TakGN commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Until the boxes run a kernel with the fix, a dedicated nft table, inet cve_2026_98432, drops IPv4 TCP that carries IP options, which is the bug's trigger. It also strips the MPTCP option from SYNs. It hooks prerouting at raw priority, ahead of conntrack and kube-proxy, so pods are covered as well as the host.

This PR only counts. cve_2026_98432_mode: count matches and counts but drops nothing, so merging it changes no traffic. enforce comes in follow-up PRs: one box first through host_vars, then the group default.

Why it can't disrupt anything

  • Nothing restarts. Loading the table is one atomic nft transaction that touches no other table. k3s, ufw, fail2ban and the workloads keep running, so there's no needle warm-up.
  • No nftables.service. Ubuntu's /etc/nftables.conf starts with flush ruleset, which would wipe the rules k3s, ufw and fail2ban keep in nf_tables. A oneshot unit of its own, cve-2026-98432-filter.service, loads only this table, early in boot.
  • Rules are checked before they load. The template is validated on the box with nft -c, which hands the transaction to the kernel and aborts it.
  • The live table decides the reload. The role loads the file into a throwaway network namespace, lists it, and reloads only when the live table is missing or differs, ignoring counters. So a run interrupted between writing the file and loading it is fixed by the next run, and an unchanged table keeps its counters. The same comparison is asserted afterwards.
  • No second MPTCP option gets through. reset tcp option strips only the first match, so in enforce mode a SYN that still carries an MPTCP option after the strip is dropped.
  • MPTCP is stripped on SYNs only. MPTCP connections already open when it loads are left alone, not broken mid-flow.
  • Undo is instant. sudo systemctl stop cve-2026-98432-filter deletes the table; off removes the unit and files.

Tested

  • A packet-level regression test on sing-1's kernel, inside a throwaway network namespace. A raw socket sends crafted SYNs over loopback through the ruleset, and a counter at the input hook records what arrives.
    • count: all four SYNs arrive.
    • enforce: the plain SYN arrives, a SYN with one MPTCP option arrives with it stripped, and SYNs with two MPTCP options or with IP options are dropped.
    • The PR's first version let the two-option SYN through with MPTCP intact, which confirms the review's finding.
  • The reload decision, run on real listings: a live count table with non-zero counters compared with the count file gives no reload; compared with the enforce file it gives a reload.
  • Both modes, count and enforce, loaded twice and listed on sing-1 inside a throwaway network namespace (unshare -n). The host's own ruleset was untouched.
  • site.yml --check --diff, limited to the new tag, on all three boxes. Each box shows 3 changes and no failures.
  • A full site.yml --check on sing-1. On the box, the new files are the only change.
  • --syntax-check passes for every playbook, and yamllint is clean on the changed files.
  • ansible-lint shows no new findings. Locally it reports the same 7 collection-path errors with and without this change.

Rollout

  1. Merge, then approve the site.yml run. Leave it counting for about an hour and read the counters: sudo nft list table inet cve_2026_98432.
  2. Follow-up PR: cve_2026_98432_mode: enforce in host_vars/sing-1. Prove it from outside with sudo nping --tcp -p 22 --ip-options R 5.199.165.77: a SYN-ACK before, none after, and the drop counter goes up. Don't use 443; only Cloudflare can reach it.
  3. Follow-up PR: enforce as the group default, for lith-1 and chic-1.
  4. Once Ubuntu ships the fixed kernel: a reboot rollout, one box at a time.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@TakGN

TakGN commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator Author

Review of c8d91b2

I recommend changes before enforcement. I reviewed the actual diff and surrounding hardening, k3s, networking, and CI configuration. Two actionable issues stood out.

[P2] Recover live rules when a previous reload was interrupted

Location: roles/hardening/tasks/cve_2026_98432.yml:53–64

If a deployment writes the new ruleset but stops before the reload handler completes, the next deployment cannot converge:

  • Both templates are already correct, so neither notifies the handler.
  • state: started leaves the active oneshot service unchanged.
  • The live-table assertion fails, leaving the previous mode running.

An interrupted count → enforce transition therefore remains counting; an interrupted enforce → count rollback continues dropping traffic. Repeating the playbook does not repair either state. These behaviors follow Ansible’s documented change-triggered handlers and idempotent started operation.

Fix: Read and compare live state before deciding whether to reload. Reload when the table is missing or differs from the desired configuration, then assert the result. Add an interrupted-transition recovery test.

[P2] Multiple MPTCP options bypass the stripping rule

Location: roles/hardening/templates/cve-2026-98432.nft.j2:26

reset tcp option mptcp removes only the first matching option. An attacker can include two MPTCP options in one SYN; the second survives and reaches the TCP stack. The kernel’s nft_exthdr_tcp_strip_eval() explicitly returns after stripping the first match.

Reproduced: With enforcement enabled, a downstream input-hook counter saw zero MPTCP-bearing packets after a single-option SYN, then one after a two-option SYN.

This defeats the advertised MPTCP-disabling layer. It does not bypass the separate IPv4 IP-options drop rule, and I did not establish an MPTCP handshake in this test.

Fix: Ensure no MPTCP option remains—for example, drop a packet if another matching option remains after stripping—and add a duplicate-option regression test.

Security assumptions and validation

  • IPv4/IPv6: The IPv4 IHL match addresses incoming TCP packets carrying IP options. The kernel CNA describes this CVE’s ownership/freeing defect in IPv4; it identifies the related IPv6 CALIPSO migration issue separately. Missing a blanket IPv6-options drop is therefore not itself a finding. The CNA recommends a fixed kernel as remediation. Kernel advisory
  • Hook ordering: raw priority is -300, after conntrack defragmentation (-400) and before conntrack (-200) and destination NAT (-100). That is appropriate for inbound host traffic and routed pod traffic. The template’s broader “every other pod” claim still requires checking actual bridge/network-namespace paths; the reported namespace loading test does not establish that coverage. Netfilter hook documentation
  • Existing firewall rules: The file replaces only its dedicated table. Its accept policy does not override filtering in later base chains. Atomic loading through nft -f is supported by nftables, and repeated-load tests preserved an unrelated table. Atomic replacement documentation

Validation performed: site.yml syntax check passed. In a disposable Ubuntu 24.04 container using nftables 1.0.9 on kernel 6.10.14-linuxkit, both modes validated and loaded twice. Ordinary IPv4 TCP received SYN-ACKs in both modes; TCP SYNs with record-route IP options received SYN-ACKs in count mode and none in enforcement mode. These were isolated packet tests, not tests on the fleet’s kernel or a live k3s cluster.

Rollout assessment: Count-first and single-host enforcement are sensible, but the current tests do not establish reboot persistence, systemd failure recovery, rollback convergence, or complete CNI coverage. The external SSH probe establishes one packet path only. Before enforcement, add tests for the two findings above and exercise reboot/rollback plus representative pod paths on the canary. Production hosts were not modified.

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 <noreply@anthropic.com>
@TakGN

TakGN commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Both findings were real; I reproduced each and fixed them in a657246.

1. Multiple MPTCP options bypass the stripping rule

I confirmed it in the fleet's kernel: nft_exthdr_tcp_strip_eval in v6.17 still returns after blanking the first match. With the PR's original ruleset in enforce mode, a SYN carrying two MPTCP options reached the input hook with MPTCP still in it.

Fix: in enforce mode only, a third rule runs after the strip, tcp flags & syn == syn tcp option mptcp exists counter drop. A real client sends one MP_CAPABLE, so only crafted SYNs reach this rule. Count mode keeps two rules, so its counters still describe what enforce would do.

2. Live rules don't recover from an interrupted reload

Fix: the handler is gone, and the reload is decided from what the kernel holds:

  • the ruleset file is loaded into a throwaway network namespace (unshare --net) and listed;
  • the live table is listed, and a missing table counts as a mismatch;
  • the role reloads when the two listings differ, with counters blanked, or when the file changed on this run;
  • afterwards it asserts the live listing equals the desired one, and that the rules match the mode.

An interrupted count → enforce, or enforce → count, is now put right by the next run. A run that finds the table already correct doesn't reload, so the counters survive.

Tests (sing-1, kernel 6.17.0-23, nft 1.0.9; throwaway namespaces only, host ruleset untouched)

Packet test: a raw socket sends crafted IPv4 SYNs over loopback through the ruleset, and a counter table at the input hook records what arrives.

SYN count enforce, original PR enforce, now
plain arrives arrives arrives
one MPTCP option arrives, MPTCP intact arrives, stripped arrives, stripped
two MPTCP options arrives, MPTCP intact arrives, MPTCP intact dropped
IPv4 options (IHL 6) arrives dropped dropped

Reload decision: run on real listings with the role's exact expression. A live count table with non-zero counters, compared with the count file, gives no reload. Compared with the enforce file, it gives a reload.

Also:

  • site.yml --check --diff --tags cve_2026_98432 is clean on all three boxes (3 changes each).
  • systemd-analyze verify passes on the unit with no warnings.
  • yamllint and ansible-lint report nothing new.

Your other points

  • Pod coverage: k3s sets net.bridge.bridge-nf-call-iptables = 1 on the boxes, so bridged pod-to-pod traffic on cni0 also passes the prerouting hook. Traffic from outside reaches the host's prerouting before it is delivered or forwarded. I made the template comment more precise.

  • Reboot, rollback and real pod paths can only be tested once the rules are applied. Proposed checks on sing-1 as the enforce canary:

    • load the wrong mode by hand and rerun the tag; it should switch back;
    • run systemctl stop cve-2026-98432-filter; the table should be gone;
    • try a pod port with the nping probe.

    I'd leave a dedicated reboot test until the kernel patch reboot, rather than paying an extra needle warm-up only to test boot ordering.

The next steps are unchanged: merge in count mode, then enforce on sing-1 through host_vars.

@TakGN
TakGN merged commit 7f578c4 into main Oct 30, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant