diff --git a/README.md b/README.md index 8377b5e..e9e739d 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 `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 + 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..a8f8d04 100644 --- a/modules/base.nix +++ b/modules/base.nix @@ -1,6 +1,84 @@ { 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 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" + ]; + + # 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 = { @@ -92,6 +170,44 @@ in boot.kernelParams = [ "console=hvc0" ]; + # 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 + # 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; 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[@]}"}