From f6296e73ede7a49e5d9f8eb73ea3d5bf1e67e35d Mon Sep 17 00:00:00 2001 From: alex <1331279+systemstart@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:52:07 +0000 Subject: [PATCH 1/3] harden: block on-demand autoload of tc classifiers and actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unprivileged user namespaces stay enabled in the guest, so an unprivileged user holds namespaced CAP_NET_ADMIN and can reach net/sched. The kernel faults these modules in on first use via request_module(), so a guest that never legitimately touches tc can still load a classifier or action and attack it. Refusing the load closes the route as a category rather than one CVE at a time. Uses `install false` rather than boot.blacklistedKernelModules: blacklist lines suppress alias-based loading but not a request by real name, and cls_api.c / act_api.c ask via request_module("cls_%s") / ("act_%s") with the literal name. The install command is an absolute store path, not /bin/false: modprobe runs it through /bin/sh -c and the guest's /bin holds only sh, so /bin/false would exit 127 "command not found" — refusing the load by accident rather than by design, and logging a misleading error each time. cls_route is included. It survived the 6.3-era cull that retired cls_tcindex and cls_rsvp, and is still built and modular (verified against /run/booted-system/kernel-modules/.../net/sched on 6.18.45). Supersedes #16. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 10 +++++- modules/base.nix | 85 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8377b5e..99ff111 100644 --- a/README.md +++ b/README.md @@ -240,7 +240,15 @@ Three things worth knowing before pointing this at code you don't trust: is the security boundary here, not the guest's own user separation. Disabling them (`security.allowUserNamespaces = false`) would close it but breaks the Nix sandbox inside the guest, and `security.lockKernelModules` conflicts with - `ENABLE_CRI`, so neither is on by default. + `ENABLE_CRI`, so neither is on by default. What the guest does do is refuse the + on-demand autoload of the classifiers and actions listed in + `blockedTcModules` in `modules/base.nix`, which removes the most travelled + route into `net/sched` without the `lockKernelModules` conflict. This is + guest-internal defence in depth: `modprobe.d` constrains modprobe-mediated + loads, not a direct `finit_module` from something already privileged in the + guest. It narrows the path to guest root; it does not close it, and the VM + remains the security boundary. If you add the CNI `bandwidth` plugin to the + chain, drop `act_mirred` and `cls_u32` from the list. - **On a single-user Nix install, the read-only store share is the only thing protecting the host store.** The `ro-store` share is exported `readOnly`, and on NixOS or a multi-user install the host store is additionally not writable by the diff --git a/modules/base.nix b/modules/base.nix index 4e65a50..0402763 100644 --- a/modules/base.nix +++ b/modules/base.nix @@ -1,6 +1,54 @@ { pkgs, lib, config, ... }: let cfg = config.claude-vm.agent; + + # tc classifiers and actions, blocked from on-demand autoload below. + # + # Only modules that still exist upstream are listed. cls_tcindex and cls_rsvp + # were retired from the kernel (6.3 and later), so entries for them would be + # inert. cls_route survived that cull — it is still built and still modular + # (verified against /run/booted-system/kernel-modules/…/net/sched on 6.18.45), + # so it belongs on the list. + # + # em_* (ematch) and act_meta_* are deliberately absent, but not because + # entries would be inert: both are alias-loaded (ematch-kind-N, ife-meta-*) + # and modprobe applies `install` after alias resolution, so listing them would + # take effect. They are omitted because they are only reachable through + # cls_basic / cls_flow and act_ife, which are blocked here already. If any of + # those three ever comes off the list, add the matching em_* / act_meta_* + # entries. + blockedTcFilters = [ + "cls_u32" + "cls_fw" + "cls_basic" + "cls_flow" + "cls_cgroup" + "cls_flower" + "cls_matchall" + "cls_bpf" + "cls_route" + "act_pedit" + "act_mirred" + "act_police" + "act_gact" + "act_bpf" + "act_connmark" + "act_csum" + "act_ct" + "act_ctinfo" + "act_ife" + "act_mpls" + "act_nat" + "act_sample" + "act_simple" + "act_skbedit" + "act_skbmod" + "act_tunnel_key" + "act_vlan" + "act_gate" + ]; + + blockedTcModules = blockedTcFilters; in { options.claude-vm.agent = { @@ -92,6 +140,43 @@ in boot.kernelParams = [ "console=hvc0" ]; + # Block on-demand autoload of tc classifiers and actions. + # + # Unprivileged user namespaces stay enabled (see the hardening notes in the + # README), so an unprivileged guest user holds namespaced CAP_NET_ADMIN and + # can reach net/sched. Loading is what makes that reach useful: the kernel + # pulls these in on first use via request_module(), so a guest that never + # legitimately touches tc can still fault in a classifier or action and + # attack it. Refusing the load closes the route as a category rather than + # one CVE at a time. + # + # `install false` rather than boot.blacklistedKernelModules: the + # latter emits nothing but `blacklist ` lines, which suppress + # alias-based loading but not a request by real name. cls_api.c and + # act_api.c ask through request_module("cls_%s") / ("act_%s") with the + # literal name, which a blacklist line does not stop. Please don't + # "simplify" this back. + # + # The command must be an absolute store path, not /bin/false: modprobe runs + # it through /bin/sh -c, and the guest's /bin holds exactly one entry (sh). + # /bin/false would exit 127 "command not found" -- the load is still refused, + # but by accident rather than by design, and it logs a misleading error on + # every attempt. + # + # Nothing in the default CNI chain (bridge + portmap + firewall) uses tc, + # so this is inert for ENABLE_CRI as shipped. It is compatible with + # container runtimes in a way security.lockKernelModules is not, since that + # sets kernel.modules_disabled=1 and blocks the on-demand loads CNI does + # need. + # + # If you add the `bandwidth` plugin to the chain, drop act_mirred and + # cls_u32 from the list: its egressRate path attaches a u32 filter carrying + # a mirred TCA_EGRESS_REDIR action to redirect into an ifb device (see + # CreateEgressQdisc in plugins/meta/bandwidth/ifb_creator.go upstream). Its + # ingressRate path only needs sch_tbf and is unaffected. + boot.extraModprobeConfig = + lib.concatMapStrings (m: "install ${m} ${pkgs.coreutils}/bin/false\n") blockedTcModules; + services.getty.autologinUser = "agent"; systemd.services."getty@tty1".enable = false; From 27a9b759b7e80e5efe942b455c33b5fe3503456f Mon Sep 17 00:00:00 2001 From: alex <1331279+systemstart@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:52:07 +0000 Subject: [PATCH 2/3] harden: block on-demand autoload of exotic qdiscs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocking 28 classifiers and actions while leaving ~30 sch_* faultable left the larger half of net/sched open. The qdisc side has been at least as productive for local privilege escalation as the action side — sch_qfq alone accounts for CVE-2023-4921 and CVE-2023-31436. Adds the nine exotic qdiscs and leaves the three that are actually used: sch_tbf and sch_ingress for the CNI bandwidth plugin's ingressRate path, and sch_fq_codel, which net.core.default_qdisc loads on every boot and is the only sched module live on a default guest. All nine verified present and modular on 6.18.45. Generated modprobe.d now carries 37 install lines and none of the keep-list. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- modules/base.nix | 37 ++++++++++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 99ff111..e9e739d 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,7 @@ Three things worth knowing before pointing this at code you don't trust: them (`security.allowUserNamespaces = false`) would close it but breaks the Nix sandbox inside the guest, and `security.lockKernelModules` conflicts with `ENABLE_CRI`, so neither is on by default. What the guest does do is refuse the - on-demand autoload of the classifiers and actions listed in + on-demand autoload of the `net/sched` modules listed in `blockedTcModules` in `modules/base.nix`, which removes the most travelled route into `net/sched` without the `lockKernelModules` conflict. This is guest-internal defence in depth: `modprobe.d` constrains modprobe-mediated diff --git a/modules/base.nix b/modules/base.nix index 0402763..a8f8d04 100644 --- a/modules/base.nix +++ b/modules/base.nix @@ -15,7 +15,7 @@ let # and modprobe applies `install` after alias resolution, so listing them would # take effect. They are omitted because they are only reachable through # cls_basic / cls_flow and act_ife, which are blocked here already. If any of - # those three ever comes off the list, add the matching em_* / act_meta_* + # those ever comes off the list, add the matching em_* / act_meta_* # entries. blockedTcFilters = [ "cls_u32" @@ -48,7 +48,37 @@ let "act_gate" ]; - blockedTcModules = blockedTcFilters; + # Queueing disciplines, same treatment. Blocking the filters while leaving + # every sch_* faultable would leave the larger half of net/sched open, and the + # qdisc side has been at least as productive for local privilege escalation + # as the action side — sch_qfq alone accounts for CVE-2023-4921 and + # CVE-2023-31436. + # + # Only the exotic ones are listed. These are deliberately left loadable + # because they are actually used: + # + # sch_tbf — the CNI `bandwidth` plugin's ingressRate path + # sch_ingress — also provides clsact, which container networking + # and any tc-BPF attachment needs; the stronger + # reason it must stay loadable + # sch_fq_codel — net.core.default_qdisc, loaded on every boot and + # the only sched module live on a default guest + # + # All of them were present and modular on 6.18.45; none is reachable in + # normal use of this VM. + blockedTcQdiscs = [ + "sch_qfq" + "sch_choke" + "sch_teql" + "sch_dualpi2" + "sch_cbs" + "sch_taprio" + "sch_etf" + "sch_plug" + "sch_skbprio" + ]; + + blockedTcModules = blockedTcFilters ++ blockedTcQdiscs; in { options.claude-vm.agent = { @@ -140,7 +170,8 @@ in boot.kernelParams = [ "console=hvc0" ]; - # Block on-demand autoload of tc classifiers and actions. + # Block on-demand autoload of tc classifiers, actions and the exotic + # queueing disciplines. # # Unprivileged user namespaces stay enabled (see the hardening notes in the # README), so an unprivileged guest user holds namespaced CAP_NET_ADMIN and From f84adfe70d981a72ccddca7e562f1e92be1c4fa8 Mon Sep 17 00:00:00 2001 From: alex <1331279+systemstart@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:12:14 +0000 Subject: [PATCH 3/3] ci: file hardening commits under a Security changelog section harden: subjects fell through to "Other", which undersells a security change in release notes. Track 2 will produce a stream of them, so give them a home rather than renaming each one to fix(security):. Accepts harden, sec and security as types, and places the section directly after breaking changes. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/changelog.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/changelog.sh b/scripts/changelog.sh index 1e3b954..a6e0286 100755 --- a/scripts/changelog.sh +++ b/scripts/changelog.sh @@ -18,7 +18,7 @@ if [ -z "$range" ]; then range="${prev:+$prev..}HEAD" fi -breaking=() feats=() fixes=() docs=() other=() +breaking=() security=() feats=() fixes=() docs=() other=() # Records are \x1e-separated and fields \x1f-separated: commit bodies are # multi-line, so a plain line-oriented read would split them across records. @@ -42,6 +42,8 @@ while IFS= read -r -d $'\x1e' record; do breaking+=("$entry") else case "$type" in + harden|sec|security) + security+=("$entry") ;; feat) feats+=("$entry") ;; fix) fixes+=("$entry") ;; docs) docs+=("$entry") ;; @@ -59,6 +61,7 @@ section() { } section "Breaking changes" ${breaking+"${breaking[@]}"} +section "Security" ${security+"${security[@]}"} section "Features" ${feats+"${feats[@]}"} section "Fixes" ${fixes+"${fixes[@]}"} section "Documentation" ${docs+"${docs[@]}"}