A clean laptop reported 988 critical rootkit findings; this server
3786, PID 1 among them. Every one was false, and the cause was our own
systemd hardening.
ProtectProc=invisible hides processes the daemon does not own from its
view of /proc, while kill(pid, 0) keeps answering truthfully because it
is a syscall and not a filesystem lookup. The hidden-process check
compares exactly those two sources, so with that setting every process
on the machine looked concealed. Enumerating processes is this daemon's
job, so it needs the default view.
Removing the setting is not enough on its own — hidepid= on the /proc
mount produces the same blindness and we do not control that. So the
detector now recognises when it cannot see:
- PID 1 is the control. It always exists and nothing hides init; a
rootkit that did would break the machine it is living on. If PID 1
answers kill(1, 0) but is absent from the listing, we are blind and
say so as info rather than crying rootkit.
- A plausibility ceiling of 32. Hiding a handful of processes is the
entire point of a rootkit; hundreds means a broken observer. An
antivirus that reports a critical rootkit finding on every clean
machine teaches people to ignore the one time it is real.
Also in this change, from testing on a real desktop:
- Closing the window hides it to the tray instead of exiting, with a
one-time notification so it does not read as a crash. Quit lives
only in the tray menu and confirms first. The settings already had
close_to_tray and confirm_quit fields wired to nothing; they are
honoured now rather than hardcoded.
- The tray menu and Scan Home sent the literal string "~". A shell
would have expanded it, nothing here did, so the daemon was asked
to scan a directory of that name. It failed silently until the
per-peer readability check made it audible.
- Administrative actions elevate through polkit instead of telling
people to open a terminal. The app tries unprivileged first and
only on a privilege refusal runs `pkexec hound admin-rpc`, which
forwards one request as root. auth_admin_keep, because prompting on
every settings toggle trains people to authenticate without reading
the prompt. This grants what `sudo hound` already grants to people
who could already run sudo — a transport, not a new privilege.
- `hound settings exec-gate on|off` now exists. The install script,
the AppImage banner, the rpm spec, the AUR install file and
llms.txt all told users to run `hound settings set exec_gate true`.
There was no `set` subcommand and no way to enable the execution
gate from the CLI at all: the flagship paid feature was unreachable
and the first thing a new user was told to type returned an error.
A test now asserts every documented command parses.
- `settings show` displays the exec gate state, and no longer prints
its own header twice.
- The CLI help still described ClamAV, which has not been the engine
for some time. So did the socket permission error, which now
explains the `hound` group and the log-out-and-back-in it needs.
368 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
124 lines
6 KiB
Desktop File
124 lines
6 KiB
Desktop File
[Unit]
|
|
Description=Hound Antivirus daemon
|
|
Documentation=https://houndav.com/docs
|
|
After=network.target
|
|
# The gate is only useful while the filesystem it watches is mounted.
|
|
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
|
|
|
|
# ── Privilege ────────────────────────────────────────────────────────
|
|
#
|
|
# houndd reduces its own capabilities at startup (see crates/houndd/src/
|
|
# caps.rs). This bounding set is the second half of that: it protects the
|
|
# machine if the binary is ever replaced by one that does not, and it
|
|
# means the daemon never *holds* the rest even momentarily.
|
|
#
|
|
# It cannot be narrower. fanotify needs SYS_ADMIN; scanning arbitrary
|
|
# files needs DAC_READ_SEARCH; quarantining out of a directory owned by
|
|
# someone else needs DAC_OVERRIDE; stripping the execute bit off a file
|
|
# we do not own needs FOWNER.
|
|
# SETUID/SETGID let the daemon fork a child, become the user who sent a scan
|
|
# request, and ask the kernel whether that user could have opened the file —
|
|
# which is what stops an unprivileged caller using a root scanner to probe
|
|
# files it cannot read. It is not an escalation: DAC_OVERRIDE below already
|
|
# grants this process every file on the system.
|
|
CapabilityBoundingSet=CAP_SYS_ADMIN CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE CAP_FOWNER CAP_SETUID CAP_SETGID
|
|
# AmbientCapabilities is deliberately NOT set. Ambient capabilities are
|
|
# inherited by child processes, and the daemon shells out to freshclam,
|
|
# rpm and pacman on some paths — none of which should start life holding
|
|
# CAP_SYS_ADMIN. A process running as root already receives everything in
|
|
# the bounding set as permitted and effective, so Ambient adds nothing
|
|
# here except a way for it to leak.
|
|
NoNewPrivileges=yes
|
|
|
|
# ── Filesystem ───────────────────────────────────────────────────────
|
|
#
|
|
# ProtectSystem=strict would be the obvious choice and is WRONG here: it
|
|
# remounts everything read-only, and quarantine has to remove a threat
|
|
# from wherever it landed. ProtectHome is off for the same reason — most
|
|
# of what we quarantine is under /home.
|
|
ProtectSystem=false
|
|
ProtectHome=false
|
|
ReadWritePaths=/var/lib/hound /var/log/hound
|
|
StateDirectory=hound
|
|
LogsDirectory=hound
|
|
RuntimeDirectory=hound
|
|
RuntimeDirectoryMode=0750
|
|
# The daemon stays uid 0 — it has to read files no user can. Its *group* is
|
|
# `hound`, which is what lets it open the control socket to the desktop app
|
|
# without CAP_CHOWN: a process can already chgrp a file it owns to a group it
|
|
# belongs to. Membership of `hound` buys the read side of the API and nothing
|
|
# that writes; the daemon checks SO_PEERCRED per method (see peer.rs).
|
|
Group=hound
|
|
PrivateTmp=no
|
|
|
|
# ── Everything else we can shut off ──────────────────────────────────
|
|
ProtectKernelTunables=yes
|
|
ProtectKernelModules=yes
|
|
ProtectKernelLogs=yes
|
|
ProtectControlGroups=yes
|
|
ProtectClock=yes
|
|
ProtectHostname=yes
|
|
# ProtectProc is deliberately NOT set, and this one bit hard. `invisible`
|
|
# hides every process the daemon does not own from its view of /proc — while
|
|
# kill(pid, 0) keeps answering truthfully, because it is a syscall and not a
|
|
# filesystem lookup. The rootkit check compares those two sources and reports
|
|
# anything present in one but not the other. With ProtectProc=invisible that
|
|
# is EVERY process on the machine: a clean laptop reported 988 hidden
|
|
# processes, and this server 3786, PID 1 among them. An antivirus that
|
|
# announces a rootkit on every clean machine is worse than one that does not
|
|
# look. Enumerating processes is this daemon's job, so it needs the default
|
|
# view; the detector also now refuses to report when it can tell it is blind.
|
|
RestrictNamespaces=yes
|
|
RestrictRealtime=yes
|
|
RestrictSUIDSGID=yes
|
|
LockPersonality=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
|
|
# fanotify_init and fanotify_mark live in @privileged, which
|
|
# @system-service deliberately excludes — so the base filter kills the
|
|
# daemon with SIGSYS the moment the execution gate is switched on. This
|
|
# was invisible until the gate was armed for the first time on a real
|
|
# install: the service starts fine with the gate off.
|
|
#
|
|
# Granted individually rather than by adding @privileged, which would
|
|
# also admit setuid, chroot, bpf, kexec_load and pivot_root. Two
|
|
# syscalls is the whole requirement.
|
|
SystemCallFilter=fanotify_init fanotify_mark
|
|
SystemCallFilter=~@clock @cpu-emulation @debug @module @mount @obsolete @raw-io @reboot @swap
|
|
UMask=0077
|
|
|
|
# ── Resources ────────────────────────────────────────────────────────
|
|
# A scanner that eats the machine is its own denial of service.
|
|
MemoryMax=1G
|
|
TasksMax=64
|
|
LimitNOFILE=65536
|
|
OOMScoreAdjust=-500
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|