From f003c589826b648e50a959e44257a0b16e1f83d6 Mon Sep 17 00:00:00 2001 From: Hound Date: Thu, 20 Aug 2026 22:51:41 -0500 Subject: [PATCH] houndd: arm the gate from the daemon and shed root while doing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the execution gate into DaemonState::boot, reports it on the wire and in `hound status`, and reduces the daemon to the two capabilities it actually needs. Verified live, gate marked on a scratch tmpfs rather than /: clean binary ran EICAR binary execve -> EPERM, "Operation not permitted" hound status Exec gate: armed on /tmp/hound-live, 2 allowed, 1 blocked CapPrm/CapEff/CapBnd 0000000000200004 (CAP_SYS_ADMIN | CAP_DAC_READ_SEARCH) Three ordering bugs found by checking rather than assuming, all of which returned success while doing nothing: * Capabilities are per-thread. Dropping them after spawning the reader and workers reduced only the main thread and left four workers holding full root — the exact opposite of the intent. The drop now happens after fanotify_init and the marks, but before any thread exists, so workers inherit the reduced set. * PR_CAPBSET_DROP needs CAP_SETPCAP in the effective set, and capset had already thrown it away. Every bounding-set drop failed EPERM, silently, leaving a full CapBnd behind a log line claiming otherwise. Bounding set is now drained first, while the authority to do it still exists. * Because both of the above looked like successes, drop_to_gate_minimum now reads CapEff and CapBnd back from /proc/self/status and errors if they are not what was asked for. A privilege reduction that cannot be observed has not happened. Also: `hound status` grew an Exec gate line. timed_out above zero is the number worth alarming on — it means the watchdog is releasing processes unscanned and the gate has quietly degraded to advisory. Gate arming and every failure path now log to stderr, so the journal records a security-relevant state change instead of only the in-memory event ring. 86 tests pass. Co-Authored-By: Claude Opus 5 --- crates/hound-api/src/lib.rs | 23 ++++ crates/hound/src/main.rs | 32 +++++ crates/houndd/src/caps.rs | 261 ++++++++++++++++++++++++++++++++++++ crates/houndd/src/main.rs | 150 +++++++++++++++++++++ 4 files changed, 466 insertions(+) create mode 100644 crates/houndd/src/caps.rs diff --git a/crates/hound-api/src/lib.rs b/crates/hound-api/src/lib.rs index 9008457..a30abd6 100644 --- a/crates/hound-api/src/lib.rs +++ b/crates/hound-api/src/lib.rs @@ -88,6 +88,29 @@ pub struct Status { /// Number of files currently held in quarantine. #[serde(default)] pub quarantined: u64, + /// Execution-gate state. + #[serde(default)] + pub gate: GateStatus, +} + +/// Execution-gate state, for the tray and `hound status`. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct GateStatus { + /// True only when fanotify is armed and answering. + pub active: bool, + /// Why it is not active, when it is not: "disabled", "needs root", + /// or the OS error we got. + #[serde(default)] + pub detail: String, + /// Mounts covered. Empty with `active` means the root filesystem. + #[serde(default)] + pub paths: Vec, + pub allowed: u64, + pub denied: u64, + /// Events released by the watchdog past the deadline. Any number + /// above zero is worth surfacing — it means scans are running slow + /// enough that the gate is degrading to advisory. + pub timed_out: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/hound/src/main.rs b/crates/hound/src/main.rs index 0035eb6..47dac7a 100644 --- a/crates/hound/src/main.rs +++ b/crates/hound/src/main.rs @@ -134,6 +134,37 @@ enum RealtimeCmd { On, } +/// Report the execution gate. +/// +/// An armed gate is the most consequential thing the daemon is doing, and +/// `timed_out` is the one number worth alarming on: above zero it means +/// scans are slow enough that the watchdog is releasing processes +/// unscanned, so the gate has quietly degraded to advisory. +fn print_gate(g: &hound_api::GateStatus) { + if !g.active { + let why = if g.detail.is_empty() { "off" } else { &g.detail }; + println!(" Exec gate: {}", why.dimmed()); + return; + } + let covered = if g.paths.is_empty() { + "/".to_string() + } else { + g.paths.join(", ") + }; + println!(" Exec gate: {} on {covered}", "armed".green()); + println!(" {} allowed · {} blocked", g.allowed, g.denied); + if g.timed_out > 0 { + println!( + " {}", + format!( + "{} released unscanned past the deadline — scans are running slow", + g.timed_out + ) + .yellow() + ); + } +} + fn main() { let cli = Cli::parse(); let client = match client(&cli.sock) { @@ -181,6 +212,7 @@ fn run(client: &Client, cmd: &Cmd) -> Result { db.file, db.updated_at ); } + print_gate(&st.gate); } else { println!( "{} {} [engine: {}]", diff --git a/crates/houndd/src/caps.rs b/crates/houndd/src/caps.rs new file mode 100644 index 0000000..0145da8 --- /dev/null +++ b/crates/houndd/src/caps.rs @@ -0,0 +1,261 @@ +//! Capability reduction. +//! +//! The execution gate needs `CAP_SYS_ADMIN` to call `fanotify_init`, and +//! `CAP_DAC_READ_SEARCH` to read files whose owner has not invited us. +//! Nothing else. But a daemon started by systemd arrives holding the full +//! root set, which includes loading kernel modules, rebooting the +//! machine, and rewriting the audit log. +//! +//! We are asking people to run a root daemon that can block execution. +//! The least we can do is make it hold only what it needs, so that a +//! compromise of Hound is a compromise of Hound rather than of the box. +//! +//! Two things happen here, in this order, and the order is the whole +//! trick: +//! +//! 1. **The bounding set is drained**, which needs `CAP_SETPCAP` in the +//! *effective* set. Irreversible for the life of the process, so +//! nothing it later execs can regain a dropped capability however it +//! is marked on disk. +//! 2. **Permitted and effective are narrowed**, which throws away +//! `CAP_SETPCAP` along with everything else. +//! +//! Doing these the other way round looks correct and silently does half +//! the job: `capset` succeeds, every subsequent `PR_CAPBSET_DROP` fails +//! with `EPERM` because the capability authorising it was just discarded, +//! and the process keeps a full bounding set while reporting success. +//! +//! Packaging will *also* set `CapabilityBoundingSet` in the systemd unit +//! in Phase 2. Belt and braces: the unit protects us if this code is +//! never reached, and this code protects us when someone runs the binary +//! by hand. + +use std::io; + +/// Capability numbers we care about, from `linux/capability.h`. +pub const CAP_DAC_READ_SEARCH: u32 = 2; +pub const CAP_SYS_ADMIN: u32 = 21; + +/// Highest capability the running kernel could define. 63 is the ceiling +/// of the v3 two-word ABI; dropping past what exists is harmless. +const CAP_LAST_CAP_CEILING: u32 = 63; + +const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522; +const PR_CAPBSET_DROP: libc::c_int = 24; + +#[repr(C)] +struct CapHeader { + version: u32, + pid: libc::c_int, +} + +#[repr(C)] +#[derive(Clone, Copy, Default)] +struct CapData { + effective: u32, + permitted: u32, + inheritable: u32, +} + +/// Split a capability list into the two 32-bit words the v3 ABI uses. +/// +/// Pure, so the bit arithmetic is tested rather than trusted — getting a +/// word boundary wrong would silently keep a capability we meant to drop. +pub fn to_words(caps: &[u32]) -> (u32, u32) { + let mut lo = 0u32; + let mut hi = 0u32; + for &c in caps { + if c < 32 { + lo |= 1 << c; + } else if c < 64 { + hi |= 1 << (c - 32); + } + } + (lo, hi) +} + +/// True when this process can actually do the reduction — i.e. is root. +pub fn is_root() -> bool { + // SAFETY: geteuid cannot fail. + unsafe { libc::geteuid() == 0 } +} + +/// Reduce this process to exactly `caps`, and nothing else. +/// +/// A no-op returning `Ok(false)` when not running as root, because an +/// unprivileged daemon has nothing to drop and failing there would stop +/// developers running the thing. +pub fn retain_only(caps: &[u32]) -> io::Result { + if !is_root() { + return Ok(false); + } + + let (lo, hi) = to_words(caps); + + // 1. Bounding set first, while CAP_SETPCAP is still held. Dropping one + // we never had, or one this kernel does not define, returns EINVAL + // and is not interesting; anything else is worth knowing about. + let mut refused = 0usize; + for c in 0..=CAP_LAST_CAP_CEILING { + if caps.contains(&c) { + continue; + } + // SAFETY: prctl with a constant option and a capability number. + let rc = unsafe { libc::prctl(PR_CAPBSET_DROP, c as libc::c_ulong, 0, 0, 0) }; + if rc != 0 && io::Error::last_os_error().raw_os_error() != Some(libc::EINVAL) { + refused += 1; + } + } + if refused > 0 { + return Err(io::Error::other(format!( + "{refused} capabilities could not be dropped from the bounding set" + ))); + } + + // 2. Then narrow permitted and effective. Inheritable stays empty: + // nothing we exec should inherit anything. + let header = CapHeader { + version: LINUX_CAPABILITY_VERSION_3, + pid: 0, // this thread + }; + let data = [ + CapData { + effective: lo, + permitted: lo, + inheritable: 0, + }, + CapData { + effective: hi, + permitted: hi, + inheritable: 0, + }, + ]; + // SAFETY: header and data are correctly shaped for CAP version 3 and + // both outlive the call. + let rc = unsafe { + libc::syscall( + libc::SYS_capset, + &header as *const CapHeader, + data.as_ptr(), + ) + }; + if rc != 0 { + return Err(io::Error::last_os_error()); + } + + Ok(true) +} + +/// Read this thread's effective capability set from `/proc/self/status`. +/// +/// Used to *verify* the reduction rather than trust the return code — the +/// bug this file exists to document was a syscall that returned success +/// while doing nothing. +pub fn effective_now() -> Option { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + let line = status.lines().find(|l| l.starts_with("CapEff:"))?; + u64::from_str_radix(line.trim_start_matches("CapEff:").trim(), 16).ok() +} + +/// Read this thread's capability bounding set. +pub fn bounding_now() -> Option { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + let line = status.lines().find(|l| l.starts_with("CapBnd:"))?; + u64::from_str_radix(line.trim_start_matches("CapBnd:").trim(), 16).ok() +} + +/// Exactly what the execution gate needs, and nothing more. +pub fn drop_to_gate_minimum() -> io::Result { + let dropped = retain_only(&[CAP_SYS_ADMIN, CAP_DAC_READ_SEARCH])?; + if !dropped { + return Ok(false); + } + // Trust the kernel, verify anyway: the failure mode here is a syscall + // that returns success and changes nothing. + let (lo, _) = to_words(&[CAP_SYS_ADMIN, CAP_DAC_READ_SEARCH]); + let want = lo as u64; + match (effective_now(), bounding_now()) { + (Some(eff), Some(bnd)) if eff == want && bnd == want => Ok(true), + (Some(eff), Some(bnd)) => Err(io::Error::other(format!( + "capability reduction did not take: CapEff={eff:#x} CapBnd={bnd:#x}, wanted {want:#x}" + ))), + _ => Ok(true), // cannot verify; the syscalls did report success + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn low_capabilities_land_in_the_low_word() { + let (lo, hi) = to_words(&[CAP_DAC_READ_SEARCH]); + assert_eq!(lo, 0b100, "CAP_DAC_READ_SEARCH is bit 2"); + assert_eq!(hi, 0); + } + + #[test] + fn cap_sys_admin_is_bit_21_of_the_low_word() { + let (lo, hi) = to_words(&[CAP_SYS_ADMIN]); + assert_eq!(lo, 1 << 21); + assert_eq!(hi, 0); + } + + #[test] + fn the_gate_set_is_exactly_two_bits() { + let (lo, hi) = to_words(&[CAP_SYS_ADMIN, CAP_DAC_READ_SEARCH]); + assert_eq!(lo.count_ones(), 2, "no capability may sneak in"); + assert_eq!(hi, 0); + } + + #[test] + fn capabilities_above_31_cross_into_the_high_word() { + let (lo, hi) = to_words(&[40]); + assert_eq!(lo, 0); + assert_eq!(hi, 1 << 8, "cap 40 is bit 8 of the high word"); + } + + #[test] + fn the_word_boundary_is_handled_exactly() { + let (lo, hi) = to_words(&[31, 32]); + assert_eq!(lo, 1 << 31, "31 is the last bit of the low word"); + assert_eq!(hi, 1, "32 is the first bit of the high word"); + } + + #[test] + fn out_of_range_capabilities_are_ignored_not_wrapped() { + // A shift past the word width would panic in debug and wrap in + // release, quietly setting the wrong bit. + let (lo, hi) = to_words(&[64, 999]); + assert_eq!((lo, hi), (0, 0)); + } + + #[test] + fn an_empty_set_grants_nothing() { + assert_eq!(to_words(&[]), (0, 0)); + } + + #[test] + fn effective_and_bounding_sets_are_readable() { + // If these ever stop parsing, the verification in + // `drop_to_gate_minimum` goes blind and a failed drop looks like a + // successful one. + assert!(effective_now().is_some(), "CapEff must be readable"); + assert!(bounding_now().is_some(), "CapBnd must be readable"); + } + + #[test] + fn the_gate_set_matches_what_proc_would_report() { + let (lo, _) = to_words(&[CAP_SYS_ADMIN, CAP_DAC_READ_SEARCH]); + assert_eq!(lo as u64, 0x20_0004, "must match the CapEff mask on the wire"); + } + + #[test] + fn unprivileged_reduction_is_a_clean_no_op() { + // Under a normal test run this returns Ok(false) rather than + // erroring, so `cargo test` works without root. + if !is_root() { + assert_eq!(retain_only(&[CAP_SYS_ADMIN]).unwrap(), false); + } + } +} diff --git a/crates/houndd/src/main.rs b/crates/houndd/src/main.rs index 95be0ba..b13112e 100644 --- a/crates/houndd/src/main.rs +++ b/crates/houndd/src/main.rs @@ -41,6 +41,7 @@ //! without ClamAV installed). mod cache; +mod caps; mod engine; mod events; mod fanotify; @@ -72,6 +73,11 @@ struct DaemonState { events: events::EventLog, quarantine: quarantine::Quarantine, realtime: realtime::RealtimeMonitor, + /// The execution gate, when it came up. `None` covers both "switched + /// off" and "could not be armed"; `gate_detail` says which. + gate: Option>, + gate_detail: std::sync::Arc, + gate_paths: Vec, } fn main() -> Result<()> { @@ -134,6 +140,8 @@ impl DaemonState { Err(e) => events.push("realtime", "warn", format!("real-time monitor idle: {e}")), }; } + let (gate, gate_detail, gate_paths) = Self::arm_gate(&s, &events); + events.push("info", "info", "houndd daemon started".into()); Self { @@ -141,8 +149,127 @@ impl DaemonState { events, quarantine, realtime, + gate, + gate_detail: std::sync::Arc::new(gate_detail), + gate_paths, } } + + /// Bring up the execution gate, if it is switched on and we can. + /// + /// Ordering is deliberate: `fanotify_init` needs CAP_SYS_ADMIN, so the + /// capability reduction happens *after* the group is open. Every + /// failure here degrades to a working daemon without a gate — never + /// to a daemon that will not start. An antivirus that refuses to run + /// protects nothing. + fn arm_gate( + s: &Settings, + events: &events::EventLog, + ) -> (Option>, String, Vec) { + if !s.exec_gate { + eprintln!("gate: disabled in settings"); + return (None, "disabled".into(), Vec::new()); + } + if !caps::is_root() { + let why = "needs root (CAP_SYS_ADMIN)"; + events.push("gate", "warn", format!("execution gate off: {why}")); + return (None, why.into(), Vec::new()); + } + + let gate = match fanotify::Gate::init() { + Ok(g) => std::sync::Arc::new(g), + Err(e) => { + events.push("gate", "warn", format!("execution gate off: {e}")); + return (None, e.to_string(), Vec::new()); + } + }; + + // Empty means the whole root filesystem, which is the production + // shape. Anything listed is treated as a mount to cover. + let paths: Vec = if s.exec_gate_paths.is_empty() { + vec!["/".to_string()] + } else { + s.exec_gate_paths.clone() + }; + let mut marked = Vec::new(); + for p in &paths { + let path = std::path::Path::new(p); + let result = if s.exec_gate_paths.is_empty() { + gate.mark_filesystem(path) + } else { + gate.mark_mount(path) + }; + match result { + Ok(()) => marked.push(p.clone()), + Err(e) => { + events.push("gate", "warn", format!("could not watch {p}: {e}")); + eprintln!("gate: could not watch {p}: {e}"); + } + } + } + if marked.is_empty() { + return (None, "no mount could be watched".into(), Vec::new()); + } + + // Give up everything we do not need, and do it HERE — before a + // single thread exists. + // + // Capabilities are per-thread. Dropping them after spawning the + // reader and workers would reduce only this thread and leave the + // workers holding full root, which is the opposite of the point. + // Threads created after this inherit the reduced set. fanotify_init + // and the marks are already done, and CAP_SYS_ADMIN is retained so + // a settings change can still add one later. + match caps::drop_to_gate_minimum() { + Ok(true) => { + events.push( + "gate", + "info", + "capabilities reduced to CAP_SYS_ADMIN + CAP_DAC_READ_SEARCH".into(), + ); + eprintln!("gate: capabilities reduced to CAP_SYS_ADMIN + CAP_DAC_READ_SEARCH"); + } + Ok(false) => {} + Err(e) => { + events.push("gate", "warn", format!("could not reduce capabilities: {e}")); + eprintln!("gate: could not reduce capabilities: {e}"); + } + } + + // The watchdog before the workers, always: it is what guarantees + // no process is held past the deadline, including during startup. + gate.start_watchdog(); + + let excludes = s.exclude_paths.clone(); + let max_size = s.max_file_size_mb.saturating_mul(1024 * 1024); + let ev = events.clone(); + gate.serve( + fanotify::GateConfig { + workers: 4, + max_size, + excludes, + }, + std::sync::Arc::new(move |path: &std::path::Path, bytes: &[u8]| { + let hit = engine::engine().scan_bytes(bytes); + if let Some(name) = &hit { + ev.push( + "gate", + "critical", + format!("blocked execution of {} ({name})", path.display()), + ); + } + hit + }), + ); + + events.push( + "gate", + "info", + format!("execution gate armed on {}", marked.join(", ")), + ); + eprintln!("gate: armed on {} (4 workers, watchdog live)", marked.join(", ")); + (Some(gate), String::new(), marked) + } } /// Read one request line, dispatch, write one response line. @@ -358,9 +485,32 @@ fn status(st: &DaemonState) -> Result { db, realtime: st.realtime.status(), quarantined: st.quarantine.count(), + gate: gate_status(st), }) } +/// Snapshot the execution gate for the wire. +fn gate_status(st: &DaemonState) -> hound_api::GateStatus { + match &st.gate { + Some(gate) => { + let (allowed, denied, timed_out) = gate.responder().counters(); + hound_api::GateStatus { + active: true, + detail: String::new(), + paths: st.gate_paths.clone(), + allowed, + denied, + timed_out, + } + } + None => hound_api::GateStatus { + active: false, + detail: st.gate_detail.as_ref().clone(), + ..Default::default() + }, + } +} + /// Refresh the signature store via the engine, then re-probe so the /// client can refresh its UI/tray from a single round-trip. fn update(st: &DaemonState) -> Result {