houndd: arm the gate from the daemon and shed root while doing it

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 <noreply@anthropic.com>
This commit is contained in:
Hound 2026-08-20 22:51:41 -05:00
parent aa22ddbc38
commit f003c58982
4 changed files with 466 additions and 0 deletions

View file

@ -88,6 +88,29 @@ pub struct Status {
/// Number of files currently held in quarantine. /// Number of files currently held in quarantine.
#[serde(default)] #[serde(default)]
pub quarantined: u64, 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<String>,
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)] #[derive(Debug, Clone, Serialize, Deserialize)]

View file

@ -134,6 +134,37 @@ enum RealtimeCmd {
On, 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() { fn main() {
let cli = Cli::parse(); let cli = Cli::parse();
let client = match client(&cli.sock) { let client = match client(&cli.sock) {
@ -181,6 +212,7 @@ fn run(client: &Client, cmd: &Cmd) -> Result<i32> {
db.file, db.updated_at db.file, db.updated_at
); );
} }
print_gate(&st.gate);
} else { } else {
println!( println!(
"{} {} [engine: {}]", "{} {} [engine: {}]",

261
crates/houndd/src/caps.rs Normal file
View file

@ -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<bool> {
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<u64> {
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<u64> {
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<bool> {
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);
}
}
}

View file

@ -41,6 +41,7 @@
//! without ClamAV installed). //! without ClamAV installed).
mod cache; mod cache;
mod caps;
mod engine; mod engine;
mod events; mod events;
mod fanotify; mod fanotify;
@ -72,6 +73,11 @@ struct DaemonState {
events: events::EventLog, events: events::EventLog,
quarantine: quarantine::Quarantine, quarantine: quarantine::Quarantine,
realtime: realtime::RealtimeMonitor, 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<std::sync::Arc<fanotify::Gate>>,
gate_detail: std::sync::Arc<String>,
gate_paths: Vec<String>,
} }
fn main() -> Result<()> { fn main() -> Result<()> {
@ -134,6 +140,8 @@ impl DaemonState {
Err(e) => events.push("realtime", "warn", format!("real-time monitor idle: {e}")), 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()); events.push("info", "info", "houndd daemon started".into());
Self { Self {
@ -141,8 +149,127 @@ impl DaemonState {
events, events,
quarantine, quarantine,
realtime, 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<std::sync::Arc<fanotify::Gate>>, String, Vec<String>) {
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<String> = 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. /// Read one request line, dispatch, write one response line.
@ -358,9 +485,32 @@ fn status(st: &DaemonState) -> Result<hound_api::Status> {
db, db,
realtime: st.realtime.status(), realtime: st.realtime.status(),
quarantined: st.quarantine.count(), 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 /// Refresh the signature store via the engine, then re-probe so the
/// client can refresh its UI/tray from a single round-trip. /// client can refresh its UI/tray from a single round-trip.
fn update(st: &DaemonState) -> Result<hound_api::UpdateResult> { fn update(st: &DaemonState) -> Result<hound_api::UpdateResult> {