# SPDX-License-Identifier: GPL-3.0-or-later """Anti-rootkit: detect things that are being hidden from the normal views. The technique a rootkit uses to hide is also how you catch it: ask the same question two different ways and compare the answers. A process the ``/proc`` listing omits but the scheduler still knows about; a loaded module absent from ``/proc/modules`` but live in ``/sys/module``; a listening port in ``/proc/net/tcp`` that ``ss`` won't show — each discrepancy is a hiding artifact. Honest limits: this runs in user space, so it reliably catches userland (LD_PRELOAD) rootkits and the common ``/proc``-hiding LKMs, but a kernel rootkit that hooks *every* path (the scheduler, /proc, and /sys consistently) can still evade it. It raises the bar and catches the common cases; it is not a guarantee against a bespoke ring-0 implant. Pair it with the off-box dead-man's switch. """ from __future__ import annotations import os import re import subprocess from collections.abc import Iterator from .alert import Alert, Severity from .config import Config from .system import SystemState SID_HIDDEN_PROC = 100022 SID_HIDDEN_MODULE = 100023 SID_HIDDEN_PORT = 100024 SID_PROMISC = 100025 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 KNOWN_ROOTKIT_MODULES = frozenset({ "adore", "adore-ng", "azazel", "diamorphine", "ipsecs_kbeast", "kbeast", "knark", "reptile", "suterusu", "syslogk", }) TAINT_FLAGS = { 0: "proprietary-module", 1: "forced-module-load", 2: "unsafe-smp", 3: "forced-module-unload", 4: "machine-check", 5: "bad-page", 6: "user-tainted", 7: "kernel-died-recently", 8: "acpi-override", 9: "kernel-warning", 10: "staging-driver", 11: "firmware-workaround", 12: "out-of-tree-module", 13: "unsigned-module", 14: "soft-lockup", 15: "live-patched", 16: "auxiliary-taint", 17: "randstruct-plugin", } 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) ------------------ def find_hidden_pids(proc_pids: set[int], alive_pids: set[int]) -> set[int]: """PIDs the kernel confirms alive but that are missing from /proc.""" return alive_pids - proc_pids def find_hidden_modules(proc_modules: set[str], sys_live: set[str]) -> set[str]: """Modules live in /sys/module but absent from /proc/modules.""" return sys_live - proc_modules def find_hidden_ports(procnet_ports: set[int], ss_ports: set[int]) -> set[int]: """Listening ports in /proc/net/tcp that ss does not report.""" return procnet_ports - ss_ports def find_hidden_udp_ports(procnet_ports: set[int], ss_ports: set[int]) -> set[int]: """UDP ports in /proc/net/udp that ss does not report.""" 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("-", "_") def find_known_rootkit_modules(modules: set[str]) -> set[str]: known = {_norm_module(m) for m in KNOWN_ROOTKIT_MODULES} return {m for m in modules if _norm_module(m) in known} def decode_kernel_taint(value: int) -> list[str]: return [label for bit, label in sorted(TAINT_FLAGS.items()) if value & (1 << bit)] def taint_severity(value: int) -> Severity: risky = any(value & (1 << bit) for bit in HIGH_RISK_TAINT_BITS) return Severity.HIGH if risky else Severity.MEDIUM # --- system views --------------------------------------------------------- def proc_pids() -> set[int]: return {int(n) for n in os.listdir("/proc") if n.isdigit()} def pid_max(cap: int) -> int: try: return min(int(open("/proc/sys/kernel/pid_max").read().strip()), cap) except (OSError, ValueError): return cap def alive_pids(cap: int) -> set[int]: """Brute-force which PIDs exist via kill(pid, 0). kill returns success or EPERM for a live PID, ESRCH (ProcessLookupError) for a dead one. Goes through the process table, not the /proc readdir a rootkit may have hooked. """ alive: set[int] = set() for pid in range(1, pid_max(cap) + 1): try: os.kill(pid, 0) alive.add(pid) except PermissionError: alive.add(pid) # exists, just not ours to signal except (ProcessLookupError, OSError): pass return alive def proc_modules() -> set[str]: out: set[str] = set() try: with open("/proc/modules") as fh: for line in fh: out.add(line.split()[0]) except OSError: pass return out def sys_live_modules() -> set[str]: """Modules under /sys/module whose initstate is 'live' (i.e. loaded, not built-in — built-ins have no initstate file).""" out: set[str] = set() base = "/sys/module" try: for m in os.listdir(base): try: with open(os.path.join(base, m, "initstate")) as fh: if fh.read().strip() == "live": out.add(m) except OSError: continue except OSError: pass return out def module_taints() -> dict[str, str]: """Read per-module taint markers from /sys/module/*/taint. Values are kernel-provided letters such as P/O/E for proprietary, out-of-tree, or unsigned modules. Built-ins often do not expose this file. """ out: dict[str, str] = {} base = "/sys/module" try: for m in os.listdir(base): try: with open(os.path.join(base, m, "taint")) as fh: taint = fh.read().strip() except OSError: continue if taint and taint != "0": out[m] = taint except OSError: pass return out def kernel_taint() -> int: try: with open("/proc/sys/kernel/tainted") as fh: return int(fh.read().strip()) except (OSError, ValueError): return 0 def _procnet_listen_ports(path: str) -> set[int]: ports: set[int] = set() try: with open(path) as fh: next(fh, None) # header for line in fh: parts = line.split() if len(parts) < 4 or parts[3] != "0A": # 0A = TCP_LISTEN continue local = parts[1] # HEX_ADDR:HEX_PORT ports.add(int(local.rsplit(":", 1)[1], 16)) except OSError: pass return ports def procnet_listen_ports() -> set[int]: return _procnet_listen_ports("/proc/net/tcp") | _procnet_listen_ports("/proc/net/tcp6") def _procnet_udp_ports(path: str) -> set[int]: ports: 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_PORT port = int(local.rsplit(":", 1)[1], 16) if port: ports.add(port) except (OSError, ValueError): pass return ports 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(): tail = s.local.rsplit(":", 1) if len(tail) == 2 and tail[1].isdigit(): ports.add(int(tail[1])) return ports def ss_udp_ports(state: SystemState) -> set[int]: ports: set[int] = set() for s in state.sockets: if s.state not in {"UNCONN", "ESTAB"}: continue tail = s.local.rsplit(":", 1) if len(tail) == 2 and tail[1].isdigit(): ports.add(int(tail[1])) 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" try: for iface in os.listdir(base): try: with open(os.path.join(base, iface, "flags")) as fh: flags = int(fh.read().strip(), 16) if flags & IFF_PROMISC: out.append(iface) except (OSError, ValueError): continue except OSError: pass return out # --- the check ------------------------------------------------------------ def run(cfg: Config, state: SystemState | None = None) -> Iterator[Alert]: state = state or SystemState() cap = cfg.rootcheck_pid_cap # Hidden processes — re-verify each candidate to shake out exit races. visible = proc_pids() candidates = find_hidden_pids(visible, alive_pids(cap)) confirmed = [] for pid in candidates: try: os.kill(pid, 0) except PermissionError: pass except OSError: continue if not os.path.exists(f"/proc/{pid}"): confirmed.append(pid) if confirmed: yield Alert( severity=Severity.CRITICAL, signature="rootkit_hidden_process", key=f"rk:hidproc:{min(confirmed)}", detail=("process(es) alive but hidden from /proc: " + ", ".join(map(str, sorted(confirmed)[:20]))), pids=tuple(sorted(confirmed)[:20]), sid=SID_HIDDEN_PROC, classtype="rootkit-hidden-process") proc_mods = proc_modules() sys_mods = sys_live_modules() hidden_mods = find_hidden_modules(proc_mods, sys_mods) for m in sorted(hidden_mods): yield Alert( severity=Severity.CRITICAL, signature="rootkit_hidden_module", key=f"rk:hidmod:{m}", detail=f"kernel module live in /sys/module but hidden from /proc/modules: {m}", sid=SID_HIDDEN_MODULE, classtype="rootkit-hidden-module") all_modules = proc_mods | sys_mods known = find_known_rootkit_modules(all_modules) for m in sorted(known): yield Alert( severity=Severity.CRITICAL, signature="rootkit_known_module", key=f"rk:knownmod:{m}", detail=f"known LKM rootkit module name loaded or visible: {m}", sid=SID_KNOWN_ROOTKIT_MODULE, classtype="rootkit-known-module") allowed = {_norm_module(m) for m in getattr(cfg, "rootcheck_module_allow", ())} for m, taint in sorted(module_taints().items()): if _norm_module(m) in allowed or m in known: continue yield Alert( severity=Severity.HIGH, signature="rootkit_tainted_module", key=f"rk:taintmod:{m}", detail=(f"kernel module has taint marker '{taint}' " f"(out-of-tree/proprietary/unsigned module): {m}"), sid=SID_TAINTED_MODULE, classtype="rootkit-tainted-module") kt = kernel_taint() if kt: flags = decode_kernel_taint(kt) yield Alert( severity=taint_severity(kt), signature="kernel_tainted", key=f"rk:ktaint:{kt}", detail=(f"kernel taint value {kt} ({', '.join(flags) or 'unknown'}) — " "may indicate unsigned/out-of-tree modules, forced loads, or kernel faults"), sid=SID_KERNEL_TAINTED, classtype="kernel-integrity") hidden_ports = find_hidden_ports(procnet_listen_ports(), ss_listen_ports(state)) if hidden_ports: yield Alert( severity=Severity.HIGH, signature="rootkit_hidden_port", key=f"rk:hidport:{min(hidden_ports)}", detail=("listening port(s) in /proc/net/tcp not reported by ss " "(tool may be hooked): " + ", ".join(map(str, sorted(hidden_ports)))), sid=SID_HIDDEN_PORT, classtype="rootkit-hidden-port") hidden_udp = find_hidden_udp_ports(procnet_udp_ports(), ss_udp_ports(state)) if hidden_udp: yield Alert( severity=Severity.HIGH, signature="rootkit_hidden_udp_port", key=f"rk:hidudp:{min(hidden_udp)}", detail=("UDP port(s) in /proc/net/udp not reported by ss " "(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", key=f"rk:promisc:{iface}", detail=f"interface in promiscuous mode (possible sniffer): {iface}", sid=SID_PROMISC, classtype="network-sniffer")