diff --git a/payload/user/remote_access/pager-webui/server.py b/payload/user/remote_access/pager-webui/server.py index 2bd4a73..3cc1529 100644 --- a/payload/user/remote_access/pager-webui/server.py +++ b/payload/user/remote_access/pager-webui/server.py @@ -953,10 +953,26 @@ ENC_CCMP = 0x08 ENC_GCMP = 0x20 ENC_GCMP256 = 0x80 ENC_CCMP256 = 0x100 +# AKM suites ride in bits 32-47 (bit k = RSN suite selector k advertised). +ENC_AKM_EAP = 1 << (32 + 1) # 802.1X / EAP (Enterprise) +ENC_AKM_PSK = 1 << (32 + 2) # PSK +ENC_AKM_FT_EAP = 1 << (32 + 3) # FT-802.1X (Enterprise) +ENC_AKM_FT_PSK = 1 << (32 + 4) # FT-PSK +ENC_AKM_EAP_SHA256 = 1 << (32 + 5) # 802.1X-SHA256 (Enterprise) +ENC_AKM_PSK_SHA256 = 1 << (32 + 6) # PSK-SHA256 +ENC_AKM_SAE = 1 << (32 + 8) # SAE (WPA3-Personal) +ENC_AKM_FT_SAE = 1 << (32 + 9) # FT-SAE +ENC_AKM_OWE = 1 << (32 + 13) # OWE +ENC_AKM_OWE_SHA192 = 1 << (32 + 14) # OWE-SHA256-192 def decode_encryption(v): - """Pager recon.db encryption bitfield -> old-UI-style display string.""" + """Pager recon.db encryption bitfield -> display string. + + Low bits carry pairwise ciphers, bits 32-47 carry the advertised AKM + suites, so WPA2-PSK vs WPA2-Enterprise (and WPA3-Personal vs + WPA3-Enterprise) can be told apart. + """ v = v or 0 if v == 0: return 'Open' @@ -969,6 +985,16 @@ def decode_encryption(v): parts.append('WPA') if v & ENC_WEP: parts.append('WEP') + if not parts: + return 'Open' + if v & (ENC_AKM_EAP | ENC_AKM_FT_EAP | ENC_AKM_EAP_SHA256): + parts.append('Enterprise') + elif v & (ENC_AKM_SAE | ENC_AKM_FT_SAE): + parts.append('SAE') + elif v & ENC_AKM_OWE: + parts.append('OWE') + elif v & (ENC_AKM_PSK | ENC_AKM_FT_PSK | ENC_AKM_PSK_SHA256): + parts.append('PSK') return ' '.join(parts) if parts else 'Open' @@ -1180,12 +1206,22 @@ def recon_scan_data(scan_id, _timeout=20, _limit=None, db=None): handshakes.append({'ap': mac_of.get(r.get('aphash'), '--'), 'client': mac_of.get(r.get('stahash'), '--'), 'time': r.get('time')}) - return {'scan': {'id': scans[0]['row_id'], 'time': scans[0]['time'], - 'name': scans[0].get('name')}, + # GPS attaches to live scans only: an archived scan's coordinates would + # be a current fix, which is misleading for historical data. + scan = {'id': scans[0]['row_id'], 'time': scans[0]['time'], + 'name': scans[0].get('name')} + if db is None: + try: + gps = _gps_status_data() + if gps.get('lock'): + scan['gps'] = {'lat': gps.get('lat'), 'lon': gps.get('lon'), + 'alt': gps.get('alt'), + 'satellites': gps.get('satellites')} + except Exception: + pass + return {'scan': scan, 'aps': aps, 'clients': clients, 'handshakes': handshakes, 'unassociated': unassociated} - - def h_recon_start(ctx): # Serialize starts: the firmware has no abort for a timed scan, so a # second /recon/new while one is running just stacks another empty scan @@ -1351,6 +1387,24 @@ def h_recon_delete(ctx): return 200, {'ok': True} +def h_recon_delete_all(ctx): + """Clear every recorded scan from the live recon database. Rotated + archive files (error-*/diagnostic-* recon dbs) are left untouched.""" + count = 0 + try: + rows = _db_rows(RECON_DB, 'SELECT COUNT(*) AS c FROM scan', timeout=20) + count = rows[0]['c'] if rows else 0 + except RuntimeError: + pass + for t in RECON_CHILD_TABLES + ['scan']: + try: + _db_write(RECON_DB, 'DELETE FROM %s' % t) + except Exception: + continue + _recon_scans_cache['updated'] = 0 + return 200, {'ok': True, 'deleted': count} + + def h_recon_scan_download(ctx): scan_id = int(ctx.args[0]) data = recon_scan_data(scan_id) @@ -1537,14 +1591,18 @@ def _enc_bucket(enc): s = (enc or '').strip() if not s or s == 'Open': return 'Open' - if 'Enterprise' in s: - return 'Enterprise' if 'WEP' in s: return 'WEP' - if 'WPA2' in s: - return 'WPA2' + if 'Enterprise' in s: + return 'WPA3-Enterprise' if 'WPA3' in s else 'WPA2-Enterprise' + if 'SAE' in s or 'OWE' in s: + return 'WPA3-Personal' + if 'WPA3' in s and 'WPA2' in s: + return 'WPA2-PSK' if 'PSK' in s else 'WPA3-PSK' if 'WPA3' in s: - return 'WPA3' + return 'WPA3-PSK' + if 'WPA2' in s: + return 'WPA2-PSK' if 'WPA' in s: return 'WPA' return 'Unknown' @@ -2621,6 +2679,46 @@ def _open_channel(value): return 1 +def _freq_to_channel(freq): + try: + freq = int(freq) + except (TypeError, ValueError): + return None + if 2412 <= freq <= 2484: + return (freq - 2412) // 5 + 1 + if 5180 <= freq <= 5885: + return (freq - 5180) // 5 + 36 + if 5955 <= freq <= 7115: + return (freq - 5955) // 5 + 1 + return None + + +def _best_channel_for(ssid): + """Resolve an 'auto' attack channel: the channel the target SSID was + last seen on in recon, else None (caller falls back to defaults).""" + if not ssid: + return None + hex_ssid = ssid.encode('utf-8').hex() + try: + rows = _db_rows(RECON_DB, + "SELECT channel, freq FROM ssid WHERE type = 8 " + "AND ssid = X'%s' ORDER BY time DESC LIMIT 1" + % hex_ssid, timeout=20) + except RuntimeError: + return None + if not rows: + return None + channel = rows[0].get('channel') + if channel is not None: + try: + channel = int(channel) + if 1 <= channel <= 233: + return channel + except (TypeError, ValueError): + pass + return _freq_to_channel(rows[0].get('freq')) + + def _read_hop(): rc, out, err = device_run(['uci', 'get', 'pineapd.wlan1mon.hop']) if rc != 0: @@ -2845,7 +2943,12 @@ def _verify_iface(name, timeout=20.0): def _deploy_wpa_open(kind, fields): - band = _band_of_channel(fields.get('channel')) + channel = fields.get('channel') + if channel is None: + channel = _best_channel_for((fields.get('ssid') or '').strip()) + if channel is None: + channel = 1 + band = _band_of_channel(channel) ssid = (fields.get('ssid') or '').strip() if not ssid: raise ValueError('SSID is required') @@ -2866,7 +2969,7 @@ def _deploy_wpa_open(kind, fields): 'ssid': ssid, 'enabled': True, 'hidden': bool(fields.get('hidden')), - 'channel': int(fields.get('channel') or 1), + 'channel': int(channel or 1), } if kind == 'wpa': daemon_cfg['enctype'] = enctype @@ -2883,7 +2986,7 @@ def _deploy_wpa_open(kind, fields): if status != 200: raise RuntimeError('daemon rejected AP config: %r' % (data,)) if kind == 'open': - _apply_open_radio({'channel': int(fields.get('channel') or 1), + _apply_open_radio({'channel': int(channel or 1), 'country': fields.get('country') or 'US'}) else: # 5/6 GHz: radio1 feature @@ -2892,14 +2995,14 @@ def _deploy_wpa_open(kind, fields): _apply_radio1_ap(None, { 'ssid': ssid, 'passphrase': fields.get('passphrase') or '', 'enctype': enctype, 'hidden': bool(fields.get('hidden')), - 'enabled': True, 'channel': int(fields.get('channel')), + 'enabled': True, 'channel': int(channel), 'country': fields.get('country') or 'US', }) iface = 'wlan1wpa' else: _apply_radio1_ap({ 'ssid': ssid, 'hidden': bool(fields.get('hidden')), - 'enabled': True, 'channel': int(fields.get('channel')), + 'enabled': True, 'channel': int(channel), 'bssid': fields.get('bssid') or '', 'country': fields.get('country') or 'US', }, None) @@ -2909,7 +3012,8 @@ def _deploy_wpa_open(kind, fields): # The daemon applies AP changes asynchronously; allow a full reload cycle. verified = _verify_iface(iface, timeout=45) return {'kind': kind, 'ssid': ssid, 'iface': iface, 'band': band, - 'channel': int(fields.get('channel')), 'verified': verified} + 'channel': int(channel or 1), 'auto': fields.get('channel') is None, + 'verified': verified} def _disable_enterprise_ap(): @@ -2967,12 +3071,14 @@ def _deploy_enterprise(fields): 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)') + if ch is None: + ch = _best_channel_for(ssid) + if ch is None or channel_band(ch) != BAND_5G: + ch = 36 else: - ch = 36 + ch = int(ch) + if channel_band(ch) != BAND_5G: + raise ValueError('enterprise AP runs on 5 GHz (36-177)') # Radio0 karma surface is shared: stop 2.4 GHz WPA/Open attacks first. for name in ('wlan0wpa', 'wlan0open'): cfg = _uci_wifi_iface(name) @@ -4863,6 +4969,7 @@ ROUTER.add('POST', r'/api/recon/start', h_recon_start) ROUTER.add('POST', r'/api/recon/stop', h_recon_stop) ROUTER.add('GET', r'/api/recon/status', h_recon_status) ROUTER.add('GET', r'/api/recon/scans', h_recon_scans) +ROUTER.add('DELETE', r'/api/recon/scans', h_recon_delete_all) ROUTER.add('GET', r'/api/recon/scans/(\d+)/download/json', h_recon_scan_download) ROUTER.add('GET', r'/api/recon/scans/(\d+)', h_recon_scan_detail) ROUTER.add('DELETE', r'/api/recon/scans/(\d+)', h_recon_delete) diff --git a/payload/user/remote_access/pager-webui/www/css/app.css b/payload/user/remote_access/pager-webui/www/css/app.css index 9853988..9cc4415 100644 --- a/payload/user/remote_access/pager-webui/www/css/app.css +++ b/payload/user/remote_access/pager-webui/www/css/app.css @@ -143,6 +143,12 @@ body { align-items: center; justify-content: center; margin-right: 14px; flex: none; } .entry-icon svg { width: 24px; height: 24px; display: block; } +#rail .entry.sub { + height: 36px; padding-left: 34px; font-size: 13px; +} +#rail .entry.sub .entry-icon { width: 18px; height: 18px; margin-right: 10px; } +#rail .entry.sub .entry-icon svg { width: 18px; height: 18px; } +#rail.open .entry.sub .entry-text { font-size: 12px; } #topbar .btn.ghost { color: #fff; border-color: #fff; } .entry-text { white-space: nowrap; opacity: 0; transition: opacity .2s; } #rail.open .entry-text { opacity: 1; } @@ -335,6 +341,9 @@ html.dark .recon-scan-status.warn { color: #ffb74d; } .recon-paginator .icon-btn svg { width: 18px; height: 18px; } .recon-row-selected td { background: #eaeaea; } html.dark .recon-row-selected td { background: #565656; } +.recon-row-compare td { background: rgba(25, 118, 210, .08); } +html.dark .recon-row-compare td { background: rgba(25, 118, 210, .18); } +.recon-gps-cell { font-variant-numeric: tabular-nums; } .recon-settings-sidebar { position: fixed; top: 64px; right: 0; bottom: 0; width: 270px; z-index: 50; background: var(--surface); box-shadow: -2px 0 6px rgba(0,0,0,.24); padding: 16px; diff --git a/payload/user/remote_access/pager-webui/www/index.html b/payload/user/remote_access/pager-webui/www/index.html index 4802230..6dd6aca 100644 --- a/payload/user/remote_access/pager-webui/www/index.html +++ b/payload/user/remote_access/pager-webui/www/index.html @@ -6,7 +6,7 @@ WiFi Pineapple - + @@ -261,14 +261,14 @@
- + - - + + diff --git a/payload/user/remote_access/pager-webui/www/js/app.js b/payload/user/remote_access/pager-webui/www/js/app.js index c41d95a..e8a1eec 100644 --- a/payload/user/remote_access/pager-webui/www/js/app.js +++ b/payload/user/remote_access/pager-webui/www/js/app.js @@ -29,12 +29,20 @@ const App = (() => { const railItems = [ { key: 'dashboard', label: 'Dashboard', hash: '#/dashboard', icon: 'dashboard' }, - { key: 'attacks', label: 'Attacks', hash: '#/attacks', icon: 'attack' }, - { key: 'pineap', label: 'PineAP', hash: '#/pineap', icon: 'wifi' }, + { + key: 'pineap', label: 'PineAP', hash: '#/pineap', icon: 'pineap', children: [ + { key: 'pineap_evilwpa', label: 'Evil WPA', hash: '#/pineap/evilwpa', icon: 'attack' }, + { key: 'pineap_open', label: 'Evil Open', hash: '#/pineap/open', icon: 'wifi' }, + { key: 'pineap_enterprise', label: 'Evil Enterprise', hash: '#/pineap/enterprise', icon: 'record' }, + { key: 'pineap_impersonation', label: 'Impersonation', hash: '#/pineap/impersonation', icon: 'place' }, + { key: 'pineap_clients', label: 'Clients', hash: '#/pineap/clients', icon: 'pager' }, + { key: 'pineap_filtering', label: 'Filtering', hash: '#/pineap/filtering', icon: 'settings' } + ] + }, { key: 'recon', label: 'Recon', hash: '#/recon', icon: 'recon' }, { key: 'logging', label: 'Logging', hash: '#/logging', icon: 'logging' }, { key: 'modules', label: 'Payloads', hash: '#/modules', icon: 'modules' }, - { key: 'harness', label: 'Harness', hash: '#/harness', icon: 'extension' }, + { key: 'harness', label: 'Harness', hash: '#/harness', icon: 'robot' }, { key: 'settings', label: 'Settings', hash: '#/settings', icon: 'settings' } ]; const railDividers = new Set(['logging']); @@ -63,6 +71,13 @@ const App = (() => { rail.appendChild(d); } rail.appendChild(railEntry(it)); + if (it.children) { + it.children.forEach((sub) => { + const s = railEntry(sub); + s.classList.add('sub'); + rail.appendChild(s); + }); + } }); const foot = document.createElement('div'); foot.className = 'rail-footer'; @@ -85,11 +100,22 @@ const App = (() => { function route() { closeToolbarMenus(); - const hash = (location.hash || '#/dashboard').replace(/\/+$/, ''); + let hash = (location.hash || '#/dashboard').replace(/\/+$/, ''); if (hash === '#/recon/survey') { location.hash = '#/recon'; return; } + if (hash.indexOf('#/attacks') === 0) { + const map = { + '#/attacks': '#/pineap', + '#/attacks/wpa': '#/pineap/evilwpa', + '#/attacks/open': '#/pineap/open', + '#/attacks/enterprise': '#/pineap/enterprise' + }; + hash = map[hash] || '#/pineap'; + location.replace(hash); + return; + } const name = routes[hash]; if (currentView && currentView.destroy) currentView.destroy(); els.content.innerHTML = ''; @@ -103,9 +129,13 @@ const App = (() => { currentView = views[name](els.content); } const key = keyOf(hash); + const subs = els.rail.querySelectorAll('.entry.sub'); + const subActive = Array.prototype.some.call(subs, (s) => s.getAttribute('href') === hash); Array.prototype.forEach.call(els.rail.querySelectorAll('.entry'), (a) => { const href = a.getAttribute('href'); - a.classList.toggle('active', !!href && (href === hash || keyOf(href) === key)); + const exact = !!href && href === hash; + const groupActive = !subActive && !!href && keyOf(href) === key; + a.classList.toggle('active', exact || groupActive); }); } @@ -395,13 +425,10 @@ const App = (() => { const routes = { '#/dashboard': 'dashboard', - '#/attacks': 'attacks', - '#/attacks/wpa': 'attacks_wpa', - '#/attacks/open': 'attacks_open', - '#/attacks/enterprise': 'attacks_enterprise', '#/pineap': 'pineap', - '#/pineap/open': 'pineap_open', '#/pineap/evilwpa': 'pineap_evilwpa', + '#/pineap/enterprise': 'pineap_enterprise', + '#/pineap/open': 'pineap_open', '#/pineap/impersonation': 'pineap_impersonation', '#/pineap/clients': 'pineap_clients', '#/pineap/filtering': 'pineap_filtering', diff --git a/payload/user/remote_access/pager-webui/www/js/icons.js b/payload/user/remote_access/pager-webui/www/js/icons.js index 487fc93..1795a27 100644 --- a/payload/user/remote_access/pager-webui/www/js/icons.js +++ b/payload/user/remote_access/pager-webui/www/js/icons.js @@ -10,12 +10,14 @@ window.PineappleIcons = { settings: '', chevron: '', terminal: '', + robot: '', wifi: '', extension: '', receipt: '', refresh: '', file_download: '', delete: '', + delete_forever: '', settings: '', search: '', first_page: '', 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 1743ae1..6f48818 100644 --- a/payload/user/remote_access/pager-webui/www/js/views.js +++ b/payload/user/remote_access/pager-webui/www/js/views.js @@ -246,8 +246,9 @@ views.dashboard = (root) => { const PINEAP_TABS = [ { label: 'PineAP', hash: '#/pineap' }, - { label: 'Open AP', hash: '#/pineap/open' }, { label: 'Evil WPA', hash: '#/pineap/evilwpa' }, + { label: 'Evil Open', hash: '#/pineap/open' }, + { label: 'Evil Enterprise', hash: '#/pineap/enterprise' }, { label: 'Impersonation', hash: '#/pineap/impersonation' }, { label: 'Clients', hash: '#/pineap/clients' }, { label: 'Filtering', hash: '#/pineap/filtering' } @@ -336,10 +337,12 @@ views.pineap = (root) => { modeRow.appendChild(quickCard); box.appendChild(modeRow); box.appendChild(h('div', { class: 'pineap-infobox info' }, - h('span', { text: 'For one-click Evil WPA / Open AP / Enterprise attacks, use the Attacks section — it deploys, enables karma, verifies on-device and captures loot automatically.' }), + h('span', { text: 'The Evil WPA / Evil Open / Evil Enterprise tabs deploy one-click attacks — they enable karma, verify on-device and capture loot automatically.' }), h('div', { class: 'pineap-infobox-actions' }, - h('a', { class: 'btn ghost', href: '#/attacks', style: 'text-decoration:none', - onclick: (e) => { e.preventDefault(); App.go('#/attacks'); } }, 'Go to Attacks')))); + h('a', { class: 'btn ghost', href: '#/pineap/evilwpa', style: 'text-decoration:none', + onclick: (e) => { e.preventDefault(); App.go('#/pineap/evilwpa'); } }, 'Evil WPA'), + h('a', { class: 'btn ghost', href: '#/pineap/enterprise', style: 'text-decoration:none', + onclick: (e) => { e.preventDefault(); App.go('#/pineap/enterprise'); } }, 'Evil Enterprise')))); const cards = { karma: {}, open: {}, wpa: {} }; const cardWrap = h('div', { class: 'pineap-title-card-container' }); @@ -487,6 +490,10 @@ views.pineap = (root) => { return { destroy: () => clearInterval(iv) }; }; +const EVIL_ENC = [ + ['psk2', 'WPA2 PSK'], ['sae', 'WPA3 SAE'], ['owe', 'WPA3 OWE'] +]; + const BAND_GROUPS = [ { band: '2.4', label: '2.4 GHz', dfs: false, channels: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] }, @@ -512,6 +519,7 @@ function chanLabel(band, ch) { } function chanSelect(sel, value) { + sel.appendChild(h('option', { value: '', text: 'Auto — best channel for the target' })); BAND_GROUPS.forEach((g) => { const og = h('optgroup', { label: g.label }); g.channels.forEach((ch) => { @@ -519,7 +527,9 @@ function chanSelect(sel, value) { }); sel.appendChild(og); }); - if (value != null) { + if (value == null || value === '') { + sel.value = ''; + } else { const opts = Array.prototype.slice.call(sel.options); const hit = opts.find((o) => Number(o.value) === Number(value)); if (hit) sel.value = hit.value; @@ -528,7 +538,7 @@ function chanSelect(sel, value) { } function bandOfChannel(ch) { - if (ch == null) return '2.4'; + if (ch == null || ch === '') return '2.4'; ch = Number(ch); if (ch >= 1 && ch <= 14) return '2.4'; if (ch >= 36 && ch <= 177) return '5'; @@ -556,347 +566,52 @@ const OPEN_COUNTRIES = [ ['VE', 'Venezuela'], ['VN', 'Vietnam'] ]; -views.pineap_open = (root) => { - const box = pineapShell(root, '#/pineap/open'); - const card = h('div', { class: 'pineap-title-card' }); - box.appendChild(card); - - card.appendChild(h('div', { class: 'pineap-card-title' }, 'PineAP Open Access Point')); - const subtitle = h('div', { class: 'pineap-card-subtitle' }); - card.appendChild(subtitle); - - const ssidIn = h('input', { id: 'oa-ssid' }); - const bssidIn = h('input', { id: 'oa-bssid' }); - const chSel = h('select', { id: 'oa-channel' }); - chanSelect(chSel, null); - const bandHint = h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px' }); - function applyOaHint() { - const b = bandOfChannel(chSel.value); - bandHint.textContent = b === '6' ? '6 GHz open APs require WPA3/OWE on real clients — most devices will not associate to an open 6 GHz network.' : ''; - } - chSel.addEventListener('change', applyOaHint); - const coSel = h('select', { id: 'oa-country' }); - OPEN_COUNTRIES.forEach(([v, l]) => coSel.appendChild(h('option', { value: v, text: l }))); - const hiddenCb = h('input', { type: 'checkbox', id: 'oa-hidden' }); - const karmaCb = h('input', { type: 'checkbox', id: 'oa-karma' }); - let karmaDirty = false; - karmaCb.addEventListener('change', () => { - karmaDirty = true; - karmaCb.indeterminate = false; - render(); - }); - - card.appendChild(h('div', { class: 'row' }, - h('div', {}, h('label', {}, 'Open SSID', ssidIn)), - h('div', {}, h('label', {}, 'BSSID', bssidIn)))); - card.appendChild(h('div', { class: 'row' }, - h('div', {}, h('label', {}, 'Channel', chSel), bandHint), - h('div', {}, h('label', {}, 'Current Country', coSel)))); - card.appendChild(h('div', { class: 'row' }, - h('div', {}, h('label', { class: 'switch' }, hiddenCb, h('span', { class: 'track' }), ' Hidden')), - h('div', {}, h('label', { class: 'switch' }, karmaCb, h('span', { class: 'track' }), ' Respond to all probe requests (impersonate all networks)')))); - - const info = h('div', { class: 'muted', style: 'margin-top:10px;font-size:13px' }); - card.appendChild(info); - const boxes = h('div', {}); - card.appendChild(boxes); - card.appendChild(h('div', { class: 'row', style: 'margin-top:10px' }, - h('div', {}, btn('Save', save)), - h('div', { class: 'muted', style: 'align-self:center;font-size:12px' }, 'Applying reconfigures the radio — you may be disconnected briefly.'))); - - const state = {}; - - function cfgLink() { - return h('a', { href: '#/pineap/filtering', style: 'color:var(--primary);cursor:pointer', onclick: (e) => { e.preventDefault(); App.go('#/pineap/filtering'); } }, 'filter configuration'); - } - function filterBtn() { - return h('a', { class: 'btn', href: '#/pineap/filtering', style: 'text-decoration:none;display:inline-block', onclick: (e) => { e.preventDefault(); App.go('#/pineap/filtering'); } }, 'Change Filters'); - } - function infobox(severity, text, ...actions) { - return h('div', { class: 'pineap-infobox ' + severity }, - h('span', { text }), - h('div', { class: 'pineap-infobox-actions' }, ...actions)); - } - function filterSentence(sm, cm) { - if (sm === 'allow' && cm === 'allow') return 'any client in the filter configuration may connect to any SSID in the filter configuration.'; - if (sm === 'deny' && cm === 'allow') return 'any client not in the filter configuration may connect to any SSID in the filter configuration.'; - if (sm === 'allow' && cm === 'deny') return 'any client in the filter configuration may connect to any SSID not in the filter configuration.'; - return 'any client not in the filter configuration may connect to any SSID not in the filter configuration.'; - } - - function save() { - const requests = [PagerAPI.post('/api/pineap/wifi/set_ap', { - open: { - ssid: ssidIn.value, - bssid: bssidIn.value.trim(), - hidden: hiddenCb.checked, - enabled: state.enabledLoaded ? !!state.enabled : true, - channel: chSel.value ? parseInt(chSel.value, 10) : null, - country: coSel.value - } - })]; - if (karmaDirty) requests.push(PagerAPI.post('/api/pineap/mimic', { enable: karmaCb.checked })); - Promise.allSettled(requests).then((results) => { - const ok = results.every((r) => r.status === 'fulfilled'); - if (ok && karmaDirty) { - PINEAP_SESSION.karma = karmaCb.checked; - karmaDirty = false; - } - App.toast(ok ? 'Open AP saved' : 'Some settings failed', ok ? '' : 'error'); - load(); - }); - } - - function render() { - const sm = state.ssidMode || 'deny'; - const cm = state.clientMode || 'deny'; - subtitle.textContent = ''; - subtitle.appendChild(document.createTextNode('The Open SSID is advertised without encryption. When client association is enabled, ')); - subtitle.appendChild(cfgLink()); - subtitle.appendChild(document.createTextNode(' ' + filterSentence(sm, cm))); - - const hidden = hiddenCb.checked; - const karma = karmaCb.checked; - let t = 'The Open access point will be ' + (hidden ? 'hidden' : 'advertised'); - if (!karma) { - t += '.'; - } else { - if (sm === 'allow' && cm === 'allow') t += ', and clients in the allowed client filter list will be able to connect to any SSID in the allowed SSID filter.'; - else if (sm === 'allow' && cm === 'deny') t += ', and clients in the allowed client filter list will be able to connect to any SSID not blocked by the SSID filter.'; - else if (sm === 'deny' && cm === 'allow') t += ', and clients not in the denied client filter list will be able to connect to any SSID in the allowed SSID filter.'; - else t += ', and clients not in the denied client filter list will be able to connect to any SSID not blocked by the SSID filter.'; - } - info.textContent = t; - - boxes.innerHTML = ''; - const openSsid = ssidIn.value; - const ssidList = state.ssidList || []; - const clientList = state.clientList || []; - if (state.ssidFetched && sm === 'allow' && openSsid && ssidList.indexOf(openSsid) === -1) { - boxes.appendChild(infobox('error', - 'The open SSID "' + openSsid + '" is not included in the filter allow list, clients will not be able to connect.', - btn('Add Allowed', () => PagerAPI.post('/api/pineap/filters/ssid', { action: 'add', value: openSsid }).then(load).catch(() => App.toast('Failed', 'error'))))); - } - if (state.ssidFetched && sm === 'deny' && openSsid && ssidList.indexOf(openSsid) !== -1) { - boxes.appendChild(infobox('error', - 'The open SSID "' + openSsid + '" is included in the filter deny list, clients will not be able to connect.', - btn('Remove Filter', () => PagerAPI.post('/api/pineap/filters/ssid', { action: 'delete', value: openSsid }).then(load).catch(() => App.toast('Failed', 'error'))))); - } - if (sm === 'allow' && ssidList.length > 0 && karmaCb.checked) { - boxes.appendChild(infobox('info', - 'Remember to add SSIDs you wish to impersonate to the PineAP SSID filter, or change to "Deny" mode to allow responding to all requested networks!', - filterBtn())); - } - if (state.clientFetched && cm === 'allow' && clientList.length === 0) { - boxes.appendChild(infobox('error', - 'The PineAP Client filter is set to "allow", but no clients are listed; no clients will be able to connect!', - btn('Change Mode', () => PagerAPI.post('/api/pineap/filters/client', { action: 'set_mode', mode: 'deny' }).then(load).catch(() => App.toast('Failed', 'error'))), - filterBtn())); +views.pineap_evilwpa = attackLauncher('wpa', { + title: 'Evil WPA', + subtitle: 'WPA2-PSK / WPA3-SAE / WPA3-OWE evil twin with handshake capture', + passphrase: true, + encodings: EVIL_ENC, + handshakes: true, + export: true, + deauth: true, + tabHash: '#/pineap/evilwpa', + playbook: { + steps: ['Deploy the evil twin', + 'Wait for a client to associate', + 'Deauth the target client to force the 4-way', + 'Export .hc22000 and crack with hashcat'], + currentStep: (s, w) => { + if (!w || !w.enabled) return 'Deploy the evil twin'; + if (!s || !(s.handshakes > 0)) return 'Wait for a client to associate'; + return 'Export .hc22000 and crack with hashcat'; + }, + hint: (s, w) => { + if (!w || !w.enabled) return '1. Set the target SSID and passphrase, pick a channel (Auto finds it from recon), Deploy. 2. When the target client is near, use Deauth Targeting below. 3. Captured handshakes appear above — Export and run the hashcat command.'; + if (s && s.handshakes > 0) return 'Handshake captured! Export .hc22000 and run hashcat -m 22000.'; + return 'AP is live on ' + (w.ssid || 'the target') + '. Watch the handshakes list — use Deauth Targeting to nudge the client. If a client refuses to join the twin, its reconnect to the real AP is still captured passively.'; } } +}); - function load() { - Promise.all([ - PagerAPI.post('/api/pineap/wifi/get_ap').catch(() => ({ data: {} })), - PagerAPI.get('/api/pineap/filters/ssid').catch(() => ({ data: {} })), - PagerAPI.get('/api/pineap/filters/client').catch(() => ({ data: {} })) - ]).then(([ap, sf, cf]) => { - const a = ap.data || {}; - const open = a.open || {}; - ssidIn.value = open.ssid || ''; - bssidIn.value = open.bssid || ''; - if (open.channel != null) { - const opts = Array.prototype.slice.call(chSel.options); - if (opts.some((o) => Number(o.value) === Number(open.channel))) { - chSel.value = String(open.channel); - } - } - applyOaHint(); - if (open.country) coSel.value = open.country; - hiddenCb.checked = !!open.hidden; - state.enabledLoaded = !!(a.open); - state.enabled = !!open.enabled; - if (!karmaDirty) setKnownCheckbox(karmaCb, PINEAP_SESSION.karma); - const sd = sf.data || {}, cd = cf.data || {}; - state.ssidFetched = !!sd.mode; - state.clientFetched = !!cd.mode; - state.ssidMode = sd.mode; - state.clientMode = cd.mode; - state.ssidList = sd.entries || []; - state.clientList = cd.entries || []; - render(); - }); +views.pineap_open = attackLauncher('open', { + title: 'Evil Open', + subtitle: 'Open network evil twin', + bssid: true, + country: true, + tabHash: '#/pineap/open', + playbook: { + steps: ['Deploy the open AP', + 'Wait for clients to associate', + 'Watch connected clients under PineAP → Clients'], + currentStep: (s, w) => { + if (!w || !w.enabled) return 'Deploy the open AP'; + return 'Wait for clients to associate'; + }, + hint: (s, w) => !w || !w.enabled + ? 'Set the SSID (optionally spoof a BSSID), pick a channel (Auto finds it from recon), Deploy.' + : 'Open AP is live on ' + (w.ssid || 'the target') + ' — clients that join appear in the Clients list.' } - load(); - return { destroy: () => {} }; -}; - -const EVIL_ENC = [ - ['psk2', 'WPA2 PSK'], ['sae', 'WPA3 SAE'], ['owe', 'WPA3 OWE'] -]; - -views.pineap_evilwpa = (root) => { - const box = pineapShell(root, '#/pineap/evilwpa'); - const cfg = h('div', { class: 'pineap-title-card' }, - h('div', { class: 'pineap-card-title' }, 'Evil WPA')); - box.appendChild(cfg); - const ssidIn = h('input', { id: 'ew-ssid' }); - const pskIn = h('input', { id: 'ew-psk', type: 'password', autocomplete: 'new-password' }); - const encSel = h('select', { id: 'ew-enc' }); - EVIL_ENC.forEach(([v, l]) => encSel.appendChild(h('option', { value: v, text: l }))); - const hiddenCb = h('input', { type: 'checkbox', id: 'ew-hidden' }); - const enabledCb = h('input', { type: 'checkbox', id: 'ew-enabled' }); - const wpaChan = h('select', { id: 'ew-channel' }); - chanSelect(wpaChan, null); - const wpaHint = h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px' }); - function applyWpaHint() { - const six = bandOfChannel(wpaChan.value) === '6'; - Array.prototype.forEach.call(encSel.options, (o) => { o.disabled = six && o.value === 'psk2'; }); - if (six && encSel.value === 'psk2') encSel.value = 'sae'; - wpaHint.textContent = six ? '6 GHz requires WPA3 (SAE or OWE).' : ''; - } - wpaChan.addEventListener('change', applyWpaHint); - cfg.appendChild(h('label', {}, 'SSID', ssidIn)); - cfg.appendChild(h('label', {}, 'Passphrase', pskIn)); - cfg.appendChild(h('label', {}, 'Encryption', encSel)); - cfg.appendChild(h('label', {}, 'Channel', wpaChan)); - cfg.appendChild(wpaHint); - cfg.appendChild(h('label', { class: 'switch' }, hiddenCb, h('span', { class: 'track' }), 'Hidden')); - cfg.appendChild(h('label', { class: 'switch' }, enabledCb, h('span', { class: 'track' }), 'Enabled')); - cfg.appendChild(h('div', { class: 'row' }, - h('div', {}, btn('Save', () => { - PagerAPI.post('/api/pineap/wifi/set_ap', { - wpa: { ssid: ssidIn.value, passphrase: pskIn.value, enctype: encSel.value, - hidden: hiddenCb.checked, enabled: enabledCb.checked, - channel: wpaChan.value ? parseInt(wpaChan.value, 10) : 1 } - }).then(() => { App.toast('Evil WPA saved'); load(); }).catch(() => App.toast('Failed', 'error')); - })), - h('div', { class: 'muted', style: 'align-self:center;font-size:12px' }, 'Applying reconfigures the radio — you may be disconnected briefly.'))); - - const capBox = h('div', { class: 'pineap-title-card' }, - h('div', { class: 'pineap-card-title' }, 'Handshake Capture')); - box.appendChild(capBox); - const captureCb = h('input', { type: 'checkbox', id: 'ew-capture' }); - const partialCb = h('input', { type: 'checkbox', id: 'ew-partial' }); - capBox.appendChild(h('div', { class: 'pineap-settings-section', text: 'Automatic Capture' })); - capBox.appendChild(h('label', { class: 'switch' }, captureCb, h('span', { class: 'track' }), 'Capture WPA handshakes')); - capBox.appendChild(h('label', { class: 'switch' }, partialCb, h('span', { class: 'track' }), 'Keep partial handshakes')); - capBox.appendChild(h('div', { class: 'muted', style: 'margin:6px 0 10px;font-size:12px' }, - 'Automatically save handshakes observed by PineAP. Partial captures may not contain enough material for password recovery.')); - capBox.appendChild(btn('Save capture settings', () => { - PagerAPI.post('/api/pineap/set_config', { - loghandshake: captureCb.checked, - logpartialhandshake: partialCb.checked - }).then(() => { App.toast('Handshake capture settings saved'); load(); }) - .catch(() => App.toast('Failed to save capture settings', 'error')); - }, 'ghost')); - capBox.appendChild(h('div', { class: 'pineap-settings-section', text: 'Targeted Capture' })); - const bssidIn = h('input', { id: 'ew-bssid', placeholder: 'BSSID' }); - const secsIn = h('input', { id: 'ew-secs', type: 'number', value: '30', style: 'max-width:80px' }); - capBox.appendChild(h('div', { class: 'row' }, - h('div', {}, h('label', {}, 'BSSID', bssidIn)), - h('div', {}, h('label', {}, 'Seconds', secsIn)), - h('div', {}, btn('Examine', () => { - const b = bssidIn.value.trim(); - if (!b) { App.toast('BSSID required', 'error'); return; } - PagerAPI.post('/api/pineap/examine', { bssid: b, seconds: parseInt(secsIn.value, 10) || 30 }) - .then(() => App.toast('Examining ' + b)).catch(() => App.toast('Failed', 'error')); - })), - h('div', {}, btn('Stop', () => PagerAPI.post('/api/pineap/examine', { reset: true }).then(() => App.toast('Stopped')), 'danger')))); - - const hsBody = h('div', {}); - const hsBox = h('div', { class: 'pineap-title-card pineap-card-handshakes' }, - h('div', { class: 'pineap-card-title' }, 'Captured Handshakes'), - hsBody); - box.appendChild(hsBox); - - function load() { - PagerAPI.post('/api/pineap/wifi/get_ap').then((r) => { - const w = (r.data || {}).wpa || {}; - ssidIn.value = w.ssid || ''; - pskIn.value = w.passphrase || ''; - if (w.enctype && Array.prototype.some.call(encSel.options, (o) => o.value === w.enctype)) { - encSel.value = w.enctype; - } - hiddenCb.checked = !!w.hidden; - enabledCb.checked = !!w.enabled; - if (w.channel != null) { - const opts = Array.prototype.slice.call(wpaChan.options); - if (opts.some((o) => Number(o.value) === Number(w.channel))) { - wpaChan.value = String(w.channel); - } - } - applyWpaHint(); - }).catch(() => {}); - PagerAPI.get('/api/pineap/get_config').then((r) => { - const p = r.data || {}; - captureCb.checked = !!p.loghandshake; - partialCb.checked = !!p.logpartialhandshake; - }).catch(() => {}); - PagerAPI.get('/api/pineap/handshakes').then((r) => { - hsBody.innerHTML = ''; - const rows = (r.data.handshakes || []).map((x) => ({ - name: x.name || '--', ap: x.ap || '--', client: x.client || '--', type: x.type || '--' - })); - hsBody.appendChild(table( - [{ label: 'File', key: 'name' }, { label: 'AP', key: 'ap' }, - { label: 'Client', key: 'client' }, { label: 'Type', key: 'type' }], - rows)); - if (!rows.length) hsBody.appendChild(h('div', { class: 'pineap-handshakes-none', text: 'No handshakes captured yet.' })); - }).catch(() => {}); - } - load(); - const iv = setInterval(load, 5000); - return { destroy: () => clearInterval(iv) }; -}; - -views.pineap_enterprise = (root) => { - const box = pineapShell(root, '#/pineap/enterprise'); - const cfg = h('div', { class: 'pineap-title-card' }, - h('div', { class: 'pineap-card-title' }, 'Evil Enterprise')); - box.appendChild(cfg); - const enabledCb = h('input', { type: 'checkbox', id: 'ee-enabled' }); - const authCb = h('input', { type: 'checkbox', id: 'ee-auth' }); - cfg.appendChild(h('label', { class: 'switch' }, enabledCb, h('span', { class: 'track' }), 'Enabled')); - cfg.appendChild(h('label', { class: 'switch' }, authCb, h('span', { class: 'track' }), 'Auth Pass Capture')); - enabledCb.addEventListener('change', () => PagerAPI.post('/api/pineap/hostapd', { pineape_disabled: !enabledCb.checked }).then(load).catch(() => { enabledCb.checked = !enabledCb.checked; App.toast('Failed', 'error'); })); - authCb.addEventListener('change', () => PagerAPI.post('/api/pineap/hostapd', { pineape_auth_pass: authCb.checked }).then(load).catch(() => { authCb.checked = !authCb.checked; App.toast('Failed', 'error'); })); - - function tableBox(name, endpoint, clearTable) { - const body = h('div', {}); - const tb = h('div', { class: 'pineap-title-card pineap-card-inject' }, - h('div', { class: 'pineap-card-title-flex' }, - h('span', { text: name }), - h('span', { class: 'toolbar-spacer' }), - btn('Clear', () => PagerAPI.post('/api/pineap/enterprise/clear', { table: clearTable }).then(load), 'danger')), - body); - box.appendChild(tb); - return { body, endpoint }; - } - const basic = tableBox('Basic Data', '/api/pineap/enterprise/basic', 'basic'); - const chall = tableBox('Challenge Data', '/api/pineap/enterprise/challenge', 'challenge'); - - function load() { - PagerAPI.get('/api/pineap/hostapd').then((r) => { - const hh = r.data || {}; - enabledCb.checked = !hh.pineape_disabled; - authCb.checked = !!hh.pineape_auth_pass; - }).catch(() => {}); - [basic, chall].forEach((t) => { - PagerAPI.get(t.endpoint).then((r) => { - const rows = (r.data.rows || []).slice(); - t.body.innerHTML = ''; - const cols = rows.length ? Object.keys(rows[0]).map((k) => ({ label: k, key: k })) - : [{ label: '—', key: '_none' }]; - t.body.appendChild(table(cols, rows)); - if (!rows.length) t.body.appendChild(h('div', { class: 'empty', text: 'No data captured.' })); - }).catch(() => {}); - }); - } - load(); - const iv = setInterval(load, 5000); - return { destroy: () => clearInterval(iv) }; -}; +}); views.pineap_impersonation = (root) => { const box = pineapShell(root, '#/pineap/impersonation'); @@ -1207,22 +922,6 @@ function reconFiltered(rows, q, colsArr) { // Attacks: one-click Evil WPA / Open / Enterprise launchers. // --------------------------------------------------------------------------- -const ATTACK_TABS = [ - { label: 'Overview', hash: '#/attacks' }, - { label: 'Evil WPA', hash: '#/attacks/wpa' }, - { label: 'Open AP', hash: '#/attacks/open' }, - { label: 'Evil Enterprise', hash: '#/attacks/enterprise' } -]; - -function attacksShell(root, activeHash) { - root.appendChild(h('h1', { class: 'page-title', text: 'Attacks' })); - tabBar(root, ATTACK_TABS, activeHash); - const box = h('div', {}); - box.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin:8px 0' }, - 'Targets: only networks you are authorized to test. The device is the source of truth — every change is verified against it.')); - root.appendChild(box); - return box; -} function attackBadge(ap) { if (!ap) return badge(false); @@ -1246,52 +945,9 @@ function verifiedToast(result) { else App.toast('Deploy failed', 'error'); } -views.attacks = (root) => { - const box = attacksShell(root, '#/attacks'); - const wrap = h('div', { class: 'pineap-title-card-container' }); - box.appendChild(wrap); - const kinds = [ - ['wpa', 'Evil WPA (PSK)', 'Clone a WPA2/WPA3-PSK network and capture the four-way handshake.', '#/attacks/wpa'], - ['open', 'Evil Open', 'Advertise an open network and watch who connects.', '#/attacks/open'], - ['enterprise', 'Evil Enterprise', 'Serve WPA2/3-Enterprise with PineAPE and harvest 802.1X credentials.', '#/attacks/enterprise'] - ]; - const statusEls = {}; - kinds.forEach(([kind, label, desc, hash]) => { - const card = h('div', { class: 'pineap-title-card' }); - const st = h('span', { class: 'badge', text: '—' }); - statusEls[kind] = st; - card.appendChild(h('div', { class: 'pineap-card-title' }, - h('a', { href: hash, style: 'color:var(--primary);cursor:pointer', - onclick: (e) => { e.preventDefault(); App.go(hash); } }, label))); - card.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin:6px 0', text: desc })); - card.appendChild(h('div', { class: 'row' }, st, - h('a', { class: 'btn ghost', href: hash, style: 'text-decoration:none', - onclick: (e) => { e.preventDefault(); App.go(hash); } }, 'Configure'))); - wrap.appendChild(card); - }); - function load() { - PagerAPI.get('/api/attacks/status').then((r) => { - const s = r.data || {}; - const live = (x) => !!(x && x.enabled); - const summary = { - wpa: live(s.wpa && s.wpa.radio0) || live(s.wpa && s.wpa.radio1) ? 'LIVE' : 'OFF', - open: live(s.open && s.open.radio0) || live(s.open && s.open.radio1) ? 'LIVE' : 'OFF', - enterprise: live(s.enterprise && s.enterprise.ap) ? 'LIVE' : 'OFF' - }; - Object.keys(summary).forEach((k) => { - statusEls[k].textContent = summary[k]; - statusEls[k].className = 'badge ' + (summary[k] === 'LIVE' ? 'on' : 'off'); - }); - }).catch(() => {}); - } - load(); - const iv = setInterval(load, 5000); - return { destroy: () => clearInterval(iv) }; -}; - function attackLauncher(kind, opts) { return (root) => { - const box = attacksShell(root, '#/attacks/' + kind); + const box = pineapShell(root, opts.tabHash || '#/pineap/evilwpa'); const form = h('div', { class: 'pineap-title-card' }); form.appendChild(h('div', { class: 'pineap-card-title' }, opts.title + (opts.subtitle ? ' — ' + opts.subtitle : ''))); @@ -1500,7 +1156,12 @@ views.harness = (root) => { const tok = h('code', { style: 'font-size:12px', text: '…' }); const endpoint = h('code', { style: 'font-size:12px', text: location.origin + '/mcp' }); infoBody.appendChild(h('div', { class: 'row' }, h('div', { style: 'min-width:130px', text: 'Endpoint' }), endpoint)); - infoBody.appendChild(h('div', { class: 'row' }, h('div', { style: 'min-width:130px', text: 'Bearer token' }), tok)); + infoBody.appendChild(h('div', { class: 'row' }, + h('div', { style: 'min-width:130px', text: 'Bearer token' }), tok, + h('div', {}, btn('Copy Token', () => { + navigator.clipboard.writeText(tok.textContent).then(() => App.toast('Token copied')) + .catch(() => App.toast('Copy failed', 'error')); + })))); infoBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px', text: 'Agents call POST /mcp with JSON-RPC 2.0 (MCP Streamable HTTP). The token is the current session token.' })); const snippet = h('pre', { style: 'font-size:12px;overflow:auto;background:rgba(127,127,127,.12);padding:10px;border-radius:4px;white-space:pre-wrap' }); @@ -1510,38 +1171,6 @@ views.harness = (root) => { capBox.appendChild(capBody); box.appendChild(capBox); - const promptBox = h('div', { class: 'pineap-title-card' }); - promptBox.appendChild(h('div', { class: 'pineap-card-title' }, 'Prompt for pi.dev')); - const promptArea = h('textarea', { rows: 14, style: 'width:100%;font-family:monospace;font-size:12px;box-sizing:border-box' }); - promptBox.appendChild(promptArea); - promptBox.appendChild(h('div', { class: 'row', style: 'margin-top:8px' }, - h('div', {}, btn('Copy Prompt', () => { - promptArea.select(); - document.execCommand('copy'); - App.toast('Copied'); - })), - h('div', {}, btn('Copy Token', () => { - navigator.clipboard.writeText(tok.textContent).then(() => App.toast('Token copied')) - .catch(() => App.toast('Copy failed', 'error')); - })))); - box.appendChild(promptBox); - - function buildPrompt(token) { - return 'You are driving a WiFi Pineapple Pager (FENRIS firmware) through its local MCP harness.\n' + - 'Endpoint: ' + location.origin + '/mcp (Streamable HTTP, POST JSON-RPC 2.0).\n' + - 'Authorization: Bearer ' + token + '\n\n' + - 'Before acting, read these resources (MCP resources/read) — they are the field-verified operating manual:\n' + - ' skills://pineapple-control (device access, radios, UCI truth, pineapd crash-loop fix)\n' + - ' skills://wifi-deauth (deauth + handshake methodology, PMKSA failure modes)\n' + - ' skills://aircrack-suite (hashcat handoff)\n\n' + - 'Rules:\n' + - '1. The DEVICE is the source of truth: read device.state / UCI before and after every change; never assume.\n' + - '2. Only attack the network the operator explicitly authorized (currently Zuccaro_iPhone_15). No deauth blasts — short targeted bursts.\n' + - '3. After attack.deploy, verify with attack.status (live flag) before proceeding.\n' + - '4. Use the playbook prompts (prompts/get): evil-wpa-attack, evil-enterprise-attack, recon-survey.\n' + - '5. Report verified outcomes only; say what you changed on the device.'; - } - function load() { PagerAPI.get('/api/harness/capabilities').then((r) => { const d = r.data || {}; @@ -1562,7 +1191,6 @@ views.harness = (root) => { ' -H "Content-Type: application/json" \\\n' + ' -H "Authorization: Bearer ' + t + '" \\\n' + ' -d \'{"jsonrpc":"2.0","id":1,"method":"tools/list"}\''; - promptArea.value = buildPrompt(t); infoBody.appendChild(snippet); }).catch(() => {}); } @@ -1633,53 +1261,8 @@ function deauthPanel(ssidRef) { return wrap; } -views.attacks_wpa = attackLauncher('wpa', { - title: 'Evil WPA', - subtitle: 'WPA2-PSK / WPA3-SAE / WPA3-OWE evil twin with handshake capture', - passphrase: true, - encodings: EVIL_ENC, - handshakes: true, - export: true, - deauth: true, - playbook: { - steps: ['Deploy the evil twin', - 'Wait for a client to associate', - 'Deauth the target client to force the 4-way', - 'Export .hc22000 and crack with hashcat'], - currentStep: (s, w) => { - if (!w || !w.enabled) return 'Deploy the evil twin'; - if (!s || !(s.handshakes > 0)) return 'Wait for a client to associate'; - return 'Export .hc22000 and crack with hashcat'; - }, - hint: (s, w) => { - if (!w || !w.enabled) return '1. Set the target SSID and passphrase, pick a channel, Deploy. 2. When the target client is near, use Deauth Targeting below. 3. Captured handshakes appear above — Export and run the hashcat command.'; - if (s && s.handshakes > 0) return 'Handshake captured! Export .hc22000 and run hashcat -m 22000.'; - return 'AP is live on ' + (w.ssid || 'the target') + '. Watch the handshakes list — use Deauth Targeting to nudge the client. If a client refuses to join the twin, its reconnect to the real AP is still captured passively.'; - } - } -}); - -views.attacks_open = attackLauncher('open', { - title: 'Evil Open', - subtitle: 'Open network evil twin', - bssid: true, - country: true, - playbook: { - steps: ['Deploy the open AP', - 'Wait for clients to associate', - 'Watch connected clients under PineAP → Clients'], - currentStep: (s, w) => { - if (!w || !w.enabled) return 'Deploy the open AP'; - return 'Wait for clients to associate'; - }, - hint: (s, w) => !w || !w.enabled - ? 'Set the SSID (optionally spoof a BSSID), pick a channel, Deploy.' - : 'Open AP is live on ' + (w.ssid || 'the target') + ' — clients that join appear in the Clients list.' - } -}); - -views.attacks_enterprise = (root) => { - const box = attacksShell(root, '#/attacks/enterprise'); +views.pineap_enterprise = (root) => { + const box = pineapShell(root, '#/pineap/enterprise'); const form = h('div', { class: 'pineap-title-card' }); form.appendChild(h('div', { class: 'pineap-card-title' }, 'Evil Enterprise — WPA2/3-Enterprise with PineAPE credential harvest')); @@ -1692,15 +1275,19 @@ views.attacks_enterprise = (root) => { .forEach(([v, l]) => encSel.appendChild(h('option', { value: v, text: l }))); const pskIn = h('input', { id: 'ent-pass', type: 'password', autocomplete: 'new-password' }); const hiddenCb = h('input', { type: 'checkbox', id: 'ent-hidden' }); + const chanSel = h('select', { id: 'ent-channel' }); + chanSelect(chanSel, null); f.appendChild(h('label', {}, 'SSID', ssidIn)); f.appendChild(h('label', {}, 'Encryption', encSel)); f.appendChild(h('label', {}, 'Passphrase (EAP server secret)', pskIn)); f.appendChild(h('label', { class: 'switch' }, hiddenCb, h('span', { class: 'track' }), 'Hidden')); + f.appendChild(h('label', {}, 'Channel (5 GHz only — Auto uses the target SSID\u2019s recon channel)', chanSel)); f.appendChild(h('div', { class: 'row', style: 'margin-top:10px' }, h('div', {}, btn('Deploy Attack', () => { PagerAPI.post('/api/attacks/deploy', { kind: 'enterprise', ssid: ssidIn.value.trim(), - enctype: encSel.value, passphrase: pskIn.value, hidden: hiddenCb.checked + enctype: encSel.value, passphrase: pskIn.value, hidden: hiddenCb.checked, + channel: chanSel.value ? parseInt(chanSel.value, 10) : null }).then((r) => { verifiedToast(r.data || {}); load(); }) .catch((e) => App.toast(e.message || 'Deploy failed', 'error')); })), @@ -1779,11 +1366,15 @@ views.attacks_enterprise = (root) => { }; function reconEncBucket(enc) { const s = (enc || '').trim(); - if (s === 'Open') return 'Open'; - if (s.indexOf('Enterprise') !== -1) return 'Enterprise'; + if (!s || s === 'Open') return 'Open'; if (s.indexOf('WEP') !== -1) return 'WEP'; - if (s.indexOf('WPA2') !== -1) return 'WPA2'; - if (s.indexOf('WPA3') !== -1) return 'WPA3'; + if (s.indexOf('Enterprise') !== -1) { + return s.indexOf('WPA3') !== -1 ? 'WPA3-Enterprise' : 'WPA2-Enterprise'; + } + if (s.indexOf('SAE') !== -1 || s.indexOf('OWE') !== -1) return 'WPA3-Personal'; + if (s.indexOf('WPA3') !== -1 && s.indexOf('WPA2') !== -1) return 'WPA2-PSK'; + if (s.indexOf('WPA3') !== -1) return 'WPA3-PSK'; + if (s.indexOf('WPA2') !== -1) return 'WPA2-PSK'; if (s.indexOf('WPA') !== -1) return 'WPA'; return s || 'Unknown'; } @@ -1878,8 +1469,6 @@ views.recon = (root) => { hsCol.appendChild(hsAuto); const psContent = titleCard('Previous Scans', false); - const psRow = h('div', { class: 'recon-ps-row' }); - psContent.appendChild(psRow); let pickerOptions = []; const sel = h('select', { class: 'sel', id: 'recon-scan-select' }); sel.addEventListener('change', () => { @@ -1891,7 +1480,6 @@ views.recon = (root) => { state.detailId = null; state.detailArchive = null; loadDetail(); }); - psRow.appendChild(sel); function dlBase() { if (state.selected == null) return null; return state.archive @@ -1910,9 +1498,6 @@ views.recon = (root) => { const base = dlBase(); if (base) window.location = base + '/download/html'; }); - psRow.appendChild(dlJson); - psRow.appendChild(dlCsv); - psRow.appendChild(dlHtml); const delBtn = iconBtn('delete', 'Delete scan', () => { if (state.selected == null || state.archive) return; if (!confirm('Delete scan #' + state.selected + '? This cannot be undone.')) return; @@ -1920,7 +1505,26 @@ views.recon = (root) => { .then(() => { App.toast('Scan deleted'); load(); }) .catch(() => App.toast('Delete failed', 'error')); }); - psRow.appendChild(delBtn); + const delAllBtn = iconBtn('delete_forever', 'Delete all scans', () => { + if (!confirm('Delete ALL recorded scans? This cannot be undone.')) return; + PagerAPI.del('/api/recon/scans') + .then((r) => { + App.toast('All scans deleted' + (r.data && r.data.deleted ? ' (' + r.data.deleted + ')' : '')); + state.selected = null; state.archive = null; + load(); + }) + .catch(() => App.toast('Delete failed', 'error')); + }); + const psActions = h('div', { class: 'row', style: 'margin:6px 0 8px' }); + psActions.appendChild(dlJson); + psActions.appendChild(dlCsv); + psActions.appendChild(dlHtml); + psActions.appendChild(delBtn); + psActions.appendChild(delAllBtn); + psContent.appendChild(psActions); + const psRow = h('div', { class: 'recon-ps-row' }); + psContent.appendChild(psRow); + psRow.appendChild(sel); // ---- scan bar ---- const scanBar = h('div', { class: 'section recon-scan-bar' }); @@ -2280,7 +1884,8 @@ views.recon = (root) => { const groups = [ ['Band', 'apBand', [['all', 'All'], ['2.4', '2.4 GHz'], ['5', '5 GHz'], ['6', '6 GHz']]], ['Encryption', 'apEnc', [['all', 'All'], ['Open', 'Open'], ['WEP', 'WEP'], ['WPA', 'WPA'], - ['WPA2', 'WPA2'], ['WPA3', 'WPA3'], ['Enterprise', 'Enterprise']]] + ['WPA2-PSK', 'WPA2-PSK'], ['WPA2-Enterprise', 'WPA2-Enterprise'], + ['WPA3-PSK', 'WPA3-Personal'], ['WPA3-Enterprise', 'WPA3-Enterprise']]] ]; groups.forEach(([label, key, opts]) => { chipRow.appendChild(h('span', { class: 'recon-chips-label', text: label })); @@ -2342,21 +1947,12 @@ views.recon = (root) => { function filteredRows(key) { const d = state.detail || {}; if (key === 'client') { - if (state.compare.length) return []; return reconFiltered(d.clients || [], state.clientSearch, RECON_CLIENT_COLS); } + // Comparing never hides the list: selection just adds chips, a compare + // table and charts. The full AP set stays visible so more boxes can be + // ticked without clearing the selection first. const all = d.aps || []; - if (state.compare.length) { - if (state.apSearch) { - // Candidate list: search the full AP list so more networks can be - // added without clearing the selection. Band/enc chips apply here. - let out = reconFiltered(all, state.apSearch, RECON_AP_COLS); - if (state.apBand !== 'all') out = out.filter((a) => (a.band || '') === state.apBand); - if (state.apEnc !== 'all') out = out.filter((a) => reconEncBucket(a.encryption) === state.apEnc); - return out; - } - return all.filter((a) => state.compare.indexOf(a.bssid) !== -1); - } let out = reconFiltered(all, state.apSearch, RECON_AP_COLS); if (state.apBand !== 'all') out = out.filter((a) => (a.band || '') === state.apBand); if (state.apEnc !== 'all') out = out.filter((a) => reconEncBucket(a.encryption) === state.apEnc); @@ -2408,7 +2004,8 @@ views.recon = (root) => { const slice = rows.slice(start, start + per); const rowAttrs = key === 'ap' ? (r) => ({ - class: state.focusAp && state.focusAp.bssid === r.bssid ? 'recon-row-selected' : '', + class: (state.focusAp && state.focusAp.bssid === r.bssid ? 'recon-row-selected' : '') + + (state.compare.indexOf(r.bssid) !== -1 ? ' recon-row-compare' : ''), style: 'cursor:pointer', onclick: () => toggleFocus(r) }) @@ -2475,12 +2072,10 @@ views.recon = (root) => { const d = state.detail || { aps: [], clients: [], handshakes: [] }; const apF = filteredRows('ap'); const cliF = filteredRows('client'); - cliCard.classList.toggle('hidden', state.compare.length > 0); + cliCard.classList.remove('hidden'); renderTable(apBody, 'ap', sortRows(apF, 'ap', apCols), apCols, - state.compare.length && !state.apSearch ? 'No access points selected.' : 'No access points in this scan.'); - if (!state.compare.length) { - renderTable(cliBody, 'client', sortRows(cliF, 'client', RECON_CLIENT_COLS), RECON_CLIENT_COLS, 'No clients in this scan.'); - } + 'No access points in this scan.'); + renderTable(cliBody, 'client', sortRows(cliF, 'client', RECON_CLIENT_COLS), RECON_CLIENT_COLS, 'No clients in this scan.'); } function drawCharts(d) { @@ -2756,24 +2351,34 @@ views.recon_reports = (root) => { reportCard.appendChild(h('h2', { text: 'Scan Reports' })); const box = h('div'); reportCard.appendChild(box); - PagerAPI.get('/api/recon/scans').then((r) => { - const scans = (r.data && r.data.scans) || []; - box.innerHTML = ''; - if (!scans.length) { box.appendChild(h('div', { class: 'empty', text: 'No scans recorded yet.' })); return; } - box.appendChild(table( - [ - { key: 'id', label: 'Scan', render: (s) => '#' + s.id }, - { key: 'time', label: 'Started', render: (s) => fmtTime(s.time) }, - { key: 'aps', label: 'APs' }, - { key: 'devices', label: 'Clients' }, - { key: 'handshakes', label: 'Handshakes' }, - { key: 'actions', label: 'Download', render: (s) => h('span', { class: 'hs-actions' }, - iconBtn('file_download', 'JSON', () => dl('/api/recon/scans/' + s.id + '/download/json')), - iconBtn('table_chart', 'CSV', () => dl('/api/recon/scans/' + s.id + '/download/csv')), - iconBtn('description', 'HTML report', () => dl('/api/recon/scans/' + s.id + '/download/html'))) } - ], - scans)); - }).catch(() => box.appendChild(h('div', { class: 'empty', text: 'Failed to load scans.' }))); + let gpsFix = null; + PagerAPI.get('/api/recon/gps').then((r) => { + const g = r.data || {}; + gpsFix = g.lock ? { lat: g.lat, lon: g.lon, sats: g.satellites } : null; + }).catch(() => {}).then(() => { + PagerAPI.get('/api/recon/scans').then((r) => { + const scans = (r.data && r.data.scans) || []; + box.innerHTML = ''; + if (!scans.length) { box.appendChild(h('div', { class: 'empty', text: 'No scans recorded yet.' })); return; } + const gpsCell = (s) => gpsFix + ? h('span', { class: 'recon-gps-cell', text: Number(gpsFix.lat).toFixed(5) + ', ' + Number(gpsFix.lon).toFixed(5) }) + : h('span', { class: 'muted', text: '—' }); + box.appendChild(table( + [ + { key: 'id', label: 'Scan', render: (s) => '#' + s.id }, + { key: 'time', label: 'Started', render: (s) => fmtTime(s.time) }, + { key: 'gps', label: 'GPS', render: gpsCell }, + { key: 'aps', label: 'APs' }, + { key: 'devices', label: 'Clients' }, + { key: 'handshakes', label: 'Handshakes' }, + { key: 'actions', label: 'Download', render: (s) => h('span', { class: 'hs-actions' }, + iconBtn('file_download', 'JSON', () => dl('/api/recon/scans/' + s.id + '/download/json')), + iconBtn('table_chart', 'CSV', () => dl('/api/recon/scans/' + s.id + '/download/csv')), + iconBtn('description', 'HTML report', () => dl('/api/recon/scans/' + s.id + '/download/html'))) } + ], + scans)); + }).catch(() => box.appendChild(h('div', { class: 'empty', text: 'Failed to load scans.' }))); + }); } function renderWigle() { diff --git a/tests/test_attacks.py b/tests/test_attacks.py index a6e8ddd..11cf777 100644 --- a/tests/test_attacks.py +++ b/tests/test_attacks.py @@ -1,5 +1,6 @@ import os import shutil +import sqlite3 import sys import tempfile import unittest @@ -133,6 +134,54 @@ class AttacksDeployTest(unittest.TestCase): self.assertEqual(payload['iface'], 'wlan1wpa') self.assertEqual(payload['band'], server.BAND_5G) + def test_deploy_wpa_auto_channel_defaults_to_1_without_recon(self): + status, payload = server.h_attacks_deploy(ctx({ + 'kind': 'wpa', 'ssid': 'UnknownNet', 'passphrase': 'secretpass1', + 'enctype': 'psk2', 'hidden': False})) + self.assertEqual(status, 200) + self.assertTrue(payload['auto']) + self.assertEqual(payload['channel'], 1) + self.assertEqual(payload['band'], server.BAND_2G) + cfg = [s for s in self.f.sock if s[0] == 'PUT' and s[1] == '/api/settings/wifi/set_ap'][0][2] + self.assertEqual(cfg['configs'][0]['channel'], 1) + + def test_deploy_wpa_auto_channel_uses_recon_target_channel(self): + db = self._make_recon_db() + old = server.RECON_DB + server.RECON_DB = db + try: + status, payload = server.h_attacks_deploy(ctx({ + 'kind': 'wpa', 'ssid': 'Anderson-5', 'passphrase': 'secretpass1', + 'enctype': 'psk2', 'hidden': False})) + finally: + server.RECON_DB = old + os.unlink(db) + self.assertEqual(status, 200) + self.assertEqual(payload['channel'], 149) + self.assertEqual(payload['band'], server.BAND_5G) + self.assertEqual(self.f.state['wireless.radio1.channel'], '149') + + def _make_recon_db(self): + fd, db = tempfile.mkstemp(suffix='.db') + os.close(fd) + conn = sqlite3.connect(db) + conn.executescript( + 'CREATE TABLE scan(id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT,' + ' time INT, name TEXT);' + 'CREATE TABLE wifi_device(hash INT PRIMARY KEY, scan INT, mac TEXT,' + ' time INT, signal INT, freq INT, packets INT);' + 'CREATE TABLE ssid(hash INT PRIMARY KEY, wifi_device INT, scan INT,' + ' type INT, bssid TEXT, ssid BLOB, hidden INT, time INT, signal INT,' + ' freq INT, channel INT, encryption INT);') + conn.execute("INSERT INTO scan (uuid, time, name) VALUES ('u1', 1, 'pager')") + conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden," + " time, signal, freq, channel, encryption) VALUES" + " (10, 1, 1, 8, 'C89E43648080', X'416E646572736F6E2D35', 0," + " 1786466532, -76, 5745, 149, 0x400400108)") + conn.commit() + conn.close() + return db + def test_deploy_open_2g4_includes_bssid_and_country(self): status, payload = server.h_attacks_deploy(ctx({ 'kind': 'open', 'ssid': 'Guest', 'hidden': False, diff --git a/tests/test_recon.py b/tests/test_recon.py index 5d06a76..1d518eb 100644 --- a/tests/test_recon.py +++ b/tests/test_recon.py @@ -76,8 +76,13 @@ class DecodersTest(unittest.TestCase): self.assertEqual(server.decode_encryption(0x04), 'WPA') self.assertEqual(server.decode_encryption(0x08), 'WPA2') self.assertEqual(server.decode_encryption(0x04 | 0x08), 'WPA2 WPA') - self.assertEqual(server.decode_encryption(0x400400108), 'WPA3 WPA2') - self.assertEqual(server.decode_encryption(0x20050004C), 'WPA2 WPA') + self.assertEqual(server.decode_encryption(0x400400108), 'WPA3 WPA2 PSK') + self.assertEqual(server.decode_encryption(0x400400108 | (1 << 33)), 'WPA3 WPA2 Enterprise') + self.assertEqual(server.decode_encryption(0x400400108 | (1 << 40)), 'WPA3 WPA2 SAE') + self.assertEqual(server.decode_encryption(0x400400108 | (1 << 33) | (1 << 40)), 'WPA3 WPA2 Enterprise') + self.assertEqual(server.decode_encryption(0x400400108 | (1 << 45)), 'WPA3 WPA2 OWE') + self.assertEqual(server.decode_encryption(0x400400110), 'WPA3 PSK') + self.assertEqual(server.decode_encryption(0x20050004C), 'WPA2 WPA Enterprise') class ReconDataTest(unittest.TestCase): @@ -111,7 +116,7 @@ class ReconDataTest(unittest.TestCase): self.assertEqual(a['ssid'], 'Anderson-5') self.assertEqual(a['channel'], 149) self.assertEqual(a['signal'], -76) - self.assertEqual(a['encryption'], 'WPA3 WPA2') + self.assertEqual(a['encryption'], 'WPA3 WPA2 PSK') self.assertFalse(a['hidden']) hidden = aps['50:6F:9A:01:00:00'] self.assertTrue(hidden['hidden']) @@ -1193,4 +1198,3 @@ class ReconRoutesTest(unittest.TestCase): method = 'GET' if path.endswith(('status', 'scans', 'events')) else 'POST' h, args = server.ROUTER.dispatch(method, path) self.assertIsNotNone(h, path) -