packaging: four bugs that only a real install could find
Installed the .deb on the live server. The daemon did not start, and everything below is what that one command surfaced. 1. MemoryDenyWriteExecute=yes stopped the service dead. yara-x compiles rules to WebAssembly and JITs them, so it needs pages that go writable then executable. With W^X enforced the daemon aborts at startup: "unable to make memory executable". The unit had passed systemd-analyze verify, which checks syntax and cannot know this. Now off, with the reasoning in the unit rather than in a commit nobody will read: a hardening directive that stops the service is worse than the exposure it prevents, because the machine ends up with no antivirus at all. What compensates is spelled out beside it. 2. Hound detected itself. The goodware gate reported /usr/bin/houndd as Linux.Coinminer.XMRig and Linux.Rootkit.Preload. Correctly: the built-in pack matches on "stratum+tcp://", "xmrig", "RTLD_NEXT" and "ld.so.preload", and the pack was embedded verbatim, so the daemon's own binary contained all of them. Not cosmetic. With the execution gate armed, Hound would have refused to execute itself or quarantined its own binary — a scanner that eats its own daemon the moment protection is switched on. The pack is now XOR-masked at build time (build.rs) and unmasked at startup. Not a secret — the rules are open source — the only job is keeping the literal bytes out of the executable. Two regression tests: the embedded blob carries no plaintext rule strings, and a built daemon binary in target/ carries none either. 3. The postinst copied the built-in pack into /var/lib/hound/rules, where the daemon compiled it a second time and logged a duplicate declaration on every start. That directory is for ADDITIONAL packs; the built-ins live in the binary. Removed from deb, rpm and AUR. 4. The daemon and the CLI disagreed about the socket. systemd gives the service /run/hound; the CLI looked in $XDG_RUNTIME_DIR and reported the daemon unreachable — technically true, entirely unhelpful. default_socket_path() now prefers /run/hound when it exists, the unit states HOUNDD_SOCK explicitly, and a permission error on the socket says "try: sudo hound" instead of "Permission denied". Also: Recommends: clamav-daemon was wrong and apt duly installed clamd, which took 970 MB of RSS on the live server. clamd is an optional arm's-length engine, so it is a Suggests. I stopped and disabled the copy my install pulled in. Verified on the server after fixing: service active, status reports the engine and the gate, EICAR caught, rootkit scan clean, and the goodware gate passes across 3,955 system binaries including the now-installed houndd. The gate remains OFF. Turning it on for the host that serves Caddy is a separate decision. 295 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
42cd97d59f
commit
432e2b825e
9 changed files with 190 additions and 16 deletions
|
|
@ -14,14 +14,31 @@ use std::os::unix::net::UnixStream;
|
|||
use std::os::windows::net::UnixStream;
|
||||
|
||||
/// The default socket path when `$HOUNDD_SOCK` is unset.
|
||||
///
|
||||
/// A system daemon and a per-user one live in different places, and the
|
||||
/// CLI has to find whichever is actually running. On the first packaged
|
||||
/// install the daemon bound `/run/hound/houndd.sock` (systemd's
|
||||
/// `RuntimeDirectory=`) while `hound status` looked in
|
||||
/// `$XDG_RUNTIME_DIR` and reported the daemon unreachable — technically
|
||||
/// true and completely unhelpful.
|
||||
///
|
||||
/// `/run/hound` exists only when systemd created it for the service, so
|
||||
/// its presence is a reliable signal that the system daemon is the one to
|
||||
/// talk to.
|
||||
pub fn default_socket_path() -> String {
|
||||
if let Ok(sock) = std::env::var("HOUNDD_SOCK") {
|
||||
return sock;
|
||||
}
|
||||
if std::path::Path::new(SYSTEM_RUNTIME_DIR).is_dir() {
|
||||
return format!("{SYSTEM_RUNTIME_DIR}/houndd.sock");
|
||||
}
|
||||
let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string());
|
||||
format!("{runtime}/houndd.sock")
|
||||
}
|
||||
|
||||
/// Created by systemd's `RuntimeDirectory=hound` for the packaged service.
|
||||
pub const SYSTEM_RUNTIME_DIR: &str = "/run/hound";
|
||||
|
||||
// ── Request / Response envelopes ────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -452,7 +469,22 @@ impl Client {
|
|||
/// Connect and issue a single request. Returns the decoded `result`.
|
||||
pub fn call(&self, id: u64, method: &str, params: Option<Value>) -> anyhow::Result<Value> {
|
||||
let mut stream = UnixStream::connect(&self.sock)
|
||||
.map_err(|e| anyhow::anyhow!("cannot reach houndd at {}: {e}", self.sock))?;
|
||||
.map_err(|e| {
|
||||
// "Permission denied" on a root-owned socket is technically
|
||||
// accurate and useless. The system daemon's socket is
|
||||
// root-only on purpose — anything that can reach it can
|
||||
// quarantine files — so the answer is almost always sudo.
|
||||
let hint = match e.kind() {
|
||||
std::io::ErrorKind::PermissionDenied => {
|
||||
"\n the system daemon's socket is root-only — try: sudo hound …"
|
||||
}
|
||||
std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused => {
|
||||
"\n is the daemon running? try: sudo systemctl status houndd"
|
||||
}
|
||||
_ => "",
|
||||
};
|
||||
anyhow::anyhow!("cannot reach houndd at {}: {e}{hint}", self.sock)
|
||||
})?;
|
||||
let req = Request {
|
||||
jsonrpc: "2.0".into(),
|
||||
id,
|
||||
|
|
|
|||
34
crates/houndd/build.rs
Normal file
34
crates/houndd/build.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
//! Obfuscate the embedded rule pack at build time.
|
||||
//!
|
||||
//! An antivirus that ships its signatures as literal strings inside its
|
||||
//! own binary detects itself. Hound's built-in pack matches on
|
||||
//! "stratum+tcp://", "donate-level", "xmrig", "RTLD_NEXT" and
|
||||
//! "ld.so.preload"; with the pack embedded verbatim, `/usr/bin/houndd`
|
||||
//! matched Linux.Coinminer.XMRig and Linux.Rootkit.Preload. The goodware
|
||||
//! gate caught it on the first packaged install.
|
||||
//!
|
||||
//! That is not cosmetic. With the execution gate armed, Hound would have
|
||||
//! refused to execute itself, or quarantined its own binary — a scanner
|
||||
//! that eats its own daemon the moment protection is switched on.
|
||||
//!
|
||||
//! A single-byte XOR is enough. This is not a secret: the rules are open
|
||||
//! source and anybody can read them in the repository. The only job is to
|
||||
//! stop the literal bytes appearing in the executable, and a trivial
|
||||
//! transform does that as well as an elaborate one would.
|
||||
use std::io::Write;
|
||||
|
||||
/// Chosen only so the transform is not the identity function.
|
||||
const MASK: u8 = 0x5A;
|
||||
|
||||
fn main() {
|
||||
let src = "rules/hound-builtin.yar";
|
||||
println!("cargo:rerun-if-changed={src}");
|
||||
|
||||
let plain = std::fs::read(src).expect("reading the built-in rule pack");
|
||||
let masked: Vec<u8> = plain.iter().map(|b| b ^ MASK).collect();
|
||||
|
||||
let out = std::path::PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR"))
|
||||
.join("hound-builtin.yar.masked");
|
||||
let mut f = std::fs::File::create(&out).expect("creating the masked pack");
|
||||
f.write_all(&masked).expect("writing the masked pack");
|
||||
}
|
||||
|
|
@ -26,8 +26,24 @@ use std::path::PathBuf;
|
|||
use std::sync::{Arc, RwLock};
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// The starter pack, baked into the binary.
|
||||
const BUILTIN: &str = include_str!("../rules/hound-builtin.yar");
|
||||
/// The starter pack, baked into the binary — masked so its own strings do
|
||||
/// not appear literally in the executable.
|
||||
///
|
||||
/// See `build.rs`. Without this, Hound matches itself: the pack detects
|
||||
/// miners on "stratum+tcp://" and "xmrig", the binary contains those
|
||||
/// bytes, and the goodware gate reported `/usr/bin/houndd` as a
|
||||
/// coinminer. With the execution gate armed that becomes a daemon that
|
||||
/// refuses to run itself.
|
||||
const BUILTIN_MASKED: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/hound-builtin.yar.masked"));
|
||||
|
||||
/// Must match `build.rs`.
|
||||
const MASK: u8 = 0x5A;
|
||||
|
||||
/// Recover the built-in pack.
|
||||
fn builtin() -> String {
|
||||
let plain: Vec<u8> = BUILTIN_MASKED.iter().map(|b| b ^ MASK).collect();
|
||||
String::from_utf8(plain).expect("the built-in pack is valid UTF-8")
|
||||
}
|
||||
|
||||
/// A compiled ruleset plus the provenance a client needs to display it.
|
||||
pub struct RuleSet {
|
||||
|
|
@ -52,8 +68,9 @@ impl RuleSet {
|
|||
let mut compiler = yara_x::Compiler::new();
|
||||
let mut sources = Vec::new();
|
||||
|
||||
let builtin_src = builtin();
|
||||
compiler
|
||||
.add_source(yara_x::SourceCode::from(BUILTIN).with_origin("hound-builtin.yar"))
|
||||
.add_source(yara_x::SourceCode::from(builtin_src.as_str()).with_origin("hound-builtin.yar"))
|
||||
.map_err(|e| anyhow::anyhow!("built-in rules failed to compile: {e}"))?;
|
||||
sources.push("hound-builtin.yar (embedded)".to_string());
|
||||
|
||||
|
|
@ -179,6 +196,61 @@ impl RuleStore {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Hound must not be detectable by Hound.
|
||||
///
|
||||
/// The regression: the pack was embedded verbatim, so the daemon's own
|
||||
/// binary contained "stratum+tcp://" and "ld.so.preload" and matched
|
||||
/// its own miner and rootkit rules. With the execution gate armed that
|
||||
/// is a daemon that refuses to run itself.
|
||||
#[test]
|
||||
fn the_embedded_pack_holds_no_plaintext_rule_strings() {
|
||||
// Asserted against the embedded blob rather than against
|
||||
// current_exe(): in a test run that IS the test harness, which
|
||||
// legitimately contains these strings as fixtures. The blob is
|
||||
// what ends up in the shipped daemon, and it is what build.rs
|
||||
// guarantees.
|
||||
let haystack = String::from_utf8_lossy(BUILTIN_MASKED);
|
||||
for needle in [
|
||||
"stratum+tcp://",
|
||||
"donate-level",
|
||||
"ld.so.preload",
|
||||
"RTLD_NEXT",
|
||||
"EICAR-STANDARD-ANTIVIRUS-TEST-FILE",
|
||||
] {
|
||||
assert!(
|
||||
!haystack.contains(needle),
|
||||
"{needle:?} survives into the embedded pack — Hound will detect itself"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Belt and braces: if a built daemon is sitting in target/, check the
|
||||
/// real artefact too. Skipped when there is not one.
|
||||
#[test]
|
||||
fn a_built_daemon_binary_does_not_detect_itself() {
|
||||
let mut checked = 0;
|
||||
for candidate in ["target/release/houndd", "target/debug/houndd"] {
|
||||
let Ok(bytes) = std::fs::read(candidate) else { continue };
|
||||
checked += 1;
|
||||
let haystack = String::from_utf8_lossy(&bytes);
|
||||
for needle in ["stratum+tcp://", "ld.so.preload", "RTLD_NEXT"] {
|
||||
assert!(
|
||||
!haystack.contains(needle),
|
||||
"{needle:?} appears literally in {candidate}"
|
||||
);
|
||||
}
|
||||
}
|
||||
let _ = checked; // a fresh checkout has neither; that is fine
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_masked_pack_recovers_exactly() {
|
||||
let recovered = builtin();
|
||||
assert!(recovered.starts_with("/*"), "the pack should begin with its header comment");
|
||||
assert!(recovered.contains("rule EICAR_Test_File"));
|
||||
assert!(recovered.contains("Linux_Coinminer_XMRig"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_pack_compiles() {
|
||||
let set = RuleSet::compile().expect("built-in pack must always compile");
|
||||
|
|
|
|||
BIN
dist/hound_0.1.0_amd64.deb
vendored
BIN
dist/hound_0.1.0_amd64.deb
vendored
Binary file not shown.
|
|
@ -7,7 +7,8 @@ arch=('x86_64' 'aarch64')
|
|||
url="https://houndav.com"
|
||||
license=('Apache-2.0')
|
||||
depends=('systemd-libs')
|
||||
optdepends=('clamav: the Windows-malware corpus, for the file-server carrier case')
|
||||
optdepends=('clamav: the Windows-malware corpus, for the file-server carrier case'
|
||||
'clamav-daemon: needed only if you enable the optional clamd engine')
|
||||
makedepends=('rust>=1.91' 'cargo')
|
||||
backup=('etc/hound/hound.toml')
|
||||
install=hound.install
|
||||
|
|
|
|||
19
packaging/aur/hound.install
Normal file
19
packaging/aur/hound.install
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
post_install() {
|
||||
cat <<'MSG'
|
||||
|
||||
Hound is installed and scanning on demand.
|
||||
|
||||
hound status what the daemon sees
|
||||
hound scan ~/Downloads scan a directory
|
||||
|
||||
Real-time execution blocking is OFF until you turn it on:
|
||||
|
||||
sudo systemctl enable --now houndd
|
||||
sudo hound settings set exec_gate true
|
||||
|
||||
MSG
|
||||
}
|
||||
|
||||
post_upgrade() {
|
||||
post_install
|
||||
}
|
||||
|
|
@ -64,7 +64,7 @@ Priority: optional
|
|||
Architecture: ${ARCH}
|
||||
Maintainer: Hound <support@houndav.com>
|
||||
Depends: libc6 (>= 2.34)
|
||||
Recommends: clamav-daemon
|
||||
Suggests: clamav-daemon
|
||||
Homepage: https://houndav.com
|
||||
Description: Hound Antivirus for Linux
|
||||
Endpoint and supply-chain protection built for the distributions people
|
||||
|
|
@ -118,11 +118,10 @@ case "$1" in
|
|||
chmod 0755 /var/lib/hound /var/lib/hound/rules
|
||||
chmod 0750 /var/log/hound
|
||||
|
||||
# Seed the built-in rules where the daemon looks for packs, so an
|
||||
# offline install still detects something.
|
||||
if [ -f /usr/share/hound/rules/hound-builtin.yar ]; then
|
||||
cp -n /usr/share/hound/rules/hound-builtin.yar /var/lib/hound/rules/ || true
|
||||
fi
|
||||
# The built-in rules are compiled INTO the binary; /var/lib/hound/rules
|
||||
# is for additional packs only. Copying the built-ins there made the
|
||||
# daemon compile them twice and log a duplicate-declaration error on
|
||||
# every start. The copy under /usr/share is documentation, not input.
|
||||
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload || true
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ URL: https://houndav.com
|
|||
Source0: %{name}-%{version}.tar.gz
|
||||
BuildRequires: rust >= 1.91, cargo, systemd-rpm-macros
|
||||
Requires: systemd
|
||||
Recommends: clamd
|
||||
Suggests: clamd
|
||||
|
||||
%description
|
||||
Endpoint and supply-chain protection built for the distributions people
|
||||
|
|
@ -42,9 +42,8 @@ install -Dm644 assets/icons/hound-app.svg \
|
|||
%{buildroot}%{_datadir}/icons/hicolor/scalable/apps/hound.svg
|
||||
|
||||
%post
|
||||
# Seed the built-in rules so an offline install still detects something.
|
||||
cp -n %{_datadir}/hound/rules/hound-builtin.yar \
|
||||
%{_sharedstatedir}/hound/rules/ 2>/dev/null || :
|
||||
# The built-in rules are compiled into the binary; %{_sharedstatedir}/hound/rules
|
||||
# is for additional packs only. Seeding it there compiles them twice.
|
||||
%systemd_post houndd.service
|
||||
|
||||
%preun
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ RequiresMountsFor=/var/lib/hound
|
|||
[Service]
|
||||
Type=exec
|
||||
ExecStart=/usr/bin/houndd
|
||||
# Stated explicitly so the daemon and the CLI cannot drift apart.
|
||||
Environment=HOUNDD_SOCK=/run/hound/houndd.sock
|
||||
Restart=on-failure
|
||||
RestartSec=2s
|
||||
|
||||
|
|
@ -52,7 +54,23 @@ RestrictNamespaces=yes
|
|||
RestrictRealtime=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
|
||||
# MemoryDenyWriteExecute is deliberately NOT set, and this is a real
|
||||
# trade-off rather than an oversight.
|
||||
#
|
||||
# yara-x compiles rules to WebAssembly and JITs them, so it needs pages
|
||||
# that are writable and then executable. With W^X enforced the daemon
|
||||
# aborts at startup with "unable to make memory executable" — which is
|
||||
# exactly what happened on the first real install, after the unit had
|
||||
# passed systemd-analyze verify. A hardening directive that stops the
|
||||
# service is worse than the exposure it prevents, because the machine
|
||||
# ends up with no antivirus at all.
|
||||
#
|
||||
# What compensates: the scanner never executes scanned content, the
|
||||
# capability set is four of forty-one, the syscall filter below blocks
|
||||
# @module/@mount/@raw-io/@reboot, and the process cannot gain privileges.
|
||||
# Revisit if yara-x ever ships an interpreter-only mode.
|
||||
# MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
SystemCallArchitectures=native
|
||||
SystemCallFilter=@system-service
|
||||
|
|
|
|||
Loading…
Reference in a new issue