From 88aa1619573e799349e57dd961183ec8ae1cecc4 Mon Sep 17 00:00:00 2001 From: DevomB Date: Wed, 7 Oct 2026 08:48:23 -0700 Subject: [PATCH 1/5] 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/5] 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/5] 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")); From 79e76c2ec08ce85ebe87fa4dc95720028472d702 Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 8 Oct 2026 19:25:20 -0700 Subject: [PATCH 4/5] dnsmasq in the net zone drops to nobody once its sockets are bound, and reads its servers' file itself when it changes It ran as the zone's root under --no-daemon, which skips dnsmasq's own privilege drop whatever --user says. Now it binds as root and runs as the zone's nobody with at most CAP_NET_BIND_SERVICE, kept for fd19::1, which stays tentative until the bridge has a port. Its drop calls capset, which CAP_SETUID now opens beside setuid for the nic zone. The zone's root cannot signal nobody, so dnsmasq polls the file sync_upstream replaces, with --clear-on-reload, and logs to the zone's pipe with --log-facility=-. --- build/guest-tests/zones-check.sh | 55 ++++++++++++++++++---- compartments/kryptikd/src/seccomp.rs | 8 ++-- compartments/kryptikd/src/seccomp/tests.rs | 5 +- docs/design/net-zone.md | 22 +++++---- docs/design/zone-policy-files.md | 17 +++---- tools/image/zones-test.sh | 2 +- tools/net/netzone-init.sh | 26 +++++----- tools/tests/netzone-dns.sh | 20 +++++--- 8 files changed, 109 insertions(+), 46 deletions(-) diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index a73c65a7..00fe6967 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -78,6 +78,26 @@ if [[ "$separated" -ge 1 && "$helpers" -ge 1 && -n "$leased" ]]; then else fail "dhcpcd-separated" "separated ${separated}, helpers ${helpers}, lease ${leased:-none}:${seen:- no dhcpcd running}" fi +# dnsmasq answers the routed zones as the net zone's nobody (host uid_base + +# 65534), keeping at most CAP_NET_BIND_SERVICE: fd19::1 stays tentative on a +# bridge with no port yet, so dnsmasq binds it later. +dns_dropped=0; dns_root=0; dns_seen="" +for p in $(pgrep -x dnsmasq); do + ids="$(awk '/^Uid:/ { print $2, $3, $4, $5 }' "/proc/$p/status" 2>/dev/null)" + eff="$(awk '/^CapEff:/ { print $2 }' "/proc/$p/status" 2>/dev/null)" + prm="$(awk '/^CapPrm:/ { print $2 }' "/proc/$p/status" 2>/dev/null)" + dns_seen="${dns_seen} ${p}:uid=${ids// /,},eff=${eff},prm=${prm}" + n=$((net_base + 65534)) + if [[ "$ids" == "$n $n $n $n" && "$eff" =~ ^0000000000000(000|400)$ && "$prm" =~ ^0000000000000(000|400)$ ]]; then + dns_dropped=$((dns_dropped + 1)) + fi + [[ "$ids" == "$net_base "* ]] && dns_root=$((dns_root + 1)) +done +if [[ "$dns_dropped" -ge 1 && "$dns_root" -eq 0 ]]; then + pass "dnsmasq-unprivileged" "${dns_dropped} dnsmasq process(es) as uid $((net_base + 65534)) with no capability but CAP_NET_BIND_SERVICE, none as the net zone's root" +else + fail "dnsmasq-unprivileged" "dropped ${dns_dropped}, as root ${dns_root}:${dns_seen:- no dnsmasq 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 @@ -121,8 +141,9 @@ fi # The resolver follows the servers a lease names. The net zone's resolv.conf # is written with the servers dnsmasq has and one more, as a lease that came # late or another network would change it, and then without it: each time -# dnsmasq must be told within a few of the zone's 10 s passes. Its own servers -# stay throughout, so names still resolve. +# the zone's 10 s pass writes dnsmasq's file, and dnsmasq, woken by a query, +# reads it and logs the servers it now uses. Its own servers stay throughout, +# so names still resolve. forwards_to() { # forwards_to yes|no: wait until dnsmasq's file does, or does not, name the added server for _ in $(seq 1 40); do if netsh 'grep -q "^nameserver 192\.0\.2\.53$" /run/uplink-resolv.conf' > /dev/null; then [[ "$1" == yes ]] && return 0 @@ -131,18 +152,36 @@ forwards_to() { # forwards_to yes|no: wait until dnsmasq's file does, or does done return 1 } -told() { grep -hc 'netzone: dnsmasq: now forwarding to' /run/uncaught-logs/current /run/uncaught-logs/@* 2>/dev/null | awk '{ n += $1 } END { print n + 0 }'; } -told_before="$(told)" +reads() { uncaught | grep -ac 'dnsmasq\[[0-9]*\]: reading /run/uplink-resolv\.conf'; } +# dnsmasq's lines from its last read of the file on. +last_read() { uncaught | grep -a 'dnsmasq\[[0-9]*\]: ' | awk '/: reading \/run\/uplink-resolv\.conf/ { s = "" } { s = s $0 "\n" } END { printf "%s", s }'; } +poke='import socket, struct +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(2) +s.sendto(struct.pack(">HHHHHH", 7, 0x100, 1, 0, 0, 0) + b"\x07kryptik\x04test\x00\x00\x01\x00\x01", ("127.0.0.1", 53)) +s.recv(512)' +read_after() { # read_after N: query dnsmasq, which looks at its file at most once a second, until it has read it more than N times + for _ in $(seq 1 20); do + nsenter -t "${net_init:-0}" -n python3 -c "$poke" > /dev/null 2>&1 + [[ "$(reads)" -gt "$1" ]] && { sleep 2; return 0; } # its server lines follow + sleep 1 + done + return 1 +} was="$(netsh 'grep "^nameserver" /run/uplink-resolv.conf')" +before="$(reads)" if [[ "$was" == nameserver* ]] && wrote="$(netsh "printf '%s\n' '${was}' 'nameserver 192.0.2.53' > /etc/resolv.conf")"; then forwards_to yes; came=$? + read_after "$before"; read1=$? + gained="$(last_read | grep -c 'using nameserver 192\.0\.2\.53#53')" + before="$(reads)" netsh "printf '%s\n' '${was}' > /etc/resolv.conf" > /dev/null forwards_to no; went=$? - sleep 2 # the zone says so after it has told dnsmasq - if [[ "$came" -eq 0 && "$went" -eq 0 && "$(( $(told) - told_before ))" -ge 2 ]]; then - pass "dns-follows-lease" "a server the net zone's resolv.conf gained reached dnsmasq, and left it again with the lease" + read_after "$before"; read2=$? + kept="$(last_read | grep -c 'using nameserver 192\.0\.2\.53#53')" + if [[ "$came" -eq 0 && "$went" -eq 0 && "$read1" -eq 0 && "$read2" -eq 0 && "$gained" -ge 1 && "$kept" -eq 0 ]]; then + pass "dns-follows-lease" "a server the net zone's resolv.conf gained reached dnsmasq, which used it, and left it again with the lease" else - fail "dns-follows-lease" "gained: rc=$came, lost: rc=$went, told $(( $(told) - told_before )) time(s); $(grep -h 'netzone: dnsmasq' /run/uncaught-logs/current 2>/dev/null | tail -2 | tr '\n' ' ')" + fail "dns-follows-lease" "file gained: rc=$came, read: rc=$read1, used: $gained; file lost: rc=$went, read: rc=$read2, still used: $kept; $(last_read | tail -3 | tr '\n' ' ')" fi else fail "dns-follows-lease" "dnsmasq's servers could not be read in the net zone (init ${net_init:-none}), or its resolv.conf not written: ${was:-nothing read} ${wrote:-}" diff --git a/compartments/kryptikd/src/seccomp.rs b/compartments/kryptikd/src/seccomp.rs index be3daf2c..4e085f82 100644 --- a/compartments/kryptikd/src/seccomp.rs +++ b/compartments/kryptikd/src/seccomp.rs @@ -299,7 +299,7 @@ syscalls! { denied! { /// 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. + /// exception is `CAP_CALLS`: a zone that keeps `caps::PRIVSEP` gets the five 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"), @@ -670,9 +670,11 @@ pub fn confine_zone_with(extra: &[libc::c_long], sockets: &SocketPolicy, kept_ca /// 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. +/// zone's user and mount namespaces they reach only its mapped ids and its own tree. capset +/// goes with setuid: a daemon that keeps a capability across the drop names it with capset, +/// and capset can only narrow or move what the process already holds. pub const CAP_CALLS: &[(libc::c_int, &[libc::c_long])] = &[ - (crate::caps::cap::SETUID, &[libc::SYS_setuid]), + (crate::caps::cap::SETUID, &[libc::SYS_setuid, libc::SYS_capset]), (crate::caps::cap::SETGID, &[libc::SYS_setgid, libc::SYS_setgroups]), (crate::caps::cap::SYS_CHROOT, &[libc::SYS_chroot]), ]; diff --git a/compartments/kryptikd/src/seccomp/tests.rs b/compartments/kryptikd/src/seccomp/tests.rs index 67c3120c..38deba91 100644 --- a/compartments/kryptikd/src/seccomp/tests.rs +++ b/compartments/kryptikd/src/seccomp/tests.rs @@ -465,7 +465,7 @@ 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]; + let calls = [libc::SYS_setuid, libc::SYS_capset, 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(); @@ -477,6 +477,8 @@ fn kept_capabilities_open_their_calls() { // 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 setuid = widened_for(&[], &[crate::caps::cap::SETUID]).unwrap(); + assert!(setuid.contains(&libc::SYS_capset) && !setuid.contains(&libc::SYS_setgid)); let p = build_program(&all).unwrap(); for nr in calls { assert_eq!(evaluate(&p, X86, nr as u32), SECCOMP_RET_ALLOW, "{nr}"); @@ -485,6 +487,7 @@ fn kept_capabilities_open_their_calls() { 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_capset as u32), errno_action(EPERM)); assert_eq!(evaluate(&base, X86, libc::SYS_chroot as u32), SECCOMP_RET_KILL_PROCESS); } diff --git a/docs/design/net-zone.md b/docs/design/net-zone.md index efb31ae4..6fac6a33 100755 --- a/docs/design/net-zone.md +++ b/docs/design/net-zone.md @@ -109,19 +109,25 @@ query and the [update](update-channel.md) fetcher. Builds on connections arriving on an uplink dropped. - **The resolver:** `dnsmasq` on 10.19.0.1, fd19::1 and 127.0.0.1, forwarding to the uplink lease's servers (QEMU's 10.0.2.3 when nothing else - is known), restarted if it dies. A lease that comes after it started, or - another network's, reaches it within ten seconds: the zone's loop compares - the servers `resolv.conf` names with the ones dnsmasq was given, and on a - 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. + is known), restarted if it dies. It binds as the zone's root and then runs + as the zone's nobody, with no capability but `CAP_NET_BIND_SERVICE` (for + fd19::1, which stays tentative until the bridge has a port), so a bug in + what parses an answer does not hold the zone's root. A lease that comes + after it started, or another network's, reaches it within ten seconds: the + zone's loop compares the servers `resolv.conf` names with the ones dnsmasq + was given and on a change replaces the file, which dnsmasq reads before its + next query (at most once a second), forgetting what the last servers + answered. 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 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 + by the nic zone alone), its filter allows the five calls they serve + (`setuid`, `capset`, `setgid`, `setgroups`, `chroot`; + `seccomp::CAP_CALLS`; dnsmasq's drop to nobody uses the same), 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. diff --git a/docs/design/zone-policy-files.md b/docs/design/zone-policy-files.md index 1ddff2fc..e94850af 100755 --- a/docs/design/zone-policy-files.md +++ b/docs/design/zone-policy-files.md @@ -24,8 +24,8 @@ keep-capability CAP_NET_RAW # left in the bounding set 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`). + `CAP_SETGID` and `CAP_SYS_CHROOT` open `setuid` and `capset`, `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 @@ -38,12 +38,13 @@ keep-capability CAP_NET_RAW # left in the bounding set (`Policy::check_for_zone`): with either, a routed zone could re-address its 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. + they let dhcpcd drop to a user of its own and dnsmasq to nobody, and a + zone that keeps them also gets dhcpcd's 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 diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index 1d4027d0..8de3d4ac 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -65,7 +65,7 @@ zp="$(sed -n 's/.*passed=\([0-9]*\).*/\1/p' <<<"$summary")"; zf="$(sed -n 's/.*f if [[ -n "$summary" && "${zf:-1}" -eq 0 && "${zp:-0}" -ge 30 ]]; then green "every guest check passed (${zp})"; else red "guest checks: ${zp:-0} passed, ${zf:-?} failed"; fi grep 'ZT FAIL' <<<"$T2" | sed 's/^/ /' # The key verdicts one by one, so a pass is not a single line. -for name in kernel-support policies net-ready net-dns dhcpcd-separated zone0-nic zone0-no-route zone0-offline routed-egress routed-ping routed-ping6 routed-dns net-lease-names-resolver dns-follows-lease dns-after-reload routed-ipv6-noglobal zone-separation volume-hidden home-hidden fail-closed net-restart-ready reattach-after-restart uplink-returned uplink-retaken reattach-egress uplink-refused wifi-beyond routed-restart-path uplink-address-refused \ +for name in kernel-support policies net-ready net-dns dhcpcd-separated dnsmasq-unprivileged zone0-nic zone0-no-route zone0-offline routed-egress routed-ping routed-ping6 routed-dns net-lease-names-resolver dns-follows-lease dns-after-reload routed-ipv6-noglobal zone-separation volume-hidden home-hidden fail-closed net-restart-ready reattach-after-restart uplink-returned uplink-retaken reattach-egress uplink-refused wifi-beyond routed-restart-path uplink-address-refused \ wifi-module wifi-ap wifi-add wifi-associated wifi-lease wifi-egress wifi-forget \ time-floor-ran time-clamp time-floor-forged time-claim-stepped time-claim-floor time-claim-consent pids-limit ephemeral-size-bound cpu-max-set lifecycle-repeat lifecycle-registry \ terminal-terminfo man-page text-browser tls-trust \ diff --git a/tools/net/netzone-init.sh b/tools/net/netzone-init.sh index 739a2a74..5ad5b331 100755 --- a/tools/net/netzone-init.sh +++ b/tools/net/netzone-init.sh @@ -252,22 +252,27 @@ sync_upstream() { new="$(grep '^nameserver' "$RESOLV" 2>/dev/null)" [ -n "$new" ] || return 1 [ "$new" != "$(cat "$UPSTREAM" 2>/dev/null)" ] || return 1 - printf '%s\n' "$new" > "$UPSTREAM.new" 2>/dev/null && mv -f "$UPSTREAM.new" "$UPSTREAM" 2>/dev/null + # 644: dnsmasq reads it as nobody. + printf '%s\n' "$new" > "$UPSTREAM.new" 2>/dev/null && chmod 644 "$UPSTREAM.new" && mv -f "$UPSTREAM.new" "$UPSTREAM" 2>/dev/null } +# dnsmasq runs as nobody, whom this zone's root cannot signal, so kill -0 cannot tell. +dns_alive() { [ -d "/proc/$DNSPID" ] && ! grep -q '^State:.*zombie' "/proc/$DNSPID/status" 2>/dev/null; } start_dns() { command -v dnsmasq >/dev/null 2>&1 || { say "no dnsmasq; routed zones have no resolver"; return 1; } sync_upstream # QEMU user networking's resolver, when nothing else is known - grep -q '^nameserver' "$UPSTREAM" 2>/dev/null || echo "nameserver 10.0.2.3" > "$UPSTREAM" + grep -q '^nameserver' "$UPSTREAM" 2>/dev/null || { echo "nameserver 10.0.2.3" > "$UPSTREAM"; chmod 644 "$UPSTREAM"; } # --local=/test/ (RFC 6761) stays here: the guest check resolves kryptik.test through it. - # --no-poll: the file is read again on SIGHUP, which the loop below sends. - dnsmasq --keep-in-foreground --no-daemon --no-hosts --bind-interfaces \ + # It binds as root and then drops to nobody, which --no-daemon would stop. A changed + # file is read before the next query, at most once a second, and --clear-on-reload + # forgets what the last servers answered. --pid-file with no path writes none. + dnsmasq --keep-in-foreground --log-facility=- --no-hosts --bind-interfaces \ --listen-address=10.19.0.1 --listen-address=fd19::1 --listen-address=127.0.0.1 \ - --resolv-file="$UPSTREAM" --no-poll --cache-size=1000 --local-service --local=/test/ \ - --pid-file=/run/dnsmasq.pid --user=root & + --resolv-file="$UPSTREAM" --clear-on-reload --cache-size=1000 --local-service --local=/test/ \ + --pid-file --user=nobody --group=nogroup & DNSPID=$! sleep 1 - if kill -0 "$DNSPID" 2>/dev/null; then + if dns_alive; then say "dnsmasq listening on 10.19.0.1/fd19::1, forwarding to $(grep '^nameserver' "$UPSTREAM" | tr '\n' ' ')" return 0 fi @@ -365,7 +370,7 @@ status_line "$@" cleanup() { say "stopping" forwarding off - [ -n "$DNSPID" ] && kill "$DNSPID" 2>/dev/null + # dnsmasq and dhcpcd's parsers run as users this root cannot signal; the zone's end ends them. [ -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 exit 0 @@ -381,12 +386,11 @@ while :; do elif ! sync_gateways "$@"; then forwarding off; policy_ok=0; changed=1 fi - if [ -n "$DNSPID" ] && ! kill -0 "$DNSPID" 2>/dev/null; then + if [ -n "$DNSPID" ] && ! dns_alive; then say "dnsmasq died; restarting"; DNSPID=""; dns_ok=0; changed=1 start_dns && dns_ok=1 elif [ -n "$DNSPID" ] && sync_upstream; then - kill -HUP "$DNSPID" 2>/dev/null - say "dnsmasq: now forwarding to $(tr '\n' ' ' < "$UPSTREAM")" + say "dnsmasq: forwarding to $(tr '\n' ' ' < "$UPSTREAM")from its next query" fi for n in $WIRELESS; do p="$(wpa_pid "$n")" diff --git a/tools/tests/netzone-dns.sh b/tools/tests/netzone-dns.sh index 51b4eea5..ff401ded 100755 --- a/tools/tests/netzone-dns.sh +++ b/tools/tests/netzone-dns.sh @@ -16,15 +16,22 @@ T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT sed -n '/^sync_upstream() {/,/^}/p' "$SCRIPT" > "$T/functions.sh" grep -q '^sync_upstream() {' "$T/functions.sh" \ || { echo "could not find sync_upstream in ${SCRIPT#"$ROOT"/} (did its first line move?)"; exit 1; } -# The loop's own use of it: a resolver that is running is told, and only then. -grep -qE '^ elif \[ -n "\$DNSPID" \] && sync_upstream; then$' "$SCRIPT" && grep -qE '^ kill -HUP "\$DNSPID"' "$SCRIPT" \ - && green "the loop sends the resolver SIGHUP when sync_upstream says the servers changed" \ - || red "the loop no longer reloads the resolver on a change of servers" -grep -q -- '--resolv-file="\$UPSTREAM" --no-poll' "$SCRIPT" && green "dnsmasq reads the file sync_upstream writes, on SIGHUP" \ - || red "dnsmasq is not started on the file sync_upstream writes" +# The loop's own use of it: a resolver that is running is given the file, and +# no signal, which the zone's root cannot send its nobody. +grep -qE '^ elif \[ -n "\$DNSPID" \] && sync_upstream; then$' "$SCRIPT" && ! grep -q 'kill -HUP "\$DNSPID"' "$SCRIPT" \ + && green "the loop writes the servers for a running resolver, and sends it no signal" \ + || red "the loop no longer writes the servers on a change, or signals a resolver it cannot" +start="$(sed -n '/^start_dns() {/,/^}/p' "$SCRIPT")" +# --no-poll would leave the file unread, and --no-daemon keeps dnsmasq root. +grep -q -- '--resolv-file="\$UPSTREAM" --clear-on-reload' <<<"$start" && grep -q -- '--user=nobody' <<<"$start" \ + && ! grep -qE -- '--no-poll|--no-daemon' <<<"$start" \ + && green "dnsmasq runs as nobody and reads the file sync_upstream writes when it changes" \ + || red "dnsmasq is not started as nobody on the file sync_upstream writes" # step WHAT: one pass, as the loop makes it; prints its status and the file. +# umask 077: the file must still be one dnsmasq's nobody can read. cat > "$T/harness.sh" </dev/null | tr '\n' ',')" @@ -42,6 +49,7 @@ for sh in sh bash dash; do same "a resolv.conf that names no server leaves the file as it is" "$(step)" "rc=1 nameserver 10.0.2.3," printf '# Generated by dhcpcd from eth0.dhcp\ndomain lan\nnameserver 192.168.1.1\nnameserver fd00::1\noptions edns0\n' > "$T/resolv.conf" same "a lease that came late: its servers, IPv4 and IPv6, and nothing else of the file" "$(step)" "rc=0 nameserver 192.168.1.1,nameserver fd00::1," + same "the file is readable by all" "$(stat -c %a "$T/run/uplink-resolv.conf")" "644" same "the same servers again: no change, so no reload" "$(step)" "rc=1 nameserver 192.168.1.1,nameserver fd00::1," [[ ! -e "$T/run/uplink-resolv.conf.new" ]] && green "the file is replaced whole: no temporary is left" || red "a temporary is left beside the file" printf 'nameserver 10.77.0.1\n' > "$T/resolv.conf" From e60836b29d250bc8fae0a62e945ad9f6037afde9 Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 8 Oct 2026 19:32:09 -0700 Subject: [PATCH 5/5] The resolver's start check reads the command, not the comment above it, which names --no-daemon --- tools/tests/netzone-dns.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/tests/netzone-dns.sh b/tools/tests/netzone-dns.sh index ff401ded..73568eb3 100755 --- a/tools/tests/netzone-dns.sh +++ b/tools/tests/netzone-dns.sh @@ -21,7 +21,7 @@ grep -q '^sync_upstream() {' "$T/functions.sh" \ grep -qE '^ elif \[ -n "\$DNSPID" \] && sync_upstream; then$' "$SCRIPT" && ! grep -q 'kill -HUP "\$DNSPID"' "$SCRIPT" \ && green "the loop writes the servers for a running resolver, and sends it no signal" \ || red "the loop no longer writes the servers on a change, or signals a resolver it cannot" -start="$(sed -n '/^start_dns() {/,/^}/p' "$SCRIPT")" +start="$(sed -n '/^start_dns() {/,/^}/p' "$SCRIPT" | grep -v '^ *#')" # --no-poll would leave the file unread, and --no-daemon keeps dnsmasq root. grep -q -- '--resolv-file="\$UPSTREAM" --clear-on-reload' <<<"$start" && grep -q -- '--user=nobody' <<<"$start" \ && ! grep -qE -- '--no-poll|--no-daemon' <<<"$start" \