gate: stop blocking reads, and stop calling documents malware

Armed the gate on / on the live server. Aborted after about twenty
seconds. The box was never at risk — Caddy stayed sub-millisecond and
load never rose — but the gate blocked reads of an AI agent's session
transcript, reporting it as Linux.Coinminer.XMRig.

It was not wrong about the bytes. That transcript contains
"stratum+tcp://", "donate-level" and "xmrig" because the miner rule was
being written in that session. The rule matched a document ABOUT
malware.

Three bugs, none of which the tmpfs stage could have shown:

1. The miner rule had no file-type condition, so any text mentioning
   mining tripped it: threat-intelligence reports, security blog posts,
   support tickets, an antivirus's own logs. It now requires ELF magic,
   as the rootkit rule always did. Two regression tests: a transcript
   discussing the rule is clean, and an ELF carrying the same strings
   still matches — the fix must not cost the detection it exists for.

2. The gate requested FAN_OPEN_PERM, so it held every OPEN, not every
   execve. A matching file could not be read by anything. That is a
   different product from the one advertised, and on a multi-tenant box
   it is a denial of service against the operator rather than a defence.

   Read events are no longer requested at all. FAN_OPEN_EXEC_PERM and
   FAN_CLOSE_WRITE cover the threat: execution is refused before it
   happens, and anything malicious written to disk is quarantined when
   the write completes. An interpreted script is caught as it lands
   rather than as it is read — the same protection, one step earlier.
   `serve` also guards deny-on-exec explicitly, so re-requesting read
   events later cannot silently restore the old behaviour.

3. Hound did not exclude its own state. /var/lib/hound and /run/hound
   are now always excluded; the vault holds live malware by definition.

Henry asked whether the single watchdog rescue was queue pressure or
scan time. It was scan time: the gate inherited the on-demand 100 MB
limit and tried to read and match a multi-megabyte transcript inline
while holding a process. A gate's budget is a deadline, not a size, so
it now caps at 32 MB — anything larger is allowed through unread rather
than turned into a rescue, which is a process released unscanned and
worse than never having looked.

Dropping read events made everything faster, because most opens on a
running machine are reads:

  latency     +1.38 -> +0.79 ms per exec
  throughput  2,680 -> 4,178 execs/sec (58% of ungated, was 36%)
  events      1,179 in five seconds on an idle tmpfs -> 1

Re-verified on the tmpfs: an ELF miner is quarantined before it can even
be made executable, a document naming every one of its strings is
readable, and a clean binary runs.

297 tests pass. The gate stays off; stage 3 gets attempted again with
these fixes and fresh numbers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hound 2026-08-21 08:13:02 -05:00
parent 9f0c4e08d7
commit be5396821d
5 changed files with 138 additions and 11 deletions

View file

@ -37,7 +37,15 @@ rule Linux_Coinminer_XMRig
$cfg3 = "randomx" ascii nocase
$name = "xmrig" ascii nocase
condition:
($pool1 or $pool2) and 2 of ($cfg*) and $name
// ELF magic is not optional here.
//
// Without it this rule matches any TEXT that mentions mining:
// a blog post, a support ticket, a threat-intelligence report,
// or — as happened on a live server — an AI session transcript
// in which somebody was writing this very rule. Malware is a
// program; a document about malware is not.
uint32(0) == 0x464c457f
and ($pool1 or $pool2) and 2 of ($cfg*) and $name
}
rule Linux_Webshell_PHP_Eval

View file

