Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions tests/test_sae_handshake.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,20 +118,36 @@ def test_validate_with_hcxpcapngtool_not_installed(self, mock_process_class):
@patch('wifite.model.sae_handshake.Tshark')
@patch('wifite.model.sae_handshake.Process')
def test_validate_with_tshark_success(self, mock_process_class, mock_tshark):
"""Test validation with tshark when handshake is valid."""
"""A complete handshake has both a Commit (seq 1) and Confirm (seq 2)."""
# Mock Tshark.exists to return True
mock_tshark.exists.return_value = True
# Mock process execution with valid output

# Mock process output: auth-sequence numbers (1=Commit, 2=Confirm).
mock_proc = Mock()
mock_proc.stdout.return_value = 'frame1\nframe2\n'
mock_proc.stdout.return_value = '1\n1\n2\n2\n'
mock_process_class.return_value = mock_proc

hs = SAEHandshake(self.capfile, self.bssid, self.essid)
result = hs._validate_with_tshark()

self.assertTrue(result)

@patch('wifite.model.sae_handshake.Tshark')
@patch('wifite.model.sae_handshake.Process')
def test_validate_with_tshark_commits_only(self, mock_process_class, mock_tshark):
"""Two Commits with no Confirm must NOT count as a complete handshake."""
mock_tshark.exists.return_value = True

# Only Commit (seq 1) frames — no Confirm (seq 2).
mock_proc = Mock()
mock_proc.stdout.return_value = '1\n1\n'
mock_process_class.return_value = mock_proc

hs = SAEHandshake(self.capfile, self.bssid, self.essid)
result = hs._validate_with_tshark()

self.assertFalse(result)

