diff --git a/tests/test_adaptive_deauth_integration.py b/tests/test_adaptive_deauth_integration.py index a04da52fd..298a209c3 100644 --- a/tests/test_adaptive_deauth_integration.py +++ b/tests/test_adaptive_deauth_integration.py @@ -30,7 +30,7 @@ def test_adaptive_deauth_initialized(self, mock_config): """Test that adaptive deauth manager is initialized on attack creation.""" # Set up Configuration mock with all required attributes mock_config.interface = 'wlan0' - mock_config.evil_twin_deauth_interval = 5.0 + mock_config.eviltwin_deauth_interval = 5.0 mock_config.wpa_attack_timeout = 60 from wifite.attack.eviltwin import EvilTwin diff --git a/tests/test_eviltwin_e2e.py b/tests/test_eviltwin_e2e.py index fd63d3da4..e1cbfe230 100644 --- a/tests/test_eviltwin_e2e.py +++ b/tests/test_eviltwin_e2e.py @@ -28,9 +28,9 @@ # Set required Configuration attributes before importing other modules Configuration.wpa_attack_timeout = 600 Configuration.interface = 'wlan0' -Configuration.evil_twin_timeout = 0 -Configuration.evil_twin_portal_template = 'generic' -Configuration.evil_twin_deauth_interval = 5 +Configuration.eviltwin_timeout = 0 +Configuration.eviltwin_template = 'generic' +Configuration.eviltwin_deauth_interval = 5 from wifite.attack.eviltwin import EvilTwin, AttackState from wifite.model.target import Target @@ -47,7 +47,7 @@ class TestRealRouterScenarios(unittest.TestCase): def setUp(self): """Set up test fixtures.""" Configuration.interface = 'wlan0' - Configuration.evil_twin_timeout = 0 + Configuration.eviltwin_timeout = 0 def test_wpa2_personal_router_scenario(self): """Test scenario: WPA2-Personal router with standard settings.""" @@ -593,7 +593,7 @@ def setUp(self): self.mock_target.wps = False Configuration.interface = 'wlan0' - Configuration.evil_twin_timeout = 0 + Configuration.eviltwin_timeout = 0 @patch('wifite.attack.eviltwin.Color') @patch('wifite.attack.eviltwin.input') diff --git a/tests/test_eviltwin_unit.py b/tests/test_eviltwin_unit.py index 4ed26b72a..586c0032c 100644 --- a/tests/test_eviltwin_unit.py +++ b/tests/test_eviltwin_unit.py @@ -35,11 +35,17 @@ def test_hostapd_initialization(self): self.assertEqual(hostapd.password, 'testpassword') self.assertFalse(hostapd.running) - def test_hostapd_default_password(self): - """Test hostapd uses default password when none provided.""" + def test_hostapd_open_network_when_no_password(self): + """With no password, the AP must be OPEN (captive portal requirement).""" hostapd = Hostapd('wlan0', 'TestNetwork', 6) - - self.assertEqual(hostapd.password, 'temporarypassword123') + + # No passphrase is stored, and the generated config must not enable WPA2 + # (otherwise clients couldn't associate to reach the portal). + self.assertIsNone(hostapd.password) + config = hostapd.generate_config() + self.assertNotIn('wpa=2', config) + self.assertNotIn('wpa_passphrase', config) + self.assertIn('auth_algs=1', config) def test_hostapd_config_generation(self): """Test hostapd configuration file generation.""" @@ -66,8 +72,29 @@ def test_hostapd_config_special_characters(self): """Test hostapd handles special characters in SSID.""" hostapd = Hostapd('wlan0', 'Test Network 2.4GHz', 6, 'pass123') config = hostapd.generate_config() - + self.assertIn('ssid=Test Network 2.4GHz', config) + + def test_hostapd_ssid_newline_injection_neutralized(self): + """A newline in the SSID must not inject hostapd directives.""" + # Malicious SSID attempting to append a directive on a new config line. + malicious = 'Evil\nmacaddr_acl=1\nctrl_interface=/tmp/x' + hostapd = Hostapd('wlan0', malicious, 6, 'pass123') + config = hostapd.generate_config() + + # The injected directives must NOT appear as standalone config lines. + lines = config.split('\n') + self.assertNotIn('ctrl_interface=/tmp/x', lines) + # The SSID line must be hex-encoded (ssid2=) rather than a raw ssid=. + self.assertTrue(any(line.startswith('ssid2=') for line in lines)) + self.assertFalse(any(line.startswith('ssid=Evil') for line in lines)) + + def test_hostapd_ssid_non_ascii_hex_encoded(self): + """Non-ASCII SSIDs are emitted as ssid2= (hostapd-safe).""" + hostapd = Hostapd('wlan0', 'Café📶', 6, 'pass123') + config = hostapd.generate_config() + lines = config.split('\n') + self.assertTrue(any(line.startswith('ssid2=') for line in lines)) def test_hostapd_config_file_creation(self): """Test hostapd creates configuration file.""" diff --git a/wifite/args.py b/wifite/args.py index a877d2725..16abfad4f 100644 --- a/wifite/args.py +++ b/wifite/args.py @@ -531,6 +531,13 @@ def _add_eviltwin_args(self, group): type=int, help=self._verbose('Seconds between deauth bursts (default: {G}5{W})')) + group.add_argument('--eviltwin-timeout', + action='store', + dest='eviltwin_timeout', + metavar='[seconds]', + type=int, + help=self._verbose('Give up after N seconds (default: {G}0{W} = run until success/interrupt)')) + group.add_argument('--eviltwin-template', action='store', dest='eviltwin_template', diff --git a/wifite/attack/eviltwin.py b/wifite/attack/eviltwin.py index 528b48d81..0bd860829 100755 --- a/wifite/attack/eviltwin.py +++ b/wifite/attack/eviltwin.py @@ -101,6 +101,12 @@ def __init__(self, target, interface_ap=None, interface_deauth=None): self.portal_thread = None self.deauth_process = None self.client_monitor = None + # Lazily-created validator that checks captured credentials against the + # real target AP (see _verify_password). None until first use. + self.credential_validator = None + # True once we've switched the dual-mode deauth interface into monitor + # mode, so _cleanup() knows to restore it to managed afterwards. + self._deauth_monitor_enabled = False # Statistics and tracking self.clients_connected = [] @@ -122,7 +128,8 @@ def __init__(self, target, interface_ap=None, interface_deauth=None): self.cleanup_manager = CleanupManager() # Adaptive deauth manager for intelligent deauth timing - deauth_interval = getattr(Configuration, 'evil_twin_deauth_interval', 5.0) + # `or 5.0` guards the pre-initialize state where the class attr is None. + deauth_interval = getattr(Configuration, 'eviltwin_deauth_interval', 5.0) or 5.0 self.adaptive_deauth = AdaptiveDeauthManager( base_interval=deauth_interval, min_interval=2.0, @@ -215,7 +222,7 @@ def _get_interface_assignment(self): return None # Create assignment from manual configuration - from ..model.interface_assignment import InterfaceAssignment + from ..model.interface_info import InterfaceAssignment assignment = InterfaceAssignment( attack_type='evil_twin', primary=Configuration.interface_primary, @@ -417,7 +424,11 @@ def _configure_deauth_interface(self, interface: str) -> bool: if monitor_interface != interface: log_info('EvilTwin', f'Deauth interface renamed: {interface} -> {monitor_interface}') self.interface_deauth = monitor_interface - + + # Record that we put this interface into monitor mode so _cleanup() + # restores it to managed when the attack ends. + self._deauth_monitor_enabled = True + # Set channel to match target if hasattr(self.target, 'channel') and self.target.channel: log_debug('EvilTwin', f'Setting {self.interface_deauth} to channel {self.target.channel}') @@ -517,7 +528,9 @@ def _start_network_services_dual(self) -> bool: # Start captive portal portal_port = getattr(Configuration, 'eviltwin_port', 80) - self.portal_server = PortalServer(port=portal_port) + self.portal_server = PortalServer( + port=portal_port, + template_renderer=self._build_template_renderer()) self.portal_server.set_credential_callback(self._portal_credential_callback) if not self.portal_server.start(): @@ -605,7 +618,7 @@ def _start_deauth_dual(self, interface: str) -> bool: import contextlib with contextlib.suppress(Exception): current_channel = Airmon.get_interface_channel(interface) - if current_channel and current_channel != self.target.channel: + if current_channel and str(current_channel) != str(self.target.channel): log_warning('EvilTwin', f'Deauth interface on channel {current_channel}, target on {self.target.channel}') Color.pl('{!} {O}Warning: Channel mismatch - deauth may be less effective{W}') @@ -632,7 +645,7 @@ def _monitor_attack_loop(self) -> bool: True if credentials captured, False otherwise """ try: - timeout = getattr(Configuration, 'evil_twin_timeout', 0) + timeout = getattr(Configuration, 'eviltwin_timeout', 0) or 0 last_session_save = time.time() session_save_interval = 30 @@ -737,12 +750,41 @@ def run(self) -> bool: if self.attack_view: self.attack_view.add_log("No conflicts detected", timestamp=True) - # Setup attack components + # Decide interface mode BEFORE setup. Dual-interface mode performs + # its own interface configuration AND service startup inside + # _run_dual_interface(), so we must NOT also run the single-interface + # _setup() — doing both would start hostapd/dnsmasq/portal twice on + # the same interface and the second start would fail. + self.interface_assignment = self._get_interface_assignment() + + if self.interface_assignment and self.interface_assignment.is_dual_interface(): + # Run in dual interface mode (no mode switching) + log_info('EvilTwin', 'Using dual interface mode') + Color.pl('{+} {G}Using dual interface mode{W}') + Color.pl('{+} {C}Primary (AP):{W} {G}%s{W}' % self.interface_assignment.primary) + Color.pl('{+} {C}Secondary (Deauth):{W} {G}%s{W}' % self.interface_assignment.secondary) + Color.pl('') + + if self.attack_view: + self.attack_view.add_log("Using dual interface mode - no mode switching required", timestamp=True) + + result = self._run_dual_interface() + self.setup_time = time.time() - self.start_time + return result + + # Single interface mode (traditional, with mode switching): set up + # and start all components here. + log_info('EvilTwin', 'Using single interface mode') + Color.pl('{+} {C}Using single interface mode{W}') + Color.pl('') + if self.attack_view: + self.attack_view.add_log("Using single interface mode", timestamp=True) + Color.pl('{+} {C}Setting up Evil Twin attack...{W}') self.state = AttackState.SETTING_UP if self.attack_view: self.attack_view.add_log("Setting up attack components...") - + if not self._setup(): Color.pl('{!} {R}Failed to setup Evil Twin attack{W}') if self.error_message: @@ -751,37 +793,13 @@ def run(self) -> bool: self.attack_view.add_log(f"Setup failed: {self.error_message}", timestamp=True) self.state = AttackState.FAILED return False - + self.setup_time = time.time() - self.start_time log_info('EvilTwin', f'Setup completed in {self.setup_time:.2f}s') - + if self.attack_view: self.attack_view.add_log(f"Setup completed in {self.setup_time:.2f}s", timestamp=True) - - # Check for dual interface mode - self.interface_assignment = self._get_interface_assignment() - - if self.interface_assignment and self.interface_assignment.is_dual_interface(): - # Run in dual interface mode (no mode switching) - log_info('EvilTwin', 'Using dual interface mode') - Color.pl('{+} {G}Using dual interface mode{W}') - Color.pl('{+} {C}Primary (AP):{W} {G}%s{W}' % self.interface_assignment.primary) - Color.pl('{+} {C}Secondary (Deauth):{W} {G}%s{W}' % self.interface_assignment.secondary) - Color.pl('') - - if self.attack_view: - self.attack_view.add_log("Using dual interface mode - no mode switching required", timestamp=True) - - return self._run_dual_interface() - else: - # Run in single interface mode (traditional with mode switching) - log_info('EvilTwin', 'Using single interface mode') - Color.pl('{+} {C}Using single interface mode{W}') - Color.pl('') - - if self.attack_view: - self.attack_view.add_log("Using single interface mode", timestamp=True) - + # Start attack (single interface mode) Color.pl('{+} {G}Evil Twin attack started{W}') Color.pl('{+} {C}Rogue AP:{W} {G}%s{W} on channel {G}%s{W}' % ( @@ -800,7 +818,7 @@ def run(self) -> bool: self.state = AttackState.RUNNING # Main attack loop (single interface mode) - timeout = getattr(Configuration, 'evil_twin_timeout', 0) + timeout = getattr(Configuration, 'eviltwin_timeout', 0) or 0 last_session_save = time.time() session_save_interval = 30 # Save session every 30 seconds @@ -1171,7 +1189,9 @@ def _setup(self) -> bool: log_info('EvilTwin', f'Starting captive portal on port {portal_port}') Color.pl('{+} {C}Starting captive portal...{W}') - self.portal_server = PortalServer(port=portal_port) + self.portal_server = PortalServer( + port=portal_port, + template_renderer=self._build_template_renderer()) self.portal_server.set_credential_callback(self._portal_credential_callback) if not self.portal_server.start(): @@ -1201,7 +1221,7 @@ def _setup(self) -> bool: if hasattr(self.target, 'channel') and self.target.channel: with contextlib.suppress(Exception): current_ch = Airmon.get_interface_channel(self.interface_deauth) - if current_ch and current_ch != self.target.channel: + if current_ch and str(current_ch) != str(self.target.channel): log_warning('EvilTwin', f'Deauth interface on ch {current_ch}, ' f'target on ch {self.target.channel}') @@ -1215,33 +1235,177 @@ def _setup(self) -> bool: self.error_message = f'Setup failed: {e}' return False + def _build_template_renderer(self): + """ + Build a TemplateRenderer for the configured captive-portal template. + + Honors ``Configuration.eviltwin_template`` (--eviltwin-template) and + seeds the renderer with the target ESSID so the portal shows the real + network name. Returns None on failure, in which case PortalServer falls + back to its built-in default page. + """ + try: + from .portal.templates import TemplateRenderer + template_name = getattr(Configuration, 'eviltwin_template', 'generic') or 'generic' + renderer = TemplateRenderer( + template_name=template_name, + target_ssid=self.target.essid or '', + ) + log_info('EvilTwin', f'Captive portal template: {template_name}') + return renderer + except Exception as e: + log_warning('EvilTwin', + f'Failed to build portal template renderer ' + f'(using default page): {e}') + return None + def _portal_credential_callback(self, ssid: str, password: str, client_ip: str) -> bool: """ Callback invoked by the portal server when a client submits credentials. - Records the attempt in the client monitor, creates a CrackResult on - the first submission, and signals the main attack loop to stop. + When credential validation is enabled (``eviltwin_validate_credentials``) + and a spare interface is available, the submitted password is checked + against the REAL target AP before being accepted — so a reported success + means a *verified* key. A confirmed-wrong password is rejected (the + client sees the error page and can retry). When validation can't run, the + credential is still captured but clearly flagged as unverified. Returns: - True (always accepts — we capture and stop). + True if accepted (verified, or captured-unverified) — stops the attack. + False if confirmed wrong — the client should retry, attack continues. """ log_info('EvilTwin', f'Credential received from {client_ip}: SSID={ssid}') - # Record in client monitor statistics + verified = self._verify_password(ssid, password) + + if verified is False: + # Confirmed wrong against the real AP — reject and keep the attack + # running so the client can retry (portal shows the error page). + if self.client_monitor: + self.client_monitor.record_credential_attempt(client_ip, success=False) + self.credential_attempts.append({ + 'client_ip': client_ip, + 'mac': client_ip, + 'ssid': ssid, + 'password': password, + 'success': False, + 'timestamp': time.time(), + }) + Color.pl('\n{!} {O}Rejected credential from {C}%s{O}: ' + 'failed validation against the real AP{W}' % client_ip) + return False + + # verified is True (confirmed) or None (could not verify → capture-only) if self.client_monitor: self.client_monitor.record_credential_attempt(client_ip, success=True) # Store the result — the monitoring loop checks self.crack_result self.crack_result = self.create_result(password) + # Annotate verification status for downstream reporting. + self.crack_result.verified = (verified is True) self.credential_attempts.append({ 'client_ip': client_ip, + 'mac': client_ip, 'ssid': ssid, 'password': password, + 'success': True, 'timestamp': time.time(), }) - Color.pl('\n{+} {G}Credential captured from {C}%s{W}' % client_ip) + if verified is True: + Color.pl('\n{+} {G}Credential captured and {C}VERIFIED{G} against the ' + 'real AP from {C}%s{W}' % client_ip) + else: + Color.pl('\n{+} {G}Credential captured from {C}%s{W}' % client_ip) + Color.pl('{!} {O}Note: not verified against the real AP ' + '(validation unavailable) — treat as unconfirmed{W}') return True + + def _verify_password(self, ssid: str, password: str): + """ + Verify a submitted password against the REAL target AP. + + Validation runs wpa_supplicant against the legitimate AP, which needs a + managed-mode radio. The AP interface is busy serving the rogue AP, so we + can only validate when a separate (deauth) interface exists — which we + briefly switch to managed mode and restore afterwards. + + Returns: + True — confirmed correct against the real AP. + False — confirmed incorrect. + None — could not validate (disabled, no spare interface, missing + wpa_supplicant, or error); caller treats it as an unverified + capture rather than a confirmed key. + """ + import contextlib + + if not getattr(Configuration, 'eviltwin_validate_credentials', True): + return None # validation disabled → capture-only + + iface = self.interface_deauth + if not iface or iface == self.interface_ap: + log_warning('EvilTwin', + 'No spare interface to verify credentials against the ' + 'real AP; capturing as unverified') + return None + + if not Process.exists('wpa_supplicant'): + log_warning('EvilTwin', + 'wpa_supplicant not found; capturing credential unverified') + return None + + from ..tools.iw import Iw + from ..tools.ip import Ip + from ..util.credential_validator import CredentialValidator + + # Pause deauth and borrow the secondary interface in managed mode so + # wpa_supplicant can associate with the real AP. + was_paused = self.adaptive_deauth.is_paused + self.adaptive_deauth.pause() + switched = False + try: + with contextlib.suppress(Exception): + Ip.down(iface) + Iw.mode(iface, 'managed') + Ip.up(iface) + switched = True + + if not switched: + log_warning('EvilTwin', + 'Could not switch %s to managed mode for validation; ' + 'capturing unverified' % iface) + return None + + if self.credential_validator is None: + self.credential_validator = CredentialValidator( + interface=iface, + target_bssid=self.target.bssid, + target_channel=int(self.target.channel), + ) + + Color.pl('{+} {C}Verifying credential against the real AP...{W}') + is_valid, _vtime, err = self.credential_validator.validate_credentials( + ssid, password) + if is_valid: + log_info('EvilTwin', 'Credential verified against real AP') + else: + log_info('EvilTwin', f'Credential failed validation: {err}') + return bool(is_valid) + + except Exception as e: + log_warning('EvilTwin', + f'Credential validation error: {e}; capturing unverified') + return None + + finally: + # Restore monitor mode so deauth can resume. + with contextlib.suppress(Exception): + if switched: + Ip.down(iface) + Iw.mode(iface, 'monitor') + Ip.up(iface) + if not was_paused: + self.adaptive_deauth.resume() def _handle_deauth(self): """ @@ -1491,7 +1655,24 @@ def _cleanup(self): self.hostapd = None if self.hostapd_process: self.hostapd_process = None - + + # Restore the dual-mode deauth interface from monitor back to + # managed mode (it was switched to monitor in + # _configure_deauth_interface). Best-effort — never abort cleanup. + if self._deauth_monitor_enabled and self.interface_deauth \ + and self.interface_deauth != self.interface_ap: + try: + from ..tools.airmon import Airmon + log_debug('EvilTwin', + f'Restoring deauth interface {self.interface_deauth} to managed mode') + Airmon.set_interface_mode(self.interface_deauth, 'managed') + except Exception as e: + log_warning('EvilTwin', + f'Failed to restore deauth interface {self.interface_deauth} ' + f'to managed mode: {e}') + finally: + self._deauth_monitor_enabled = False + # Register temporary files for removal for temp_file in self.temp_files: self.cleanup_manager.register_temp_file(temp_file) @@ -1573,7 +1754,7 @@ def create_result(self, password: str, validation_time: float = 0.0) -> CrackRes clients_connected=len(self.clients_connected), credential_attempts=len(self.credential_attempts), validation_time=validation_time, - portal_template=getattr(Configuration, 'evil_twin_portal_template', 'generic') + portal_template=getattr(Configuration, 'eviltwin_template', 'generic') ) log_info('EvilTwin', f'Created result for {self.target.essid}: {mask_sensitive(password)}') @@ -1865,8 +2046,8 @@ def save_state_to_session(self) -> 'EvilTwinAttackState': state = EvilTwinAttackState( interface_ap=self.interface_ap, interface_deauth=self.interface_deauth, - portal_template=getattr(Configuration, 'evil_twin_portal_template', 'generic'), - deauth_interval=getattr(Configuration, 'evil_twin_deauth_interval', 5), + portal_template=getattr(Configuration, 'eviltwin_template', 'generic'), + deauth_interval=getattr(Configuration, 'eviltwin_deauth_interval', 5), attack_phase=self.state.value if hasattr(self.state, 'value') else str(self.state), start_time=self.start_time, setup_time=self.setup_time, diff --git a/wifite/attack/portal/credential_handler.py b/wifite/attack/portal/credential_handler.py index 0d1b2f3a2..7b69b9cc9 100755 --- a/wifite/attack/portal/credential_handler.py +++ b/wifite/attack/portal/credential_handler.py @@ -173,6 +173,38 @@ def submit_credentials(self, ssid: str, password: str, client_ip: str) -> Tuple[ log_error('CredentialHandler', f'Error handling submission: {e}', e) return False, 'An error occurred. Please try again.' + def check_and_record(self, ssid: str, password: str, client_ip: str) -> Tuple[bool, str]: + """ + Gate a submission inline: validate input format and enforce per-client + rate limiting, recording the attempt. + + Unlike submit_credentials(), this does NOT enqueue the submission for + async validation — it is meant to be called synchronously from the + portal POST path as a lightweight admission check before the real + credential callback runs. + + Args: + ssid: Network SSID + password: Network password + client_ip: IP address of submitting client + + Returns: + Tuple of (accepted, message). When accepted is False, message + explains why (bad input or rate limited). + """ + validation_error = self._validate_input(ssid, password) + if validation_error: + log_warning('CredentialHandler', f'Invalid input from {client_ip}: {validation_error}') + return False, validation_error + + if not self._check_rate_limit(client_ip): + log_warning('CredentialHandler', f'Rate limit exceeded for {client_ip}') + return False, 'Too many attempts. Please wait before trying again.' + + self.total_submissions += 1 + self._update_client_attempts(client_ip) + return True, 'OK' + def _validate_input(self, ssid: str, password: str) -> Optional[str]: """ Validate input format. diff --git a/wifite/attack/portal/server.py b/wifite/attack/portal/server.py index 191b2b6a4..466dd8317 100755 --- a/wifite/attack/portal/server.py +++ b/wifite/attack/portal/server.py @@ -10,13 +10,14 @@ import os import threading import time -from http.server import HTTPServer, BaseHTTPRequestHandler +from http.server import HTTPServer, ThreadingHTTPServer, BaseHTTPRequestHandler from urllib.parse import parse_qs, urlparse from typing import Optional, Callable, Dict, Any import socket from ...util.color import Color from ...util.logger import log_info, log_error, log_warning, log_debug +from .credential_handler import CredentialHandler class PortalRequestHandler(BaseHTTPRequestHandler): @@ -27,6 +28,14 @@ class PortalRequestHandler(BaseHTTPRequestHandler): credential_callback = None request_log_callback = None server_instance = None + credential_handler = None + + # Per-connection socket timeout (seconds). Without this a client that opens + # a connection and never finishes its request would hold its handler thread + # indefinitely; with ThreadingHTTPServer that's one thread per stalled + # client, and the timeout reaps them. BaseHTTPRequestHandler honors this in + # handle_one_request(). + timeout = 30 def log_message(self, format, *args): """Override to use our logging system.""" @@ -86,9 +95,18 @@ def do_POST(self): self._send_error_response(404, 'Not Found') return - # Read POST data with size limit to prevent memory exhaustion + # Read POST data with bounds checking to prevent memory exhaustion. MAX_POST_SIZE = 8192 # 8KB — plenty for form credentials - content_length = int(self.headers.get('Content-Length', 0)) + try: + content_length = int(self.headers.get('Content-Length', 0)) + except (TypeError, ValueError): + content_length = -1 + # A missing/non-numeric or negative Content-Length is malformed; a + # negative value would otherwise make rfile.read(-1) drain until EOF. + if content_length < 0: + log_warning('Portal', f'Rejected POST with invalid Content-Length from {client_ip}') + self._send_error_response(400, 'Bad Request') + return if content_length > MAX_POST_SIZE: log_warning('Portal', f'Rejected oversized POST ({content_length} bytes) from {client_ip}') self._send_error_response(413, 'Request Entity Too Large') @@ -103,7 +121,17 @@ def do_POST(self): password = form_data.get('password', [''])[0] log_info('Portal', f'Credential submission from {client_ip}: SSID={ssid}') - + + # Admission gate: server-side input validation + per-client rate + # limiting before the (potentially expensive) credential callback. + if self.credential_handler is not None: + accepted, message = self.credential_handler.check_and_record( + ssid, password, client_ip) + if not accepted: + log_warning('Portal', f'Submission rejected from {client_ip}: {message}') + self._redirect_to_error() + return + # Validate credentials via callback if self.credential_callback: try: @@ -386,7 +414,8 @@ class PortalServer: Optimized for fast response times and low memory usage. """ - def __init__(self, host=None, port=80, template_renderer=None): + def __init__(self, host=None, port=80, template_renderer=None, + credential_handler=None): """ Initialize portal server. @@ -396,6 +425,9 @@ def __init__(self, host=None, port=80, template_renderer=None): when binding on all interfaces is intentionally required. port: Port to listen on (default: 80) template_renderer: Optional template renderer for custom pages + credential_handler: Optional CredentialHandler providing server-side + input validation and per-client rate limiting. Defaults to a + fresh handler so these protections are always active. """ # Default to the standard evil-twin AP IP rather than all interfaces. # This prevents the portal from being reachable on unrelated interfaces @@ -405,6 +437,8 @@ def __init__(self, host=None, port=80, template_renderer=None): self.template_renderer = template_renderer self.credential_callback = None self.request_log_callback = None + # Always-on admission control (input validation + rate limiting). + self.credential_handler = credential_handler or CredentialHandler() self.server = None self.server_thread = None @@ -454,12 +488,23 @@ def start(self) -> bool: # Set class variables for request handler PortalRequestHandler.template_renderer = self.template_renderer - PortalRequestHandler.credential_callback = self.credential_callback - PortalRequestHandler.request_log_callback = self.request_log_callback + # Wrap function callbacks in staticmethod: assigning a plain function + # to a handler CLASS attribute would otherwise turn it into a bound + # method (injecting the handler instance as an extra first arg). + # Bound methods and None pass through unchanged. + PortalRequestHandler.credential_callback = ( + staticmethod(self.credential_callback) if self.credential_callback else None) + PortalRequestHandler.request_log_callback = ( + staticmethod(self.request_log_callback) if self.request_log_callback else None) PortalRequestHandler.server_instance = self + PortalRequestHandler.credential_handler = self.credential_handler # Create HTTP server - self.server = HTTPServer((self.host, self.port), PortalRequestHandler) + # ThreadingHTTPServer handles each client in its own thread, so one + # slow/stalled client can't block the captive portal for everyone + # else. daemon_threads=True (set by ThreadingHTTPServer) lets the + # process exit without waiting on in-flight request threads. + self.server = ThreadingHTTPServer((self.host, self.port), PortalRequestHandler) # Start server in background thread self.server_thread = threading.Thread(target=self._run_server, daemon=True) @@ -800,10 +845,26 @@ def get_cached_static(self, filename: str) -> Optional[tuple]: """ return self._static_cache.get(filename) + def __enter__(self): + """Context-manager entry: start the server.""" + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context-manager exit: stop the server.""" + self.stop() + return False + def __del__(self): - """Cleanup on deletion.""" + """Release in-memory caches on GC. + + Deliberately does NOT perform network/thread teardown here — calling + server.shutdown()/join() at GC time (especially during interpreter + shutdown) is fragile and can hang or raise. Lifecycle is managed + explicitly via stop() or the context manager; the serve_forever thread + is a daemon, so it never blocks process exit. + """ try: - self.stop() self._template_cache.clear() self._static_cache.clear() except Exception: diff --git a/wifite/attack/portal/templates.py b/wifite/attack/portal/templates.py index 236322d15..17a58ab5e 100755 --- a/wifite/attack/portal/templates.py +++ b/wifite/attack/portal/templates.py @@ -7,6 +7,7 @@ Provides template rendering with support for multiple portal styles. """ +import html import os from typing import Dict, Any, Optional @@ -136,12 +137,15 @@ def _substitute_variables(self, template: str) -> str: # Add custom variables variables.update(self.custom_vars) - # Substitute variables + # Substitute variables. Values (notably the target SSID, which comes + # from the air and is attacker-influenceable) are HTML-escaped to + # prevent attribute/markup injection into the served portal pages and + # to render correctly for SSIDs containing & < > " '. result = template for key, value in variables.items(): placeholder = '{{' + key + '}}' - result = result.replace(placeholder, str(value)) - + result = result.replace(placeholder, html.escape(str(value), quote=True)) + return result def _get_builtin_login_template(self) -> str: diff --git a/wifite/config/__init__.py b/wifite/config/__init__.py index b43a7ac49..461d7b9ed 100644 --- a/wifite/config/__init__.py +++ b/wifite/config/__init__.py @@ -74,6 +74,7 @@ class Configuration: # Evil Twin settings eviltwin_port = None eviltwin_deauth_interval = None + eviltwin_timeout = None eviltwin_template = None eviltwin_channel = None eviltwin_validate_credentials = None diff --git a/wifite/config/defaults.py b/wifite/config/defaults.py index a1fac73d8..210f97fb2 100644 --- a/wifite/config/defaults.py +++ b/wifite/config/defaults.py @@ -52,6 +52,7 @@ def initialize_defaults(cls): cls.use_eviltwin = False cls.eviltwin_port = 80 cls.eviltwin_deauth_interval = 5 + cls.eviltwin_timeout = 0 # Seconds before giving up (0 = run until success/interrupt) cls.eviltwin_template = 'generic' cls.eviltwin_channel = None cls.eviltwin_validate_credentials = True diff --git a/wifite/config/parsers/eviltwin.py b/wifite/config/parsers/eviltwin.py index a297db277..098846daa 100644 --- a/wifite/config/parsers/eviltwin.py +++ b/wifite/config/parsers/eviltwin.py @@ -54,6 +54,10 @@ def parse_eviltwin_args(cls, args): cls.eviltwin_deauth_interval = args.eviltwin_deauth_interval Color.pl('{+} {C}option:{W} Evil Twin deauth interval: {G}%d seconds{W}' % args.eviltwin_deauth_interval) + if hasattr(args, 'eviltwin_timeout') and args.eviltwin_timeout is not None: + cls.eviltwin_timeout = args.eviltwin_timeout + Color.pl('{+} {C}option:{W} Evil Twin timeout: {G}%d seconds{W}' % args.eviltwin_timeout) + if hasattr(args, 'eviltwin_template') and args.eviltwin_template: cls.eviltwin_template = args.eviltwin_template Color.pl('{+} {C}option:{W} Evil Twin portal template: {G}%s{W}' % args.eviltwin_template) diff --git a/wifite/tools/airmon.py b/wifite/tools/airmon.py index 717d78947..fd57cee28 100644 --- a/wifite/tools/airmon.py +++ b/wifite/tools/airmon.py @@ -454,6 +454,54 @@ def put_interface_up(interface): Ip.up(interface) Color.pl('{+}{W} Done !') + @staticmethod + def put_interface_down(interface): + """Bring an interface down (e.g. before changing mode/channel).""" + Color.p('{!}{W} Putting interface {R}%s{W} {O}down{W}...\n' % interface) + Ip.down(interface) + Color.pl('{+}{W} Done !') + + @staticmethod + def set_interface_mode(interface, mode_name): + """Set an interface's 802.11 mode (e.g. 'managed', 'monitor'). + + The interface must be down to change type, so we toggle it down→set→up. + """ + log_debug('Airmon', f'Setting {interface} to {mode_name} mode') + Ip.down(interface) + Iw.mode(interface, mode_name) + Ip.up(interface) + return True + + @staticmethod + def get_interface_mode(interface): + """Return the current 802.11 mode of an interface, or None. + + Parses `iw dev info` (e.g. 'type managed' / 'type monitor'). + """ + out, _err = Process.call(f'iw dev {interface} info') + if match := re.search(r'^\s*type\s+(\w+)', out or '', re.MULTILINE): + return match.group(1) + return None + + @staticmethod + def set_interface_channel(interface, channel): + """Tune an interface to a channel via `iw dev set channel`.""" + log_debug('Airmon', f'Setting {interface} to channel {channel}') + Process.call(f'iw dev {interface} set channel {int(channel)}') + return True + + @staticmethod + def get_interface_channel(interface): + """Return the current channel of an interface as int, or None. + + Parses `iw dev info` (e.g. 'channel 6 (2437 MHz)'). + """ + out, _err = Process.call(f'iw dev {interface} info') + if match := re.search(r'^\s*channel\s+(\d+)', out or '', re.MULTILINE): + return int(match.group(1)) + return None + @staticmethod def start_network_manager(): Color.p('{!} {O}start {R}NetworkManager{O}...') diff --git a/wifite/tools/dnsmasq.py b/wifite/tools/dnsmasq.py index cbfe15145..029b46303 100755 --- a/wifite/tools/dnsmasq.py +++ b/wifite/tools/dnsmasq.py @@ -67,7 +67,11 @@ def generate_config(self) -> str: # Interface to listen on config.append(f'interface={self.interface}') - + # Never bind loopback. Without this dnsmasq still binds 127.0.0.1:53 by + # default, which collides with a host resolver (systemd-resolved / + # another dnsmasq) and makes startup fail with "Address already in use". + config.append('except-interface=lo') + # Don't read /etc/resolv.conf or /etc/hosts config.append('no-resolv') config.append('no-hosts') @@ -197,12 +201,14 @@ def start(self) -> bool: # Check if process is running if self.process.poll() is not None: - # Process died — clean up temp files - output = self.process.stdout() - log_error('Dnsmasq', f'Failed to start: {output}') + # Process died — dnsmasq reports startup errors on STDERR + # (e.g. "failed to create listening socket ... Address already + # in use"), so surface both streams to make failures diagnosable. + out, err = self.process.get_output() + detail = (err or '').strip() or (out or '').strip() or '(no output)' + log_error('Dnsmasq', f'Failed to start: {detail}') Color.pl('{!} {R}Dnsmasq failed to start{W}') - if Configuration.verbose > 0: - Color.pl('{!} {O}Output:{W}\n%s' % output) + Color.pl('{!} {O}%s{W}' % detail) self._remove_temp_files() return False diff --git a/wifite/tools/hostapd.py b/wifite/tools/hostapd.py index 6bced3f6e..077844bc1 100755 --- a/wifite/tools/hostapd.py +++ b/wifite/tools/hostapd.py @@ -42,7 +42,10 @@ def __init__(self, interface, ssid, channel, password=None): self.interface = interface self.ssid = ssid self.channel = channel - self.password = password or "temporarypassword123" + # None/empty password => OPEN network. The Evil Twin captive portal + # requires an open AP so victims can associate without a PSK; only + # create a WPA2-PSK AP when a real passphrase is supplied. + self.password = password or None self.config_file = None self.process = None @@ -122,8 +125,12 @@ def generate_config(self) -> str: # Basic settings config.append(f'interface={self.interface}') config.append('driver=nl80211') - config.append(f'ssid={self.ssid}') - config.append(f'channel={self.channel}') + # SSID comes from the air (target beacon) and is attacker-influenceable. + # 802.11 SSIDs are arbitrary bytes; a raw `ssid=` line with an + # embedded newline would inject attacker-controlled hostapd directives + # (which run as root). _format_ssid_directive() prevents that. + config.append(self._format_ssid_directive()) + config.append(f'channel={int(self.channel)}') # Hardware mode (g = 2.4GHz) config.append('hw_mode=g') @@ -132,14 +139,17 @@ def generate_config(self) -> str: config.append('ieee80211n=1') config.append('wmm_enabled=1') - # Authentication + # Authentication (Open System) config.append('auth_algs=1') - # WPA2 settings - config.append('wpa=2') - config.append('wpa_key_mgmt=WPA-PSK') - config.append('rsn_pairwise=CCMP') - config.append(f'wpa_passphrase={self.password}') + if self.password: + # WPA2-PSK protected AP (only when an explicit passphrase is given) + config.append('wpa=2') + config.append('wpa_key_mgmt=WPA-PSK') + config.append('rsn_pairwise=CCMP') + config.append(f'wpa_passphrase={self.password}') + # else: OPEN network — emit no wpa/passphrase lines so clients can + # associate freely and reach the captive portal. # Logging config.append('logger_syslog=-1') @@ -153,6 +163,28 @@ def generate_config(self) -> str: return '\n'.join(config) + '\n' + def _format_ssid_directive(self) -> str: + """Build a safe hostapd SSID directive for an untrusted SSID. + + 802.11 SSIDs are arbitrary 0-32 byte values, so a raw ``ssid=`` + line can break (or be injected into) the line-based hostapd config — + e.g. an SSID containing a newline could append attacker-controlled + directives that run as root. + + Strategy: + * Plain printable-ASCII SSIDs use the readable ``ssid=`` form. + * Anything containing control characters (newline/CR/etc.) or + non-ASCII bytes is emitted as ``ssid2=``, which hostapd parses + as raw bytes regardless of content — injection-proof. + The value is also clamped to the 32-byte 802.11 limit. + """ + ssid_bytes = (self.ssid or '').encode('utf-8', errors='surrogateescape')[:32] + # Printable ASCII only (0x20-0x7e) is safe in a raw ssid= line; this + # excludes newline (0x0a) and carriage return (0x0d) by construction. + if ssid_bytes and all(0x20 <= b <= 0x7e for b in ssid_bytes): + return 'ssid=' + ssid_bytes.decode('ascii') + return 'ssid2=' + ssid_bytes.hex() + def create_config_file(self) -> str: """ Create temporary hostapd configuration file.