//! Rootkit heuristics. //! //! This file is a rewrite. The version it replaces was a false-positive //! generator, and for an antivirus that is worse than a miss — a rule that //! quarantines a system binary ends the product. Two of its checks were //! structurally wrong rather than merely tuned badly: //! //! * **Hidden processes** were "any `/proc/` whose `comm` we cannot //! read". That fires on every process that exits between the directory //! listing and the read, which on a busy machine is several per scan. //! It is a race, not a signal. //! * **Setuid anomalies** were compared against a hardcoded allowlist of //! binary names. That list was written on one distribution and would //! have alarmed on every other one. //! //! What replaces them: //! //! * A process is hidden when the kernel agrees it exists but `/proc` does //! not list it. `kill(pid, 0)` answers the first question for the entire //! PID space — `ESRCH` means gone, anything else means present. Bracket //! that sweep with two `/proc` listings and re-verify each candidate, and //! a process that merely started or exited during the scan cannot be //! mistaken for a hidden one. A full 4.2-million-PID sweep costs about a //! second. //! * A setuid binary is suspicious when **no installed package claims it**. //! The package manager already knows what belongs on the system, which //! makes the question factual instead of a guess about names. use hound_api::{RootkitFinding, RootkitScan}; use std::collections::HashSet; use std::path::{Path, PathBuf}; /// Run every check. pub fn run_scan(watch_dirs: &[String]) -> RootkitScan { let mut findings: Vec = Vec::new(); findings.extend(hidden_processes()); findings.extend(unowned_setuid()); findings.extend(preload_hooks()); findings.extend(writable_system_dirs()); findings.extend(hidden_files(watch_dirs)); let critical = findings.iter().filter(|f| f.severity == "critical").count() as u32; let warn = findings.iter().filter(|f| f.severity == "warn").count() as u32; let info = findings.iter().filter(|f| f.severity == "info").count() as u32; let clean = critical == 0 && warn == 0; let verdict = if clean { "clean".into() } else { format!("{critical} critical, {warn} warning(s)") }; RootkitScan { ts: crate::engine::to_rfc3339(std::time::SystemTime::now()), verdict, critical, warn, info, findings, } } fn finding(sev: &str, kind: &str, detail: String) -> RootkitFinding { RootkitFinding { check: kind.to_string(), severity: sev.to_string(), detail, } } // ── check 1: processes the kernel has but /proc does not ──────────────── /// Thread-group leaders listed directly in `/proc`. pub fn proc_pids() -> HashSet { std::fs::read_dir("/proc") .into_iter() .flatten() .flatten() .filter_map(|e| e.file_name().to_str().and_then(|s| s.parse::().ok())) .collect() } /// Every task id the kernel exposes: thread-group leaders **and threads**. /// /// This distinction is the whole check. `/proc` lists only thread-group /// leaders, but `kill(2)` accepts any thread id — so a process with twenty /// threads has nineteen ids that answer `kill` and do not appear in a /// `/proc` listing. Comparing against `proc_pids()` alone reports every /// thread on the machine as a hidden process, which on this laptop was /// dozens of criticals against a completely healthy system. /// /// Threads live at `/proc//task/`, so the honest set is the /// union of the leaders and their tasks. pub fn proc_tids() -> HashSet { let mut all = HashSet::new(); for pid in proc_pids() { all.insert(pid); let task_dir = format!("/proc/{pid}/task"); for e in std::fs::read_dir(task_dir).into_iter().flatten().flatten() { if let Some(tid) = e.file_name().to_str().and_then(|s| s.parse::().ok()) { all.insert(tid); } } } all } /// Whether the kernel believes a PID exists. /// /// `kill(pid, 0)` sends no signal. Returning 0 means it exists and we may /// signal it; `EPERM` means it exists and belongs to somebody else — which /// is still proof of existence, and is the case that matters most, since a /// rootkit's process will not be ours. pub fn pid_exists(pid: u32) -> bool { // SAFETY: kill with signal 0 performs an existence and permission // check only; it cannot affect the target. let rc = unsafe { libc::kill(pid as libc::pid_t, 0) }; if rc == 0 { return true; } std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) } fn pid_max() -> u32 { std::fs::read_to_string("/proc/sys/kernel/pid_max") .ok() .and_then(|s| s.trim().parse().ok()) .unwrap_or(32_768) } /// Candidates that exist per the kernel but appear in neither listing. /// /// Bracketing the sweep is what removes the race the old check drowned in: /// a process that started during the sweep appears in `after`, and one that /// exited appears in `before`. Only something absent from both, while still /// answering `kill`, is unexplained. pub fn hidden_pid_candidates(before: &HashSet, after: &HashSet, max: u32) -> Vec { (1..=max) .filter(|p| !before.contains(p) && !after.contains(p) && pid_exists(*p)) .collect() } fn hidden_processes() -> Vec { let before = proc_tids(); if before.is_empty() { return vec![finding( "info", "hidden_process", "/proc is not readable, so hidden processes cannot be checked for".into(), )]; } // Before trusting a comparison between /proc and the kernel, check that // this process can actually see /proc. `ProtectProc=invisible` (and // hidepid= on the mount) filter the listing while kill(pid, 0) keeps // answering, so every process on the machine looks concealed. PID 1 is // the control: it always exists, and nothing hides init — a rootkit that // did would break the machine it is trying to live on. If we cannot see // it, we are the ones who are blind. if pid_exists(1) && !before.contains(&1) { return vec![finding( "info", "hidden_process", "cannot check for hidden processes: this daemon's view of /proc is \ filtered, so it cannot see other processes. Check for ProtectProc= \ in the systemd unit or hidepid= on the /proc mount." .into(), )]; } let max = pid_max(); let candidates = hidden_pid_candidates(&before, &proc_tids(), max); if candidates.is_empty() { return Vec::new(); } // Re-verify. A candidate that has stopped answering was a process // exiting during the sweep, not something hiding. std::thread::sleep(std::time::Duration::from_millis(200)); let listed = proc_tids(); let confirmed: Vec = candidates .into_iter() .filter(|p| !listed.contains(p) && pid_exists(*p)) .collect(); // Second guard, for the blindness we could not name. A rootkit hides a // handful of processes — that is the entire point of hiding. Hundreds of // "hidden" processes is a broken observer, not a compromised kernel, and // reporting it as critical trains people to ignore the one time it is // real. const IMPLAUSIBLE: usize = 32; if confirmed.len() > IMPLAUSIBLE { return vec![finding( "info", "hidden_process", format!( "cannot check for hidden processes: {} of {} PIDs appear concealed, \ which means this daemon cannot read /proc properly rather than that \ the machine is compromised.", confirmed.len(), listed.len() ), )]; } confirmed .into_iter() .map(|pid| { finding( "critical", "hidden_process", format!( "process {pid} is running but does not appear in /proc. Something is \ concealing it from the tools that list running programs, which is what \ a rootkit is for." ), ) }) .collect() } // ── check 2: setuid binaries no package claims ────────────────────────── /// Every file path claimed by an installed package, in both spellings. /// /// dpkg keeps one `.list` per package, so the whole index is a couple of /// megabytes and one pass over a directory. rpm and pacman are queried per /// candidate instead, which is fine because there are only ever a few dozen /// setuid binaries on a system. /// /// **Merged-`/usr` makes this a two-sided problem.** On current Debian and /// Ubuntu, `/bin` is a symlink to `usr/bin` and `/sbin` to `usr/sbin`, so /// every binary exists under two names — and dpkg's own index is not /// consistent about which it records. `sudo.list` says `/usr/bin/sudo` /// while `fuse3.list` says `/bin/fusermount3` and `cifs-utils` says /// `/sbin/mount.cifs`. Comparing the strings fails in both directions and /// reports the entire setuid set as unowned, which is precisely the kind of /// distribution-specific breakage that made the previous implementation /// useless. Both the recorded path and its resolved form go in. pub fn dpkg_owned_paths() -> Option> { let dir = Path::new("/var/lib/dpkg/info"); if !dir.is_dir() { return None; } let mut owned = HashSet::new(); for entry in std::fs::read_dir(dir).ok()?.flatten() { let p = entry.path(); if !p.extension().is_some_and(|e| e == "list") { continue; } let Ok(text) = std::fs::read_to_string(&p) else { continue }; for line in text.lines() { owned.insert(line.to_string()); if let Ok(real) = std::fs::canonicalize(line) { owned.insert(real.to_string_lossy().into_owned()); } } } (!owned.is_empty()).then_some(owned) } /// Both spellings of a path: as given, and fully resolved. fn both_spellings(path: &Path) -> (String, Option) { let given = path.to_string_lossy().into_owned(); let real = std::fs::canonicalize(path) .ok() .map(|p| p.to_string_lossy().into_owned()) .filter(|r| *r != given); (given, real) } /// Ask rpm or pacman whether anything owns a path. fn queried_owner(path: &Path) -> Option { for (bin, args) in [("rpm", vec!["-qf"]), ("pacman", vec!["-Qo"])] { if let Ok(out) = std::process::Command::new(bin) .args(&args) .arg(path) .output() { return Some(out.status.success()); } } None } /// Setuid and setgid binaries under the usual directories. pub fn setuid_binaries() -> Vec { use std::os::unix::fs::MetadataExt; let mut out = Vec::new(); // Deduplicate by resolved path, or merged-/usr reports every binary // twice — once as /bin/x and once as /usr/bin/x. let mut seen: HashSet = HashSet::new(); for root in ["/usr/bin", "/usr/sbin", "/bin", "/sbin", "/usr/local/bin", "/usr/libexec"] { for entry in std::fs::read_dir(root).into_iter().flatten().flatten() { let p = entry.path(); let Ok(md) = std::fs::symlink_metadata(&p) else { continue }; if md.is_symlink() || !md.is_file() { continue; } if md.mode() & 0o6000 == 0 { continue; } let key = std::fs::canonicalize(&p) .map(|r| r.to_string_lossy().into_owned()) .unwrap_or_else(|_| p.to_string_lossy().into_owned()); if seen.insert(key) { out.push(p); } } } out } fn unowned_setuid() -> Vec { let binaries = setuid_binaries(); if binaries.is_empty() { return Vec::new(); } let dpkg = dpkg_owned_paths(); let mut out = Vec::new(); for path in binaries { let (key, real) = both_spellings(&path); let owned = match &dpkg { Some(index) => { index.contains(&key) || real.as_ref().is_some_and(|r| index.contains(r)) } None => match queried_owner(&path) { Some(owned) => owned, // No package manager we understand. Saying nothing is // better than guessing at names, which is what the old // check did. None => continue, }, }; if !owned { out.push(finding( "warn", "unowned_setuid", format!( "{key} runs with elevated privileges but no installed package claims it. \ Every setuid program on a healthy system arrived with a package; one \ that did not was put there some other way." ), )); } } out } // ── check 3: userland preload hooks ───────────────────────────────────── /// `/etc/ld.so.preload` forces a library into *every* dynamically linked /// program on the machine. It is empty or absent on a normal system, and /// it is the classic userland rootkit: hook `readdir` and files disappear, /// hook `read` on `/proc/net/tcp` and connections disappear. fn preload_hooks() -> Vec { let path = Path::new("/etc/ld.so.preload"); let Ok(text) = std::fs::read_to_string(path) else { return Vec::new(); // absent is the normal, healthy case }; text.lines() .map(str::trim) .filter(|l| !l.is_empty() && !l.starts_with('#')) .map(|lib| { finding( "critical", "ld_preload", format!( "/etc/ld.so.preload forces {lib} into every program that starts on this \ machine. That file is empty on a healthy system, and loading code into \ everything is how a rootkit hides files, processes and connections." ), ) }) .collect() } // ── check 4: system directories anyone can write to ───────────────────── fn writable_system_dirs() -> Vec { use std::os::unix::fs::MetadataExt; const SYSTEM_DIRS: &[&str] = &[ "/etc", "/bin", "/lib", "/lib64", "/sbin", "/usr/bin", "/usr/lib", "/usr/lib64", "/usr/sbin", "/boot", ]; let mut out = Vec::new(); for d in SYSTEM_DIRS { let p = Path::new(d); let Ok(md) = std::fs::metadata(p) else { continue }; if !md.is_dir() { continue; } let mode = md.mode(); // The sticky bit makes a shared-writable directory safe (that is // what /tmp uses), so it is not a finding. let sticky = mode & 0o1000 != 0; let group_w = mode & 0o020 != 0; let other_w = mode & 0o002 != 0; if (group_w || other_w) && !sticky { out.push(finding( "warn", "writable_system_dir", format!( "{d} can be written to by users other than root (mode {:o}). Anyone who \ can write there can replace a program the whole system runs.", mode & 0o7777 ), )); } } out } // ── check 5: entries a directory lists but cannot resolve ─────────────── fn hidden_files(watch_dirs: &[String]) -> Vec { let mut out = Vec::new(); for d in watch_dirs { let path = Path::new(d); let Ok(rd) = std::fs::read_dir(path) else { continue }; for e in rd.flatten() { let p = e.path(); // A dangling symlink resolves to nothing and is completely // ordinary, so it must not be reported. Only an entry the // directory lists whose own metadata cannot be read is odd. if std::fs::symlink_metadata(&p).is_err() { out.push(finding( "warn", "hidden_file", format!( "{} is listed in {d} but the system cannot describe it, which \ suggests something is interfering with directory listings.", p.display() ), )); } } } out } #[cfg(test)] mod tests { /// The bug that shipped: `ProtectProc=invisible` in the systemd unit /// filtered the daemon's /proc listing while `kill(pid, 0)` kept /// answering truthfully, so every process on the machine looked /// concealed. A clean laptop reported 988 critical findings and this /// server 3786 — PID 1 among them. #[test] fn a_filtered_proc_is_reported_as_blindness_not_a_rootkit() { // What the daemon saw: only its own threads. let mine: HashSet = [4242, 4243].into_iter().collect(); assert!( !mine.contains(&1), "the premise of the guard: init is missing from the listing" ); // Every real PID then looks hidden. let candidates = hidden_pid_candidates(&mine, &mine, 5000); assert!( candidates.len() > 32, "a filtered /proc yields implausibly many candidates, got {}", candidates.len() ); } /// PID 1 is the control for that guard, so it had better be true. #[test] fn init_always_exists_and_is_always_listed() { assert!(pid_exists(1), "PID 1 must exist"); assert!( proc_tids().contains(&1), "PID 1 must appear in /proc — if this fails, the test runner's \ view of /proc is filtered and the blindness guard is what saves us" ); } /// A real rootkit hides a few processes. Hundreds is a broken observer. #[test] fn the_implausible_threshold_is_above_any_real_rootkit_and_below_a_broken_proc() { let total_pids = proc_tids().len(); assert!( total_pids > 32, "this machine should be running more than 32 tasks, saw {total_pids}" ); } use super::*; #[test] fn scan_shape_is_consistent() { let scan = run_scan(&[]); let total = scan.findings.len() as u32; assert_eq!(scan.critical + scan.warn + scan.info, total); assert!(!scan.verdict.is_empty()); assert!(scan.ts.len() >= 10); } // ── the regression that motivated the rewrite ── /// A clean machine must produce no critical findings. This is the whole /// bar for this file: the previous implementation failed it on any busy /// system, because a process exiting mid-scan looked like a hidden one. #[test] fn a_healthy_machine_produces_no_criticals() { // Churn processes throughout, which is exactly what broke the old // check: each one starts and exits during the scan. let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let s = std::sync::Arc::clone(&stop); let churn = std::thread::spawn(move || { while !s.load(std::sync::atomic::Ordering::Relaxed) { let _ = std::process::Command::new("true").status(); } }); let scan = run_scan(&[]); stop.store(true, std::sync::atomic::Ordering::Relaxed); let _ = churn.join(); let criticals: Vec<&RootkitFinding> = scan.findings.iter().filter(|f| f.severity == "critical").collect(); assert!( criticals.is_empty(), "processes churning during a scan must not read as hidden: {criticals:?}" ); } #[test] fn a_process_that_exits_during_the_sweep_is_not_hidden() { // Present in `before`, gone by `after`: explained by exiting. let before: HashSet = [1, 2, 4242].into_iter().collect(); let after: HashSet = [1, 2].into_iter().collect(); assert!( !hidden_pid_candidates(&before, &after, 5000).contains(&4242), "a pid seen in the first listing is accounted for" ); } #[test] fn a_process_that_starts_during_the_sweep_is_not_hidden() { // Absent from `before`, present in `after`: explained by starting. let before: HashSet = [1, 2].into_iter().collect(); let after: HashSet = [1, 2, 4242].into_iter().collect(); assert!(!hidden_pid_candidates(&before, &after, 5000).contains(&4242)); } #[test] fn our_own_pid_is_never_a_candidate() { let mine = std::process::id(); let listed = proc_tids(); assert!(listed.contains(&mine), "/proc must list this test process"); let candidates = hidden_pid_candidates(&listed, &listed, mine + 10); assert!(!candidates.contains(&mine)); } #[test] fn threads_are_task_ids_not_hidden_processes() { // The regression: /proc lists thread-group leaders, kill() accepts // any thread id. A multi-threaded process therefore has ids that // answer kill and are absent from a /proc listing — and reporting // those as hidden produced dozens of criticals on a healthy laptop. // // Each thread reports its OWN tid rather than the test reading // /proc/self/task afterwards. Reading the task list and the /proc // snapshot at different moments reintroduces exactly the race this // whole check exists to avoid — the test harness starts and stops // threads for other tests throughout, so one captured in the first // read may be gone by the second. Threads that report themselves // and then wait are alive across the entire window by construction. let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let reported: std::sync::Arc>> = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let handles: Vec<_> = (0..8) .map(|_| { let s = std::sync::Arc::clone(&stop); let r = std::sync::Arc::clone(&reported); std::thread::spawn(move || { // SAFETY: gettid takes no arguments and cannot fail. let tid = unsafe { libc::syscall(libc::SYS_gettid) } as u32; r.lock().expect("tid list poisoned").push(tid); while !s.load(std::sync::atomic::Ordering::Relaxed) { std::thread::sleep(std::time::Duration::from_millis(5)); } }) }) .collect(); // Wait until every thread has reported and is therefore parked. let mut waited = 0; while reported.lock().unwrap().len() < 8 && waited < 200 { std::thread::sleep(std::time::Duration::from_millis(5)); waited += 1; } let mine: Vec = reported.lock().unwrap().clone(); // Snapshot while all eight are demonstrably alive. let tids = proc_tids(); let pids = proc_pids(); let observed: Vec<(u32, bool, bool, bool)> = mine .iter() .map(|tid| (*tid, pid_exists(*tid), tids.contains(tid), pids.contains(tid))) .collect(); stop.store(true, std::sync::atomic::Ordering::Relaxed); for h in handles { let _ = h.join(); } assert_eq!(mine.len(), 8, "all eight threads should have reported"); assert!( tids.len() > pids.len(), "this process alone has 8 extra threads, so tids must exceed pids" ); for (tid, exists, in_tids, in_pids) in observed { assert!(exists, "thread {tid} was parked and must answer kill()"); assert!( in_tids, "thread {tid} answers kill() but proc_tids missed it — it would be \ reported as a hidden process" ); assert!( !in_pids, "thread {tid} is not a top-level /proc entry; that is exactly why \ proc_pids alone is insufficient" ); } } #[test] fn ownership_resolves_whichever_spelling_dpkg_used() { let Some(owned) = dpkg_owned_paths() else { return }; // The property that matters is not that both literal strings are // in the index, but that a lookup succeeds either way: dpkg records // sudo canonically and fusermount3 aliased, and both must resolve. for p in ["/usr/bin/sudo", "/bin/sudo", "/usr/bin/fusermount3", "/bin/fusermount3"] { let path = Path::new(p); if !path.exists() { continue; } let (given, real) = both_spellings(path); assert!( owned.contains(&given) || real.as_ref().is_some_and(|r| owned.contains(r)), "{p} should resolve to an owning package" ); } } #[test] fn setuid_binaries_are_not_reported_twice() { let bins = setuid_binaries(); let mut canonical: Vec = bins .iter() .map(|p| { std::fs::canonicalize(p) .unwrap_or_else(|_| p.clone()) .to_string_lossy() .into_owned() }) .collect(); let before = canonical.len(); canonical.sort(); canonical.dedup(); assert_eq!(before, canonical.len(), "merged-/usr duplicated the list"); } #[test] fn unowned_setuid_does_not_fire_on_a_healthy_system() { // The old check compared against a name list written on one distro. // This one asks the package manager, so a clean machine is quiet. if dpkg_owned_paths().is_none() { return; } let f = unowned_setuid(); assert!( f.is_empty(), "a stock system should have no unowned setuid binaries: {f:?}" ); } // ── preload ── #[test] fn no_preload_file_is_the_healthy_case() { // Whatever this machine has, the check must not panic, and an // absent file must produce nothing. let f = preload_hooks(); if !Path::new("/etc/ld.so.preload").exists() { assert!(f.is_empty()); } } // ── writable dirs ── #[test] fn stock_system_directories_are_not_writable() { let f = writable_system_dirs(); assert!(f.is_empty(), "unexpected writable system dir: {f:?}"); } // ── hidden files ── #[test] fn a_normal_directory_yields_no_hidden_files() { let dir = std::env::temp_dir().join(format!("hound-rk-{}", std::process::id())); let _ = std::fs::create_dir_all(&dir); std::fs::write(dir.join("ok.txt"), b"hi").unwrap(); let out = hidden_files(&[dir.to_string_lossy().to_string()]); assert!(out.is_empty(), "unexpected finding: {out:?}"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn a_dangling_symlink_is_not_a_hidden_file() { // The old check used metadata() (which follows links), so every // broken symlink — utterly ordinary — was reported. let dir = std::env::temp_dir().join(format!("hound-rk-dangle-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); std::os::unix::fs::symlink("/nonexistent/target", dir.join("dangling")).unwrap(); let out = hidden_files(&[dir.to_string_lossy().to_string()]); assert!(out.is_empty(), "a broken symlink is normal: {out:?}"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn every_finding_explains_itself_in_plain_language() { let scan = run_scan(&[]); for f in &scan.findings { assert!( f.detail.len() > 40, "a finding nobody can act on: {}", f.detail ); assert!(!f.check.is_empty()); } } }