houndd: rewrite the rootkit checks so they stop crying wolf
Phase 5's correctness half. The previous implementation could not ship:
its two main checks were structurally wrong rather than badly tuned.
hidden processes was "any /proc/<pid> whose comm we cannot read",
which fires on every process that exits between the
listing and the read. A race, not a signal.
setuid anomalies compared against a hardcoded allowlist of binary
names, written on one distribution.
Replaced with questions that have factual answers:
A process is hidden when the kernel agrees it exists and /proc does
not list it. kill(pid, 0) answers the first half for the whole PID
space — ESRCH means gone, EPERM means it exists and belongs to
somebody else, which is the case that matters since a rootkit's
process will not be ours. The sweep is bracketed by two listings and
candidates are re-verified, so a process that merely started or
exited during the scan cannot be mistaken for a hidden one.
A setuid binary is suspicious when no installed package claims it.
The package manager already knows what belongs on the system.
Two bugs found by testing against this machine rather than reasoning
about it, both of which would have made the feature useless in the
field:
* /proc lists thread-group leaders; kill() accepts any THREAD id. A
process with twenty threads therefore has nineteen ids that answer
kill and appear in no /proc listing. Comparing against the pid set
alone reported dozens of criticals on a completely healthy laptop.
The honest set is the union of leaders and their /proc/<tgid>/task
entries.
* Merged-/usr breaks package ownership in BOTH directions. /bin is a
symlink to usr/bin, so every binary has two names, and dpkg's own
index is inconsistent about which it records: sudo.list says
/usr/bin/sudo while fuse3.list says /bin/fusermount3 and cifs-utils
says /sbin/mount.cifs. String comparison reported the entire setuid
set as unowned. Both spellings now go into the index, candidates are
deduplicated by resolved path, and lookups try both.
Also: ld.so.preload is now checked (it is empty on a healthy system and
is the classic userland rootkit), the writable-directory check no
longer counts sticky-bit directories, and the hidden-file check uses
symlink_metadata so an ordinary dangling symlink is not an incident.
Verified on this machine, privileged and not: clean, 0 findings, 2.8s
including the full 4.2-million-pid sweep. The exit criterion asks for
five machines across three distributions and only one was available
here, so treat the cross-distro half as unmet.
The regression tests are the point: a scan run while processes churn
continuously must produce no criticals, and a process with eight live
threads must not produce eight findings.
200 tests pass across the workspace.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
6beb73771a
commit
909afacb19
1 changed files with 586 additions and 266 deletions
|
|
@ -1,43 +1,43 @@
|
||||||
//! Rootkit detection.
|
//! Rootkit heuristics.
|
||||||
//!
|
//!
|
||||||
//! Without a kernel module we can't do full DKM/rootkit detection, but we
|
//! This file is a rewrite. The version it replaces was a false-positive
|
||||||
//! can run a set of userspace heuristics that catch the *common* rootkit
|
//! generator, and for an antivirus that is worse than a miss — a rule that
|
||||||
//! tricks. Each check is a "finding" with a severity; the aggregate scan
|
//! quarantines a system binary ends the product. Two of its checks were
|
||||||
//! reports every finding plus a pass/fail verdict.
|
//! structurally wrong rather than merely tuned badly:
|
||||||
//!
|
//!
|
||||||
//! Checks performed (each individually testable):
|
//! * **Hidden processes** were "any `/proc/<pid>` 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.
|
||||||
//!
|
//!
|
||||||
//! 1. **Hidden processes** — every `pid` in `/proc` must be readable.
|
//! What replaces them:
|
||||||
//! Rootkits that `hide` a process by making `/proc/<pid>` unreadable
|
|
||||||
//! (or via a `hidepid` mount) surface here as a "unreadable pid".
|
|
||||||
//! 2. **Hidden files** — every entry reported by a raw `readdir` of a
|
|
||||||
//! watched dir must be visible to `fs::read_dir`'s metadata probe.
|
|
||||||
//! A file that exists in the dir listing but whose `stat` fails is a
|
|
||||||
//! strong signal (classic `lsof`-vs-`ls` discrepancy).
|
|
||||||
//! 3. **Writable system dirs** — `/etc`, `/bin`, `/lib`, `/lib64`,
|
|
||||||
//! `/sbin`, `/usr/bin`, `/usr/lib`, `/usr/lib64`, `/boot` should be
|
|
||||||
//! write-protected for non-root. A writable system dir is where
|
|
||||||
//! rootkits drop modified binaries.
|
|
||||||
//! 4. **Setuid/setgid anomalies** — collect setuid binaries under
|
|
||||||
//! `/usr` and flag ones we don't expect (a plain list, easy to extend).
|
|
||||||
//!
|
//!
|
||||||
//! These run without root for the common case; root gives stronger
|
//! * A process is hidden when the kernel agrees it exists but `/proc` does
|
||||||
//! signals (e.g. the hidden-pid check is only meaningful when we can
|
//! not list it. `kill(pid, 0)` answers the first question for the entire
|
||||||
//! actually read `/proc`).
|
//! 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 hound_api::{RootkitFinding, RootkitScan};
|
||||||
use std::path::Path;
|
use std::collections::HashSet;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// The full rootkit scan. `watch_dirs` are the dirs to run the
|
/// Run every check.
|
||||||
/// hidden-file check against (typically the same set the real-time
|
|
||||||
/// monitor watches, so a dropped rootkit file gets caught on both
|
|
||||||
/// paths).
|
|
||||||
pub fn run_scan(watch_dirs: &[String]) -> RootkitScan {
|
pub fn run_scan(watch_dirs: &[String]) -> RootkitScan {
|
||||||
let mut findings: Vec<RootkitFinding> = Vec::new();
|
let mut findings: Vec<RootkitFinding> = Vec::new();
|
||||||
findings.extend(hidden_pids());
|
findings.extend(hidden_processes());
|
||||||
findings.extend(hidden_files(watch_dirs));
|
findings.extend(unowned_setuid());
|
||||||
|
findings.extend(preload_hooks());
|
||||||
findings.extend(writable_system_dirs());
|
findings.extend(writable_system_dirs());
|
||||||
findings.extend(setuid_anomalies());
|
findings.extend(hidden_files(watch_dirs));
|
||||||
|
|
||||||
let critical = findings.iter().filter(|f| f.severity == "critical").count() as u32;
|
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 warn = findings.iter().filter(|f| f.severity == "warn").count() as u32;
|
||||||
|
|
@ -60,224 +60,6 @@ pub fn run_scan(watch_dirs: &[String]) -> RootkitScan {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check 1: any pid in /proc that is unreadable.
|
|
||||||
fn hidden_pids() -> Vec<RootkitFinding> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let Ok(rd) = std::fs::read_dir("/proc") else {
|
|
||||||
out.push(finding(
|
|
||||||
"info",
|
|
||||||
"proc",
|
|
||||||
"/proc not readable — running without enough privilege for a hidden-pid check"
|
|
||||||
.to_string(),
|
|
||||||
));
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
for e in rd.flatten() {
|
|
||||||
let name = e.file_name();
|
|
||||||
let s = match name.to_str() {
|
|
||||||
Some(s) if s.chars().all(|c| c.is_ascii_digit()) => s.to_string(),
|
|
||||||
_ => continue,
|
|
||||||
};
|
|
||||||
let p = Path::new("/proc").join(&s);
|
|
||||||
// A rootkit hiding a process makes /proc/<pid> unreadable.
|
|
||||||
if let Ok(md) = e.metadata() {
|
|
||||||
if !md.is_dir() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Read the first byte of /proc/<pid>/comm — a hidden process
|
|
||||||
// often makes this fail with EACCES/EAGAIN.
|
|
||||||
let comm = p.join("comm");
|
|
||||||
if std::fs::read(&comm).is_err() {
|
|
||||||
out.push(finding(
|
|
||||||
"warn",
|
|
||||||
"hidden_pid",
|
|
||||||
format!("/proc/{s} exists but is unreadable — process may be hidden"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check 2: files in a watched dir that readdir sees but stat can't resolve.
|
|
||||||
fn hidden_files(watch_dirs: &[String]) -> Vec<RootkitFinding> {
|
|
||||||
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();
|
|
||||||
// If readdir gave us the entry but stat-by-path fails, that's
|
|
||||||
// a discrepancy. We re-stat by full path (not the DirEntry's
|
|
||||||
// already-cached metadata) to detect this class of rootkit.
|
|
||||||
if std::fs::metadata(&p).is_err() {
|
|
||||||
out.push(finding(
|
|
||||||
"warn",
|
|
||||||
"hidden_file",
|
|
||||||
format!(
|
|
||||||
"{} visible in dir listing but stat-by-path fails",
|
|
||||||
p.display()
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check 3: writable system dirs.
|
|
||||||
fn writable_system_dirs() -> Vec<RootkitFinding> {
|
|
||||||
const SYSTEM_DIRS: &[&str] = &[
|
|
||||||
"/etc",
|
|
||||||
"/bin",
|
|
||||||
"/lib",
|
|
||||||
"/lib64",
|
|
||||||
"/sbin",
|
|
||||||
"/usr/bin",
|
|
||||||
"/usr/lib",
|
|
||||||
"/usr/lib64",
|
|
||||||
"/boot",
|
|
||||||
];
|
|
||||||
let mut out = Vec::new();
|
|
||||||
// Determine our effective uid so we know whether "writable" is
|
|
||||||
// actually a concern (if we're root, everything is writable).
|
|
||||||
let uid = current_uid();
|
|
||||||
let root = uid == 0;
|
|
||||||
for d in SYSTEM_DIRS {
|
|
||||||
let p = Path::new(d);
|
|
||||||
if !p.is_dir() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// A dir is writable by *someone other than us* if:
|
|
||||||
// - group/other write bits are set, OR
|
|
||||||
// - the owning group/other is writable and we're not the owner.
|
|
||||||
// We approximate with the simple heuristic: group or other write
|
|
||||||
// bit set. This is intentionally conservative — it flags real
|
|
||||||
// problems and accepts a few false positives on permissive setups.
|
|
||||||
let Ok(md) = std::fs::metadata(p) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::MetadataExt;
|
|
||||||
let mode = md.mode();
|
|
||||||
let group_w = mode & 0o020 != 0;
|
|
||||||
let other_w = mode & 0o002 != 0;
|
|
||||||
if (group_w || other_w) && !root {
|
|
||||||
out.push(finding(
|
|
||||||
"warn",
|
|
||||||
"writable_system_dir",
|
|
||||||
format!("{d} is group/other writable (mode {mode:o})"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
{
|
|
||||||
let _ = (uid, md);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check 4: setuid/setgid binaries under /usr that look unusual.
|
|
||||||
fn setuid_anomalies() -> Vec<RootkitFinding> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let expected = [
|
|
||||||
"sudo",
|
|
||||||
"sudoedit",
|
|
||||||
"su",
|
|
||||||
"sg",
|
|
||||||
"newgrp",
|
|
||||||
"pkexec",
|
|
||||||
"doas",
|
|
||||||
"at",
|
|
||||||
"crontab",
|
|
||||||
"chfn",
|
|
||||||
"chsh",
|
|
||||||
"chage",
|
|
||||||
"chgpasswd",
|
|
||||||
"passwd",
|
|
||||||
"gpasswd",
|
|
||||||
"expiry",
|
|
||||||
"unix_chkpwd",
|
|
||||||
"pam_extrausers_chkpwd",
|
|
||||||
"pam_timestamp_check",
|
|
||||||
"mount",
|
|
||||||
"umount",
|
|
||||||
"mount.cifs",
|
|
||||||
"mount.smb3",
|
|
||||||
"mount.nfs",
|
|
||||||
"mount.nfs4",
|
|
||||||
"mount.ecryptfs_private",
|
|
||||||
"umount.ecryptfs_private",
|
|
||||||
"fusermount",
|
|
||||||
"fusermount3",
|
|
||||||
"newuidmap",
|
|
||||||
"newgidmap",
|
|
||||||
"ping",
|
|
||||||
"ping6",
|
|
||||||
"ip",
|
|
||||||
"ip6",
|
|
||||||
"Xorg",
|
|
||||||
"ssh-agent",
|
|
||||||
"gpg-agent",
|
|
||||||
"dbus-daemon-launch-helper",
|
|
||||||
"polkit-agent-helper-1",
|
|
||||||
"dotlockfile",
|
|
||||||
"locate",
|
|
||||||
"plocate",
|
|
||||||
"pppd",
|
|
||||||
"postdrop",
|
|
||||||
"postqueue",
|
|
||||||
"newsyslog",
|
|
||||||
"mullvad-exclude",
|
|
||||||
"screen",
|
|
||||||
"tmux",
|
|
||||||
];
|
|
||||||
let roots = ["/usr/bin", "/usr/sbin", "/usr/local/bin"];
|
|
||||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
||||||
for r in roots {
|
|
||||||
let Ok(rd) = std::fs::read_dir(r) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
for e in rd.flatten() {
|
|
||||||
let p = e.path();
|
|
||||||
if !p.is_file() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Ok(md) = std::fs::metadata(&p) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::MetadataExt;
|
|
||||||
let mode = md.mode();
|
|
||||||
let setuid = mode & 0o4000 != 0;
|
|
||||||
let setgid = mode & 0o2000 != 0;
|
|
||||||
if !setuid && !setgid {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let name = p.file_name().map(|n| n.to_string_lossy().to_string());
|
|
||||||
let Some(name) = name else { continue };
|
|
||||||
if seen.insert(name.clone()) && !expected.iter().any(|x| *x == name) {
|
|
||||||
out.push(finding(
|
|
||||||
"info",
|
|
||||||
"setuid_anomaly",
|
|
||||||
format!("unexpected setuid/setgid binary: {}", p.display()),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
{
|
|
||||||
let _ = md;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn finding(sev: &str, kind: &str, detail: String) -> RootkitFinding {
|
fn finding(sev: &str, kind: &str, detail: String) -> RootkitFinding {
|
||||||
RootkitFinding {
|
RootkitFinding {
|
||||||
check: kind.to_string(),
|
check: kind.to_string(),
|
||||||
|
|
@ -286,20 +68,337 @@ fn finding(sev: &str, kind: &str, detail: String) -> RootkitFinding {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn current_uid() -> u32 {
|
// ── check 1: processes the kernel has but /proc does not ────────────────
|
||||||
// /proc/self is the portable, no-dependency way on Linux.
|
|
||||||
if let Ok(s) = std::fs::read_to_string("/proc/self/status") {
|
/// Thread-group leaders listed directly in `/proc`.
|
||||||
for line in s.lines() {
|
pub fn proc_pids() -> HashSet<u32> {
|
||||||
if let Some(v) = line.strip_prefix("Uid:") {
|
std::fs::read_dir("/proc")
|
||||||
if let Some(first) = v.split_whitespace().next() {
|
.into_iter()
|
||||||
if let Ok(uid) = first.parse() {
|
.flatten()
|
||||||
return uid;
|
.flatten()
|
||||||
|
.filter_map(|e| e.file_name().to_str().and_then(|s| s.parse::<u32>().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/<tgid>/task/<tid>`, so the honest set is the
|
||||||
|
/// union of the leaders and their tasks.
|
||||||
|
pub fn proc_tids() -> HashSet<u32> {
|
||||||
|
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::<u32>().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<u32>, after: &HashSet<u32>, max: u32) -> Vec<u32> {
|
||||||
|
(1..=max)
|
||||||
|
.filter(|p| !before.contains(p) && !after.contains(p) && pid_exists(*p))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hidden_processes() -> Vec<RootkitFinding> {
|
||||||
|
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(),
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
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<u32> = candidates
|
||||||
|
.into_iter()
|
||||||
|
.filter(|p| !listed.contains(p) && pid_exists(*p))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
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<HashSet<String>> {
|
||||||
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
u32::MAX
|
}
|
||||||
|
(!owned.is_empty()).then_some(owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both spellings of a path: as given, and fully resolved.
|
||||||
|
fn both_spellings(path: &Path) -> (String, Option<String>) {
|
||||||
|
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<bool> {
|
||||||
|
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<PathBuf> {
|
||||||
|
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<String> = 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<RootkitFinding> {
|
||||||
|
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<RootkitFinding> {
|
||||||
|
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<RootkitFinding> {
|
||||||
|
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<RootkitFinding> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
|
|
@ -315,21 +414,242 @@ mod tests {
|
||||||
assert!(scan.ts.len() >= 10);
|
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]
|
#[test]
|
||||||
fn hidden_file_detects_unstatable() {
|
fn a_healthy_machine_produces_no_criticals() {
|
||||||
// Create a real dir and a real file — should produce no finding.
|
// 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<u32> = [1, 2, 4242].into_iter().collect();
|
||||||
|
let after: HashSet<u32> = [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<u32> = [1, 2].into_iter().collect();
|
||||||
|
let after: HashSet<u32> = [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.
|
||||||
|
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
|
let handles: Vec<_> = (0..8)
|
||||||
|
.map(|_| {
|
||||||
|
let s = std::sync::Arc::clone(&stop);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
while !s.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||||
|
|
||||||
|
let tids = proc_tids();
|
||||||
|
let pids = proc_pids();
|
||||||
|
|
||||||
|
// Our own threads: real task ids that answer kill() and do NOT
|
||||||
|
// appear in a /proc listing. Every one must be accounted for by
|
||||||
|
// proc_tids, or it becomes a critical finding on a clean machine.
|
||||||
|
//
|
||||||
|
// Everything is measured while the threads are still alive and only
|
||||||
|
// asserted afterwards — checking a thread's existence after joining
|
||||||
|
// it tests nothing except that join() works.
|
||||||
|
let mine: Vec<u32> = std::fs::read_dir(format!("/proc/{}/task", std::process::id()))
|
||||||
|
.unwrap()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|e| e.file_name().to_str().and_then(|s| s.parse::<u32>().ok()))
|
||||||
|
.collect();
|
||||||
|
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!(
|
||||||
|
tids.len() > pids.len(),
|
||||||
|
"this process alone has 8 extra threads, so tids must exceed pids"
|
||||||
|
);
|
||||||
|
assert!(mine.len() >= 9, "expected the leader plus 8 threads, got {}", mine.len());
|
||||||
|
for (tid, exists, in_tids, in_pids) in observed {
|
||||||
|
assert!(exists, "thread {tid} was alive and must answer kill()");
|
||||||
|
assert!(
|
||||||
|
in_tids,
|
||||||
|
"thread {tid} answers kill() but proc_tids missed it — it would be \
|
||||||
|
reported as a hidden process"
|
||||||
|
);
|
||||||
|
if tid != std::process::id() {
|
||||||
|
assert!(
|
||||||
|
!in_pids,
|
||||||
|
"thread {tid} should not be 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<String> = 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 dir = std::env::temp_dir().join(format!("hound-rk-{}", std::process::id()));
|
||||||
let _ = std::fs::create_dir_all(&dir);
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
let f = dir.join("ok.txt");
|
std::fs::write(dir.join("ok.txt"), b"hi").unwrap();
|
||||||
std::fs::write(&f, b"hi").unwrap();
|
|
||||||
let out = hidden_files(&[dir.to_string_lossy().to_string()]);
|
let out = hidden_files(&[dir.to_string_lossy().to_string()]);
|
||||||
assert!(out.is_empty(), "unexpected finding: {out:?}");
|
assert!(out.is_empty(), "unexpected finding: {out:?}");
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn current_uid_is_sane() {
|
fn a_dangling_symlink_is_not_a_hidden_file() {
|
||||||
let uid = current_uid();
|
// The old check used metadata() (which follows links), so every
|
||||||
assert!(uid < 10_000);
|
// 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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue