Skip to content

Commit a0f0690

Browse files
authored
Merge pull request #526 from kimocoder/wpa3-implementation-fixes
Fix WPA3/SAE attack correctness, honesty, and dead flags
2 parents 8e22303 + 31ffbd4 commit a0f0690

8 files changed

Lines changed: 245 additions & 60 deletions

File tree

tests/test_sae_handshake.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -118,20 +118,36 @@ def test_validate_with_hcxpcapngtool_not_installed(self, mock_process_class):
118118
@patch('wifite.model.sae_handshake.Tshark')
119119
@patch('wifite.model.sae_handshake.Process')
120120
def test_validate_with_tshark_success(self, mock_process_class, mock_tshark):
121-
"""Test validation with tshark when handshake is valid."""
121+
"""A complete handshake has both a Commit (seq 1) and Confirm (seq 2)."""
122122
# Mock Tshark.exists to return True
123123
mock_tshark.exists.return_value = True
124-
125-
# Mock process execution with valid output
124+
125+
# Mock process output: auth-sequence numbers (1=Commit, 2=Confirm).
126126
mock_proc = Mock()
127-
mock_proc.stdout.return_value = 'frame1\nframe2\n'
127+
mock_proc.stdout.return_value = '1\n1\n2\n2\n'
128128
mock_process_class.return_value = mock_proc
129-
129+
130130
hs = SAEHandshake(self.capfile, self.bssid, self.essid)
131131
result = hs._validate_with_tshark()
132-
132+
133133
self.assertTrue(result)
134134

135+
@patch('wifite.model.sae_handshake.Tshark')
136+
@patch('wifite.model.sae_handshake.Process')
137+
def test_validate_with_tshark_commits_only(self, mock_process_class, mock_tshark):
138+
"""Two Commits with no Confirm must NOT count as a complete handshake."""
139+
mock_tshark.exists.return_value = True
140+
141+
# Only Commit (seq 1) frames — no Confirm (seq 2).
142+
mock_proc = Mock()
143+
mock_proc.stdout.return_value = '1\n1\n'
144+
mock_process_class.return_value = mock_proc
145+
146+
hs = SAEHandshake(self.capfile, self.bssid, self.essid)
147+
result = hs._validate_with_tshark()
148+
149+
self.assertFalse(result)
150+
135151
@patch('wifite.model.sae_handshake.Tshark')
136152
@patch('wifite.model.sae_handshake.Process')
137153
def test_validate_with_tshark_failure(self, mock_process_class, mock_tshark):

