Expand monitoring for credential theft and covert protocols

This commit is contained in:
Luna 2026-06-13 03:30:36 -07:00
parent 3e5f8fc3f7
commit cb334c0c94
17 changed files with 675 additions and 25 deletions

View file

@ -26,6 +26,7 @@ _DEFAULT_WATCH = (
_ALL_DETECTORS = (
"reverse_shell", "ld_preload", "deleted_exe",
"input_snooper", "credential_access", "stealth_network",
"new_listener", "new_suid", "persistence", "egress",
)
@ -60,6 +61,31 @@ class Config:
"/tmp", "/dev/shm", "/var/tmp", "/run/user",
)
watch_persistence: tuple[str, ...] = _DEFAULT_WATCH
# Processes expected to hold keyboard/HID event devices open.
input_snooper_allow_comms: frozenset[str] = frozenset((
"Xorg", "Xwayland", "gnome-shell", "kwin_wayland", "sway",
"Hyprland", "systemd-logind", "input-remapper", "keyd", "kanata",
))
# Processes expected to read credential stores as part of normal auth or
# user interaction. Malware harvesting those same files under another comm
# still alerts.
credential_access_allow_comms: frozenset[str] = frozenset((
"sshd", "sudo", "su", "login", "gdm-session-worker", "polkitd",
"gnome-keyring-daemon", "kwalletd5", "kwalletd6", "firefox",
"chromium", "chrome", "google-chrome", "brave", "brave-browser",
))
# Extra exact paths or directory prefixes treated as credential material.
credential_access_extra_paths: tuple[str, ...] = ()
# Expected owners of packet/raw/special protocol sockets. Keep tight: these
# families are common in sniffers, tunnels, and covert channels.
stealth_network_allow_comms: frozenset[str] = frozenset((
"NetworkManager", "systemd-networkd", "wpa_supplicant", "dhcpcd",
"tcpdump", "dumpcap", "wireshark", "suricata", "zeek",
))
# Socket family names to ignore entirely, e.g. ["mptcp"] if your host uses
# it legitimately. Valid observed kinds include raw, sctp, dccp, packet,
# mptcp, tipc, xdp, and vsock.
stealth_network_allow_kinds: frozenset[str] = frozenset()
# file integrity monitoring (FIM)
fim_enabled: bool = True

View file