@patch('wifite.model.sae_handshake.Tshark')
@patch('wifite.model.sae_handshake.Process')
def test_validate_with_tshark_failure(self, mock_process_class, mock_tshark):
Expand Down
22 changes: 21 additions & 1 deletion wifite/attack/all.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,23 @@ def attack_single(cls, target, targets_remaining, session=None, session_mgr=None
Color.pl('{+} {D}Session updated: target {C}%s{D} marked as {R}failed{W}' % target.bssid)
return True

# --wpa3-only: restrict attacks to WPA3-SAE networks. Unlike --wpa3
# (a discovery filter), this is enforced at attack time, so a non-WPA3
# target that still reaches this point (e.g. selected by BSSID, or when
# no discovery filter was set) is skipped — WPA2-only / WEP / OWE
# targets are never attacked under this mode.
if Configuration.wpa3_only:
is_wpa3_target = target.primary_encryption == 'WPA3' or (
hasattr(target, 'wpa3_info') and target.wpa3_info and
getattr(target.wpa3_info, 'has_wpa3', False))
if not is_wpa3_target:
Comment on lines +104 to +108
Color.pl('{!} {O}Skipping {C}%s{O}: {C}--wpa3-only{O} is set and '
'target is not WPA3-SAE{W}' % (target.essid or target.bssid))
if session and session_mgr:
session_mgr.mark_target_failed(session, target.bssid, "Not WPA3 (--wpa3-only)")
session_mgr.save_session(session)
return True

attacks = []

if Configuration.use_eviltwin:
Expand Down Expand Up @@ -131,7 +148,10 @@ def attack_single(cls, target, targets_remaining, session=None, session_mgr=None
attacks.append(AttackWPA3SAE(target))

# For transition mode, also try standard WPA2 attacks as fallback
if hasattr(target, 'wpa3_info') and target.wpa3_info and target.wpa3_info.get('is_transition'):
# (unless --force-sae asked us to skip WPA2 and attack SAE directly).
if not Configuration.wpa3_force_sae and \
hasattr(target, 'wpa3_info') and target.wpa3_info and \
target.wpa3_info.get('is_transition'):
if not Configuration.wps_only and not Configuration.use_pmkid_only:
# Add PMKID and WPA attacks as fallback for transition mode
if not Configuration.dont_use_pmkid:
Expand Down
65 changes: 51 additions & 14 deletions wifite/attack/wpa3.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,6 @@ def _try_sae_capture(self):
return False # Let it fall through to passive
handshake = self.capture_sae_handshake()
if handshake:
Color.pl('{+} {G}SAE handshake captured successfully{W}')
self._finalize_sae_success(handshake, key=None)
return True
Color.pl('{!} {O}SAE handshake capture failed{W}')
Expand Down Expand Up @@ -373,21 +372,49 @@ def _try_passive(self):
self.view.add_log('Starting passive capture - final fallback')
handshake = self.passive_capture()
if handshake:
Color.pl('{+} {G}SAE handshake captured passively{W}')
self._finalize_sae_success(handshake, key=None)
return True
return False

def _finalize_sae_success(self, handshake, key):
"""Wrap a captured SAE handshake in CrackResultSAE and set success.
"""Record a captured SAE handshake as a CrackResultSAE.

`handshake` is an SAEHandshake object (has .capfile / .bssid / .essid).
`key` is the cracked PSK if known, else None.
`key` is the recovered PSK if known, else None.

When `key` is None we still save the capture (for records / later
analysis) but we must NOT present it as a crackable win — see
_warn_sae_not_offline_crackable for why.
"""
self.crack_result = CrackResultSAE(
handshake.bssid, handshake.essid, handshake.capfile, key)
self.crack_result.dump()
self.success = True
if key is None:
self._warn_sae_not_offline_crackable()

def _warn_sae_not_offline_crackable(self):
"""Honest caveat: a captured SAE handshake is NOT offline-crackable.

Unlike a WPA2 4-way handshake, WPA3-SAE (Dragonfly) is a PAKE
designed to resist offline dictionary attacks. Capturing the SAE
exchange does not let hashcat/aircrack recover the password the way
a WPA2 capture does — there is no hashcat mode that cracks a captured
SAE handshake. We keep the capture, but we tell the user the truth
instead of implying the network was owned.
"""
Color.pl('{!} {O}SAE handshake captured and saved — but note:{W}')
Color.pl('{!} {O}WPA3-SAE (Dragonfly) resists offline dictionary attacks.{W}')
Color.pl('{!} {O}A captured SAE handshake {R}cannot be cracked offline{O} '
'like a WPA2 handshake.{W}')
Color.pl('{!} {O}Offline password recovery is only feasible via:{W}')
Color.pl(' {C}-{W} Transition-mode {C}downgrade{W} to WPA2 '
'(captures a crackable 4-way handshake)')
Color.pl(' {C}-{W} A successful {C}Dragonblood timing{W} partition '
'(only MODP groups 22/23/24)')
if self.view:
self.view.add_log('SAE handshake captured (NOT offline-crackable — '
'WPA3-SAE resists dictionary attacks)')

def _active_probe_for_vulnerable_groups(self):
"""
Expand Down Expand Up @@ -791,9 +818,17 @@ def attempt_downgrade(self):
return None

self.clients = []

# Set timeout for downgrade attempt (30 seconds as per requirements)
downgrade_timeout = Timer(30)

# Set timeout for the downgrade attempt. Honour --wpa3-timeout when
# the user set it; otherwise keep a short 30s window — the downgrade
# is a fast first attempt that falls back to SAE capture, so it
# shouldn't occupy the full wpa_attack_timeout (300s) by default.
timeout_value = Configuration.wpa3_attack_timeout or 30
downgrade_timeout = Timer(timeout_value)
Comment on lines +822 to +827
# Warn once if no clients appear partway through (the downgrade
# needs active clients). Half the window, floored so short timeouts
# still produce a warning.
no_clients_warn_after = max(5, timeout_value // 2)
deauth_timer = Timer(Configuration.wpa_deauth_timeout)
sae_detected = False
deauth_attempts = 0
Expand All @@ -811,7 +846,7 @@ def attempt_downgrade(self):

# Calculate progress percentage
elapsed = downgrade_timeout.running_time()
total_time = 30 # 30 second timeout
total_time = timeout_value
progress_pct = min(100, int((elapsed / total_time) * 100))

# Update TUI view if available
Expand Down Expand Up @@ -848,9 +883,11 @@ def attempt_downgrade(self):
Color.pattack('WPA3', self.target, 'Downgrade',
'Waiting for clients... (%s)' % downgrade_timeout)

# Show warning after some time with no clients
if not no_clients_warning_shown and downgrade_timeout.running_time() > 30:
Color.pl('\n{!} {O}No clients detected after 30 seconds{W}')
# Show warning once we're partway through with no clients
if not no_clients_warning_shown and \
downgrade_timeout.running_time() >= no_clients_warn_after:
Color.pl('\n{!} {O}No clients detected after %d seconds{W}'
% no_clients_warn_after)
Color.pl('{!} {O}Downgrade requires active clients to succeed{W}')
if self.view:
self.view.add_log('Warning: No clients detected')
Expand Down Expand Up @@ -899,7 +936,7 @@ def attempt_downgrade(self):
# After deauth, clients should reconnect with WPA2
# Check hcxdumptool capture (pcapng format)
try:
if hcxdump and hcxdump.has_captured_data():
if hcxdump and hcxdump.has_new_data():
handshake = Handshake(hcxdump.get_output_file(),
bssid=self.target.bssid,
essid=self.target.essid)
Expand Down Expand Up @@ -1082,7 +1119,7 @@ def capture_sae_handshake(self):

# Check if we've captured data
try:
if hcxdump.has_captured_data():
if hcxdump.has_new_data():
# Create SAEHandshake object
sae_hs = SAEHandshake(
hcxdump.get_output_file(),
Expand Down Expand Up @@ -1253,7 +1290,7 @@ def passive_capture(self):

# Check if we've captured data
try:
if hcxdump.has_captured_data():
if hcxdump.has_new_data():
# Create SAEHandshake object
sae_hs = SAEHandshake(
hcxdump.get_output_file(),
Expand Down
8 changes: 8 additions & 0 deletions wifite/attack/wpa3_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,14 @@ def can_use_downgrade(wpa3_info: Dict[str, Any]) -> bool:
Returns:
True if downgrade attack is possible, False otherwise
"""
from wifite.config import Configuration
# User overrides: --no-downgrade disables the downgrade path outright,
# and --force-sae (attack SAE directly, skip WPA2) also implies no
# downgrade since a downgrade fundamentally captures a WPA2 handshake.
if getattr(Configuration, 'wpa3_no_downgrade', False) or \
getattr(Configuration, 'wpa3_force_sae', False):
return False

# Downgrade requires transition mode (both WPA2 and WPA3 support)
return wpa3_info.get('is_transition', False)

Expand Down
44 changes: 28 additions & 16 deletions wifite/model/sae_handshake.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,20 +111,22 @@ def _validate_with_hcxpcapngtool(self) -> bool:

def _validate_with_tshark(self) -> bool:
"""
Validate SAE handshake using tshark with optimized filtering.
Validate SAE handshake using tshark.

Optimizations:
- Uses efficient BPF-style filters to reduce processing
- Streams output to avoid loading entire capture into memory
- Early termination once minimum frames are found
A complete SAE handshake must contain BOTH:
- an SAE Commit (authentication transaction sequence 1), and
- an SAE Confirm (authentication transaction sequence 2).

Merely observing two SAE authentication frames is not sufficient —
two retransmitted Commits (or a Commit from each side with no
Confirm) would otherwise be misreported as complete. We therefore
inspect the auth-sequence numbers and require both 1 and 2 to appear.

Returns:
True if tshark finds SAE commit and confirm frames
True if both an SAE Commit and an SAE Confirm are observed.
"""
try:
# Check for SAE authentication frames
# SAE uses authentication frame type (0x0b) with auth algorithm 3
# Build efficient filter string
# SAE uses authentication frame type (0x0b) with auth algorithm 3.
filter_str = 'wlan.fc.type_subtype == 0x0b && wlan.fixed.auth.alg == 3'
if self.bssid:
# Add BSSID filter for efficiency
Expand All @@ -135,18 +137,28 @@ def _validate_with_tshark(self) -> bool:
'-r', self.capfile,
'-Y', filter_str,
'-T', 'fields',
'-e', 'wlan.fixed.auth.sae.group',
'-e', 'frame.number',
'-c', '2' # Stop after finding 2 frames (optimization)
'-e', 'wlan.fixed.auth.seq',
# Bound the work — SAE auth frames are few, and we early-exit
# as soon as both a Commit and a Confirm have been seen.
'-c', '50',
]

proc = Process(command, devnull=False)
output = proc.stdout()

# Count frames - need at least 2 (commit and confirm)
# Use generator expression for memory efficiency
frame_count = sum(1 for line in output.split('\n') if line.strip())
return frame_count >= 2
seen_seqs = set()
for line in output.split('\n'):
seq = line.strip()
if not seq:
continue
# Be defensive about extra fields/separators per line.
seq = seq.split('\t')[0].split(',')[0].strip()
if seq in ('1', '2'):
seen_seqs.add(seq)
if '1' in seen_seqs and '2' in seen_seqs:
return True

return False

except Exception:
return False
Expand Down
49 changes: 47 additions & 2 deletions wifite/tools/hcxdumptool.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ def __init__(self, interface=None, channel=None, target_bssid=None,
self.pid = None
self.proc = None

# Baseline size of the empty capture (pcapng Section Header / Interface
# Description blocks are written the instant capture starts). Recorded
# in __enter__ so has_captured_data()/has_new_data() can tell a
# header-only file apart from one that actually contains frames.
self._baseline_size = None
# High-water mark of file size seen by has_new_data().
self._last_data_size = 0

def __enter__(self):
"""
Start hcxdumptool capture process.
Expand Down Expand Up @@ -131,6 +139,15 @@ def __enter__(self):
# Give it a moment to start
time.sleep(1)

# Record the header-only size now that the file exists, so subsequent
# data checks measure growth beyond the pcapng header rather than
# treating the always-present header as "captured data".
try:
self._baseline_size = os.path.getsize(self.output_file) \
if os.path.exists(self.output_file) else 0
except OSError:
self._baseline_size = 0
Comment on lines +142 to +149

return self

def __exit__(self, exc_type, exc_val, exc_tb):
Expand Down Expand Up @@ -163,8 +180,36 @@ def get_output_file(self) -> str:
return self.output_file

def has_captured_data(self) -> bool:
"""Check if any data has been captured."""
return os.path.exists(self.output_file) and os.path.getsize(self.output_file) > 0
"""Return True only when actual packet data exists beyond the header.

A fresh pcapng file is non-empty the instant capture starts (the
Section Header / Interface Description blocks are written immediately),
so a bare ``getsize() > 0`` check is always true and meaningless. We
compare against the header-only baseline recorded at start instead.
"""
try:
current = os.path.getsize(self.output_file)
except OSError:
return False
baseline = self._baseline_size if self._baseline_size is not None else 0
return current > baseline

def has_new_data(self) -> bool:
"""Return True only when new packet data was written since the last call.

Lets polling capture loops skip the expensive handshake validation
(which spawns tshark / hcxpcapngtool / aircrack) when the capture file
hasn't grown — i.e. there are no new frames worth re-evaluating.
"""
try:
current = os.path.getsize(self.output_file)
except OSError:
return False
baseline = self._baseline_size if self._baseline_size is not None else 0
if current > baseline and current > self._last_data_size:
self._last_data_size = current
return True
return False

@staticmethod
def exists() -> bool:
Expand Down
Loading
Loading