wifite/attack/all.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,23 @@ def attack_single(cls, target, targets_remaining, session=None, session_mgr=None
9696
Color.pl('{+} {D}Session updated: target {C}%s{D} marked as {R}failed{W}' % target.bssid)
9797
return True
9898

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

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

133150
# For transition mode, also try standard WPA2 attacks as fallback
134-
if hasattr(target, 'wpa3_info') and target.wpa3_info and target.wpa3_info.get('is_transition'):
151+
# (unless --force-sae asked us to skip WPA2 and attack SAE directly).
152+
if not Configuration.wpa3_force_sae and \
153+
hasattr(target, 'wpa3_info') and target.wpa3_info and \
154+
target.wpa3_info.get('is_transition'):
135155
if not Configuration.wps_only and not Configuration.use_pmkid_only:
136156
# Add PMKID and WPA attacks as fallback for transition mode
137157
if not Configuration.dont_use_pmkid:

wifite/attack/wpa3.py

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,6 @@ def _try_sae_capture(self):
341341
return False # Let it fall through to passive
342342
handshake = self.capture_sae_handshake()
343343
if handshake:
344-
Color.pl('{+} {G}SAE handshake captured successfully{W}')
345344
self._finalize_sae_success(handshake, key=None)
346345
return True
347346
Color.pl('{!} {O}SAE handshake capture failed{W}')
@@ -373,21 +372,49 @@ def _try_passive(self):
373372
self.view.add_log('Starting passive capture - final fallback')
374373
handshake = self.passive_capture()
375374
if handshake:
376-
Color.pl('{+} {G}SAE handshake captured passively{W}')
377375
self._finalize_sae_success(handshake, key=None)
378376
return True
379377
return False
380378

381379
def _finalize_sae_success(self, handshake, key):
382-
"""Wrap a captured SAE handshake in CrackResultSAE and set success.
380+
"""Record a captured SAE handshake as a CrackResultSAE.
383381
384382
`handshake` is an SAEHandshake object (has .capfile / .bssid / .essid).
385-
`key` is the cracked PSK if known, else None.
383+
`key` is the recovered PSK if known, else None.
384+
385+
When `key` is None we still save the capture (for records / later
386+
analysis) but we must NOT present it as a crackable win — see
387+
_warn_sae_not_offline_crackable for why.
386388
"""
387389
self.crack_result = CrackResultSAE(
388390
handshake.bssid, handshake.essid, handshake.capfile, key)
389391
self.crack_result.dump()
390392
self.success = True
393+
if key is None:
394+
self._warn_sae_not_offline_crackable()
395+
396+
def _warn_sae_not_offline_crackable(self):
397+
"""Honest caveat: a captured SAE handshake is NOT offline-crackable.
398+
399+
Unlike a WPA2 4-way handshake, WPA3-SAE (Dragonfly) is a PAKE
400+
designed to resist offline dictionary attacks. Capturing the SAE
401+
exchange does not let hashcat/aircrack recover the password the way
402+
a WPA2 capture does — there is no hashcat mode that cracks a captured
403+
SAE handshake. We keep the capture, but we tell the user the truth
404+
instead of implying the network was owned.
405+
"""
406+
Color.pl('{!} {O}SAE handshake captured and saved — but note:{W}')
407+
Color.pl('{!} {O}WPA3-SAE (Dragonfly) resists offline dictionary attacks.{W}')
408+
Color.pl('{!} {O}A captured SAE handshake {R}cannot be cracked offline{O} '
409+
'like a WPA2 handshake.{W}')
410+
Color.pl('{!} {O}Offline password recovery is only feasible via:{W}')
411+
Color.pl(' {C}-{W} Transition-mode {C}downgrade{W} to WPA2 '
412+
'(captures a crackable 4-way handshake)')
413+
Color.pl(' {C}-{W} A successful {C}Dragonblood timing{W} partition '
414+
'(only MODP groups 22/23/24)')
415+
if self.view:
416+
self.view.add_log('SAE handshake captured (NOT offline-crackable — '
417+
'WPA3-SAE resists dictionary attacks)')
391418

392419
def _active_probe_for_vulnerable_groups(self):
393420
"""
@@ -791,9 +818,17 @@ def attempt_downgrade(self):
791818
return None
792819

793820
self.clients = []
794-
795-
# Set timeout for downgrade attempt (30 seconds as per requirements)
796-
downgrade_timeout = Timer(30)
821+
822+
# Set timeout for the downgrade attempt. Honour --wpa3-timeout when
823+
# the user set it; otherwise keep a short 30s window — the downgrade
824+
# is a fast first attempt that falls back to SAE capture, so it
825+
# shouldn't occupy the full wpa_attack_timeout (300s) by default.
826+
timeout_value = Configuration.wpa3_attack_timeout or 30
827+
downgrade_timeout = Timer(timeout_value)
828+
# Warn once if no clients appear partway through (the downgrade
829+
# needs active clients). Half the window, floored so short timeouts
830+
# still produce a warning.
831+
no_clients_warn_after = max(5, timeout_value // 2)
797832
deauth_timer = Timer(Configuration.wpa_deauth_timeout)
798833
sae_detected = False
799834
deauth_attempts = 0
@@ -811,7 +846,7 @@ def attempt_downgrade(self):
811846

812847
# Calculate progress percentage
813848
elapsed = downgrade_timeout.running_time()
814-
total_time = 30 # 30 second timeout
849+
total_time = timeout_value
815850
progress_pct = min(100, int((elapsed / total_time) * 100))
816851

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

851-
# Show warning after some time with no clients
852-
if not no_clients_warning_shown and downgrade_timeout.running_time() > 30:
853-
Color.pl('\n{!} {O}No clients detected after 30 seconds{W}')
886+
# Show warning once we're partway through with no clients
887+
if not no_clients_warning_shown and \
888+
downgrade_timeout.running_time() >= no_clients_warn_after:
889+
Color.pl('\n{!} {O}No clients detected after %d seconds{W}'
890+
% no_clients_warn_after)
854891
Color.pl('{!} {O}Downgrade requires active clients to succeed{W}')
855892
if self.view:
856893
self.view.add_log('Warning: No clients detected')
@@ -899,7 +936,7 @@ def attempt_downgrade(self):
899936
# After deauth, clients should reconnect with WPA2
900937
# Check hcxdumptool capture (pcapng format)
901938
try:
902-
if hcxdump and hcxdump.has_captured_data():
939+
if hcxdump and hcxdump.has_new_data():
903940
handshake = Handshake(hcxdump.get_output_file(),
904941
bssid=self.target.bssid,
905942
essid=self.target.essid)
@@ -1082,7 +1119,7 @@ def capture_sae_handshake(self):
10821119

10831120
# Check if we've captured data
10841121
try:
1085-
if hcxdump.has_captured_data():
1122+
if hcxdump.has_new_data():
10861123
# Create SAEHandshake object
10871124
sae_hs = SAEHandshake(
10881125
hcxdump.get_output_file(),
@@ -1253,7 +1290,7 @@ def passive_capture(self):
12531290

12541291
# Check if we've captured data
12551292
try:
1256-
if hcxdump.has_captured_data():
1293+
if hcxdump.has_new_data():
12571294
# Create SAEHandshake object
12581295
sae_hs = SAEHandshake(
12591296
hcxdump.get_output_file(),

wifite/attack/wpa3_strategy.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,14 @@ def can_use_downgrade(wpa3_info: Dict[str, Any]) -> bool:
117117
Returns:
118118
True if downgrade attack is possible, False otherwise
119119
"""
120+
from wifite.config import Configuration
121+
# User overrides: --no-downgrade disables the downgrade path outright,
122+
# and --force-sae (attack SAE directly, skip WPA2) also implies no
123+
# downgrade since a downgrade fundamentally captures a WPA2 handshake.
124+
if getattr(Configuration, 'wpa3_no_downgrade', False) or \
125+
getattr(Configuration, 'wpa3_force_sae', False):
126+
return False
127+
120128
# Downgrade requires transition mode (both WPA2 and WPA3 support)
121129
return wpa3_info.get('is_transition', False)
122130

wifite/model/sae_handshake.py

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -111,20 +111,22 @@ def _validate_with_hcxpcapngtool(self) -> bool:
111111

112112
def _validate_with_tshark(self) -> bool:
113113
"""
114-
Validate SAE handshake using tshark with optimized filtering.
114+
Validate SAE handshake using tshark.
115115
116-
Optimizations:
117-
- Uses efficient BPF-style filters to reduce processing
118-
- Streams output to avoid loading entire capture into memory
119-
- Early termination once minimum frames are found
116+
A complete SAE handshake must contain BOTH:
117+
- an SAE Commit (authentication transaction sequence 1), and
118+
- an SAE Confirm (authentication transaction sequence 2).
119+
120+
Merely observing two SAE authentication frames is not sufficient —
121+
two retransmitted Commits (or a Commit from each side with no
122+
Confirm) would otherwise be misreported as complete. We therefore
123+
inspect the auth-sequence numbers and require both 1 and 2 to appear.
120124
121125
Returns:
122-
True if tshark finds SAE commit and confirm frames
126+
True if both an SAE Commit and an SAE Confirm are observed.
123127
"""
124128
try:
125-
# Check for SAE authentication frames
126-
# SAE uses authentication frame type (0x0b) with auth algorithm 3
127-
# Build efficient filter string
129+
# SAE uses authentication frame type (0x0b) with auth algorithm 3.
128130
filter_str = 'wlan.fc.type_subtype == 0x0b && wlan.fixed.auth.alg == 3'
129131
if self.bssid:
130132
# Add BSSID filter for efficiency
@@ -135,18 +137,28 @@ def _validate_with_tshark(self) -> bool:
135137
'-r', self.capfile,
136138
'-Y', filter_str,
137139
'-T', 'fields',
138-
'-e', 'wlan.fixed.auth.sae.group',
139-
'-e', 'frame.number',
140-
'-c', '2' # Stop after finding 2 frames (optimization)
140+
'-e', 'wlan.fixed.auth.seq',
141+
# Bound the work — SAE auth frames are few, and we early-exit
142+
# as soon as both a Commit and a Confirm have been seen.
143+
'-c', '50',
141144
]
142145

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

146-
# Count frames - need at least 2 (commit and confirm)
147-
# Use generator expression for memory efficiency
148-
frame_count = sum(1 for line in output.split('\n') if line.strip())
149-
return frame_count >= 2
149+
seen_seqs = set()
150+
for line in output.split('\n'):
151+
seq = line.strip()
152+
if not seq:
153+
continue
154+
# Be defensive about extra fields/separators per line.
155+
seq = seq.split('\t')[0].split(',')[0].strip()
156+
if seq in ('1', '2'):
157+
seen_seqs.add(seq)
158+
if '1' in seen_seqs and '2' in seen_seqs:
159+
return True
160+
161+
return False
150162

151163
except Exception:
152164
return False

wifite/tools/hcxdumptool.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ def __init__(self, interface=None, channel=None, target_bssid=None,
7373
self.pid = None
7474
self.proc = None
7575

76+
# Baseline size of the empty capture (pcapng Section Header / Interface
77+
# Description blocks are written the instant capture starts). Recorded
78+
# in __enter__ so has_captured_data()/has_new_data() can tell a
79+
# header-only file apart from one that actually contains frames.
80+
self._baseline_size = None
81+
# High-water mark of file size seen by has_new_data().
82+
self._last_data_size = 0
83+
7684
def __enter__(self):
7785
"""
7886
Start hcxdumptool capture process.
@@ -131,6 +139,15 @@ def __enter__(self):
131139
# Give it a moment to start
132140
time.sleep(1)
133141

142+
# Record the header-only size now that the file exists, so subsequent
143+
# data checks measure growth beyond the pcapng header rather than
144+
# treating the always-present header as "captured data".
145+
try:
146+
self._baseline_size = os.path.getsize(self.output_file) \
147+
if os.path.exists(self.output_file) else 0
148+
except OSError:
149+
self._baseline_size = 0
150+
134151
return self
135152

136153
def __exit__(self, exc_type, exc_val, exc_tb):
@@ -163,8 +180,36 @@ def get_output_file(self) -> str:
163180
return self.output_file
164181

165182
def has_captured_data(self) -> bool:
166-
"""Check if any data has been captured."""
167-
return os.path.exists(self.output_file) and os.path.getsize(self.output_file) > 0
183+
"""Return True only when actual packet data exists beyond the header.
184+
185+
A fresh pcapng file is non-empty the instant capture starts (the
186+
Section Header / Interface Description blocks are written immediately),
187+
so a bare ``getsize() > 0`` check is always true and meaningless. We
188+
compare against the header-only baseline recorded at start instead.
189+
"""
190+
try:
191+
current = os.path.getsize(self.output_file)
192+
except OSError:
193+
return False
194+
baseline = self._baseline_size if self._baseline_size is not None else 0
195+
return current > baseline
196+
197+
def has_new_data(self) -> bool:
198+
"""Return True only when new packet data was written since the last call.
199+
200+
Lets polling capture loops skip the expensive handshake validation
201+
(which spawns tshark / hcxpcapngtool / aircrack) when the capture file
202+
hasn't grown — i.e. there are no new frames worth re-evaluating.
203+
"""
204+
try:
205+
current = os.path.getsize(self.output_file)
206+
except OSError:
207+
return False
208+
baseline = self._baseline_size if self._baseline_size is not None else 0
209+
if current > baseline and current > self._last_data_size:
210+
self._last_data_size = current
211+
return True
212+
return False
168213

169214
@staticmethod
170215
def exists() -> bool:

0 commit comments

Comments
 (0)