@ -15,13 +15,16 @@ from ..alert import Alert
from ..config import Config
from ..system import SystemState
from . import (
credential_access,
deleted_exe,
egress,
input_snooper,
ld_preload,
new_listener,
new_suid,
persistence,
reverse_shell,
stealth_network,
)
DetectFn = Callable[[SystemState, Config], Iterable[Alert]]
@ -40,6 +43,9 @@ REGISTRY: tuple[Detector, ...] = (
Detector("reverse_shell", reverse_shell.detect),
Detector("ld_preload", ld_preload.detect),
Detector("deleted_exe", deleted_exe.detect),
Detector("input_snooper", input_snooper.detect),
Detector("credential_access", credential_access.detect),
Detector("stealth_network", stealth_network.detect),
Detector("egress", egress.detect),
Detector("new_listener", new_listener.detect, needs_baseline=True),
Detector("persistence", persistence.detect, needs_baseline=True),

View file

@ -0,0 +1,99 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""credential_access — live process has sensitive credential files open.
This catches credential harvesters that open shadow databases, private SSH
keys, browser credential stores, or NetworkManager secret material during a
poll sweep. Legitimate auth agents and browsers are configurable allow-list
entries so the rule can stay enabled on desktops.
"""
from __future__ import annotations
from collections.abc import Iterator
from ..alert import Alert, Severity
from ..config import Config
from ..system import SystemState
SID_CREDENTIAL_ACCESS = 100033
_EXACT_FILES = frozenset({
"/etc/shadow",
"/etc/gshadow",
"/etc/security/opasswd",
})
_BROWSER_SECRET_NAMES = frozenset({
"logins.json",
"key4.db",
"Login Data",
"Cookies",
})
def _fd_targets(proc) -> dict[str, str]:
targets = getattr(proc, "fd_targets", {})
return targets() if callable(targets) else targets
def _clean_target(target: str) -> str:
return target.removesuffix(" (deleted)")
def _matches_extra(path: str, entries: tuple[str, ...]) -> bool:
for entry in entries:
if not entry:
continue
if entry.endswith("/") and path.startswith(entry):
return True
if path == entry or path.startswith(entry.rstrip("/") + "/"):
return True
return False
def _sensitive_kind(path: str, cfg: Config) -> str:
if not path.startswith("/"):
return ""
if path in _EXACT_FILES:
return "system credential database"
if _matches_extra(path, cfg.credential_access_extra_paths):
return "configured credential path"
if "/.ssh/" in path:
name = path.rsplit("/", 1)[-1]
if name.startswith("id_") or name.endswith(".pem") or name.endswith(".key"):
return "private SSH key"
if path.startswith("/etc/NetworkManager/system-connections/"):
return "NetworkManager secret profile"
name = path.rsplit("/", 1)[-1]
if name in _BROWSER_SECRET_NAMES:
lowered = path.lower()
if any(marker in lowered for marker in (
"/.mozilla/", "/firefox/", "/chromium/", "/google-chrome/",
"/chrome/", "/brave", "/vivaldi/", "/edge/",
)):
return "browser credential store"
return ""
def detect(state: SystemState, cfg: Config) -> Iterator[Alert]:
for proc in state.processes:
comm = proc.comm or "?"
if comm in cfg.credential_access_allow_comms:
continue
for fd, raw_target in _fd_targets(proc).items():
target = _clean_target(raw_target)
kind = _sensitive_kind(target, cfg)
if not kind:
continue
yield Alert(
severity=Severity.CRITICAL,
signature="credential_access",
key=f"cred:{proc.pid}:{target}",
detail=(
f"pid={proc.pid} comm={comm} fd={fd} has {kind} open: "
f"{target}"
),
pids=(proc.pid,),
sid=SID_CREDENTIAL_ACCESS,
classtype="credential-theft",
)
break

View file

@ -0,0 +1,54 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""input_snooper — processes reading keyboard/HID event devices.
Keyloggers commonly open ``/dev/input/event*`` or HID raw devices directly.
Normal display servers, compositors, and input remappers can do this too, so
known local input stack processes are configurable allow-list entries.
"""
from __future__ import annotations
from collections.abc import Iterator
from ..alert import Alert, Severity
from ..config import Config
from ..system import SystemState
SID_INPUT_SNOOPER = 100032
_INPUT_PREFIXES = (
"/dev/input/event",
"/dev/uinput",
"/dev/hidraw",
)
def _fd_targets(proc) -> dict[str, str]:
targets = getattr(proc, "fd_targets", {})
return targets() if callable(targets) else targets
def _is_input_device(target: str) -> bool:
return target.startswith(_INPUT_PREFIXES)
def detect(state: SystemState, cfg: Config) -> Iterator[Alert]:
for proc in state.processes:
comm = proc.comm or "?"
if comm in cfg.input_snooper_allow_comms:
continue
for fd, target in _fd_targets(proc).items():
if not _is_input_device(target):
continue
yield Alert(
severity=Severity.HIGH,
signature="input_snooper",
key=f"input:{proc.pid}:{target}",
detail=(
f"pid={proc.pid} comm={comm} fd={fd} has input device "
f"open: {target}"
),
pids=(proc.pid,),
sid=SID_INPUT_SNOOPER,
classtype="credential-keylogging",
)
break

View file

@ -0,0 +1,66 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""stealth_network — unusual socket families used for covert channels.
TCP and UDP are covered by reverse-shell, egress, and listener checks. This
detector watches families attackers use to slide around those paths: raw IP,
SCTP, DCCP, packet sockets, XDP, TIPC, vsock, and MPTCP.
"""
from __future__ import annotations
from collections.abc import Iterator
from ..alert import Alert, Severity
from ..config import Config
from ..netutil import ip_in_cidrs, is_public_ip, split_host_port
from ..system import SystemState
SID_STEALTH_NETWORK = 100036
_WATCHED_KINDS = frozenset({
"raw", "sctp", "dccp", "packet", "mptcp", "tipc", "xdp", "vsock",
})
_ALWAYS_HIGH = frozenset({"raw", "packet", "xdp"})
_ACTIVE_STATES = frozenset({
"ESTAB", "LISTEN", "UNCONN", "CONNECTED", "SYN-SENT", "SYN-RECV",
})
def _peer_is_public(peer: str, cfg: Config) -> bool:
host, _port = split_host_port(peer)
if not is_public_ip(host):
return False
return not ip_in_cidrs(host, cfg.egress_allow_cidrs)
def _severity(kind: str, state: str, peer: str, cfg: Config) -> Severity:
if kind in _ALWAYS_HIGH:
return Severity.HIGH
if state == "LISTEN" or _peer_is_public(peer, cfg):
return Severity.HIGH
return Severity.MEDIUM
def detect(state: SystemState, cfg: Config) -> Iterator[Alert]:
allowed_kinds = set(cfg.stealth_network_allow_kinds)
for sock in state.sockets:
kind = sock.kind.lower()
if kind not in _WATCHED_KINDS:
continue
if kind in allowed_kinds:
continue
if sock.comm and sock.comm in cfg.stealth_network_allow_comms:
continue
if sock.state and sock.state not in _ACTIVE_STATES:
continue
yield Alert(
severity=_severity(kind, sock.state, sock.peer, cfg),
signature="stealth_network",
key=f"stealthnet:{kind}:{sock.pid}:{sock.local}:{sock.peer}",
detail=(
f"{kind} socket state={sock.state} local={sock.local} "
f"peer={sock.peer} comm={sock.comm or '?'} pid={sock.pid or '?'}"
),
pids=(sock.pid,) if sock.pid else (),
sid=SID_STEALTH_NETWORK,
classtype="covert-channel",
)

View file

@ -32,6 +32,9 @@ SID_KNOWN_ROOTKIT_MODULE = 100028
SID_TAINTED_MODULE = 100029
SID_KERNEL_TAINTED = 100030
SID_HIDDEN_UDP_PORT = 100031
SID_HIDDEN_RAW_SOCKET = 100034
SID_RAW_ICMP_SOCKET = 100035
SID_HIDDEN_PROTOCOL_SOCKET = 100037
IFF_PROMISC = 0x100
@ -62,6 +65,13 @@ TAINT_FLAGS = {
}
HIGH_RISK_TAINT_BITS = frozenset({1, 3, 7, 12, 13})
ICMP_RAW_PROTOCOLS = frozenset({1, 58}) # ICMP, IPv6-ICMP
RAW_PROTOCOL_NAMES = {
"icmp": 1,
"ipv6-icmp": 58,
"icmp6": 58,
"sctp": 132,
}
# --- pure diff cores (unit-tested without a live system) ------------------
@ -86,6 +96,18 @@ def find_hidden_udp_ports(procnet_ports: set[int], ss_ports: set[int]) -> set[in
return procnet_ports - ss_ports
def find_hidden_raw_protocols(procnet_protocols: set[int],
ss_protocols: set[int]) -> set[int]:
"""Raw socket protocols in /proc/net/raw that ss does not report."""
return procnet_protocols - ss_protocols
def find_hidden_protocol_kinds(procnet_kinds: set[str],
ss_kinds: set[str]) -> set[str]:
"""Special socket families visible in /proc/net but absent from ss."""
return procnet_kinds - ss_kinds
def _norm_module(name: str) -> str:
return name.lower().replace("-", "_")
@ -238,6 +260,60 @@ def procnet_udp_ports() -> set[int]:
return _procnet_udp_ports("/proc/net/udp") | _procnet_udp_ports("/proc/net/udp6")
def _procnet_raw_protocols(path: str) -> set[int]:
protocols: set[int] = set()
try:
with open(path) as fh:
next(fh, None) # header
for line in fh:
parts = line.split()
if len(parts) < 2:
continue
local = parts[1] # HEX_ADDR:HEX_PROTO
proto = int(local.rsplit(":", 1)[1], 16)
if proto:
protocols.add(proto)
except (OSError, ValueError):
pass
return protocols
def procnet_raw_protocols() -> set[int]:
return (_procnet_raw_protocols("/proc/net/raw")
| _procnet_raw_protocols("/proc/net/raw6"))
def _procnet_has_rows(path: str) -> bool:
try:
with open(path) as fh:
next(fh, None) # header
for line in fh:
if line.strip():
return True
except OSError:
pass
return False
def procnet_protocol_kinds() -> set[str]:
kinds: set[str] = set()
checks = {
"sctp": (
"/proc/net/sctp/eps",
"/proc/net/sctp/assocs",
"/proc/net/sctp/remaddr",
),
"dccp": ("/proc/net/dccp", "/proc/net/dccp6"),
"packet": ("/proc/net/packet",),
"tipc": ("/proc/net/tipc/socket",),
"xdp": ("/proc/net/xdp",),
}
for kind, paths in checks.items():
if any(_procnet_has_rows(path) for path in paths):
kinds.add(kind)
return kinds
def ss_listen_ports(state: SystemState) -> set[int]:
ports: set[int] = set()
for s in state.listening_sockets():
@ -258,6 +334,31 @@ def ss_udp_ports(state: SystemState) -> set[int]:
return ports
def _protocol_from_endpoint(endpoint: str) -> int | None:
tail = endpoint.rsplit(":", 1)
if len(tail) != 2:
return None
label = tail[1].strip("[]").lower()
if label.isdigit():
return int(label)
return RAW_PROTOCOL_NAMES.get(label)
def ss_raw_protocols(state: SystemState) -> set[int]:
protocols: set[int] = set()
for s in state.sockets:
if s.kind != "raw":
continue
proto = _protocol_from_endpoint(s.local)
if proto is not None:
protocols.add(proto)
return protocols
def ss_protocol_kinds(state: SystemState) -> set[str]:
return {s.kind for s in state.sockets if s.kind}
def promiscuous_interfaces() -> list[str]:
out: list[str] = []
base = "/sys/class/net"
@ -361,6 +462,37 @@ def run(cfg: Config, state: SystemState | None = None) -> Iterator[Alert]:
"(tool may be hooked): " + ", ".join(map(str, sorted(hidden_udp)))),
sid=SID_HIDDEN_UDP_PORT, classtype="rootkit-hidden-port")
raw_protocols = procnet_raw_protocols()
hidden_raw = find_hidden_raw_protocols(raw_protocols, ss_raw_protocols(state))
if hidden_raw:
yield Alert(
severity=Severity.HIGH, signature="rootkit_hidden_raw_socket",
key=f"rk:hidraw:{min(hidden_raw)}",
detail=("raw socket protocol(s) in /proc/net/raw not reported by ss "
"(tool may be hooked): " + ", ".join(map(str, sorted(hidden_raw)))),
sid=SID_HIDDEN_RAW_SOCKET, classtype="rootkit-hidden-socket")
raw_icmp = raw_protocols & ICMP_RAW_PROTOCOLS
if raw_icmp:
yield Alert(
severity=Severity.HIGH, signature="raw_icmp_socket",
key=f"rk:rawicmp:{min(raw_icmp)}",
detail=("persistent raw ICMP socket protocol(s) visible in /proc/net/raw: "
+ ", ".join(map(str, sorted(raw_icmp)))
+ " — validate authorized ping/monitoring tools or ICMP knockers"),
sid=SID_RAW_ICMP_SOCKET, classtype="raw-socket-backdoor")
hidden_protocols = find_hidden_protocol_kinds(
procnet_protocol_kinds(), ss_protocol_kinds(state))
if hidden_protocols:
yield Alert(
severity=Severity.HIGH, signature="rootkit_hidden_protocol_socket",
key=f"rk:hidproto:{sorted(hidden_protocols)[0]}",
detail=("special protocol socket family present in /proc/net but "
"absent from ss output (tool may be hooked): "
+ ", ".join(sorted(hidden_protocols))),
sid=SID_HIDDEN_PROTOCOL_SOCKET, classtype="rootkit-hidden-socket")
for iface in promiscuous_interfaces():
yield Alert(
severity=Severity.MEDIUM, signature="promiscuous_interface",

View file

@ -18,6 +18,10 @@ from pathlib import Path
_INO_RE = re.compile(r"\bino:(\d+)")
_USER_RE = re.compile(r'users:\(\("([^"]+)",pid=(\d+),fd=\d+')
_SS_NETIDS = frozenset({
"tcp", "udp", "raw", "sctp", "dccp", "mptcp",
"p_raw", "p_dgr", "packet", "tipc", "xdp", "vsock",
})
@dataclass
@ -65,6 +69,23 @@ class Process:
out[k] = v
return out
@cached_property
def fd_targets(self) -> dict[str, str]:
"""Open file descriptor targets, keyed by fd number as a string."""
out: dict[str, str] = {}
fd_dir = f"/proc/{self.pid}/fd"
try:
names = sorted(os.listdir(fd_dir),
key=lambda x: int(x) if x.isdigit() else 0)
except OSError:
return out
for name in names:
try:
out[name] = os.readlink(os.path.join(fd_dir, name))
except OSError:
continue
return out
@cached_property
def status(self) -> dict[str, str]:
out: dict[str, str] = {}
@ -105,6 +126,7 @@ class Socket:
inode: int | None
comm: str
pid: int | None
kind: str = ""
class SystemState:
@ -166,8 +188,19 @@ class SystemState:
if self._injected_socks is not None:
return self._injected_socks
out: list[Socket] = []
for args in (["-tanep"], ["-uanep"]):
out.extend(self._parse_ss(self._run_ss(args)))
for args, kind in (
(["-tanep"], "tcp"),
(["-uanep"], "udp"),
(["-wanep"], "raw"),
(["-Sanep"], "sctp"),
(["-danep"], "dccp"),
(["-0anep"], "packet"),
(["-Manep"], "mptcp"),
(["--tipc", "-anep"], "tipc"),
(["--xdp", "-anep"], "xdp"),
(["--vsock", "-anep"], "vsock"),
):
out.extend(self._parse_ss(self._run_ss(args), kind=kind))
return out
@staticmethod
@ -182,13 +215,16 @@ class SystemState:
return ""
@staticmethod
def _parse_ss(text: str) -> list[Socket]:
def _parse_ss(text: str, *, kind: str = "") -> list[Socket]:
socks: list[Socket] = []
for line in text.splitlines():
parts = line.split()
if len(parts) < 5:
continue
state, _rq, _sq, local, peer = parts[:5]
if parts[0].lower() in _SS_NETIDS and len(parts) >= 6:
state, local, peer = parts[1], parts[4], parts[5]
else:
state, local, peer = parts[0], parts[3], parts[4]
rest = line
ino_m = _INO_RE.search(rest)
usr_m = _USER_RE.search(rest)
@ -199,6 +235,7 @@ class SystemState:
inode=int(ino_m.group(1)) if ino_m else None,
comm=usr_m.group(1) if usr_m else "",
pid=int(usr_m.group(2)) if usr_m else None,
kind=kind,
))
return socks