Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion build/guest-tests/zones-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,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: $(netzone_said '' | 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" "$(uncaught | grep -a 'dnsmasq' | 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
Expand Down Expand Up @@ -718,7 +743,7 @@ zrun untrusted 30 -- grep -c /usr/lib/libhardened_malloc.so /proc/self/maps
[[ "$ZRC" = 0 ]] && pass "allocator-zone" "a process in untrusted runs on it too" || fail "allocator-zone" "rc=$ZRC $(tail -1 "$LOG/untrusted.err")"

# --- the installed root: privilege only where the allowlist says -------------------
# What stage 06 stripped stays stripped: on the root filesystem a setuid or
# Stage 06 fails the build on any other bit; the installed root shows it: a setuid or
# setgid bit is on the listed binaries alone (build/config/setuid-allowlist.txt)
# and file capabilities are on none (capability-allowlist.txt is empty).
setuid_found="$(find / -xdev -type f -perm /6000 2>/dev/null | LC_ALL=C sort | tr '\n' ' ')"
Expand Down
3 changes: 3 additions & 0 deletions build/recipes/netzone.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
19 changes: 14 additions & 5 deletions compartments/kryptikd/src/caps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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),
Expand All @@ -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<libc::c_int> {
CAP_NAMES.iter().find(|(n, _)| *n == name).map(|(_, v)| *v)
Expand Down
15 changes: 15 additions & 0 deletions compartments/kryptikd/src/caps/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
19 changes: 15 additions & 4 deletions compartments/kryptikd/src/isolate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
4 changes: 2 additions & 2 deletions compartments/kryptikd/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1148,7 +1148,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::<libc::seccomp_notif>());
const NOTIF_SEND: libc::c_ulong = seccomp_iowr(1, std::mem::size_of::<libc::seccomp_notif_resp>());

/// 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<libc::c_long>, 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:?}"))?;
Expand All @@ -1158,7 +1158,7 @@ fn trace_filter(dir: &Path, name: &str) -> Result<(Vec<libc::c_long>, 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, naming every call it refuses. Refusals arrive by user
Expand Down
17 changes: 15 additions & 2 deletions compartments/kryptikd/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,25 @@ pub fn parse(text: &str, source: &str) -> Result<Policy, PolicyError> {
}
}
}
// 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::<Vec<_>>().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) {
Expand Down
22 changes: 21 additions & 1 deletion compartments/kryptikd/src/policy/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
}
Expand Down Expand Up @@ -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();
Expand Down
45 changes: 32 additions & 13 deletions compartments/kryptikd/src/rootfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -289,6 +303,7 @@ pub fn pivot_into(
broker: Option<&str>,
wayland: Option<&str>,
wifi_conf: Option<&str>,
service: bool,
) -> Result<String, RootfsError> {
let home = zone_home(zone);

Expand Down Expand Up @@ -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")?;
Expand Down Expand Up @@ -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"))?;
Expand Down
9 changes: 8 additions & 1 deletion compartments/kryptikd/src/rootfs/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
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"));
Expand Down
Loading
Loading