From 88aa1619573e799349e57dd961183ec8ae1cecc4 Mon Sep 17 00:00:00 2001 From: DevomB Date: Wed, 7 Oct 2026 08:48:23 -0700 Subject: [PATCH 1/3] dhcpcd in the net zone separates its privileges: its parsers run as a user of their own in an empty root, and a hook that trusts nothing writes the zone's resolv.conf dhcpcd parsed every lease, DHCPv6 reply and router advertisement as the net zone's root, with that zone's capabilities, because the zone denied what its privilege separation needs. Now the net zone's policy keeps CAP_SETUID, CAP_SETGID and CAP_SYS_CHROOT (caps::PRIVSEP: kept together, by the nic zone alone), and those open setuid, setgid, setgroups and chroot in its filter (seccomp::CAP_CALLS) while every other zone's filter stays as it was. Its user namespace maps a third id, 100, to uid_base + 100 and allows setgroups, its passwd and group name dhcpcd there, and /var/empty is made on the root before it is sealed. dhcpcd's own seccomp filter then confines the parsers. The helper that stays root runs the hook with the environment the parsers send, so netzone-init.sh starts dhcpcd with Kryptik's hook instead of dhcpcd-run-hooks: it checks every value for form, keeps its state in /run/lease-dns, and writes only nameserver lines. dhcpcd -x leaves cleanup: the zone's root cannot signal the dhcpcd user, and the zone's end ends dhcpcd. The design doc said the opposite: that three capabilities for the hostile zone were a net loss with the zone as the sandbox. It now says what the helper still does for the parsers and what a parser bug no longer reaches. --- build/guest-tests/zones-check.sh | 25 ++++ build/recipes/netzone.sh | 3 + compartments/kryptikd/src/caps.rs | 19 ++- compartments/kryptikd/src/caps/tests.rs | 15 +++ compartments/kryptikd/src/isolate.rs | 19 ++- compartments/kryptikd/src/main.rs | 4 +- compartments/kryptikd/src/policy.rs | 17 ++- compartments/kryptikd/src/policy/tests.rs | 22 +++- compartments/kryptikd/src/rootfs.rs | 45 +++++-- compartments/kryptikd/src/rootfs/tests.rs | 9 +- compartments/kryptikd/src/seccomp.rs | 34 +++++- compartments/kryptikd/src/seccomp/tests.rs | 25 ++++ compartments/kryptikd/src/spawn.rs | 15 ++- compartments/zones/net.toml | 3 +- compartments/zones/policy/net.seccomp | 5 + docs/design/net-zone.md | 34 ++++-- docs/design/zone-policy-files.md | 18 ++- docs/status.md | 2 - tools/net/dhcpcd-hook.py | 133 +++++++++++++++++++++ tools/net/netzone-init.sh | 9 +- tools/tests/netzone-hook.sh | 73 +++++++++++ 21 files changed, 469 insertions(+), 60 deletions(-) create mode 100755 tools/net/dhcpcd-hook.py create mode 100755 tools/tests/netzone-hook.sh diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index bed74da4..615e3692 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -47,6 +47,31 @@ done if [[ "$ready" == *"nat=yes"* ]]; then pass "net-ready" "$ready"; else fail "net-ready" "no READY line with nat=yes in the catch-all log (last: $(grep -h 'netzone:' /run/uncaught-logs/current 2>/dev/null | tail -1))"; fi # The routed zones' resolver, named on its own: routed-dns only times out. if [[ "$ready" == *" dns=yes "* ]]; then pass "net-dns" "dnsmasq is running"; else fail "net-dns" "$(grep -h 'dnsmasq' /run/uncaught-logs/current 2>/dev/null | tail -2 | tr '\n' ' ')"; fi +# dhcpcd's privilege separation: what parses a lease runs as the net zone's +# dhcpcd user (host uid_base + 100) in an empty root, with no capability and +# dhcpcd's own seccomp filter over the zone's, while a helper stays its root; +# and the lease, which that helper writes, arrived. +net_base="$(sed -n 's/^uid_base *= *\([0-9]*\).*/\1/p' "$Z/net.toml")" +nz_init="$(cut -d' ' -f1 /run/kryptik/zones/net/init.pid 2>/dev/null)" +separated=0; helpers=0; seen="" +for p in $(pgrep -x dhcpcd); do + uid="$(awk '/^Uid:/ { print $2 }' "/proc/$p/status" 2>/dev/null)" + caps="$(awk '/^CapEff:/ { print $2 }' "/proc/$p/status" 2>/dev/null)" + filters="$(awk '/^Seccomp_filters:/ { print $2 }' "/proc/$p/status" 2>/dev/null)" + # 2>&1: a root that cannot be listed is not an empty one. + inside="$(ls -A "/proc/$p/root" 2>&1 | head -3 | tr '\n' ',')" + seen="${seen} ${p}:uid=${uid},caps=${caps},filters=${filters},root=$(readlink "/proc/$p/root" 2>/dev/null)[${inside}]" + if [[ "$uid" == "$((net_base + 100))" && "$caps" == 0000000000000000 && "${filters:-0}" -ge 2 && -z "$inside" ]]; then + separated=$((separated + 1)) + fi + [[ "$uid" == "$net_base" ]] && helpers=$((helpers + 1)) +done +leased="$(nsenter -t "${nz_init:-0}" -m sh -c 'ls /var/lib/dhcpcd/*.lease 2>/dev/null' | head -1)" +if [[ "$separated" -ge 1 && "$helpers" -ge 1 && -n "$leased" ]]; then + pass "dhcpcd-separated" "${separated} dhcpcd process(es) as uid $((net_base + 100)) in an empty root with no capability under two filters, ${helpers} root helper, lease ${leased}" +else + fail "dhcpcd-separated" "separated ${separated}, helpers ${helpers}, lease ${leased:-none}:${seen:- no dhcpcd running}" +fi if ip link show eth0 >/dev/null 2>&1; then fail "zone0-nic" "eth0 is still in zone 0"; else pass "zone0-nic" "eth0 is not in zone 0 (moved into the net zone)"; fi if [[ -z "$(ip route show default 2>/dev/null)" ]]; then pass "zone0-no-route" "zone 0 has no default route"; else fail "zone0-no-route" "$(ip route show default)"; fi if ping -c1 -W2 10.0.2.2 >/dev/null 2>&1; then fail "zone0-offline" "zone 0 reached the VM gateway"; else pass "zone0-offline" "zone 0 cannot reach the VM gateway"; fi diff --git a/build/recipes/netzone.sh b/build/recipes/netzone.sh index e3232d7a..ef320a78 100644 --- a/build/recipes/netzone.sh +++ b/build/recipes/netzone.sh @@ -12,6 +12,9 @@ s_netzone() { python3 -m py_compile /usr/libexec/kryptik/sntp-offset.py || { echo "sntp-offset.py does not compile under the target python"; return 1; } install -D -m 0755 "${KRYPTIK_ROOT}/tools/net/update-fetch.py" /usr/libexec/kryptik/update-fetch.py python3 -m py_compile /usr/libexec/kryptik/update-fetch.py || { echo "update-fetch.py does not compile under the target python"; return 1; } + # dhcpcd's hook: its root helper runs it with what the unprivileged side sends. + install -D -m 0755 "${KRYPTIK_ROOT}/tools/net/dhcpcd-hook.py" /usr/libexec/kryptik/dhcpcd-hook + python3 -m py_compile /usr/libexec/kryptik/dhcpcd-hook || { echo "dhcpcd-hook does not compile under the target python"; return 1; } rm -rf /usr/libexec/kryptik/__pycache__ for t in dhcpcd nft dnsmasq ip; do command -v "$t" >/dev/null 2>&1 && echo " ok $t" || { echo " MISSING $t"; return 1; } diff --git a/compartments/kryptikd/src/caps.rs b/compartments/kryptikd/src/caps.rs index 09b7beae..f135c64a 100644 --- a/compartments/kryptikd/src/caps.rs +++ b/compartments/kryptikd/src/caps.rs @@ -5,7 +5,7 @@ use std::io; /// Capability numbers from `linux/capability.h` (stable ABI; `libc` lacks them). #[allow(dead_code)] -mod cap { +pub(crate) mod cap { use libc::c_int; pub const CHOWN: c_int = 0; pub const DAC_OVERRIDE: c_int = 1; @@ -21,6 +21,7 @@ mod cap { pub const NET_RAW: c_int = 13; pub const IPC_LOCK: c_int = 14; pub const SYS_MODULE: c_int = 16; + pub const SYS_CHROOT: c_int = 18; pub const SYS_PTRACE: c_int = 19; pub const SYS_ADMIN: c_int = 21; pub const SYS_NICE: c_int = 23; @@ -35,9 +36,17 @@ pub const KEEP: libc::c_int = cap::NET_BIND_SERVICE; pub const KEEPABLE: &[libc::c_int] = &[ cap::NET_BIND_SERVICE, cap::NET_ADMIN, cap::NET_RAW, cap::NET_BROADCAST, cap::SYS_NICE, cap::IPC_LOCK, cap::KILL, cap::CHOWN, cap::FOWNER, cap::FSETID, - cap::DAC_READ_SEARCH, + cap::DAC_READ_SEARCH, cap::SETUID, cap::SETGID, cap::SYS_CHROOT, ]; +/// What a daemon needs to drop to a user of its own (dhcpcd's privilege separation): kept +/// together or not at all, and each opens the calls `seccomp::CAP_CALLS` names. +pub const PRIVSEP: &[libc::c_int] = &[cap::SETUID, cap::SETGID, cap::SYS_CHROOT]; + +pub fn keeps_privsep(keep: &[libc::c_int]) -> bool { + PRIVSEP.iter().all(|c| keep.contains(c)) +} + /// Capability numbers by name, as linux/capability.h defines them. pub const CAP_NAMES: &[(&str, libc::c_int)] = &[ ("CAP_CHOWN", 0), ("CAP_DAC_OVERRIDE", 1), ("CAP_DAC_READ_SEARCH", 2), ("CAP_FOWNER", 3), @@ -53,9 +62,9 @@ pub const CAP_NAMES: &[(&str, libc::c_int)] = &[ ("CAP_CHECKPOINT_RESTORE", 40), ]; -/// Only the nic zone may keep these (`policy::check_for_zone`): another zone could use them to -/// re-address its veth or forge frames. -pub const NIC_ONLY: &[libc::c_int] = &[cap::NET_ADMIN, cap::NET_RAW]; +/// Only the nic zone may keep these (`policy::check_for_zone`): another zone could use the +/// network ones to re-address its veth or forge frames, and only dhcpcd there needs `PRIVSEP`. +pub const NIC_ONLY: &[libc::c_int] = &[cap::NET_ADMIN, cap::NET_RAW, cap::SETUID, cap::SETGID, cap::SYS_CHROOT]; pub fn cap_by_name(name: &str) -> Option { CAP_NAMES.iter().find(|(n, _)| *n == name).map(|(_, v)| *v) diff --git a/compartments/kryptikd/src/caps/tests.rs b/compartments/kryptikd/src/caps/tests.rs index 8637ad81..e37667bd 100644 --- a/compartments/kryptikd/src/caps/tests.rs +++ b/compartments/kryptikd/src/caps/tests.rs @@ -23,6 +23,21 @@ fn keep_is_not_dangerous() { } } +#[test] +fn privsep_is_the_nic_zones_alone() { + assert_eq!(PRIVSEP, &[cap::SETUID, cap::SETGID, cap::SYS_CHROOT]); + for c in PRIVSEP { + assert!(KEEPABLE.contains(c) && NIC_ONLY.contains(c), "{}", cap_name(*c)); + } + assert_eq!(cap_by_name("CAP_SYS_CHROOT"), Some(cap::SYS_CHROOT)); + assert!(keeps_privsep(&[cap::NET_ADMIN, cap::SYS_CHROOT, cap::SETGID, cap::SETUID])); + assert!(!keeps_privsep(&[cap::SETUID, cap::SETGID])); + // Nothing else comes with them: the dangerous ones stay unkeepable. + for c in [cap::SYS_ADMIN, cap::SYS_PTRACE, cap::DAC_OVERRIDE, cap::SYS_MODULE, cap::MKNOD] { + assert!(!KEEPABLE.contains(&c), "{}", cap_name(c)); + } +} + #[test] fn last_cap_is_sane() { let n = last_cap(); diff --git a/compartments/kryptikd/src/isolate.rs b/compartments/kryptikd/src/isolate.rs index 98436cd5..77c132e9 100644 --- a/compartments/kryptikd/src/isolate.rs +++ b/compartments/kryptikd/src/isolate.rs @@ -72,22 +72,33 @@ pub fn unshare_namespaces(flags: libc::c_int) -> Result<(), IsolateError> { check("unshare", unsafe { libc::unshare(flags) }) } +/// The id a daemon in a zone that keeps `caps::PRIVSEP` drops to: dhcpcd's, in the nic zone. +pub const SERVICE_ID: u32 = 100; + /// Write a new user namespace's id maps, denying setgroups first as an unprivileged gid_map -/// needs. `with_nobody` maps 65534 too, which needs CAP_SETUID: a root launch only. +/// needs. `with_nobody` maps 65534 too, which needs CAP_SETUID: a root launch only. `service` +/// maps `SERVICE_ID` as well and leaves setgroups allowed, for that daemon's drop: a root launch +/// of a zone that keeps `caps::PRIVSEP` only. pub fn write_id_maps( pid: libc::pid_t, outer_uid: u32, outer_gid: u32, with_nobody: bool, + service: bool, ) -> Result<(), IsolateError> { use std::fs; - let deny = format!("/proc/{pid}/setgroups"); - fs::write(&deny, "deny") - .map_err(|e| IsolateError::Refused(format!("{deny}: {e}")))?; + if !service { + let deny = format!("/proc/{pid}/setgroups"); + fs::write(&deny, "deny") + .map_err(|e| IsolateError::Refused(format!("{deny}: {e}")))?; + } let map = |outer: u32| { let mut m = format!("0 {outer} 1\n"); + if service { + m.push_str(&format!("{SERVICE_ID} {} 1\n", outer + SERVICE_ID)); + } if with_nobody { m.push_str(&format!("65534 {} 1\n", outer + 65534)); } diff --git a/compartments/kryptikd/src/main.rs b/compartments/kryptikd/src/main.rs index 490bafd9..882633e4 100644 --- a/compartments/kryptikd/src/main.rs +++ b/compartments/kryptikd/src/main.rs @@ -1170,7 +1170,7 @@ const fn seccomp_iowr(nr: u32, size: usize) -> libc::c_ulong { const NOTIF_RECV: libc::c_ulong = seccomp_iowr(0, std::mem::size_of::()); const NOTIF_SEND: libc::c_ulong = seccomp_iowr(1, std::mem::size_of::()); -/// The filter zone `name` runs under: the base, widened by its policy file. +/// The filter zone `name` runs under: the base, widened by its policy file and kept capabilities. fn trace_filter(dir: &Path, name: &str) -> Result<(Vec, seccomp::SocketPolicy), String> { let zones = zone::load_all(dir).map_err(|e| e.to_string())?; let z = zones.iter().find(|z| z.name == name).ok_or_else(|| format!("no zone named {name:?}"))?; @@ -1180,7 +1180,7 @@ fn trace_filter(dir: &Path, name: &str) -> Result<(Vec, seccomp::S let p = policy::load(&policy::resolve(dir, rel)) .and_then(|p| p.check_for_zone(z).map(|_| p)) .map_err(|e| format!("{rel}: {e}"))?; - Ok((seccomp::widened(&p.extra_syscalls).map_err(|e| e.to_string())?, p.sockets)) + Ok((seccomp::widened_for(&p.extra_syscalls, &p.keep_caps).map_err(|e| e.to_string())?, p.sockets)) } /* Run CMD under a zone filter and name every call it refuses. Refused calls diff --git a/compartments/kryptikd/src/policy.rs b/compartments/kryptikd/src/policy.rs index 14025146..ec096eb8 100755 --- a/compartments/kryptikd/src/policy.rs +++ b/compartments/kryptikd/src/policy.rs @@ -154,12 +154,25 @@ pub fn parse(text: &str, source: &str) -> Result { } } } + // One of them alone opens its calls and serves no daemon's drop to its own user. + let privsep = caps::PRIVSEP.iter().filter(|c| p.keep_caps.contains(c)).count(); + if privsep != 0 && privsep != caps::PRIVSEP.len() { + return Err(PolicyError::Line { + path: source.to_string(), + line: 0, + msg: format!( + "{} are kept together or not at all", + caps::PRIVSEP.iter().map(|c| caps::cap_name(*c)).collect::>().join(", ") + ), + }); + } Ok(p) } impl Policy { - /// Only the nic zone may keep `CAP_NET_ADMIN` or `CAP_NET_RAW`; elsewhere they let a zone - /// re-address its veth, route around port isolation via the bridge address, or forge frames. + /// Only the nic zone may keep `caps::NIC_ONLY`: elsewhere `CAP_NET_ADMIN` or `CAP_NET_RAW` let a + /// zone re-address its veth, route around port isolation via the bridge address, or forge + /// frames, and no other zone runs a daemon that drops to a user of its own. pub fn check_for_zone(&self, zone: &crate::zone::Zone) -> Result<(), PolicyError> { if zone.network != crate::zone::NetworkMode::Nic { for (c, name) in self.keep_caps.iter().zip(&self.keep_cap_names) { diff --git a/compartments/kryptikd/src/policy/tests.rs b/compartments/kryptikd/src/policy/tests.rs index 82064bd2..3981fe15 100644 --- a/compartments/kryptikd/src/policy/tests.rs +++ b/compartments/kryptikd/src/policy/tests.rs @@ -52,7 +52,7 @@ fn errors_carry_line_number() { #[test] fn dangerous_capabilities_cannot_be_kept() { - for name in ["CAP_SYS_ADMIN", "CAP_SYS_PTRACE", "CAP_DAC_OVERRIDE", "CAP_SETUID", "CAP_SYS_MODULE", "CAP_MKNOD"] { + for name in ["CAP_SYS_ADMIN", "CAP_SYS_PTRACE", "CAP_DAC_OVERRIDE", "CAP_SETPCAP", "CAP_SYS_MODULE", "CAP_MKNOD"] { let err = parse(&format!("keep-capability {name}\n"), "t").unwrap_err(); assert!(err.to_string().contains("cannot be kept"), "{name}: {err}"); } @@ -87,11 +87,31 @@ fn only_nic_zone_keeps_net_caps() { assert!(e.to_string().contains("owns the NIC"), "{cap}: {e}"); assert!(p.check_for_zone(&z("none")).is_err()); } + // dhcpcd's three, for its drop to a user of its own, likewise. + let p = parse("keep-capability CAP_SETUID\nkeep-capability CAP_SETGID\nkeep-capability CAP_SYS_CHROOT\n", "t").unwrap(); + assert!(crate::caps::keeps_privsep(&p.keep_caps)); + assert!(p.check_for_zone(&z("nic")).is_ok()); + assert!(p.check_for_zone(&z("routed")).unwrap_err().to_string().contains("owns the NIC")); + assert!(p.check_for_zone(&z("none")).is_err()); // Other keepable capabilities are not mode-restricted. let p = parse("keep-capability CAP_SYS_NICE\n", "t").unwrap(); assert!(p.check_for_zone(&z("routed")).is_ok()); } +#[test] +fn privsep_capabilities_kept_together() { + for text in [ + "keep-capability CAP_SETUID\n", + "keep-capability CAP_SETGID\nkeep-capability CAP_SYS_CHROOT\n", + "keep-capability CAP_SYS_CHROOT\nkeep-capability CAP_NET_ADMIN\n", + ] { + let err = parse(text, "t").unwrap_err(); + assert!(err.to_string().contains("kept together or not at all"), "{text:?}: {err}"); + } + let p = parse("keep-capability CAP_SYS_CHROOT\nkeep-capability CAP_SETGID\nkeep-capability CAP_SETUID\n", "t").unwrap(); + assert_eq!(p.keep_caps.len(), 3); +} + #[test] fn af_netlink_lifts_protocol_check() { let p = parse("allow-socket AF_NETLINK\n", "t").unwrap(); diff --git a/compartments/kryptikd/src/rootfs.rs b/compartments/kryptikd/src/rootfs.rs index ef9f44fd..2affc055 100644 --- a/compartments/kryptikd/src/rootfs.rs +++ b/compartments/kryptikd/src/rootfs.rs @@ -198,16 +198,28 @@ pub fn zone_home(zone: &str) -> String { format!("/home/{zone}") } -/// Synthesized /etc/passwd: the zone's root and nobody. -pub fn passwd_for(zone: &str, home: &str) -> String { - format!( - "root:x:0:0:{zone}:{home}:/bin/sh\n\ - nobody:x:65534:65534:nobody:/nonexistent:/bin/false\n" - ) +/// The home and chroot of the user `service` adds: an empty directory on the sealed root. +pub const SERVICE_HOME: &str = "/var/empty"; + +/// Synthesized /etc/passwd: the zone's root and nobody, and with `service` dhcpcd at +/// `isolate::SERVICE_ID`, the user its privilege separation drops to. +pub fn passwd_for(zone: &str, home: &str, service: bool) -> String { + let mut s = format!("root:x:0:0:{zone}:{home}:/bin/sh\n"); + if service { + let id = crate::isolate::SERVICE_ID; + s.push_str(&format!("dhcpcd:x:{id}:{id}:dhcpcd:{SERVICE_HOME}:/bin/false\n")); + } + s.push_str("nobody:x:65534:65534:nobody:/nonexistent:/bin/false\n"); + s } -pub fn group_for() -> String { - "root:x:0:\nnogroup:x:65534:\n".to_string() +pub fn group_for(service: bool) -> String { + let mut s = "root:x:0:\n".to_string(); + if service { + s.push_str(&format!("dhcpcd:x:{}:\n", crate::isolate::SERVICE_ID)); + } + s.push_str("nogroup:x:65534:\n"); + s } pub fn nsswitch() -> String { @@ -280,7 +292,9 @@ pub fn check_data_dir(path: &str, expected_uid: u32) -> Result<(), RootfsError> } /// Pivot into a root holding only what the zone should see; returns the home. Runs as root in the -/// new user namespace, before Landlock and seccomp; `ephemeral` is a tmpfs home's size. +/// new user namespace, before Landlock and seccomp; `ephemeral` is a tmpfs home's size, and +/// `service` adds dhcpcd's user and `SERVICE_HOME`. +#[allow(clippy::too_many_arguments)] pub fn pivot_into( data_dir: &str, zone: &str, @@ -289,6 +303,7 @@ pub fn pivot_into( broker: Option<&str>, wayland: Option<&str>, wifi_conf: Option<&str>, + service: bool, ) -> Result { let home = zone_home(zone); @@ -348,7 +363,11 @@ pub fn pivot_into( } } - populate_etc(root, zone, &home, resolver)?; + populate_etc(root, zone, &home, resolver, service)?; + // Made on the root tmpfs, so the seal below leaves it empty and read-only. + if service { + mkdir(&SERVICE_HOME[1..])?; + } // Fresh /proc, showing only this zone's pid namespace. let proc_dir = mkdir("proc")?; @@ -507,15 +526,15 @@ pub fn pivot_into( } /// The zone's /etc: synthesized identity files plus `ETC_RO_FILES` and `ETC_RO_DIRS`. -fn populate_etc(root: &str, zone: &str, home: &str, resolver: Resolver) -> Result<(), RootfsError> { +fn populate_etc(root: &str, zone: &str, home: &str, resolver: Resolver, service: bool) -> Result<(), RootfsError> { let etc = format!("{root}/etc"); fs::create_dir_all(&etc).map_err(|e| RootfsError::Setup(format!("{etc}: {e}")))?; let write = |name: &str, content: String| -> Result<(), RootfsError> { let p = format!("{etc}/{name}"); fs::write(&p, content).map_err(|e| RootfsError::Setup(format!("{p}: {e}"))) }; - write("passwd", passwd_for(zone, home))?; - write("group", group_for())?; + write("passwd", passwd_for(zone, home, service))?; + write("group", group_for(service))?; write("nsswitch.conf", nsswitch())?; write("hosts", hosts_for(zone))?; write("hostname", format!("{zone}\n"))?; diff --git a/compartments/kryptikd/src/rootfs/tests.rs b/compartments/kryptikd/src/rootfs/tests.rs index 8f88ec05..d6e7b84b 100644 --- a/compartments/kryptikd/src/rootfs/tests.rs +++ b/compartments/kryptikd/src/rootfs/tests.rs @@ -90,9 +90,16 @@ fn bridge_resolver() { #[test] fn identity_names_zone() { - let pw = passwd_for("work", "/home/work"); + let pw = passwd_for("work", "/home/work", false); assert!(pw.starts_with("root:x:0:0:work:/home/work:"), "{pw}"); assert_eq!(pw.lines().count(), 2); + assert_eq!(group_for(false).lines().count(), 2); + // The nic zone's dhcpcd drops to the third mapped id, chrooted to an empty directory. + let pw = passwd_for("net", "/home/net", true); + assert_eq!(pw.lines().count(), 3); + assert!(pw.contains("\ndhcpcd:x:100:100:dhcpcd:/var/empty:/bin/false\n"), "{pw}"); + assert!(group_for(true).contains("\ndhcpcd:x:100:\n")); + assert_eq!(crate::isolate::SERVICE_ID, 100); assert!(hosts_for("work").contains("127.0.0.1 localhost work")); assert_eq!(zone_home("work"), "/home/work"); assert!(nsswitch().contains("passwd: files")); diff --git a/compartments/kryptikd/src/seccomp.rs b/compartments/kryptikd/src/seccomp.rs index fe667119..be3daf2c 100644 --- a/compartments/kryptikd/src/seccomp.rs +++ b/compartments/kryptikd/src/seccomp.rs @@ -298,7 +298,8 @@ syscalls! { } denied! { - /// Syscalls left out of the allowlist, and why; a zone policy cannot allow them. + /// Syscalls left out of the allowlist, and why; a zone policy cannot allow them. The one + /// exception is `CAP_CALLS`: a zone that keeps `caps::PRIVSEP` gets the four calls it serves. DENIED_RATIONALE, DENIED_NAMES = [ (libc::SYS_ptrace, "read/write another process's memory; the classic escape"), (libc::SYS_process_vm_readv, "read another process's memory directly"), @@ -661,9 +662,34 @@ pub fn confine_zone() -> Result<(), SeccompError> { install(BASE_ALLOWLIST) } -/// Install the zone filter widened by a policy's `extra` syscalls and `sockets` rule. -pub fn confine_zone_with(extra: &[libc::c_long], sockets: &SocketPolicy) -> Result<(), SeccompError> { - install_with(&widened(extra)?, SECCOMP_RET_KILL_PROCESS, sockets, SECCOMP_FILTER_FLAG_TSYNC).map(|_| ()) +/// Install the zone filter widened by a policy's `extra` syscalls, `sockets` rule and kept +/// capabilities. +pub fn confine_zone_with(extra: &[libc::c_long], sockets: &SocketPolicy, kept_caps: &[libc::c_int]) -> Result<(), SeccompError> { + install_with(&widened_for(extra, kept_caps)?, SECCOMP_RET_KILL_PROCESS, sockets, SECCOMP_FILTER_FLAG_TSYNC).map(|_| ()) +} + +/// Denied calls a kept capability opens again, for the one zone that may keep it +/// (`caps::PRIVSEP`, the nic zone's): a daemon dropping to its own user makes them, and in the +/// zone's user and mount namespaces they reach only its mapped ids and its own tree. +pub const CAP_CALLS: &[(libc::c_int, &[libc::c_long])] = &[ + (crate::caps::cap::SETUID, &[libc::SYS_setuid]), + (crate::caps::cap::SETGID, &[libc::SYS_setgid, libc::SYS_setgroups]), + (crate::caps::cap::SYS_CHROOT, &[libc::SYS_chroot]), +]; + +/// `widened`, plus the calls `CAP_CALLS` gives the capabilities in `kept_caps`. +pub fn widened_for(extra: &[libc::c_long], kept_caps: &[libc::c_int]) -> Result, SeccompError> { + let mut allow = widened(extra)?; + for &(cap, calls) in CAP_CALLS { + if kept_caps.contains(&cap) { + for &nr in calls { + if !allow.contains(&nr) { + allow.push(nr); + } + } + } + } + Ok(allow) } /// The base allowlist plus a policy's `extra` syscalls, each checked again against the denied list. diff --git a/compartments/kryptikd/src/seccomp/tests.rs b/compartments/kryptikd/src/seccomp/tests.rs index 553b5a8c..67c3120c 100644 --- a/compartments/kryptikd/src/seccomp/tests.rs +++ b/compartments/kryptikd/src/seccomp/tests.rs @@ -463,6 +463,31 @@ fn arg_rules_leave_allowlist_alone() { } } +#[test] +fn kept_capabilities_open_their_calls() { + let calls = [libc::SYS_setuid, libc::SYS_setgid, libc::SYS_setgroups, libc::SYS_chroot]; + // Kept by no zone, nothing changes. + assert_eq!(widened_for(&[], &[]).unwrap(), widened(&[]).unwrap()); + let all = widened_for(&[], crate::caps::PRIVSEP).unwrap(); + for nr in calls { + assert!(is_denied(nr) && !BASE_ALLOWLIST.contains(&nr), "{nr} is denied to every other zone"); + assert!(all.contains(&nr), "{nr} is opened by its capability"); + } + assert_eq!(all.len(), BASE_ALLOWLIST.len() + calls.len()); + // Each capability opens its own calls and no other. + let chroot = widened_for(&[], &[crate::caps::cap::SYS_CHROOT]).unwrap(); + assert!(chroot.contains(&libc::SYS_chroot) && !chroot.contains(&libc::SYS_setuid) && !chroot.contains(&libc::SYS_setgroups)); + let p = build_program(&all).unwrap(); + for nr in calls { + assert_eq!(evaluate(&p, X86, nr as u32), SECCOMP_RET_ALLOW, "{nr}"); + } + // Every other zone's program still refuses them as before. + let base = build_program(BASE_ALLOWLIST).unwrap(); + assert_eq!(evaluate(&base, X86, libc::SYS_setuid as u32), errno_action(EPERM)); + assert_eq!(evaluate(&base, X86, libc::SYS_setgroups as u32), errno_action(EPERM)); + assert_eq!(evaluate(&base, X86, libc::SYS_chroot as u32), SECCOMP_RET_KILL_PROCESS); +} + #[test] fn widened_refuses_denied() { let e = widened(&[libc::SYS_ptrace]).unwrap_err(); diff --git a/compartments/kryptikd/src/spawn.rs b/compartments/kryptikd/src/spawn.rs index 0957735d..86d99d99 100644 --- a/compartments/kryptikd/src/spawn.rs +++ b/compartments/kryptikd/src/spawn.rs @@ -952,7 +952,7 @@ pub fn run_in_zone( } // Map the child's root to the zone identity: this is the privilege drop. - if let Err(e) = isolate::write_id_maps(pid, id.uid, id.gid, id.privileged) { + if let Err(e) = isolate::write_id_maps(pid, id.uid, id.gid, id.privileged, maps_service(id.privileged, zone_policy.as_ref())) { // Close our end so the child reads EOF and dies rather than blocking. mapped.close_write(); let _ = wait_for(pid); @@ -1248,7 +1248,8 @@ fn intermediate_main( if inner == 0 { alive.close_write(); - let rc = zone_init(zone, rootfs, argv, flags, zone_policy, fs_rules, plumbed, &broker_in_zone, wayland_in_zone.as_deref(), wifi_conf, &alive); + let service = maps_service(id.privileged, zone_policy); + let rc = zone_init(zone, rootfs, argv, flags, zone_policy, fs_rules, plumbed, &broker_in_zone, wayland_in_zone.as_deref(), wifi_conf, service, &alive); unsafe { libc::_exit(rc) }; } alive.close_read(); @@ -1269,6 +1270,11 @@ fn intermediate_main( } } +/// Whether a launch maps dhcpcd's user: a root launch of a zone that keeps `caps::PRIVSEP`. +fn maps_service(privileged: bool, policy: Option<&policy::Policy>) -> bool { + privileged && policy.is_some_and(|p| caps::keeps_privsep(&p.keep_caps)) +} + /// pid 1 of the zone. Returns only on failure; on success it has exec'd. fn zone_init( zone: &Zone, @@ -1281,6 +1287,7 @@ fn zone_init( broker_path: &str, wayland_path: Option<&str>, wifi_conf: Option<&str>, + service: bool, alive: &SyncPipe, ) -> i32 { macro_rules! bail { @@ -1313,7 +1320,7 @@ fn zone_init( (crate::zone::NetworkMode::Routed, true) => rootfs::Resolver::Bridge, _ => rootfs::Resolver::None, }; - let home = match rootfs::pivot_into(rootfs, &zone.name, ephemeral, resolver, Some(broker_path), wayland_path, wifi_conf) { + let home = match rootfs::pivot_into(rootfs, &zone.name, ephemeral, resolver, Some(broker_path), wayland_path, wifi_conf, service) { Ok(h) => h, Err(e) => bail!("could not build the zone root: {e}"), }; @@ -1364,7 +1371,7 @@ fn zone_init( // seccomp last: mount() and the other setup calls are not in its allowlist. let installed = match zone_policy { - Some(p) => seccomp::confine_zone_with(&p.extra_syscalls, &p.sockets), + Some(p) => seccomp::confine_zone_with(&p.extra_syscalls, &p.sockets, &p.keep_caps), None => seccomp::confine_zone(), }; if let Err(e) = installed { diff --git a/compartments/zones/net.toml b/compartments/zones/net.toml index 9d35d5dc..3435411e 100644 --- a/compartments/zones/net.toml +++ b/compartments/zones/net.toml @@ -23,7 +23,8 @@ memory_max = "1G" pids_max = 256 [identity] -# Root inside maps to host uid/gid 196608, nobody to 262142; fixed, never derived from zone order. +# Root inside maps to host uid/gid 196608, dhcpcd's user (100) to 196708 and nobody to 262142; +# fixed, never derived from zone order. uid_base = 196608 [ui] diff --git a/compartments/zones/policy/net.seccomp b/compartments/zones/policy/net.seccomp index 12dd8f77..3e4a16be 100644 --- a/compartments/zones/policy/net.seccomp +++ b/compartments/zones/policy/net.seccomp @@ -5,3 +5,8 @@ keep-capability CAP_NET_ADMIN keep-capability CAP_NET_RAW # dhcpcd exits if refused NETLINK_GENERIC (nl80211 events), leaving routed zones no uplink. allow-netlink NETLINK_GENERIC +# dhcpcd's privilege separation: its parsers drop to the dhcpcd user, chrooted to +# /var/empty, and only a small helper stays root (kept together or not at all). +keep-capability CAP_SETUID +keep-capability CAP_SETGID +keep-capability CAP_SYS_CHROOT diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index 8a53a982..33e75d7f 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -67,11 +67,13 @@ query and the [update](update-channel.md) fetcher. Builds on routed zone's data, no broker access beyond its own clipboard and the time and update verbs, and ephemeral storage. Its seccomp policy is the base one plus `policy/net.seccomp`: `AF_PACKET`, `NETLINK_NETFILTER`, - `NETLINK_GENERIC` (dhcpcd opens one for nl80211 and exits if refused), and - `CAP_NET_ADMIN` / `CAP_NET_RAW` over its own interfaces. `chown`, which - dhcpcd calls on its control socket, is in the base list. The net zone alone - gets private tmpfs mounts at `/run` and `/var/lib` (writable under Landlock, - no exec), where dhcpcd keeps its pid file, control socket and leases. Every + `NETLINK_GENERIC` (dhcpcd opens one for nl80211 and exits if refused), + `CAP_NET_ADMIN` / `CAP_NET_RAW` over its own interfaces, and `CAP_SETUID`, + `CAP_SETGID` and `CAP_SYS_CHROOT` for dhcpcd's privilege separation. + `chown`, which dhcpcd calls on its control socket, is in the base list. + The net zone alone gets private tmpfs mounts at `/run` and `/var/lib` + (writable under Landlock, no exec), where dhcpcd keeps its pid file, + control socket and leases. Every other zone's `/run` is read-only and holds only its broker and proxy sockets. @@ -100,11 +102,23 @@ query and the [update](update-channel.md) fetcher. Builds on change replaces the file and sends SIGHUP. A lease that lapsed leaves the last servers in place. It answers the test TLD `.test` itself, so resolving `kryptik.test` tests the path to the resolver, not the internet. -- **dhcpcd runs without its own privilege separation.** That needs - `setgroups`, which the zone denies, a `dhcpcd` user, which its synthesized - passwd lacks, and `CAP_SETUID`, `CAP_SETGID` and `CAP_SYS_CHROOT`. Giving - the hostile zone three capabilities so one program can build a smaller - sandbox inside it would be a net loss; the zone is the sandbox. +- **dhcpcd separates its privileges.** What parses a lease, a DHCPv6 reply + or a router advertisement runs as the zone's `dhcpcd` user, chrooted to an + empty `/var/empty`, with no capability and dhcpcd's own seccomp filter over + the zone's; a small helper stays the zone's root. For that the net zone + keeps `CAP_SETUID`, `CAP_SETGID` and `CAP_SYS_CHROOT` (kept together, and + by the nic zone alone), its filter allows the four calls they serve + (`setuid`, `setgid`, `setgroups`, `chroot`; `seccomp::CAP_CALLS`), its + user namespace maps a third id, 100, to `uid_base` + 100 and allows + `setgroups`, and its passwd names the user. In the zone's own namespaces + these reach only its mapped ids and its own tree. + The helper still does for the parsers what dhcpcd needs: addresses, routes + and links over netlink, the net sysctls, and dhcpcd's own files. It also + runs the hook, with the environment the parsers send it, so the net zone + does not use dhcpcd's: `dhcpcd-hook` checks every value for form and writes + nothing but `nameserver` lines (`tools/tests/netzone-hook.sh`). A bug in a + parser no longer reaches the Wi-Fi credentials, the broker's socket, the + firewall's netlink socket or a program to run. - **Readiness**, printed again on any change: ```text diff --git a/docs/design/zone-policy-files.md b/docs/design/zone-policy-files.md index 1b83dd49..1ddff2fc 100755 --- a/docs/design/zone-policy-files.md +++ b/docs/design/zone-policy-files.md @@ -23,6 +23,9 @@ keep-capability CAP_NET_RAW # left in the bounding set cannot be re-allowed, one on the base allowlist is reported as already allowed, and any other name is an error. The id and capability calls and `unshare` on the denied list fail with EPERM instead of killing the caller. + The one way back for a denied call is a kept capability: `CAP_SETUID`, + `CAP_SETGID` and `CAP_SYS_CHROOT` open `setuid`, `setgid` and `setgroups`, + and `chroot` (`seccomp::CAP_CALLS`). - `allow-socket`: `AF_PACKET`, `AF_KEY`, `AF_ALG`, `AF_VSOCK`, `AF_BLUETOOTH`, `AF_CAN`, `AF_RDS`, `AF_TIPC` or `AF_XDP`; `AF_NETLINK` drops the netlink protocol check. `socketpair(2)` stays `AF_UNIX` only whatever the file @@ -33,11 +36,14 @@ keep-capability CAP_NET_RAW # left in the bounding set `CAP_NET_BIND_SERVICE`, which every zone keeps. `CAP_NET_ADMIN` and `CAP_NET_RAW` are accepted only for the `network.mode = "nic"` zone (`Policy::check_for_zone`): with either, a routed zone could re-address its - veth or forge frames. The chown, chmod and xattr calls are in the base - list, so keeping `CAP_CHOWN`, `CAP_FOWNER` or `CAP_FSETID` takes effect - with no `allow-syscall` line. A zone's user namespace maps only its root - and nobody, so the most such a zone can do is move its own files between - those two. + veth or forge frames. So are `CAP_SETUID`, `CAP_SETGID` and + `CAP_SYS_CHROOT`, which are kept together or not at all (`caps::PRIVSEP`): + they let dhcpcd drop to a user of its own, and a zone that keeps them also + gets that user, id 100, mapped and named in its passwd. The chown, chmod + and xattr calls are in the base list, so keeping `CAP_CHOWN`, `CAP_FOWNER` + or `CAP_FSETID` takes effect with no `allow-syscall` line. A zone's user + namespace maps only its root and nobody (and in the nic zone dhcpcd's + user), so the most such a zone can do is move its own files between those. To find what a program needs, run it under the base filter with `kryptikd seccomp-trace -- CMD [ARGS]`. Each call the filter would kill the program for @@ -72,7 +78,7 @@ zone names a policy file, and only `net.seccomp` adds anything. `kryptikd explain` prints the additions: ```text -policy policy/net.seccomp: socket AF_PACKET, netlink NETLINK_NETFILTER, netlink NETLINK_GENERIC, keep CAP_NET_ADMIN, keep CAP_NET_RAW +policy policy/net.seccomp: socket AF_PACKET, netlink NETLINK_NETFILTER, netlink NETLINK_GENERIC, keep CAP_NET_ADMIN, keep CAP_NET_RAW, keep CAP_SETUID, keep CAP_SETGID, keep CAP_SYS_CHROOT ``` ## Landlock policy files diff --git a/docs/status.md b/docs/status.md index f620c713..bd9cc26a 100644 --- a/docs/status.md +++ b/docs/status.md @@ -64,8 +64,6 @@ media it tested. RPATHs among them. Each has a reason in `build/config/artifact-accepted.txt`, and acceptance fails on any other. Each run's audit log has the counts. -- dhcpcd runs without its own privilege separation; the net zone is its - sandbox. - The builds are not reproducible bit for bit. - A resumed tree builds a changed step again over what its old version installed, and nothing records what a step installed. In CI a new package diff --git a/tools/net/dhcpcd-hook.py b/tools/net/dhcpcd-hook.py new file mode 100755 index 00000000..99b0a63a --- /dev/null +++ b/tools/net/dhcpcd-hook.py @@ -0,0 +1,133 @@ +#!/usr/bin/python3 -I +"""dhcpcd's hook in the net zone: the uplinks' name servers into the zone's resolv.conf. + +dhcpcd's privileged helper runs it as root with the environment the unprivileged +side sends, so nothing in that environment is trusted: each value is checked for +form, the state names only an interface and a protocol that pass, and the one +file written holds nothing but nameserver lines. Paths come from the command +line, which dhcpcd never fills, so the tests can move them and a lease cannot. + + dhcpcd-hook [--state DIR] [--out FILE] +""" +import ipaddress +import os +import re +import sys +import tempfile + +STATE = "/run/lease-dns" +OUT = "/tmp/resolv.conf" +IFACE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,14}\Z") +PROTOCOLS = ("dhcp", "dhcp6", "ra", "ipv4ll", "link", "static", "static6") +PER_SOURCE = 8 +IN_ALL = 16 + + +def address(token, iface): + """An IP literal, or None; a scope may only name the interface itself.""" + text, _, scope = token.partition("%") + if scope and scope != iface: + return None + try: + addr = ipaddress.ip_address(text) + except ValueError: + return None + if addr.is_unspecified or addr.is_multicast: + return None + return str(addr) + ("%" + scope if scope else "") + + +def servers(env, iface): + """The servers this event names, in order, each checked.""" + found = [] + words = env.get("new_domain_name_servers", "").split() + env.get("new_dhcp6_name_servers", "").split() + # Router advertisements: nd_rdnss_servers, kept while their lifetime runs. + for i in range(1, 9): + for j in range(1, 9): + listed = env.get("nd%d_rdnss%d_servers" % (i, j)) + if listed is None: + continue + life = env.get("nd%d_rdnss%d_lifetime" % (i, j), "0") + if life.isdigit() and int(life) > 0: + words += listed.split() + for w in words[:4 * PER_SOURCE]: + a = address(w, iface) + if a is not None and a not in found: + found.append(a) + return found[:PER_SOURCE] + + +def write_state(state, key, addrs): + os.makedirs(state, mode=0o700, exist_ok=True) + path = os.path.join(state, key) + if not addrs: + try: + os.unlink(path) + except FileNotFoundError: + pass + return + fd, tmp = tempfile.mkstemp(dir=state, prefix=".new.") + with os.fdopen(fd, "w") as f: + f.write("".join(a + "\n" for a in addrs)) + os.replace(tmp, path) + + +def rebuild(state, out): + """resolv.conf from every source's file, each line checked again.""" + names = [] + try: + keys = sorted(os.listdir(state)) + except FileNotFoundError: + keys = [] + for key in keys: + iface, _, proto = key.rpartition(".") + if not IFACE.match(iface) or proto not in PROTOCOLS: + continue + try: + fd = os.open(os.path.join(state, key), os.O_RDONLY | os.O_NOFOLLOW) + except OSError: + continue + with os.fdopen(fd) as f: + lines = f.read(4096).split("\n") + for line in lines[:PER_SOURCE]: + a = address(line.strip(), iface) + if a is not None and a not in names: + names.append(a) + names = names[:IN_ALL] + fd, tmp = tempfile.mkstemp(dir=os.path.dirname(out), prefix=".resolv.") + with os.fdopen(fd, "w") as f: + f.write("".join("nameserver %s\n" % a for a in names)) + os.chmod(tmp, 0o644) + os.replace(tmp, out) + + +def main(argv, env): + state, out = STATE, OUT + args = argv[1:] + while args: + if args[0] == "--state" and len(args) > 1: + state = args[1] + elif args[0] == "--out" and len(args) > 1: + out = args[1] + else: + print("usage: dhcpcd-hook [--state DIR] [--out FILE]", file=sys.stderr) + return 2 + args = args[2:] + iface = env.get("interface", "") + proto = env.get("protocol", "") + reason = env.get("reason", "") + if not IFACE.match(iface) or proto not in PROTOCOLS: + return 0 + key = "%s.%s" % (iface, proto) + if env.get("if_up") == "true" or reason == "ROUTERADVERT": + write_state(state, key, servers(env, iface)) + elif env.get("if_down") == "true": + write_state(state, key, []) + else: + return 0 + rebuild(state, out) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv, dict(os.environ))) diff --git a/tools/net/netzone-init.sh b/tools/net/netzone-init.sh index 4c64c6c5..c00b86f2 100755 --- a/tools/net/netzone-init.sh +++ b/tools/net/netzone-init.sh @@ -202,10 +202,10 @@ uplink_addr() { # the first IPv4 address any uplink holds } if command -v dhcpcd >/dev/null 2>&1; then mkdir -p /run/dhcpcd /var/lib/dhcpcd 2>/dev/null # the zone's own tmpfs mounts - # -b: background and retry; --nodev: no device manager; its hook writes the zone's resolv.conf. - if dhcpcd -b -q --nodev "$@" 2>/tmp/dhcpcd.err; then - # The zone has no dhcpcd user, so dhcpcd runs as its root, sandboxed by the zone. - grep -v 'no such user dhcpcd' /tmp/dhcpcd.err + # -b: background and retry; --nodev: no device manager. Its parsers drop to the dhcpcd user; + # Kryptik's hook, which trusts nothing it is handed, writes the zone's resolv.conf. + if dhcpcd -b -q --nodev -c /usr/libexec/kryptik/dhcpcd-hook "$@" 2>/tmp/dhcpcd.err; then + cat /tmp/dhcpcd.err # Up to 15 s for a lease, so the resolver starts with its servers. i=0 while [ "$i" -lt 30 ] && [ -z "$(uplink_addr "$@")" ]; do sleep 0.5; i=$((i+1)); done @@ -349,7 +349,6 @@ cleanup() { [ -n "$DNSPID" ] && kill "$DNSPID" 2>/dev/null [ -n "$UPDATE_PID" ] && kill "$UPDATE_PID" 2>/dev/null for n in $WIRELESS; do p="$(wpa_pid "$n")"; [ -n "$p" ] && kill "$p" 2>/dev/null; done - command -v dhcpcd >/dev/null 2>&1 && dhcpcd -x 2>/dev/null exit 0 } trap cleanup TERM INT diff --git a/tools/tests/netzone-hook.sh b/tools/tests/netzone-hook.sh new file mode 100755 index 00000000..9a277f29 --- /dev/null +++ b/tools/tests/netzone-hook.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Tests for dhcpcd's hook in the net zone (tools/net/dhcpcd-hook.py): the +# servers a lease, a DHCPv6 reply or a router advertisement names reach the +# zone's resolv.conf, a lease that ends takes them away, and nothing else that +# dhcpcd's unprivileged side could put in the environment reaches a file. +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +HOOK="${ROOT}/tools/net/dhcpcd-hook.py" + +PASS=0; FAIL=0 +green() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } +red() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); [[ $# -gt 1 ]] && printf ' %s\n' "$2"; } +same() { if [[ "$2" == "$3" ]]; then green "$1"; else red "$1" "got '$(tr '\n' '|' <<<"$2")', want '$(tr '\n' '|' <<<"$3")'"; fi; } +command -v python3 >/dev/null 2>&1 || { echo "no python3 here"; exit 77; } +T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT +mkdir -p "$T/state" "$T/tmp" + +# hook VAR=VALUE...: one event, as dhcpcd's helper runs it, with nothing else +# in the environment; prints the resolv.conf it leaves. +hook() { + env -i "$@" python3 -I "$HOOK" --state "$T/state" --out "$T/tmp/resolv.conf" + cat "$T/tmp/resolv.conf" 2>/dev/null +} + +echo "-- what a network names" +same "a lease's servers" \ + "$(hook interface=eth0 protocol=dhcp reason=BOUND if_up=true new_domain_name_servers='10.0.2.3 192.0.2.53')" \ + "$(printf 'nameserver 10.0.2.3\nnameserver 192.0.2.53')" +same "a router advertisement's, beside them, while its lifetime runs" \ + "$(hook interface=eth0 protocol=ra reason=ROUTERADVERT nd1_rdnss1_servers='fec0::3' nd1_rdnss1_lifetime=3600)" \ + "$(printf 'nameserver 10.0.2.3\nnameserver 192.0.2.53\nnameserver fec0::3')" +same "a lifetime of 0 takes it away" \ + "$(hook interface=eth0 protocol=ra reason=ROUTERADVERT nd1_rdnss1_servers='fec0::3' nd1_rdnss1_lifetime=0)" \ + "$(printf 'nameserver 10.0.2.3\nnameserver 192.0.2.53')" +same "a lease that ends takes its servers with it" \ + "$(hook interface=eth0 protocol=dhcp reason=EXPIRE if_down=true)" "" +same "a link-local server keeps its scope only when it names the interface" \ + "$(hook interface=wlan0 protocol=dhcp6 reason=BOUND6 if_up=true new_dhcp6_name_servers='fe80::1%wlan0 fe80::2%eth9 2001:db8::53')" \ + "$(printf 'nameserver fe80::1%%wlan0\nnameserver 2001:db8::53')" +hook interface=wlan0 protocol=dhcp6 reason=STOP6 if_down=true > /dev/null + +echo "-- what the unprivileged side could send instead" +same "words that are not addresses are dropped, and with them a second line" \ + "$(hook interface=eth0 protocol=dhcp reason=BOUND if_up=true new_domain_name_servers=$'10.0.2.3;\nsearch evil.example\n192.0.2.7 0.0.0.0 224.0.0.1')" \ + "nameserver 192.0.2.7" +before="$(cat "$T/tmp/resolv.conf")" +same "an interface name that is a path writes nothing" \ + "$(hook interface=../../etc protocol=dhcp reason=BOUND if_up=true new_domain_name_servers=192.0.2.66)" "$before" +same "a protocol not on the list writes nothing" \ + "$(hook interface=eth0 protocol=../x reason=BOUND if_up=true new_domain_name_servers=192.0.2.66)" "$before" +same "an event that is neither up nor down changes nothing" \ + "$(hook interface=eth0 protocol=dhcp reason=PREINIT new_domain_name_servers=192.0.2.66)" "$before" +[[ "$(ls -A "$T/state")" == "eth0.dhcp" ]] && green "the state holds one file per interface and protocol that passed, and no other" \ + || red "files in the state" "$(ls -A "$T/state" | tr '\n' ' ')" +printf '192.0.2.99\nnameserver 1.1.1.1\n../../x\n' > "$T/state/eth0.static" +same "a state file is checked again when read: a line that is not an address is no server" \ + "$(hook interface=eth0 protocol=dhcp reason=RENEW if_up=true new_domain_name_servers=192.0.2.7)" \ + "$(printf 'nameserver 192.0.2.7\nnameserver 192.0.2.99')" +same "the paths come from the command line alone: PYTHON* and the like change nothing" \ + "$(hook PYTHONPATH="$T" PYTHONSTARTUP=/dev/null interface=eth0 protocol=dhcp reason=RENEW if_up=true new_domain_name_servers=192.0.2.7)" \ + "$(printf 'nameserver 192.0.2.7\nnameserver 192.0.2.99')" +python3 -I "$HOOK" --nonsense > /dev/null 2>&1 +[[ $? -eq 2 ]] && green "an argument it does not know is refused" || red "an unknown argument was taken" + +echo "-- the net zone runs it" +grep -q -- '-c /usr/libexec/kryptik/dhcpcd-hook' "${ROOT}/tools/net/netzone-init.sh" \ + && green "netzone-init.sh starts dhcpcd with this hook" || red "netzone-init.sh does not name the hook" +grep -q 'dhcpcd-hook.py.*/usr/libexec/kryptik/dhcpcd-hook' "${ROOT}/build/recipes/netzone.sh" \ + && green "the recipe installs it where dhcpcd is told to look" || red "the recipe does not install the hook" + +echo +echo "${PASS} passed, ${FAIL} failed" +[[ "$FAIL" -eq 0 ]] From ce3189df46ba3945dc5c8a6cfd914fd1a47096e5 Mon Sep 17 00:00:00 2001 From: DevomB Date: Wed, 7 Oct 2026 09:16:07 -0700 Subject: [PATCH 2/3] dhcpcd's hook takes an advertisement's lifetime only as plain ASCII digits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit str.isdigit() is true for "²", which int() then refuses, so a lifetime like that from the unprivileged side crashed the hook before it rebuilt resolv.conf: nothing written, but a crash where a refusal was meant. The suite now sends one and needs the hook to exit 0 with the file unchanged. --- tools/net/dhcpcd-hook.py | 3 ++- tools/tests/netzone-hook.sh | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/tools/net/dhcpcd-hook.py b/tools/net/dhcpcd-hook.py index 99b0a63a..1739e946 100755 --- a/tools/net/dhcpcd-hook.py +++ b/tools/net/dhcpcd-hook.py @@ -48,7 +48,8 @@ def servers(env, iface): if listed is None: continue life = env.get("nd%d_rdnss%d_lifetime" % (i, j), "0") - if life.isdigit() and int(life) > 0: + # ASCII digits only: isdigit() also takes "²", which int() refuses. + if re.fullmatch(r"[0-9]{1,10}", life) and int(life) > 0: words += listed.split() for w in words[:4 * PER_SOURCE]: a = address(w, iface) diff --git a/tools/tests/netzone-hook.sh b/tools/tests/netzone-hook.sh index 9a277f29..07940a67 100755 --- a/tools/tests/netzone-hook.sh +++ b/tools/tests/netzone-hook.sh @@ -48,6 +48,11 @@ same "an interface name that is a path writes nothing" \ "$(hook interface=../../etc protocol=dhcp reason=BOUND if_up=true new_domain_name_servers=192.0.2.66)" "$before" same "a protocol not on the list writes nothing" \ "$(hook interface=eth0 protocol=../x reason=BOUND if_up=true new_domain_name_servers=192.0.2.66)" "$before" +# "²", which str.isdigit() takes and int() refuses, as UTF-8 bytes whatever the locale. +env -i interface=eth0 protocol=ra reason=ROUTERADVERT nd1_rdnss1_servers=fec0::9 nd1_rdnss1_lifetime=$'\xc2\xb2' \ + python3 -I "$HOOK" --state "$T/state" --out "$T/tmp/resolv.conf" 2> "$T/err"; rc=$? +same "a lifetime that is not plain digits is no lifetime, and the hook does not fall over on it" \ + "rc=${rc} $(cat "$T/tmp/resolv.conf") $(head -1 "$T/err")" "rc=0 ${before} " same "an event that is neither up nor down changes nothing" \ "$(hook interface=eth0 protocol=dhcp reason=PREINIT new_domain_name_servers=192.0.2.66)" "$before" [[ "$(ls -A "$T/state")" == "eth0.dhcp" ]] && green "the state holds one file per interface and protocol that passed, and no other" \ From 5e8c3fe9305fbb98ec3186ef18f2dbcc62f79bfd Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 8 Oct 2026 19:03:32 -0700 Subject: [PATCH 3/3] The passwd test's assertion no longer prints the passwd text CodeQL read the failure message of an assertion on passwd_for's output as cleartext logging of sensitive information. The synthesized passwd holds no secret, but the message adds nothing the assertion does not already say. --- compartments/kryptikd/src/rootfs/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compartments/kryptikd/src/rootfs/tests.rs b/compartments/kryptikd/src/rootfs/tests.rs index d6e7b84b..6b4fb69a 100644 --- a/compartments/kryptikd/src/rootfs/tests.rs +++ b/compartments/kryptikd/src/rootfs/tests.rs @@ -97,7 +97,7 @@ fn identity_names_zone() { // The nic zone's dhcpcd drops to the third mapped id, chrooted to an empty directory. let pw = passwd_for("net", "/home/net", true); assert_eq!(pw.lines().count(), 3); - assert!(pw.contains("\ndhcpcd:x:100:100:dhcpcd:/var/empty:/bin/false\n"), "{pw}"); + assert!(pw.contains("\ndhcpcd:x:100:100:dhcpcd:/var/empty:/bin/false\n")); assert!(group_for(true).contains("\ndhcpcd:x:100:\n")); assert_eq!(crate::isolate::SERVICE_ID, 100); assert!(hosts_for("work").contains("127.0.0.1 localhost work"));