@ -434,7 +434,24 @@ impl Gate {
libc::fanotify_mark(
self.responder.fan.as_raw_fd(),
flags,
FAN_OPEN_EXEC_PERM | FAN_OPEN_PERM | FAN_CLOSE_WRITE,
// FAN_OPEN_PERM is deliberately NOT requested.
//
// It fires on every open of every file on the filesystem,
// which is an enormous event volume for no protective
// value we can actually use: we do not block reads (see
// the deny-only-on-exec note in `serve`), so each one is a
// read, a scan and an allow. On a live server that meant
// re-scanning a multi-megabyte agent transcript on every
// open, which is what pushed an event past the watchdog
// deadline within seconds of arming.
//
// What remains covers the threat: execution is refused
// before it happens, and anything malicious written to
// disk is quarantined when the write completes. An
// interpreted script is caught as it lands rather than as
// it is read, which is the same protection one step
// earlier.
FAN_OPEN_EXEC_PERM | FAN_CLOSE_WRITE,
libc::AT_FDCWD,
c_path.as_ptr(),
)
@ -690,12 +707,29 @@ impl Gate {
}
if event.needs_response() {
// Someone is blocked. Answer first, report
// second — the report must never sit between
// a process and its verdict.
gate.answer_and_retire(event.seq, event.fd, hit.is_none());
// DENY ONLY ON EXECUTION.
//
// A permission event covers both execve and
// an ordinary open. Denying the open means a
// matching file cannot be READ by anything —
// which on a live server blocked reads of an
// AI agent's own transcript and came within a
// whisker of breaking the session doing the
// testing. Blocking execution is the feature;
// blocking every read of a matching file is a
// denial of service against the operator.
//
// A read that matches is still reported, and
// the write path still quarantines. The file
// simply is not held hostage.
let deny = hit.is_some() && event.is_exec();
gate.answer_and_retire(event.seq, event.fd, !deny);
if let (Some(name), Some(p)) = (&hit, path.as_deref()) {
on_detect(p, name, Verdict::Blocked);
on_detect(
p,
name,
if deny { Verdict::Blocked } else { Verdict::Seen },
);
}
} else {
// A completed write: nothing is waiting, so
@ -792,6 +826,9 @@ pub enum Verdict {
/// Caught after a completed write. It is on disk and should be
/// quarantined — this is the path that replaces inotify.
Written,
/// Matched while being read rather than executed. Reported and
/// allowed through: see the deny-only-on-exec note in `serve`.
Seen,
}
/// How the gate should behave once running.
@ -805,11 +842,20 @@ pub struct GateConfig {
pub excludes: Vec<String>,
}
/// Files above this are allowed through unread by the gate, whatever the
/// on-demand scan limit is.
///
/// A process is blocked while we decide, so the budget is a deadline
/// rather than a size: reading and matching 100 MB inline cannot finish
/// inside [`DEADLINE`], and every attempt is a watchdog rescue — a
/// process released unscanned, which is worse than never having looked.
pub const GATE_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024;
impl Default for GateConfig {
fn default() -> Self {
Self {
workers: 4,
max_size: 100 * 1024 * 1024,
max_size: GATE_MAX_FILE_BYTES,
excludes: vec!["/proc".into(), "/sys".into(), "/dev".into(), "/run".into()],
}
}

View file

@ -283,8 +283,21 @@ impl DaemonState {
// no process is held past the deadline, including during startup.
gate.start_watchdog();
let excludes = s.exclude_paths.clone();
let max_size = s.max_file_size_mb.saturating_mul(1024 * 1024);
let mut excludes = s.exclude_paths.clone();
// Never gate our own state. The vault holds live malware by
// definition, and holding a process hostage over our own database
// is a way to deadlock the daemon against itself.
for own in ["/var/lib/hound", "/run/hound"] {
if !excludes.iter().any(|e| e == own) {
excludes.push(own.to_string());
}
}
// The gate holds a process while it decides, so its budget is the
// deadline, not the on-demand scan limit.
let max_size = s
.max_file_size_mb
.saturating_mul(1024 * 1024)
.min(fanotify::GATE_MAX_FILE_BYTES);
let ev = events.clone();
let quarantine_on_write = s.on_detect == "quarantine";
let q = quarantine.clone();
@ -307,7 +320,16 @@ impl DaemonState {
format!("blocked execution of {} ({name})", path.display()),
);
}
fanotify::Verdict::Written => {
fanotify::Verdict::Seen => {
// Only reachable if read events are ever requested
// again. Reported, never blocked.
ev.push(
"gate",
"warn",
format!("{} matched {name} while being read", path.display()),
);
}
fanotify::Verdict::Written => {
// Nothing was waiting on this one, so the file is
// already on disk. This is the path that replaces
// what inotify used to do, with whole-filesystem

View file

@ -275,6 +275,57 @@ mod tests {
);
}
/// The false positive that aborted the first live rollout.
///
/// Hound blocked reads of an AI session transcript because the
/// transcript contained the miner rule's own strings — somebody had
/// been writing that rule in that session. Every threat-intelligence
/// report, security blog post and support ticket has the same shape.
#[test]
fn a_document_about_malware_is_not_malware() {
let set = RuleSet::compile().unwrap();
let mut scanner = yara_x::Scanner::new(&set.rules);
let transcript = r#"
{"role":"assistant","content":"The miner rule matches on stratum+tcp://
plus donate-level and rig-id, and the binary is usually named xmrig.
For rootkits we look for dlsym with RTLD_NEXT and /etc/ld.so.preload."}
"#;
let hits: Vec<String> = scanner
.scan(transcript.as_bytes())
.unwrap()
.matching_rules()
.map(|r| RuleSet::detection_name(&r))
.collect();
assert!(
hits.is_empty(),
"a text file discussing malware must not be malware: {hits:?}"
);
}
#[test]
fn a_real_elf_miner_is_still_caught() {
// Requiring ELF magic must not cost the detection it exists for.
let set = RuleSet::compile().unwrap();
let mut scanner = yara_x::Scanner::new(&set.rules);
let mut fake_elf = vec![0x7f, b'E', b'L', b'F'];
fake_elf.extend_from_slice(&[0u8; 60]);
fake_elf.extend_from_slice(
b"stratum+tcp://pool.example:3333 --donate-level=1 rig-id=x randomx xmrig",
);
let hits: Vec<String> = scanner
.scan(&fake_elf)
.unwrap()
.matching_rules()
.map(|r| RuleSet::detection_name(&r))
.collect();
assert!(
hits.iter().any(|h| h == "Linux.Coinminer.XMRig"),
"an ELF with miner strings must still match: {hits:?}"
);
}
#[test]
fn clean_text_is_clean() {
let set = RuleSet::compile().unwrap();

Binary file not shown.