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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
116 changes: 116 additions & 0 deletions modules/base.nix
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"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 = {
Expand Down Expand Up @@ -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 <mod> false` rather than boot.blacklistedKernelModules: the
# latter emits nothing but `blacklist <name>` 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;

Expand Down
5 changes: 4 additions & 1 deletion scripts/changelog.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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") ;;
Expand All @@ -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[@]}"}
Expand Down