//! Who is on the other end of the socket, and what they may ask for. //! //! The daemon runs as root, so every method it exposes runs as root. Until //! the desktop app existed that was academic — the socket was `0700 root:root` //! and only `sudo hound` could reach it. But a GUI runs as the logged-in user //! and has to reach the daemon without a password prompt on every status poll, //! so the socket is now group-readable by `hound`. //! //! That group is a real trust grant and it is worth being exact about how far //! it goes. `quarantine.add` moves any path into the vault, and //! `quarantine.restore` writes a file back out as root — between them that is //! arbitrary file replacement on the system, which is root. Group members //! therefore get the read side of the API and nothing that writes: //! administrative methods still require a peer with uid 0. //! //! Scanning is the exception worth explaining. It reads as root, so an //! unprivileged caller could otherwise use it as an oracle for files it cannot //! open. So a non-root caller's scan is checked against what *that caller* //! could read, by forking a child, dropping it to the peer's uid, gid and //! supplementary groups, and letting the kernel answer with `access(2)` — //! which honours ACLs and mount options, unlike anything we could reconstruct //! from a mode bitfield. use anyhow::{bail, Result}; use std::ffi::CString; use std::os::unix::io::AsRawFd; use std::os::unix::net::UnixStream; use std::path::Path; /// The credentials the kernel attaches to a connection. Unforgeable: these /// come from `SO_PEERCRED`, not from anything the client sent us. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Peer { pub pid: i32, pub uid: u32, pub gid: u32, } impl Peer { pub fn is_root(&self) -> bool { self.uid == 0 } /// May this peer use the methods that write? /// /// Root, or whoever the daemon is running as. The second clause is not a /// loophole: a user running `houndd` under their own account can already /// kill it, attach a debugger to it, or edit its settings file directly, /// so refusing them the API buys nothing and breaks every developer and /// test run. In the shipped configuration the daemon is uid 0, and this /// collapses to exactly "the peer is root". pub fn may_administer(&self) -> bool { // SAFETY: getuid takes no arguments and cannot fail. self.is_root() || self.uid == unsafe { libc::getuid() } } pub fn from_stream(stream: &UnixStream) -> Result { let mut cred = libc::ucred { pid: 0, uid: u32::MAX, gid: u32::MAX, }; let mut len = std::mem::size_of::() as libc::socklen_t; // SAFETY: cred and len are correctly sized for SO_PEERCRED, and the fd // is owned by the borrowed stream for the duration of the call. let rc = unsafe { libc::getsockopt( stream.as_raw_fd(), libc::SOL_SOCKET, libc::SO_PEERCRED, (&mut cred as *mut libc::ucred).cast(), &mut len, ) }; if rc != 0 { bail!( "reading peer credentials: {}", std::io::Error::last_os_error() ); } Ok(Peer { pid: cred.pid, uid: cred.uid, gid: cred.gid, }) } } /// What a method costs the caller in trust. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Access { /// Reports the daemon's own state. Anyone who can open the socket. Read, /// Reads a caller-supplied path as root; allowed once we have confirmed /// the caller could have read it themselves. ReadsPath, /// Changes the system, or the daemon's configuration. Root only. Admin, } pub fn access_for(method: &str) -> Access { match method { "status" | "settings.get" | "events.list" | "quarantine.list" | "realtime.status" | "rootkit.scan" | "persistence.scan" // Asking whether a newer version exists changes nothing, and reveals // nothing the website does not already say. | "release.check" // Reports on this daemon's own blind spots. Reveals nothing about // the machine that its own operator cannot already see. | "selfcheck" => Access::Read, "scan" | "supply.sweep" => Access::ReadsPath, // Everything that writes: definitions updates, settings, the event // log, and every quarantine mutation. _ => Access::Admin, } } /// Can `peer` read `path` in its own right? Answered by the kernel, in a child /// process that has actually become the peer. /// /// Errs on the side of refusal: a fork failure, a child that dies on a signal, /// or any unexpected exit status is a no. pub fn peer_can_read(peer: &Peer, path: &Path) -> bool { if peer.is_root() { return true; } let Ok(c_path) = CString::new(path.as_os_str().as_encoded_bytes()) else { return false; // an interior NUL is not a path we will scan }; // Impersonating someone requires being root. The packaged daemon is, but // a developer running `houndd` from a build tree is not — and there the // only peer it can answer for is itself. // SAFETY: geteuid takes no arguments and cannot fail. if unsafe { libc::geteuid() } != 0 { // SAFETY: as above. if unsafe { libc::getuid() } != peer.uid { return false; } // SAFETY: c_path is NUL-terminated and outlives the call. return unsafe { libc::access(c_path.as_ptr(), libc::R_OK) } == 0; } let groups = supplementary_groups(peer.uid, peer.gid); // SAFETY: between fork and _exit the child calls only async-signal-safe // functions — setgroups, setgid, setuid, access, _exit. It allocates // nothing and unwinds nothing. let pid = unsafe { libc::fork() }; if pid < 0 { return false; } if pid == 0 { unsafe { // Order matters: setgroups and setgid must precede setuid, or the // child no longer has the privilege to make those calls. if libc::setgroups(groups.len() as _, groups.as_ptr()) != 0 { libc::_exit(101); } if libc::setgid(peer.gid) != 0 { libc::_exit(102); } if libc::setuid(peer.uid) != 0 { libc::_exit(103); } // Belt and braces: if the drop somehow left us root, refuse. if libc::getuid() == 0 || libc::geteuid() == 0 { libc::_exit(104); } let ok = libc::access(c_path.as_ptr(), libc::R_OK) == 0; libc::_exit(if ok { 0 } else { 1 }); } } let mut status = 0; // SAFETY: pid is a child we just created; status is a valid out-param. if unsafe { libc::waitpid(pid, &mut status, 0) } != pid { return false; } if !libc::WIFEXITED(status) { return false; } match libc::WEXITSTATUS(status) { 0 => true, 1 => false, // The child could not become the peer, so the question was never // asked. Refuse — but say so, because a daemon that has lost // CAP_SETUID will refuse every non-root scan and the reason should // not have to be guessed from a permission error. code => { eprintln!( "peer check: could not drop to uid {} (stage {code}) — refusing to \ vouch for a read this daemon cannot verify", peer.uid ); false } } } /// The peer's supplementary groups, so the check honours group-readable files. /// Falls back to the primary gid alone if the user cannot be resolved. fn supplementary_groups(uid: u32, gid: u32) -> Vec { let Some(name) = username_for(uid) else { return vec![gid]; }; let Ok(c_name) = CString::new(name) else { return vec![gid]; }; let mut ngroups: libc::c_int = 32; let mut groups: Vec = vec![0; ngroups as usize]; // SAFETY: c_name outlives the call; groups has ngroups capacity. A -1 // return rewrites ngroups with the count actually needed, so we retry once. let rc = unsafe { libc::getgrouplist(c_name.as_ptr(), gid, groups.as_mut_ptr(), &mut ngroups) }; if rc < 0 { groups = vec![0; ngroups.max(1) as usize]; // SAFETY: as above, now with the size the kernel asked for. if unsafe { libc::getgrouplist(c_name.as_ptr(), gid, groups.as_mut_ptr(), &mut ngroups) } < 0 { return vec![gid]; } } groups.truncate(ngroups.max(0) as usize); if groups.is_empty() { groups.push(gid); } groups } /// Resolve a uid to a login name via /etc/passwd. Reading the file directly /// keeps this free of NSS, which can block on a network directory service — /// not something a scan request should ever wait on. fn username_for(uid: u32) -> Option { let passwd = std::fs::read_to_string("/etc/passwd").ok()?; for line in passwd.lines() { let mut f = line.split(':'); let name = f.next()?; let _pw = f.next(); let this_uid: u32 = f.next()?.parse().ok()?; if this_uid == uid { return Some(name.to_string()); } } None } #[cfg(test)] mod tests { use super::*; #[test] fn reads_are_open_and_writes_are_not() { assert_eq!(access_for("status"), Access::Read); assert_eq!(access_for("quarantine.list"), Access::Read); // Asking whether a newer version exists changes nothing and reveals // nothing the website does not. assert_eq!(access_for("release.check"), Access::Read); assert_eq!(access_for("scan"), Access::ReadsPath); assert_eq!(access_for("supply.sweep"), Access::ReadsPath); for admin in [ "update", "settings.set", "events.clear", "quarantine.add", "quarantine.restore", "quarantine.remove", "realtime.set_enabled", // Installing a licence writes a file the daemon acts on. "license.install", ] { assert_eq!(access_for(admin), Access::Admin, "{admin} must require root"); } } /// An unknown method must not fall through to the permissive arm. If /// someone adds a mutating method and forgets to classify it, it lands /// in Admin and fails closed rather than silently becoming public. #[test] fn unclassified_methods_fail_closed() { assert_eq!(access_for("some.future.method"), Access::Admin); assert_eq!(access_for(""), Access::Admin); } /// The owning user is an administrator of their own daemon; anybody else /// is not. Under a root daemon the two clauses are the same clause. #[test] fn the_owner_administers_and_strangers_do_not() { let me = unsafe { libc::getuid() }; assert!(Peer { pid: 1, uid: 0, gid: 0 }.may_administer(), "root always"); assert!(Peer { pid: 1, uid: me, gid: 0 }.may_administer(), "the owner"); let stranger = if me == 0 { 1000 } else { me + 1 }; assert!( !Peer { pid: 1, uid: stranger, gid: 0 }.may_administer(), "a different unprivileged user must not administer the daemon" ); } #[test] fn root_peer_reads_anything() { let root = Peer { pid: 1, uid: 0, gid: 0, }; assert!(peer_can_read(&root, Path::new("/etc/shadow"))); } /// The check is only meaningful if it can say no. Running as an /// unprivileged user, /etc/shadow is the canonical unreadable file; /// running as root there is nothing to prove, so skip. This exercises /// the non-root branch, where the daemon can only answer for itself. #[test] fn unprivileged_peer_is_refused_a_file_it_cannot_open() { let uid = unsafe { libc::getuid() }; if uid == 0 { return; } let gid = unsafe { libc::getgid() }; let me = Peer { pid: std::process::id() as i32, uid, gid, }; if Path::new("/etc/shadow").exists() { assert!(!peer_can_read(&me, Path::new("/etc/shadow"))); } // ...and it must still say yes to something the peer owns. let mine = std::env::temp_dir().join(format!("hound-peer-{uid}-{}", std::process::id())); std::fs::write(&mine, b"x").unwrap(); assert!(peer_can_read(&me, &mine)); let _ = std::fs::remove_file(&mine); } /// An unprivileged daemon cannot impersonate anybody, and must refuse /// rather than answer a question it has no way to check. #[test] fn an_unprivileged_daemon_refuses_to_vouch_for_another_user() { if unsafe { libc::geteuid() } == 0 { return; } let someone_else = Peer { pid: 1, uid: unsafe { libc::getuid() } + 1, gid: 0, }; let readable = std::env::temp_dir(); assert!(!peer_can_read(&someone_else, &readable)); } #[test] fn a_missing_file_is_not_readable() { let uid = unsafe { libc::getuid() }; if uid == 0 { return; } let me = Peer { pid: 1, uid, gid: unsafe { libc::getgid() }, }; assert!(!peer_can_read(&me, Path::new("/nonexistent/hound/path"))); } }