From 3b6d5af08e63fa6d46b39f46af300a500c8ba4eb Mon Sep 17 00:00:00 2001 From: NRGLine4sec Date: Thu, 20 Aug 2026 11:01:55 +0000 Subject: [PATCH] feat: block on-demand autoload of tc classifiers and actions Unprivileged user namespaces stay enabled in the guest, so an unprivileged user there holds namespaced CAP_NET_ADMIN and can reach net/sched. The kernel faults these modules in on first use via request_module(), which lets a guest that never legitimately touches tc load a classifier or action and attack it. Refusing the load closes that route as a category instead of one CVE at a time. Uses `install /bin/false` rather than boot.blacklistedKernelModules, which emits only `blacklist ` lines: those suppress alias-based loading but not a request by real name, and cls_api.c / act_api.c ask through request_module("cls_%s") / ("act_%s") with the literal name. Only modules that still exist upstream are listed. cls_tcindex, cls_rsvp and cls_route were retired from the kernel in 6.3 and later, so entries for them would be inert here. Qdiscs are left loadable, and nothing in the default CNI chain (bridge + portmap + firewall) uses tc, so this is inert for ENABLE_CRI as shipped. Unlike security.lockKernelModules, it does not set kernel.modules_disabled and so does not block the on-demand loads CNI itself needs. Adding the bandwidth plugin requires dropping act_mirred and cls_u32, documented in the list and in the README. --- README.md | 7 ++++- modules/base.nix | 68 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ca55e1c..4e6fbb2 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,12 @@ 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 tc classifiers and actions (`cls_*` and `act_*`, + via `install ... /bin/false`), which removes the most travelled route into + `net/sched` without the `lockKernelModules` conflict. Qdiscs stay loadable. + If you add the CNI `bandwidth` plugin to the chain, drop `act_mirred` and + `cls_u32` from `blockedTcModules` in `modules/base.nix`. - **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..3a4e50e 100644 --- a/modules/base.nix +++ b/modules/base.nix @@ -1,6 +1,43 @@ { 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, cls_rsvp + # and cls_route were retired from the kernel (6.3 and later), so entries for + # them would be inert on any kernel this runs on. + # + # Qdiscs (sch_*) are deliberately absent, and so is ifb. + blockedTcModules = [ + "cls_u32" + "cls_fw" + "cls_basic" + "cls_flow" + "cls_cgroup" + "cls_flower" + "cls_matchall" + "cls_bpf" + "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" + ]; in { options.claude-vm.agent = { @@ -92,6 +129,37 @@ 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 /bin/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. + # + # 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} /bin/false\n") blockedTcModules; + services.getty.autologinUser = "agent"; systemd.services."getty@tty1".enable = false;