276 lines
13 KiB
Markdown
276 lines
13 KiB
Markdown
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||
# 2. Poll Detectors
|
||
|
||
Scope: the per-detector behavioral contract for the twelve pure detectors in
|
||
`detectors/`. Each is a `detect(state, cfg) -> Iterable[Alert]` over the
|
||
`SystemState` built in §1. This section documents, per detector, the inputs it
|
||
reads, the match algorithm, the exact alert it emits, and its allow-list /
|
||
fail-open rules. The registry order and the baseline-arming gate are in §1.3 /
|
||
§1.5; the alert fields and dedup keying are in §1.1.
|
||
|
||
Every detector is fail-open by inheritance: it reads already-captured
|
||
`SystemState` fields, and missing `/proc`/`ss` data is an empty value, never an
|
||
exception.
|
||
|
||
## 2.0 Signature summary
|
||
|
||
The stable contract. A port emitting a different SID, severity, classtype, or
|
||
key for the same condition is a parity break.
|
||
|
||
| SID | signature | severity | classtype | key |
|
||
|-----|-----------|----------|-----------|-----|
|
||
| 100010 | `reverse_shell` | CRITICAL | `c2-reverse-shell` | `rsh:<pid>` |
|
||
| 100011 | `ld_preload` | CRITICAL | `rootkit-preload` | `ldp:global`, `ldp:<pid>` |
|
||
| 100012 | `deleted_exe` | CRITICAL | `fileless-execution` | `del:<pid>` |
|
||
| 100013 | `new_listener` | HIGH | `backdoor-listener` | `lis:<port>/<comm>` |
|
||
| 100014 | `new_suid` | CRITICAL / HIGH | `privilege-escalation` | `suid:<path>` |
|
||
| 100015 | `persistence` | HIGH | `persistence` | `persist:<path>` |
|
||
| 100016 | `egress` | HIGH | `c2-exfil` | `egr:<pid>:<host>` |
|
||
| 100032 | `input_snooper` | HIGH | `credential-keylogging` | `input:<pid>:<target>` |
|
||
| 100033 | `credential_access` | CRITICAL | `credential-theft` | `cred:<pid>:<target>` |
|
||
| 100036 | `stealth_network` | MEDIUM / HIGH | `covert-channel` | `stealthnet:<kind>:<pid>:<local>:<peer>` |
|
||
| 100039 | `memory_obfuscation` | HIGH / CRITICAL | `memory-obfuscation` | `mem:memory_obfuscation:<pid>:<lo>-<hi>` |
|
||
| 100048 | `process_hiding_library` | CRITICAL | `process-hiding` | `mem:process_hiding_library:<pid>:<lo>-<hi>` |
|
||
| 100049 | `process_injection_library` | HIGH | `process-injection` | `mem:process_injection_library:<pid>:<lo>-<hi>` |
|
||
| 100074 | `first_public_destination` | MEDIUM | `network-rarity` | `firstdest:<comm>:<ip:port>` |
|
||
| 100075 | `first_listener_port` | MEDIUM | `network-rarity` | `firstlisten:<comm>:<port>` |
|
||
|
||
`<lo>-<hi>` are the map bounds formatted as lowercase hex (`{start:x}-{end:x}`).
|
||
|
||
Allow-list config keys and their defaults live in `config.py`; this section
|
||
names each key where used. The Go port mirrors the same defaults (see
|
||
`go-agent/internal/config`), so the values are asserted by config tests, not
|
||
repeated here.
|
||
|
||
---
|
||
|
||
## 2.1 Shared address helpers (`netutil.py`)
|
||
|
||
Used by `egress`, `stealth_network`, and `first_seen`.
|
||
|
||
- `split_host_port(endpoint)` — splits an `ss` endpoint. If it starts with `[`,
|
||
partition on `]` (IPv6, e.g. `[::1]:22` → `::1`, `22`); else `rpartition(":")`
|
||
(e.g. `10.0.0.2%eth0:443` → `10.0.0.2%eth0`, `443`). Returns `(host, port)`
|
||
strings.
|
||
- `parse_addr(addr)` — strips `[]`, drops an IPv6 `%zone`, returns an
|
||
`ipaddress` object or `None`.
|
||
- `is_public_ip(addr)` — `True` only if `parse_addr(...).is_global`: excludes
|
||
loopback, RFC1918, link-local, CGNAT (100.64/10), and other non-global
|
||
ranges. Unparseable → `False`.
|
||
- `ip_in_cidrs(addr, cidrs)` — `True` if `addr` is inside any CIDR (the operator
|
||
trust list); version-matched; bad CIDRs skipped.
|
||
|
||
## 2.2 Package provenance (`provenance.py`)
|
||
|
||
Used by `new_listener` (§2.11) as an optional suppressor.
|
||
|
||
`package_owner(path)` returns the owning package string or `None`, using the
|
||
first available of `pacman -Qo` / `dpkg -S` / `rpm -qf` (5s timeout each, cached
|
||
via `lru_cache`; `(deleted)` suffix stripped first). `is_package_owned(path) =
|
||
package_owner(path) is not None`. **Contract:** ownership *ranks/explains*, it
|
||
never proves safety — it may suppress a `new_listener` alert but is never used
|
||
to silently drop anything else.
|
||
|
||
---
|
||
|
||
## 2.3 `reverse_shell` — SID 100010, CRITICAL
|
||
|
||
Interpreter process with a **network** socket on its stdio.
|
||
|
||
- Input: `state.processes`, `state.net_peer_by_inode`.
|
||
- Match, per process: `proc.comm in cfg.interpreters`; `proc.stdio_socket_inode()`
|
||
is not `None` (fd 0/1/2 links to `socket:[…]`); and that inode appears in the
|
||
network socket table (`net_peer_by_inode`). The last condition is what
|
||
excludes benign unix sockets / pipes and keeps false positives near zero.
|
||
- Emit: key `rsh:<pid>`, `pids=(pid,)`, detail
|
||
`pid=… comm=… stdio=net-socket peer=[local -> peer] cmd=[<cmdline first 90>]`.
|
||
- Config: `interpreters` allow-*set of shell/scripting comms* (membership means
|
||
"watch", not "trust").
|
||
|
||
## 2.4 `ld_preload` — SID 100011, CRITICAL
|
||
|
||
Two independent signatures, same SID:
|
||
|
||
1. **Global** — `/etc/ld.so.preload` exists and is non-empty (fail-open read).
|
||
Key `ldp:global`, no pids, detail includes the file contents (newlines →
|
||
spaces).
|
||
2. **Per-process** — a process whose `environ["LD_PRELOAD"]` starts with a
|
||
suspicious prefix: `("/tmp/", "/dev/shm/", "/var/tmp/", "/run/user/", "./")`.
|
||
Key `ldp:<pid>`, `pids=(pid,)`.
|
||
|
||
Note the global check reads `/etc/ld.so.preload` directly (not the injected
|
||
`state`), so a port must read it live inside the detector or supply it on state.
|
||
|
||
## 2.5 `deleted_exe` — SID 100012, CRITICAL
|
||
|
||
Process running from a deleted or memfd-backed binary in an attacker-controlled
|
||
location.
|
||
|
||
- Match: `proc.exe` is fileless — `"memfd:" in exe`, **or** `"(deleted)" in exe`
|
||
**and** `exe` starts with `("/tmp/", "/dev/shm/", "/var/tmp/", "/run/")`.
|
||
Normal-path deleted exes (a daemon still running after a package upgrade) are
|
||
ignored on purpose.
|
||
- Emit: key `del:<pid>`, `pids=(pid,)`, detail `pid=… comm=… exe=[…]`.
|
||
|
||
## 2.6 `input_snooper` — SID 100032, HIGH
|
||
|
||
Process holding a keyboard/HID event device open (keylogger tell).
|
||
|
||
- Match, per process: skip if `comm in cfg.input_snooper_allow_comms`; then scan
|
||
`fd_targets` for a target starting with `("/dev/input/event", "/dev/uinput",
|
||
"/dev/hidraw")`. **First** matching fd emits and then `break` (at most one
|
||
alert per process).
|
||
- Emit: key `input:<pid>:<target>`, `pids=(pid,)`, detail
|
||
`pid=… comm=… fd=… has input device open: <target>`.
|
||
- Config: `input_snooper_allow_comms` (display servers, compositors, remappers).
|
||
|
||
## 2.7 `credential_access` — SID 100033, CRITICAL
|
||
|
||
Live process with a sensitive credential file open.
|
||
|
||
- Match, per process: skip if `comm in cfg.credential_access_allow_comms`; then
|
||
per fd target (with a trailing ` (deleted)` stripped), classify via
|
||
`_sensitive_kind`, first hit emits then `break`. Classification precedence:
|
||
1. exact file in `{/etc/shadow, /etc/gshadow, /etc/security/opasswd}` →
|
||
"system credential database";
|
||
2. matches `cfg.credential_access_extra_paths` (entry ending `/` = prefix;
|
||
else exact or `entry/` prefix) → "configured credential path";
|
||
3. path contains `/.ssh/` and basename starts `id_` or ends `.pem`/`.key` →
|
||
"private SSH key";
|
||
4. path under `/etc/NetworkManager/system-connections/` → "NetworkManager
|
||
secret profile";
|
||
5. basename in `{logins.json, key4.db, "Login Data", Cookies}` **and** path
|
||
(lowercased) contains a browser marker (`/.mozilla/`, `/firefox/`,
|
||
`/chromium/`, `/google-chrome/`, `/chrome/`, `/brave`, `/vivaldi/`,
|
||
`/edge/`) → "browser credential store".
|
||
|
||
Non-absolute paths are never sensitive.
|
||
- Emit: key `cred:<pid>:<target>`, `pids=(pid,)`, detail
|
||
`pid=… comm=… fd=… has <kind> open: <target>`.
|
||
- Config: `credential_access_allow_comms`, `credential_access_extra_paths`.
|
||
|
||
## 2.8 `stealth_network` — SID 100036, MEDIUM or HIGH
|
||
|
||
Sockets in families attackers use to slide around TCP/UDP checks.
|
||
|
||
- Watched kinds: `{raw, sctp, dccp, packet, mptcp, tipc, xdp, vsock}` (matched
|
||
on `sock.kind.lower()`).
|
||
- Skip if: kind in `cfg.stealth_network_allow_kinds`; or `sock.comm` in
|
||
`cfg.stealth_network_allow_comms`; or `sock.state` is non-empty and **not** in
|
||
`{ESTAB, LISTEN, UNCONN, CONNECTED, SYN-SENT, SYN-RECV}`.
|
||
- Severity: HIGH if kind in `{raw, packet, xdp}`; else HIGH if `state == LISTEN`
|
||
or the peer is a public IP outside the egress trust list; else MEDIUM.
|
||
- Emit: key `stealthnet:<kind>:<pid>:<local>:<peer>`, `pids=(pid,)` only when
|
||
pid is known, detail `<kind> socket state=… local=… peer=… comm=<comm or ?>
|
||
pid=<pid or ?>`.
|
||
|
||
## 2.9 `egress` — SID 100016, HIGH
|
||
|
||
Interpreter holding an outbound connection to a public IP.
|
||
|
||
- Match, per `established_sockets()`: `s.comm in cfg.interpreters`; peer host is
|
||
public (`is_public_ip`); peer not in `cfg.egress_allow_cidrs`.
|
||
- Emit: key `egr:<pid>:<host>`, `pids=(pid,)` when pid known, detail
|
||
`pid=… comm=… -> <host>:<port> (interpreter to public IP)`.
|
||
|
||
## 2.10 `memory_obfuscation` — SIDs 100039 / 100048 / 100049
|
||
|
||
Inspects `/proc/<pid>/maps` shapes (never reads process memory). Per process,
|
||
each map is classified by `_indicator`; **at most one alert per signature per
|
||
process** (a per-process `seen` set). Precedence within a single map:
|
||
|
||
1. If `path` matches `cfg.memory_obfuscation_allow_paths` (prefix) → ignore.
|
||
2. `path` basename contains a hide hint (`libhide`, `libprocesshide`,
|
||
`process_hide`, `proc_hide`, `rootkit_hide`) → **`process_hiding_library`**,
|
||
SID 100048, CRITICAL, classtype `process-hiding`.
|
||
3. executable **and** library from a writable/runtime path (`/tmp/`, `/dev/shm/`,
|
||
`/var/tmp/`, `/run/user/` with a `.so`/`.so.`/`.dylib` in the name) →
|
||
**`process_injection_library`**, SID 100049, HIGH, classtype
|
||
`process-injection`.
|
||
4. executable **and** path contains `memfd:` or `(deleted)` →
|
||
**`memory_obfuscation`**, SID 100039, CRITICAL.
|
||
5. executable **and** writable (RWX) → `memory_obfuscation`, HIGH.
|
||
6. executable **and** anonymous (`""`, `[heap]`, `[stack]`, `[anon…`,
|
||
`[stack:…`) → `memory_obfuscation`, HIGH.
|
||
|
||
The `memory_obfuscation` signature (only) is additionally suppressed when
|
||
`comm in cfg.memory_obfuscation_allow_comms` (JITs: java, node, browsers,
|
||
dotnet, qemu, wine, wasmtime). The library signatures are **not** suppressed by
|
||
that allow-list.
|
||
|
||
- Emit: key `mem:<signature>:<pid>:<lo>-<hi>`, `pids=(pid,)`, detail
|
||
`pid=… comm=… <indicator detail> range=<lo>-<hi>`.
|
||
|
||
## 2.11 `new_listener` — SID 100013, HIGH
|
||
|
||
New listening socket absent from the startup baseline (see §1.5.1). No-op when
|
||
`state.listener_baseline is None` (unarmed).
|
||
|
||
- Match, per `LISTEN` socket: identity `key = port/comm` where
|
||
`port = local.rpartition(":")[2]` and `comm` defaults `?`. Skip if key in
|
||
`state.listener_baseline`, or `port in cfg.listener_allow_ports`, or
|
||
`comm in cfg.listener_allow_comms`.
|
||
- Optional provenance gate: if `cfg.suppress_package_owned_listeners` and the
|
||
owning process's `exe` `is_package_owned`, skip.
|
||
- Emit: key `lis:<port>/<comm>`, `pids=(pid,)` when known, detail
|
||
`new listening socket <local> by comm=<comm>`.
|
||
|
||
## 2.12 `new_suid` — SID 100014, HIGH or CRITICAL
|
||
|
||
SUID/SGID binary not in the baseline. No-op when `state.suid_binaries is None`
|
||
**or** `state.suid_baseline is None` (see §1.5.3 — no alerts until an async scan
|
||
result exists).
|
||
|
||
- Match, per path in `state.suid_binaries` not in `state.suid_baseline`:
|
||
`hot = any(path.startswith(d.rstrip("/") + "/") for d in cfg.suid_hot_dirs)`.
|
||
- Severity: CRITICAL if hot (writable dir), else HIGH.
|
||
- Emit: key `suid:<path>`, no pids, detail
|
||
`SUID/SGID binary in writable dir: <path>` (hot) or `new SUID/SGID binary:
|
||
<path>`.
|
||
|
||
## 2.13 `persistence` — SID 100015, HIGH
|
||
|
||
Watched persistence-relevant file modified since the previous sweep. No-op when
|
||
`state.persist_since is None` (unarmed).
|
||
|
||
- Input: walk `cfg.watch_persistence` (a dir → recurse `os.walk`, a file →
|
||
itself). For each existing file, `mtime = os.lstat(path).st_mtime`
|
||
(unreadable → skip).
|
||
- Match: `mtime > state.persist_since` (the *previous* sweep's timestamp; §1.5.4).
|
||
- Emit: key `persist:<path>`, no pids, detail `persistence file modified:
|
||
<path>`.
|
||
|
||
## 2.14 `first_seen` — SIDs 100074 / 100075, MEDIUM
|
||
|
||
Network-rarity tracking with a self-owned persistent store. The store format,
|
||
the `initialized` learn-once semantics, and the restart asymmetry are specified
|
||
in §1.5.5 — not repeated here. Match summary:
|
||
|
||
- `first_public_destination` (100074): a `comm` reaches an `ESTAB` peer that is
|
||
a public IP:port not previously recorded for it. Key
|
||
`firstdest:<comm>:<ip:port>`, `pids=(pid,)` when known.
|
||
- `first_listener_port` (100075): a `comm` opens a `LISTEN`/`UNCONN` tcp/udp
|
||
socket on a numeric non-zero port not previously recorded for it. Key
|
||
`firstlisten:<comm>:<port>`.
|
||
|
||
Both only alert when the store is already `initialized`; the recording of the
|
||
new value happens regardless (so the alert fires once).
|
||
|
||
---
|
||
|
||
## 2.15 Port parity notes (Go port)
|
||
|
||
- The Go poll detectors for §2.3–§2.14 are **ported and pass the parity
|
||
fixture** (`scripts/check-go-parity.py`), covering exact SID/severity/key/
|
||
classtype/detail output. This document is the human-readable form of that
|
||
fixture's contract.
|
||
- `first_seen`/`new_listener`/`new_suid`/`persistence` receive injected baseline
|
||
inputs in parity mode and use the opt-in live lifecycle described in §1 when
|
||
`--state-dir` is supplied.
|
||
- Watch two easily-missed rules when reviewing a port: the **first-match-then-
|
||
break** behavior in `input_snooper`/`credential_access` (one alert per
|
||
process), and the **per-signature `seen` dedup** in `memory_obfuscation` (one
|
||
alert per signature per process, precedence-ordered).
|
||
- `ld_preload`'s global check reads `/etc/ld.so.preload` **inside the detector**
|
||
(or from an injected state field), not from the shared `SystemState` process
|
||
view.
|