Add signed-package verification and anti-rootkit cross-view (v0.7)
Closes the tamper-evidence loop with two trust anchors the attacker can't forge from userland: - pkgdb Layer 2: verify on-disk files against the .MTREE in the *signed* cache package, surviving a rewritten local checksum DB. Rotating-sample cadence keeps it affordable; flags pkg_signature_mismatch (sid 100027) and SigLevel downgrades (sid 100026). Fixes parse_mtree, which required type=file on every line and so matched nothing on real pacman MTREEs (which use a /set type=file default with bare file entries). - rootcheck: anti-rootkit cross-view — hidden processes, modules, ports, and promiscuous interfaces, each caught by diffing two views of the same state (sids 100022-100025). Wired both through config, the daemon (off-loop slow cadence), and CLI (pkgdb-verify, rootcheck). 14 new tests (95 total). Docs + version bump. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
34bc09041a
commit
1de5e86fed
10 changed files with 766 additions and 5 deletions
|
|
@ -6,4 +6,4 @@ captures a forensic snapshot with incident-response guidance whenever a known
|
|||
attack signature appears. The Python re-architecture of the bash v0 prototype.
|
||||
"""
|
||||
|
||||
__version__ = "0.6.0"
|
||||
__version__ = "0.7.0"
|
||||
|
|
|
|||
|
|
@ -69,6 +69,12 @@ def main(argv: list[str] | None = None) -> int:
|
|||
fc.add_argument("--packages", action="store_true",
|
||||
help="also verify package-owned files via pacman -Qkk")
|
||||
sub.add_parser("pkgdb-check", help="check the package DB for out-of-band tampering")
|
||||
pv = sub.add_parser("pkgdb-verify",
|
||||
help="verify on-disk files against the signed cache packages")
|
||||
pv.add_argument("--sample", type=int, default=0,
|
||||
help="packages to verify (0 = config default; rotates)")
|
||||
sub.add_parser("rootcheck",
|
||||
help="anti-rootkit cross-view: hidden procs/modules/ports")
|
||||
wd = sub.add_parser("watchdog",
|
||||
help="poll a remote dashboard and push if Sentinel is silent")
|
||||
wd.add_argument("--url", required=True, help="dashboard base URL")
|
||||
|
|
@ -108,6 +114,10 @@ def main(argv: list[str] | None = None) -> int:
|
|||
return 1
|
||||
print("Package DB: consistent with the anchor (no out-of-band changes).")
|
||||
return 0
|
||||
if args.cmd == "pkgdb-verify":
|
||||
return _cmd_pkgdb_verify(cfg, args.sample)
|
||||
if args.cmd == "rootcheck":
|
||||
return _cmd_rootcheck(cfg)
|
||||
if args.cmd == "watchdog":
|
||||
return _cmd_watchdog(cfg, args.url, args.token, args.max_age)
|
||||
return _cmd_run(cfg)
|
||||
|
|
@ -132,6 +142,35 @@ def _cmd_watchdog(cfg: Config, url: str, token: str, max_age: int) -> int:
|
|||
return 0 if ok else 1
|
||||
|
||||
|
||||
def _cmd_pkgdb_verify(cfg: Config, sample: int) -> int:
|
||||
from . import pkgdb
|
||||
if sample > 0:
|
||||
cfg.pkgdb_pkgverify_sample = sample
|
||||
sl = pkgdb.siglevel_alert()
|
||||
if sl:
|
||||
print(f"[CRITICAL] {sl.detail}")
|
||||
if not pkgdb.keyring_present():
|
||||
print("[WARN] pacman keyring not found — signature trust may be unestablished.")
|
||||
alerts = [a for a in pkgdb.verify_alerts(cfg) if a.signature != "pacman_siglevel_disabled"]
|
||||
for a in alerts:
|
||||
print(f"[CRITICAL] {a.detail}")
|
||||
if not sl and not alerts:
|
||||
print(f"Package verify: sampled files match the signed cache packages "
|
||||
f"(sample={cfg.pkgdb_pkgverify_sample}).")
|
||||
return 1 if (sl or alerts) else 0
|
||||
|
||||
|
||||
def _cmd_rootcheck(cfg: Config) -> int:
|
||||
from . import rootcheck
|
||||
alerts = list(rootcheck.run(cfg))
|
||||
if not alerts:
|
||||
print("Rootcheck: no hidden processes, modules, ports, or sniffers found.")
|
||||
return 0
|
||||
for a in sorted(alerts, key=lambda x: -x.severity):
|
||||
print(f"[{a.severity}] {a.signature:<22} {a.detail}")
|
||||
return 1
|
||||
|
||||
|
||||
def _cmd_fim_check(cfg: Config, packages: bool) -> int:
|
||||
from . import fim
|
||||
sentinel = Sentinel(cfg)
|
||||
|
|
|
|||
|
|
@ -71,6 +71,17 @@ class Config:
|
|||
pkgdb_verify: bool = True # detect out-of-band package-DB edits
|
||||
pkgdb_interval: int = 600 # seconds between DB integrity checks
|
||||
heartbeat_max_age: int = 120 # heartbeat older than this = stale
|
||||
# Layer 2 — verify on-disk files against the signed cache package (survives
|
||||
# a rewritten checksum DB). Expensive, so it samples a rotating slice of
|
||||
# packages each pass; off by default (needs the package cache populated).
|
||||
pkgdb_pkgverify: bool = False
|
||||
pkgdb_pkgverify_interval: int = 21600 # seconds between Layer-2 passes
|
||||
pkgdb_pkgverify_sample: int = 40 # packages verified per pass (rotates)
|
||||
|
||||
# anti-rootkit (cross-view: ask the same question two ways, compare)
|
||||
rootcheck_enabled: bool = True
|
||||
rootcheck_interval: int = 300 # seconds between cross-view sweeps
|
||||
rootcheck_pid_cap: int = 65536 # upper PID to brute-force via kill(0)
|
||||
|
||||
# eBPF on-ramp
|
||||
capture_execve_bpftrace: bool = False
|
||||
|
|
|
|||
|
|
@ -43,6 +43,13 @@ class Sentinel:
|
|||
self._fim_pkg_thread: threading.Thread | None = None
|
||||
self._last_fim_pkg = 0.0
|
||||
self._last_pkgdb = 0.0
|
||||
# Layer-2 package verification (rotating sample each pass).
|
||||
self._pkgverify_thread: threading.Thread | None = None
|
||||
self._last_pkgverify = 0.0
|
||||
self._pkgverify_offset = 0
|
||||
# Anti-rootkit cross-view sweep (off the loop thread; brute-forces PIDs).
|
||||
self._rootcheck_thread: threading.Thread | None = None
|
||||
self._last_rootcheck = 0.0
|
||||
self._stop = threading.Event()
|
||||
|
||||
# -- baselines ---------------------------------------------------------
|
||||
|
|
@ -158,6 +165,44 @@ class Sentinel:
|
|||
for alert in pacman_verify_alerts():
|
||||
self._on_exec_alert(alert)
|
||||
|
||||
# -- package signature verification (Layer 2, off the loop thread) ------
|
||||
def _maybe_pkgdb_verify(self, now: float) -> None:
|
||||
if not self.cfg.pkgdb_pkgverify:
|
||||
return
|
||||
if self._pkgverify_thread and self._pkgverify_thread.is_alive():
|
||||
return
|
||||
if (now - self._last_pkgverify) < self.cfg.pkgdb_pkgverify_interval:
|
||||
return
|
||||
self._last_pkgverify = now
|
||||
self._pkgverify_thread = threading.Thread(
|
||||
target=self._pkgdb_verify, daemon=True)
|
||||
self._pkgverify_thread.start()
|
||||
|
||||
def _pkgdb_verify(self) -> None:
|
||||
from . import pkgdb
|
||||
for alert in pkgdb.verify_alerts(self.cfg, offset=self._pkgverify_offset):
|
||||
self._on_exec_alert(alert)
|
||||
# Advance the rotating window so the next pass covers a different slice.
|
||||
self._pkgverify_offset += self.cfg.pkgdb_pkgverify_sample
|
||||
|
||||
# -- anti-rootkit cross-view (off the loop thread) ---------------------
|
||||
def _maybe_rootcheck(self, now: float) -> None:
|
||||
if not self.cfg.rootcheck_enabled:
|
||||
return
|
||||
if self._rootcheck_thread and self._rootcheck_thread.is_alive():
|
||||
return
|
||||
if (now - self._last_rootcheck) < self.cfg.rootcheck_interval:
|
||||
return
|
||||
self._last_rootcheck = now
|
||||
self._rootcheck_thread = threading.Thread(
|
||||
target=self._rootcheck, daemon=True)
|
||||
self._rootcheck_thread.start()
|
||||
|
||||
def _rootcheck(self) -> None:
|
||||
from . import rootcheck
|
||||
for alert in rootcheck.run(self.cfg):
|
||||
self._on_exec_alert(alert)
|
||||
|
||||
# -- one sweep ---------------------------------------------------------
|
||||
def sweep(self, *, force_suid: bool = False) -> list[Alert]:
|
||||
now = time.time()
|
||||
|
|
@ -221,6 +266,8 @@ class Sentinel:
|
|||
self._maybe_scan_fim(now)
|
||||
self._maybe_pkg_verify(now)
|
||||
self._maybe_check_pkgdb(now)
|
||||
self._maybe_pkgdb_verify(now)
|
||||
self._maybe_rootcheck(now)
|
||||
alerts = self.sweep()
|
||||
fresh = self.fresh_alerts(alerts, now)
|
||||
if fresh:
|
||||
|
|
|
|||
|
|
@ -18,10 +18,12 @@ off-box and verification against signed packages (see README hardening notes).
|
|||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
|
||||
from .alert import Alert, Severity
|
||||
|
|
@ -29,7 +31,12 @@ from .config import Config
|
|||
|
||||
DB_DIR = "/var/lib/pacman/local"
|
||||
PACMAN_LOG = "/var/log/pacman.log"
|
||||
PACMAN_CONF = "/etc/pacman.conf"
|
||||
KEYRING_DIR = "/etc/pacman.d/gnupg"
|
||||
PKG_CACHE = "/var/cache/pacman/pkg"
|
||||
SID_PKGDB = 100021
|
||||
SID_SIGLEVEL = 100026
|
||||
SID_PKGVERIFY = 100027
|
||||
_SLOP = 120 # seconds of clock slop allowed between a transaction and the anchor
|
||||
|
||||
_TS_RE = re.compile(r"^\[([0-9T:+\-]+)\]")
|
||||
|
|
@ -130,3 +137,206 @@ def check(cfg: Config,
|
|||
sid=SID_PKGDB,
|
||||
classtype="anti-tamper",
|
||||
)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Layer 2 — verification against signed packages (the independent anchor)
|
||||
# ==========================================================================
|
||||
# Layer 1 detects an *out-of-band* DB edit. But an attacker with root can edit
|
||||
# a binary, rewrite its hash in the local DB, AND run `fim-update` to re-anchor.
|
||||
# The only reference they cannot forge is the distro's signing key: packages and
|
||||
# repo databases are signed. So we verify on-disk files against the .MTREE inside
|
||||
# the *cached package* — independent of the local DB the attacker controls.
|
||||
|
||||
def parse_global_siglevel(conf_text: str) -> str:
|
||||
"""Return the [options] SigLevel value from pacman.conf text ('' if unset)."""
|
||||
section = ""
|
||||
value = ""
|
||||
for line in conf_text.splitlines():
|
||||
line = line.split("#", 1)[0].strip()
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
section = line[1:-1].strip().lower()
|
||||
elif section == "options" and line.lower().startswith("siglevel"):
|
||||
value = line.split("=", 1)[1].strip() if "=" in line else ""
|
||||
return value
|
||||
|
||||
|
||||
def siglevel_insecure(value: str) -> bool:
|
||||
"""True if signature verification is effectively disabled."""
|
||||
tokens = value.replace(",", " ").split()
|
||||
return any(t in ("Never", "TrustAll") for t in tokens)
|
||||
|
||||
|
||||
def siglevel_alert(conf_path: str = PACMAN_CONF) -> Alert | None:
|
||||
try:
|
||||
text = open(conf_path, encoding="utf-8", errors="replace").read()
|
||||
except OSError:
|
||||
return None
|
||||
value = parse_global_siglevel(text)
|
||||
# pacman's compiled-in default ("Required DatabaseOptional") is secure, so an
|
||||
# unset SigLevel is fine; only an explicit downgrade is suspicious.
|
||||
if value and siglevel_insecure(value):
|
||||
return Alert(
|
||||
severity=Severity.CRITICAL, signature="pacman_siglevel_disabled",
|
||||
key="pkgdb:siglevel",
|
||||
detail=(f"pacman SigLevel is insecure ('{value}') — package signature "
|
||||
"verification is disabled, allowing unsigned/forged packages"),
|
||||
sid=SID_SIGLEVEL, classtype="anti-tamper")
|
||||
return None
|
||||
|
||||
|
||||
def keyring_present(keyring_dir: str = KEYRING_DIR) -> bool:
|
||||
return os.path.isfile(os.path.join(keyring_dir, "pubring.gpg"))
|
||||
|
||||
|
||||
# --- mtree extraction & comparison ----------------------------------------
|
||||
|
||||
_MTREE_SHA = re.compile(r"sha256digest=([0-9a-f]{64})")
|
||||
|
||||
|
||||
def parse_mtree(text: str) -> dict[str, str]:
|
||||
"""Parse a pacman .MTREE into {absolute_path: sha256} for regular files.
|
||||
|
||||
pacman emits a `/set type=file …` default and then bare path entries that
|
||||
*omit* `type=` for regular files (only dirs/links carry an explicit type).
|
||||
So a file is "has a sha256digest and isn't explicitly a non-file type" —
|
||||
we track the `/set` default and honor per-line overrides.
|
||||
"""
|
||||
out: dict[str, str] = {}
|
||||
set_type = "file" # pacman's /set default; updated by /set lines
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("/set "):
|
||||
for tok in line.split()[1:]:
|
||||
if tok.startswith("type="):
|
||||
set_type = tok[len("type="):]
|
||||
continue
|
||||
if not line.startswith("./"):
|
||||
continue
|
||||
# explicit per-line type overrides the /set default
|
||||
line_type = set_type
|
||||
for tok in line.split()[1:]:
|
||||
if tok.startswith("type="):
|
||||
line_type = tok[len("type="):]
|
||||
if line_type != "file":
|
||||
continue
|
||||
m = _MTREE_SHA.search(line)
|
||||
if not m:
|
||||
continue
|
||||
rel = line.split(None, 1)[0][1:] # './usr/bin/x' -> '/usr/bin/x'
|
||||
rel = rel.replace("\\040", " ") # mtree space escape
|
||||
out[rel] = m.group(1)
|
||||
return out
|
||||
|
||||
|
||||
def package_mtree(pkg_path: str) -> dict[str, str]:
|
||||
"""Extract and parse the .MTREE from a cached package archive (via bsdtar)."""
|
||||
try:
|
||||
raw = subprocess.run(["bsdtar", "-xOqf", pkg_path, ".MTREE"],
|
||||
capture_output=True, timeout=60).stdout
|
||||
text = gzip.decompress(raw).decode("utf-8", "replace")
|
||||
except (OSError, subprocess.SubprocessError, gzip.BadGzipFile, ValueError):
|
||||
return {}
|
||||
return parse_mtree(text)
|
||||
|
||||
|
||||
def find_cached_package(name: str, version: str,
|
||||
cache: str = PKG_CACHE) -> str | None:
|
||||
import glob
|
||||
for ext in ("zst", "xz"):
|
||||
hits = glob.glob(os.path.join(cache, f"{name}-{version}-*.pkg.tar.{ext}"))
|
||||
if hits:
|
||||
return sorted(hits)[-1]
|
||||
return None
|
||||
|
||||
|
||||
def verify_package(name: str, version: str, root: str = "/",
|
||||
cache: str = PKG_CACHE) -> dict:
|
||||
"""Compare a package's on-disk files to the hashes in its signed cache pkg.
|
||||
|
||||
Returns {'pkg', 'cached', 'checked', 'mismatched':[(path, on_disk, expected)]}.
|
||||
A mismatch means the on-disk file differs from what the distro shipped —
|
||||
independent of the local DB, so it survives a rewritten checksum database.
|
||||
"""
|
||||
from .fim import hash_file
|
||||
result = {"pkg": f"{name}-{version}", "cached": None,
|
||||
"checked": 0, "mismatched": []}
|
||||
pkg = find_cached_package(name, version, cache)
|
||||
if not pkg:
|
||||
return result
|
||||
result["cached"] = pkg
|
||||
for path, expected in package_mtree(pkg).items():
|
||||
full = os.path.join(root, path.lstrip("/"))
|
||||
if not os.path.isfile(full):
|
||||
continue
|
||||
result["checked"] += 1
|
||||
actual = hash_file(full)
|
||||
if actual and actual != expected:
|
||||
result["mismatched"].append((path, actual, expected))
|
||||
return result
|
||||
|
||||
|
||||
def installed_packages() -> list[tuple[str, str]]:
|
||||
"""(name, version) for every installed package, via `pacman -Q`."""
|
||||
try:
|
||||
out = subprocess.run(["pacman", "-Q"], capture_output=True,
|
||||
text=True, timeout=30).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return []
|
||||
pkgs = []
|
||||
for line in out.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) == 2:
|
||||
pkgs.append((parts[0], parts[1]))
|
||||
return pkgs
|
||||
|
||||
|
||||
# --- the Layer-2 check ----------------------------------------------------
|
||||
# Verifying every cached package on each pass is expensive (untar + hash every
|
||||
# file), so this runs on its own slow cadence and verifies a rotating *sample*
|
||||
# of packages per pass. Over enough passes the whole installed set is covered,
|
||||
# and any single mismatch fires immediately — an attacker can't know which slice
|
||||
# we'll check next.
|
||||
|
||||
def _sample(items: list, n: int, offset: int) -> list:
|
||||
"""A rotating window of `n` items starting at `offset` (wraps around)."""
|
||||
if n <= 0 or not items:
|
||||
return items
|
||||
n = min(n, len(items))
|
||||
start = offset % len(items)
|
||||
return [items[(start + i) % len(items)] for i in range(n)]
|
||||
|
||||
|
||||
def verify_alerts(cfg: Config, *,
|
||||
_installed=installed_packages,
|
||||
_verify=verify_package,
|
||||
offset: int = 0) -> list[Alert]:
|
||||
"""Layer 2: flag config that disables signing, and on-disk files that
|
||||
diverge from the distro's *signed* cache package.
|
||||
|
||||
A mismatch here survives a rewritten local checksum DB — the reference is
|
||||
the maintainer's signature, not the mutable DB the attacker controls. The
|
||||
cache package itself is only trusted because pacman verified its signature
|
||||
on download (so we also flag SigLevel downgrades that would skip that).
|
||||
"""
|
||||
alerts: list[Alert] = []
|
||||
sl = siglevel_alert()
|
||||
if sl:
|
||||
alerts.append(sl)
|
||||
|
||||
pkgs = _installed()
|
||||
sample = _sample(pkgs, cfg.pkgdb_pkgverify_sample, offset)
|
||||
for name, version in sample:
|
||||
res = _verify(name, version)
|
||||
if res["mismatched"]:
|
||||
paths = ", ".join(p for p, _a, _e in res["mismatched"][:8])
|
||||
alerts.append(Alert(
|
||||
severity=Severity.CRITICAL,
|
||||
signature="pkg_signature_mismatch",
|
||||
key=f"pkgverify:{name}",
|
||||
detail=(f"on-disk files of package '{res['pkg']}' differ from the "
|
||||
f"signed cache package — possible trojaned binary that a "
|
||||
f"rewritten checksum DB would hide: {paths}"),
|
||||
sid=SID_PKGVERIFY,
|
||||
classtype="anti-tamper"))
|
||||
return alerts
|
||||
|
|
|
|||
209
enodia_sentinel/rootcheck.py
Normal file
209
enodia_sentinel/rootcheck.py
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
# 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
|
||||
|
||||
IFF_PROMISC = 0x100
|
||||
|
||||
|
||||
# --- 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
|
||||
|
||||
|
||||
# --- 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 _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 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 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")
|
||||
|
||||
hidden_mods = find_hidden_modules(proc_modules(), sys_live_modules())
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue