501 lines
17 KiB
Python
501 lines
17 KiB
Python
# 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")
|