houndd: the persistence ledger
Completes Phase 5. Half of a Linux compromise is not a file on disk, it is a line added to a startup file — a curl in a shell profile, a systemd unit with a dull name, one extra key in authorized_keys. The payload is often unremarkable; what makes it an incident is that it survives a reboot and nobody reads those files from one year to the next. So this is not a scanner but an inventory with a memory. It records systemd units (system and per-user), cron in all its locations, autostart entries, shell profiles, authorized_keys and ld.so.preload, then reports what CHANGED. Three decisions, all of which are the difference between a report people read and one they turn off: * Content is hashed, not stat'd. An mtime can be set backwards with one touch, and someone editing a startup file is exactly the person who would. Verified: a backdated edit is still caught. * A first run reports no changes and says so. Everything would be a change, and a first-run report full of alarms is one nobody reads. What a first run can honestly say is how many entries no package claims — 97 of 1,026 on this machine — because that is true regardless of history. * Writing the baseline is an explicit act (`--accept`, or update_baseline on the wire). A plain check must never quietly record whatever is currently installed as normal; that is how a compromise becomes the new baseline. Package ownership decides what is ordinary: a unit that arrived with a package is the system working, the same unit unowned is somebody's decision. Reuses the merged-/usr-aware index from the rootkit rewrite, with a test asserting most units resolve to a package — if that ratio collapses, ownership lookup has broken and the whole report is noise. Exercised end to end against this machine: baseline of 1,026 items, a planted user unit caught as ADDED, an in-place edit with a backdated mtime caught as CHANGED, and its deletion caught as REMOVED. The test artifact was removed afterwards. Cross-distro verification of the rootkit rewrite is now MET. Henry ran the suite on the Ubuntu box (26.04, glibc 2.43): 20/20, including both unowned_setuid_does_not_fire_on_a_healthy_system and no_false_positives_on_system_binaries. 214 tests pass across the workspace. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
909afacb19
commit
778236822b
9 changed files with 737 additions and 0 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1061,6 +1061,7 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"time",
|
"time",
|
||||||
"yara-x",
|
"yara-x",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ time = { version = "0.3", features = ["serde", "std", "formatting"] }
|
||||||
inotify = "0.10"
|
inotify = "0.10"
|
||||||
yara-x = "1.19"
|
yara-x = "1.19"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
sha2 = "0.10"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = true
|
lto = true
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,51 @@ pub struct Status {
|
||||||
pub gate: GateStatus,
|
pub gate: GateStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One thing on this machine that can make code run again after a reboot.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct PersistenceEntry {
|
||||||
|
/// "systemd", "cron", "autostart", "shell-profile", "authorized-keys".
|
||||||
|
pub kind: String,
|
||||||
|
pub path: String,
|
||||||
|
/// Content hash. Mtime alone is not enough — it can be set backwards.
|
||||||
|
pub sha256: String,
|
||||||
|
pub size: u64,
|
||||||
|
/// Package that installed it, when one claims it. `None` is the
|
||||||
|
/// interesting case: nothing on a stock system arrives unowned.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub owned_by: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How an entry differs from the recorded baseline.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PersistenceChange {
|
||||||
|
/// "added" | "modified" | "removed".
|
||||||
|
pub change: String,
|
||||||
|
pub entry: PersistenceEntry,
|
||||||
|
/// Plain language, for someone who has never read an audit log.
|
||||||
|
pub detail: String,
|
||||||
|
pub severity: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The result of a persistence sweep.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct PersistenceReport {
|
||||||
|
pub ts: String,
|
||||||
|
/// When the baseline this was compared against was taken.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub baseline_ts: Option<String>,
|
||||||
|
/// True when there was nothing to compare against and this run just
|
||||||
|
/// recorded what is here. Nothing is reported as a change on a first
|
||||||
|
/// run, because everything would be.
|
||||||
|
pub first_run: bool,
|
||||||
|
/// Everything currently in place.
|
||||||
|
pub total: u64,
|
||||||
|
/// Entries no package claims, which is the signal that survives a
|
||||||
|
/// first run.
|
||||||
|
pub unowned: u64,
|
||||||
|
pub changes: Vec<PersistenceChange>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Execution-gate state, for the tray and `hound status`.
|
/// Execution-gate state, for the tray and `hound status`.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct GateStatus {
|
pub struct GateStatus {
|
||||||
|
|
@ -512,6 +557,16 @@ impl Client {
|
||||||
Ok(serde_json::from_value(v)?)
|
Ok(serde_json::from_value(v)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── persistence ──
|
||||||
|
pub fn persistence_scan(&self, update_baseline: bool) -> anyhow::Result<PersistenceReport> {
|
||||||
|
let v = self.call(
|
||||||
|
14,
|
||||||
|
"persistence.scan",
|
||||||
|
Some(serde_json::json!({"update_baseline": update_baseline})),
|
||||||
|
)?;
|
||||||
|
Ok(serde_json::from_value(v)?)
|
||||||
|
}
|
||||||
|
|
||||||
// ── supply chain ──
|
// ── supply chain ──
|
||||||
/// Sweep a project root. Returns the raw value so the CLI can
|
/// Sweep a project root. Returns the raw value so the CLI can
|
||||||
/// deserialise it into `hound_supply::Report` without hound-api
|
/// deserialise it into `hound_supply::Report` without hound-api
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,19 @@ enum Cmd {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
json: bool,
|
json: bool,
|
||||||
},
|
},
|
||||||
|
/// What on this machine can make code run again after a reboot
|
||||||
|
///
|
||||||
|
/// Records systemd units, cron jobs, autostart entries, shell profiles
|
||||||
|
/// and authorized_keys, then reports what has changed since last time.
|
||||||
|
/// Half of a Linux compromise is persistence rather than a file on disk.
|
||||||
|
Persistence {
|
||||||
|
/// Accept the current state as normal from now on
|
||||||
|
#[arg(long)]
|
||||||
|
accept: bool,
|
||||||
|
/// Emit machine-readable JSON instead of human text
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
/// Run userspace rootkit heuristics
|
/// Run userspace rootkit heuristics
|
||||||
Rootkit {
|
Rootkit {
|
||||||
/// Emit machine-readable JSON instead of human text
|
/// Emit machine-readable JSON instead of human text
|
||||||
|
|
@ -149,6 +162,83 @@ enum RealtimeCmd {
|
||||||
On,
|
On,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Print the persistence ledger for a human.
|
||||||
|
fn print_persistence_human(r: &hound_api::PersistenceReport, accepted: bool) {
|
||||||
|
if r.first_run {
|
||||||
|
println!(
|
||||||
|
"{} recorded {} startup item(s) as the baseline",
|
||||||
|
"✔".green().bold(),
|
||||||
|
r.total
|
||||||
|
);
|
||||||
|
if r.unowned > 0 {
|
||||||
|
println!(
|
||||||
|
" {} of them are claimed by no installed package",
|
||||||
|
r.unowned.to_string().yellow()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {}",
|
||||||
|
"That is not necessarily wrong — anything you set up by hand looks like this."
|
||||||
|
.dimmed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"\n {}",
|
||||||
|
"From now on this command reports what CHANGED, which is the useful part.".dimmed()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let unexplained = r
|
||||||
|
.changes
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.severity == "warn" || c.severity == "critical")
|
||||||
|
.count();
|
||||||
|
|
||||||
|
if r.changes.is_empty() {
|
||||||
|
println!(
|
||||||
|
"{} nothing has changed — {} startup item(s), baseline {}",
|
||||||
|
"✔".green().bold(),
|
||||||
|
r.total,
|
||||||
|
r.baseline_ts.as_deref().unwrap_or("unknown").dimmed()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{} {} change(s) since {}, {} unexplained\n",
|
||||||
|
if unexplained > 0 { "!".yellow().bold() } else { "·".dimmed() },
|
||||||
|
r.changes.len(),
|
||||||
|
r.baseline_ts.as_deref().unwrap_or("the baseline"),
|
||||||
|
unexplained
|
||||||
|
);
|
||||||
|
|
||||||
|
for c in &r.changes {
|
||||||
|
let verb = match c.change.as_str() {
|
||||||
|
"added" => "ADDED ".green(),
|
||||||
|
"modified" => "CHANGED ".yellow(),
|
||||||
|
_ => "REMOVED ".dimmed(),
|
||||||
|
};
|
||||||
|
let path = match c.severity.as_str() {
|
||||||
|
"warn" | "critical" => c.entry.path.yellow().bold(),
|
||||||
|
_ => c.entry.path.normal(),
|
||||||
|
};
|
||||||
|
println!("{verb} {path}");
|
||||||
|
for line in wrap(&c.detail, 74) {
|
||||||
|
println!(" {line}");
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
if !accepted && unexplained > 0 {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
"If you made these changes yourself, run `hound persistence --accept` to \
|
||||||
|
record them as normal."
|
||||||
|
.dimmed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Print a supply-chain report for a human.
|
/// Print a supply-chain report for a human.
|
||||||
///
|
///
|
||||||
/// The explanation comes first and the rule identifier last, because the
|
/// The explanation comes first and the rule identifier last, because the
|
||||||
|
|
@ -476,6 +566,20 @@ fn run(client: &Client, cmd: &Cmd) -> Result<i32> {
|
||||||
print_supply_human(&report);
|
print_supply_human(&report);
|
||||||
Ok(if report.count(hound_supply::Severity::Critical) > 0 { 1 } else { 0 })
|
Ok(if report.count(hound_supply::Severity::Critical) > 0 { 1 } else { 0 })
|
||||||
}
|
}
|
||||||
|
Cmd::Persistence { accept, json } => {
|
||||||
|
let r = client.persistence_scan(*accept)?;
|
||||||
|
if *json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&r)?);
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
print_persistence_human(&r, *accept);
|
||||||
|
let unexplained = r
|
||||||
|
.changes
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.severity == "warn" || c.severity == "critical")
|
||||||
|
.count();
|
||||||
|
Ok(if unexplained > 0 { 1 } else { 0 })
|
||||||
|
}
|
||||||
Cmd::Rootkit { json } => {
|
Cmd::Rootkit { json } => {
|
||||||
let r: RootkitScan = client.rootkit_scan()?;
|
let r: RootkitScan = client.rootkit_scan()?;
|
||||||
if *json {
|
if *json {
|
||||||
|
|
|
||||||
|
|
@ -20,3 +20,4 @@ time.workspace = true
|
||||||
inotify.workspace = true
|
inotify.workspace = true
|
||||||
yara-x.workspace = true
|
yara-x.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ mod engine;
|
||||||
mod events;
|
mod events;
|
||||||
mod fanotify;
|
mod fanotify;
|
||||||
mod native;
|
mod native;
|
||||||
|
mod persistence;
|
||||||
mod quarantine;
|
mod quarantine;
|
||||||
mod realtime;
|
mod realtime;
|
||||||
mod rootkit;
|
mod rootkit;
|
||||||
|
|
@ -489,6 +490,36 @@ fn dispatch(req: &hound_api::Request, st: &DaemonState) -> Result<Value> {
|
||||||
Ok(serde_json::to_value(scan)?)
|
Ok(serde_json::to_value(scan)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── persistence ledger ──
|
||||||
|
"persistence.scan" => {
|
||||||
|
// Writing the baseline is an explicit act. A plain check must
|
||||||
|
// not quietly record whatever is currently installed as normal
|
||||||
|
// — that is how a compromise becomes the new baseline.
|
||||||
|
let update = req
|
||||||
|
.params
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.get("update_baseline"))
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false);
|
||||||
|
let report = persistence::scan(update);
|
||||||
|
let warns = report
|
||||||
|
.changes
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.severity == "warn" || c.severity == "critical")
|
||||||
|
.count();
|
||||||
|
if !report.first_run && warns > 0 {
|
||||||
|
st.events.push(
|
||||||
|
"persistence",
|
||||||
|
"warn",
|
||||||
|
format!(
|
||||||
|
"{warns} unexplained change(s) to startup configuration across {} item(s)",
|
||||||
|
report.total
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(serde_json::to_value(report)?)
|
||||||
|
}
|
||||||
|
|
||||||
// ── supply chain ──
|
// ── supply chain ──
|
||||||
"supply.sweep" => {
|
"supply.sweep" => {
|
||||||
let path = req
|
let path = req
|
||||||
|
|
|
||||||
486
crates/houndd/src/persistence.rs
Normal file
486
crates/houndd/src/persistence.rs
Normal file
|
|
@ -0,0 +1,486 @@
|
||||||
|
//! The persistence ledger.
|
||||||
|
//!
|
||||||
|
//! Most of what people picture as "getting hacked" is a file on disk, and
|
||||||
|
//! most of what actually happens is a line added to a startup file. The
|
||||||
|
//! payload is often boring and sometimes not even malicious by itself —
|
||||||
|
//! a `curl` in a shell profile, a systemd unit with an innocuous name, one
|
||||||
|
//! extra key in `authorized_keys`. What makes it an incident is that it
|
||||||
|
//! survives a reboot, and nobody looks at those files from one year to the
|
||||||
|
//! next.
|
||||||
|
//!
|
||||||
|
//! So this is not a scanner. It is an inventory with a memory: record
|
||||||
|
//! everything that can make code run again, and afterwards report what
|
||||||
|
//! *changed*. Three decisions follow from that:
|
||||||
|
//!
|
||||||
|
//! * **Content is hashed, not stat'd.** An mtime can be set backwards with
|
||||||
|
//! one `touch`, and an attacker editing a startup file is exactly the
|
||||||
|
//! person who would.
|
||||||
|
//! * **A first run reports no changes.** Everything would be a change, and
|
||||||
|
//! a first-run report full of alarms is one nobody reads. What a first
|
||||||
|
//! run *can* say is which entries no package claims, because that is
|
||||||
|
//! true regardless of history.
|
||||||
|
//! * **Package ownership decides what is ordinary.** A systemd unit that
|
||||||
|
//! arrived with a package is the system working. The same unit with no
|
||||||
|
//! package behind it is somebody's decision, and worth knowing about.
|
||||||
|
|
||||||
|
use hound_api::{PersistenceChange, PersistenceEntry, PersistenceReport};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Where the baseline lives. Beside the quarantine vault, because both
|
||||||
|
/// are daemon state rather than user configuration.
|
||||||
|
pub fn baseline_path() -> PathBuf {
|
||||||
|
let base = std::env::var("XDG_DATA_HOME")
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
|
||||||
|
PathBuf::from(home).join(".local").join("share")
|
||||||
|
});
|
||||||
|
base.join("hound").join("persistence-baseline.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Directories whose every file is a persistence mechanism.
|
||||||
|
const UNIT_DIRS: &[(&str, &str)] = &[
|
||||||
|
("systemd", "/etc/systemd/system"),
|
||||||
|
("systemd", "/usr/lib/systemd/system"),
|
||||||
|
("systemd", "/lib/systemd/system"),
|
||||||
|
("systemd", "/etc/systemd/user"),
|
||||||
|
("cron", "/etc/cron.d"),
|
||||||
|
("cron", "/etc/cron.hourly"),
|
||||||
|
("cron", "/etc/cron.daily"),
|
||||||
|
("cron", "/etc/cron.weekly"),
|
||||||
|
("cron", "/etc/cron.monthly"),
|
||||||
|
("cron", "/var/spool/cron/crontabs"),
|
||||||
|
("autostart", "/etc/xdg/autostart"),
|
||||||
|
("shell-profile", "/etc/profile.d"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Individual files worth watching.
|
||||||
|
const UNIT_FILES: &[(&str, &str)] = &[
|
||||||
|
("cron", "/etc/crontab"),
|
||||||
|
("shell-profile", "/etc/profile"),
|
||||||
|
("shell-profile", "/etc/bash.bashrc"),
|
||||||
|
("shell-profile", "/etc/zsh/zshrc"),
|
||||||
|
("preload", "/etc/ld.so.preload"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Per-home files, resolved for every real user account.
|
||||||
|
const HOME_FILES: &[(&str, &str)] = &[
|
||||||
|
("shell-profile", ".bashrc"),
|
||||||
|
("shell-profile", ".bash_profile"),
|
||||||
|
("shell-profile", ".bash_login"),
|
||||||
|
("shell-profile", ".profile"),
|
||||||
|
("shell-profile", ".zshrc"),
|
||||||
|
("shell-profile", ".zprofile"),
|
||||||
|
("authorized-keys", ".ssh/authorized_keys"),
|
||||||
|
("authorized-keys", ".ssh/authorized_keys2"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Per-home directories.
|
||||||
|
const HOME_DIRS: &[(&str, &str)] = &[
|
||||||
|
("autostart", ".config/autostart"),
|
||||||
|
("systemd", ".config/systemd/user"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Home directories of real accounts, from `/etc/passwd`.
|
||||||
|
///
|
||||||
|
/// Parsed rather than globbed over `/home`, because root's home is
|
||||||
|
/// `/root` and service accounts live in odd places — and `authorized_keys`
|
||||||
|
/// under `/root` is the one most worth watching.
|
||||||
|
pub fn user_homes() -> Vec<PathBuf> {
|
||||||
|
let Ok(passwd) = std::fs::read_to_string("/etc/passwd") else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut homes: Vec<PathBuf> = passwd
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let f: Vec<&str> = line.split(':').collect();
|
||||||
|
if f.len() < 7 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let uid: u32 = f[2].parse().ok()?;
|
||||||
|
let home = f[5];
|
||||||
|
let shell = f[6];
|
||||||
|
// Accounts that cannot log in are not interesting, and their
|
||||||
|
// "home" is often a shared directory like /nonexistent.
|
||||||
|
let usable = !shell.ends_with("nologin") && !shell.ends_with("/false");
|
||||||
|
let real = uid == 0 || uid >= 1000;
|
||||||
|
(usable && real && home.starts_with('/')).then(|| PathBuf::from(home))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
homes.sort();
|
||||||
|
homes.dedup();
|
||||||
|
homes.retain(|h| h.is_dir());
|
||||||
|
homes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256_file(path: &Path) -> Option<(String, u64)> {
|
||||||
|
let data = std::fs::read(path).ok()?;
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&data);
|
||||||
|
Some((format!("{:x}", hasher.finalize()), data.len() as u64))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inventory everything on this machine that can make code run again.
|
||||||
|
pub fn inventory() -> Vec<PersistenceEntry> {
|
||||||
|
let owned = crate::rootkit::dpkg_owned_paths();
|
||||||
|
let mut entries: Vec<PersistenceEntry> = Vec::new();
|
||||||
|
let mut seen: HashSet<String> = HashSet::new();
|
||||||
|
|
||||||
|
let mut add = |kind: &str, path: &Path, entries: &mut Vec<PersistenceEntry>, seen: &mut HashSet<String>| {
|
||||||
|
let Ok(md) = std::fs::symlink_metadata(path) else { return };
|
||||||
|
// A symlink into a unit directory is itself a persistence decision,
|
||||||
|
// but its content is the target's; recording the target keeps the
|
||||||
|
// hash meaningful. Skip broken ones rather than reporting them.
|
||||||
|
if !md.is_file() && !(md.is_symlink() && path.is_file()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let key = path.to_string_lossy().into_owned();
|
||||||
|
if !seen.insert(key.clone()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some((sha256, size)) = sha256_file(path) else { return };
|
||||||
|
let owned_by = match &owned {
|
||||||
|
Some(index) => {
|
||||||
|
let canonical = std::fs::canonicalize(path)
|
||||||
|
.map(|p| p.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|_| key.clone());
|
||||||
|
(index.contains(&key) || index.contains(&canonical))
|
||||||
|
.then(|| "package".to_string())
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
entries.push(PersistenceEntry {
|
||||||
|
kind: kind.to_string(),
|
||||||
|
path: key,
|
||||||
|
sha256,
|
||||||
|
size,
|
||||||
|
owned_by,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
for (kind, dir) in UNIT_DIRS {
|
||||||
|
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||||
|
add(kind, &e.path(), &mut entries, &mut seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (kind, file) in UNIT_FILES {
|
||||||
|
add(kind, Path::new(file), &mut entries, &mut seen);
|
||||||
|
}
|
||||||
|
for home in user_homes() {
|
||||||
|
for (kind, rel) in HOME_FILES {
|
||||||
|
add(kind, &home.join(rel), &mut entries, &mut seen);
|
||||||
|
}
|
||||||
|
for (kind, rel) in HOME_DIRS {
|
||||||
|
for e in std::fs::read_dir(home.join(rel)).into_iter().flatten().flatten() {
|
||||||
|
add(kind, &e.path(), &mut entries, &mut seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort_by(|a, b| a.path.cmp(&b.path));
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the baseline file holds.
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize, Default)]
|
||||||
|
struct Baseline {
|
||||||
|
ts: String,
|
||||||
|
entries: Vec<PersistenceEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare a current inventory against a baseline.
|
||||||
|
///
|
||||||
|
/// Pure, so the whole diff is testable without touching a real machine.
|
||||||
|
pub fn diff(baseline: &[PersistenceEntry], current: &[PersistenceEntry]) -> Vec<PersistenceChange> {
|
||||||
|
let old: HashMap<&str, &PersistenceEntry> =
|
||||||
|
baseline.iter().map(|e| (e.path.as_str(), e)).collect();
|
||||||
|
let new: HashMap<&str, &PersistenceEntry> =
|
||||||
|
current.iter().map(|e| (e.path.as_str(), e)).collect();
|
||||||
|
|
||||||
|
let mut changes = Vec::new();
|
||||||
|
|
||||||
|
for entry in current {
|
||||||
|
match old.get(entry.path.as_str()) {
|
||||||
|
None => changes.push(PersistenceChange {
|
||||||
|
change: "added".into(),
|
||||||
|
detail: format!(
|
||||||
|
"Something new was installed that will run again after a reboot: {}. \
|
||||||
|
{}",
|
||||||
|
entry.path,
|
||||||
|
if entry.owned_by.is_some() {
|
||||||
|
"It arrived with a software package, so it was most likely an \
|
||||||
|
ordinary install or update."
|
||||||
|
} else {
|
||||||
|
"No installed package claims it, so it did not arrive through \
|
||||||
|
the package manager. That is worth knowing where it came from."
|
||||||
|
}
|
||||||
|
),
|
||||||
|
severity: if entry.owned_by.is_some() { "info" } else { "warn" }.into(),
|
||||||
|
entry: entry.clone(),
|
||||||
|
}),
|
||||||
|
Some(before) if before.sha256 != entry.sha256 => changes.push(PersistenceChange {
|
||||||
|
change: "modified".into(),
|
||||||
|
detail: format!(
|
||||||
|
"The contents of {} changed. This file decides what runs at startup, \
|
||||||
|
so a change here changes what your machine does before you log in.",
|
||||||
|
entry.path
|
||||||
|
),
|
||||||
|
severity: if entry.owned_by.is_some() { "info" } else { "warn" }.into(),
|
||||||
|
entry: entry.clone(),
|
||||||
|
}),
|
||||||
|
Some(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in baseline {
|
||||||
|
if !new.contains_key(entry.path.as_str()) {
|
||||||
|
changes.push(PersistenceChange {
|
||||||
|
change: "removed".into(),
|
||||||
|
detail: format!(
|
||||||
|
"{} is gone. Usually that is an uninstall; occasionally it is \
|
||||||
|
something covering its tracks.",
|
||||||
|
entry.path
|
||||||
|
),
|
||||||
|
severity: "info".into(),
|
||||||
|
entry: entry.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most consequential first: unowned changes before packaged ones.
|
||||||
|
changes.sort_by_key(|c| match c.severity.as_str() {
|
||||||
|
"critical" => 0,
|
||||||
|
"warn" => 1,
|
||||||
|
_ => 2,
|
||||||
|
});
|
||||||
|
changes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a sweep, comparing against the stored baseline and updating it.
|
||||||
|
pub fn scan(update_baseline: bool) -> PersistenceReport {
|
||||||
|
let current = inventory();
|
||||||
|
let path = baseline_path();
|
||||||
|
let stored: Option<Baseline> = std::fs::read_to_string(&path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| serde_json::from_str(&t).ok());
|
||||||
|
|
||||||
|
let unowned = current.iter().filter(|e| e.owned_by.is_none()).count() as u64;
|
||||||
|
let now = crate::engine::to_rfc3339(std::time::SystemTime::now());
|
||||||
|
|
||||||
|
let (first_run, baseline_ts, changes) = match &stored {
|
||||||
|
None => (true, None, Vec::new()),
|
||||||
|
Some(b) => (false, Some(b.ts.clone()), diff(&b.entries, ¤t)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if update_baseline {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
let baseline = Baseline {
|
||||||
|
ts: now.clone(),
|
||||||
|
entries: current.clone(),
|
||||||
|
};
|
||||||
|
if let Ok(json) = serde_json::to_string_pretty(&baseline) {
|
||||||
|
let _ = std::fs::write(&path, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PersistenceReport {
|
||||||
|
ts: now,
|
||||||
|
baseline_ts,
|
||||||
|
first_run,
|
||||||
|
total: current.len() as u64,
|
||||||
|
unowned,
|
||||||
|
changes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn entry(path: &str, sha: &str, owned: bool) -> PersistenceEntry {
|
||||||
|
PersistenceEntry {
|
||||||
|
kind: "systemd".into(),
|
||||||
|
path: path.into(),
|
||||||
|
sha256: sha.into(),
|
||||||
|
size: 10,
|
||||||
|
owned_by: owned.then(|| "package".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the diff ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unchanged_system_reports_nothing() {
|
||||||
|
let a = vec![entry("/etc/crontab", "aaa", true)];
|
||||||
|
assert!(diff(&a, &a).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_unowned_unit_is_a_warning() {
|
||||||
|
let before = vec![];
|
||||||
|
let after = vec![entry("/etc/systemd/system/pulse-helper.service", "bbb", false)];
|
||||||
|
let c = diff(&before, &after);
|
||||||
|
assert_eq!(c.len(), 1);
|
||||||
|
assert_eq!(c[0].change, "added");
|
||||||
|
assert_eq!(c[0].severity, "warn");
|
||||||
|
assert!(c[0].detail.contains("No installed package claims it"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_packaged_unit_is_only_informational() {
|
||||||
|
// Installing software adds units constantly. Alarming on that
|
||||||
|
// teaches people to ignore the report.
|
||||||
|
let after = vec![entry("/lib/systemd/system/nginx.service", "ccc", true)];
|
||||||
|
let c = diff(&[], &after);
|
||||||
|
assert_eq!(c[0].severity, "info");
|
||||||
|
assert!(c[0].detail.contains("ordinary install"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_edited_startup_file_is_detected_by_content() {
|
||||||
|
// The point of hashing: an attacker can restore an mtime, so the
|
||||||
|
// diff must not depend on one.
|
||||||
|
let before = vec![entry("/home/joe/.bashrc", "aaa", false)];
|
||||||
|
let after = vec![entry("/home/joe/.bashrc", "zzz", false)];
|
||||||
|
let c = diff(&before, &after);
|
||||||
|
assert_eq!(c.len(), 1);
|
||||||
|
assert_eq!(c[0].change, "modified");
|
||||||
|
assert!(c[0].detail.contains("before you log in"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_removed_entry_is_reported_quietly() {
|
||||||
|
let before = vec![entry("/etc/cron.d/backup", "aaa", true)];
|
||||||
|
let c = diff(&before, &[]);
|
||||||
|
assert_eq!(c[0].change, "removed");
|
||||||
|
assert_eq!(c[0].severity, "info");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unowned_changes_sort_above_packaged_ones() {
|
||||||
|
let after = vec![
|
||||||
|
entry("/lib/systemd/system/a.service", "x", true),
|
||||||
|
entry("/etc/systemd/system/evil.service", "y", false),
|
||||||
|
];
|
||||||
|
let c = diff(&[], &after);
|
||||||
|
assert_eq!(c[0].severity, "warn", "the unowned one must be read first");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_moved_file_reads_as_a_removal_and_an_addition() {
|
||||||
|
let before = vec![entry("/etc/cron.d/a", "same", false)];
|
||||||
|
let after = vec![entry("/etc/cron.d/b", "same", false)];
|
||||||
|
let c = diff(&before, &after);
|
||||||
|
assert_eq!(c.len(), 2);
|
||||||
|
assert!(c.iter().any(|x| x.change == "added"));
|
||||||
|
assert!(c.iter().any(|x| x.change == "removed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── inventory against this machine ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inventory_finds_real_startup_files() {
|
||||||
|
let inv = inventory();
|
||||||
|
assert!(
|
||||||
|
inv.len() > 10,
|
||||||
|
"a running system has startup files; found {}",
|
||||||
|
inv.len()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
inv.iter().any(|e| e.kind == "systemd"),
|
||||||
|
"systemd units should be inventoried"
|
||||||
|
);
|
||||||
|
// Every entry must carry a usable hash.
|
||||||
|
for e in &inv {
|
||||||
|
assert_eq!(e.sha256.len(), 64, "bad hash for {}", e.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inventory_has_no_duplicate_paths() {
|
||||||
|
// /lib/systemd/system is a symlink to /usr/lib/systemd/system on
|
||||||
|
// merged-/usr systems, so the same unit is reachable twice.
|
||||||
|
let inv = inventory();
|
||||||
|
let mut paths: Vec<&str> = inv.iter().map(|e| e.path.as_str()).collect();
|
||||||
|
let before = paths.len();
|
||||||
|
paths.sort();
|
||||||
|
paths.dedup();
|
||||||
|
assert_eq!(before, paths.len(), "an entry was inventoried twice");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn most_startup_files_are_claimed_by_a_package() {
|
||||||
|
// If ownership resolution breaks, everything looks unowned and the
|
||||||
|
// report becomes noise — the same failure the setuid check had.
|
||||||
|
if crate::rootkit::dpkg_owned_paths().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let inv = inventory();
|
||||||
|
let systemd: Vec<&PersistenceEntry> =
|
||||||
|
inv.iter().filter(|e| e.kind == "systemd").collect();
|
||||||
|
if systemd.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let owned = systemd.iter().filter(|e| e.owned_by.is_some()).count();
|
||||||
|
let ratio = owned as f64 / systemd.len() as f64;
|
||||||
|
assert!(
|
||||||
|
ratio > 0.5,
|
||||||
|
"only {owned}/{} systemd units resolved to a package — ownership lookup is broken",
|
||||||
|
systemd.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn user_homes_include_root_and_a_real_account() {
|
||||||
|
let homes = user_homes();
|
||||||
|
assert!(!homes.is_empty(), "there is at least one usable account");
|
||||||
|
for h in &homes {
|
||||||
|
assert!(h.is_dir(), "{h:?} should exist");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn service_accounts_are_not_treated_as_users() {
|
||||||
|
// nologin accounts share directories like /nonexistent and would
|
||||||
|
// otherwise be inventoried repeatedly.
|
||||||
|
let homes = user_homes();
|
||||||
|
assert!(
|
||||||
|
!homes.iter().any(|h| h.to_string_lossy().contains("nonexistent")),
|
||||||
|
"a nologin account leaked into the home list"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hashing_is_content_sensitive() {
|
||||||
|
let d = std::env::temp_dir().join(format!("hound-pers-{}", std::process::id()));
|
||||||
|
let _ = std::fs::create_dir_all(&d);
|
||||||
|
let f = d.join("unit.service");
|
||||||
|
std::fs::write(&f, b"ExecStart=/bin/true").unwrap();
|
||||||
|
let (a, size_a) = sha256_file(&f).unwrap();
|
||||||
|
std::fs::write(&f, b"ExecStart=/bin/evil").unwrap();
|
||||||
|
let (b, _) = sha256_file(&f).unwrap();
|
||||||
|
assert_ne!(a, b, "a content change must change the hash");
|
||||||
|
assert_eq!(size_a, 19);
|
||||||
|
let _ = std::fs::remove_dir_all(&d);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_scan_that_does_not_write_leaves_no_baseline() {
|
||||||
|
// Read-only mode must not have side effects — someone running a
|
||||||
|
// check should not silently accept whatever is currently installed
|
||||||
|
// as normal.
|
||||||
|
let scan_a = scan(false);
|
||||||
|
assert!(scan_a.total > 0);
|
||||||
|
let scan_b = scan(false);
|
||||||
|
assert_eq!(
|
||||||
|
scan_a.first_run, scan_b.first_run,
|
||||||
|
"a read-only scan must not change what the next one sees"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
dist/HOUND-VERIFY-README.txt
vendored
Normal file
58
dist/HOUND-VERIFY-README.txt
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
hound-verify — cross-distro verification for Hound Antivirus
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
What this is
|
||||||
|
------------
|
||||||
|
The Hound test suite, compiled. It is READ-ONLY: it installs nothing,
|
||||||
|
starts no daemon, needs no root, and does not modify system state. It
|
||||||
|
reads /usr/bin, /bin, /usr/sbin, /proc and dpkg's package index.
|
||||||
|
|
||||||
|
Requirements
|
||||||
|
------------
|
||||||
|
x86_64, glibc 2.39 or newer — Ubuntu 24.04 and up. (The floor comes from
|
||||||
|
Rust's standard library, not from Hound.) Check with: ldd --version
|
||||||
|
|
||||||
|
How to run
|
||||||
|
----------
|
||||||
|
chmod +x hound-verify
|
||||||
|
|
||||||
|
# The two checks that matter, together:
|
||||||
|
./hound-verify rootkit rules
|
||||||
|
|
||||||
|
# Or everything (~30s, mostly the goodware scan):
|
||||||
|
./hound-verify
|
||||||
|
|
||||||
|
What is actually being verified
|
||||||
|
-------------------------------
|
||||||
|
1. rootkit::tests::unowned_setuid_does_not_fire_on_a_healthy_system
|
||||||
|
|
||||||
|
Hound flags setuid binaries that no installed package claims. It asks
|
||||||
|
dpkg. Ubuntu's merged-/usr layout means every binary has two names
|
||||||
|
(/bin/sudo and /usr/bin/sudo), and dpkg records some packages under
|
||||||
|
one and some under the other. Getting this wrong makes Hound alarm on
|
||||||
|
a clean machine.
|
||||||
|
|
||||||
|
FAILURE OUTPUT names each binary it wrongly flagged. That is the
|
||||||
|
useful part — please paste it.
|
||||||
|
|
||||||
|
2. rules::tests::no_false_positives_on_system_binaries
|
||||||
|
|
||||||
|
Every Hound detection rule is scanned against every binary in
|
||||||
|
/usr/bin, /bin and /usr/sbin. A single hit fails the build. Ubuntu
|
||||||
|
ships binaries Linux Mint does not, so this is genuinely new ground.
|
||||||
|
|
||||||
|
FAILURE OUTPUT names the binary and the rule. A hit means I delete
|
||||||
|
that rule rather than tune it — a rule that flags a system binary is
|
||||||
|
worse than no rule.
|
||||||
|
|
||||||
|
3. Everything else in the suite comes along for the ride and is a bonus
|
||||||
|
signal: the fanotify policy tests, the capability arithmetic, the
|
||||||
|
supply-chain detectors.
|
||||||
|
|
||||||
|
What to send back
|
||||||
|
-----------------
|
||||||
|
Either "all passed" (which is a real result — it closes the last open
|
||||||
|
criterion on the rootkit rewrite), or the assertion text of anything
|
||||||
|
that failed. The messages are written to be self-explanatory.
|
||||||
|
|
||||||
|
Thanks — Hound
|
||||||
BIN
dist/hound-verify
vendored
Executable file
BIN
dist/hound-verify
vendored
Executable file
Binary file not shown.
Loading…
Reference in a new issue