A four-device, distributed wireless reconnaissance & WIDS toolkit for authorized homelab experiments and penetration-testing engagements.
One coordinator hosts a WiFi access point and a live web dashboard; three headless ESP32-S3 nodes join it and stream WiFi, 802.11-monitor, and BLE telemetry over JSON. Everything aggregates into a single terminal-styled dashboard you can drive from a phone or laptop.
Warning
Passive recon + defensive monitoring only. At the radio layer this toolkit transmits only what a normal WiFi/BLE scan emits, plus a single on-demand GATT client connection. It does not deauth, inject, run a rogue AP, or store raw payloads/handshakes. Run it only on networks and devices you own or have explicit written authorization to test. See Security & legal.
- Architecture
- Devices at a glance
- Hardware / bill of materials
- Quick start
- Configuration
- The nodes in detail
- The C2 dashboard
- Status LEDs
- HTTP API & data export
- /ingest protocol
- Project layout
- Manual page
- Troubleshooting
- Security & legal
[WarDriver] [WarSniffer] [BlueDriver] ESP32-S3 nodes (headless)
\ | /
\ | / WiFi STA → POST /ingest (JSON)
\ | /
[ C2 coordinator ] ESP32-S2, SoftAP "ESP32-NET"
│
HTTP :80 dashboard browser @ http://192.168.4.1/
The three S3 nodes are headless — no display, no local web UI. They
associate to the coordinator's SoftAP, POST a compact JSON snapshot to
/ingest every few seconds, and receive operator control commands back in the
response. The coordinator keeps a capped in-RAM inventory, aggregates all node
data, and serves the four-tab dashboard at http://192.168.4.1/ (or
http://esp32net.local/ where mDNS is supported).
| Directory | Hardware | Role |
|---|---|---|
c2-esp32s2/ |
ESP32-S2 (Flipper WiFi Dev Board) | SoftAP + unified web dashboard + command relay |
wardriver-node/ |
ESP32-S3-DevKitC-1 N16R8 | WiFi survey + GPS + WigleWifi CSV log |
warsniffer-node/ |
ESP32-S3-DevKitC-1 N16R8 | Passive 802.11 monitor + WIDS + probe harvest |
bluedriver-node/ |
ESP32-S3-DevKitC-1 N16R8 | Continuous BLE scanner + GATT enumeration |
Note
ESP32-S3 = BLE 5.0 only. No Bluetooth Classic (BR/EDR) — a hardware limit of the S3 silicon. Only the original ESP32 (non-S/C) has Classic.
| Qty | Part | Notes |
|---|---|---|
| 1 | ESP32-S2 board | Flipper Zero "WiFi Dev Board" (ESP32-S2-WROVER) or any S2 — runs the C2 |
| 3 | ESP32-S3-DevKitC-1 N16R8 | 16 MB flash, 8 MB octal PSRAM — the scan nodes |
| 1 | NEO-6M GPS module | WarDriver only; UART + 3V3/GND |
| — | USB-C cables, a battery/power bank | for field use |
You can run a subset — e.g. just the C2 + WarDriver. Nodes that never report simply show OFFLINE on the dashboard.
Each directory is a self-contained PlatformIO project. Flash the C2 first so its SoftAP exists before the nodes try to join; order among the three nodes does not matter.
# 1) Coordinator (creates the ESP32-NET access point + dashboard)
cd c2-esp32s2 && pio pkg install && pio run -t upload
# 2) The three scan nodes
cd ../wardriver-node && pio pkg install && pio run -t upload
cd ../warsniffer-node && pio pkg install && pio run -t upload
cd ../bluedriver-node && pio pkg install && pio run -t upload
# Watch any node's serial console (115200 baud)
pio device monitorThen on your phone/laptop:
- Join WiFi
ESP32-NET(default passworddropnetc2!). - Browse to
http://192.168.4.1/(orhttp://esp32net.local/).
Common PlatformIO targets:
| Command | Action |
|---|---|
pio run |
Compile only |
pio run -t upload |
Compile + flash firmware over USB |
pio run -t uploadfs |
Flash the LittleFS data partition |
pio device monitor |
Serial console at 115200 baud |
Compile-time settings live in each project's src/config.h and must be
edited before flashing.
Important
Change the default AP credentials before any field use. The coordinator's
AP_SSID / AP_PASSWORD (in c2-esp32s2/src/config.h) must match the
C2_SSID / C2_PASSWORD in every node's config.
| Setting | Default | Where |
|---|---|---|
| AP SSID | ESP32-NET |
C2 AP_SSID, nodes C2_SSID |
| AP password | dropnetc2! |
C2 AP_PASSWORD, nodes C2_PASSWORD |
| Dashboard URL | http://192.168.4.1/ |
— |
| mDNS host | esp32net.local |
C2 MDNS_HOST |
Other notable knobs:
- WarDriver —
GPS_RX_PIN/GPS_TX_PIN/GPS_BAUD,WIFI_LOG_PATH,LOG_MAX_BYTES. - WarSniffer —
CHANNEL_MIN/CHANNEL_MAX/HOP_INTERVAL_MS, and the WIDS thresholdsDEAUTH_THRESHOLD,BEACON_FLOOD_THRESHOLD,EVIL_TWIN_RSSI_DELTA. - BlueDriver —
DEFAULT_ACTIVE_SCAN,SCAN_INTERVAL/SCAN_WINDOW. - All nodes —
STATUS_LED_ENABLED,STATUS_LED_PIN,LED_BRIGHTNESS.
Runs active WiFi scans, stamps each network with a NEO-6M GPS fix, writes a
WigleWifi-1.4 CSV to LittleFS (/wigle.csv), and flags surveillance/rogue
patterns heuristically.
GPS wiring (NEO-6M):
| NEO-6M pin | ESP32-S3 GPIO |
|---|---|
| TX (GPS output) | GPIO 18 (firmware RX) |
| RX (GPS input) | GPIO 17 (firmware TX) |
| VCC | 3.3 V |
| GND | GND |
Change GPS_RX_PIN / GPS_TX_PIN in wardriver-node/src/config.h for other
GPIOs. CSV rows are written only once a network has a GPS-stamped position, so
the log doesn't fill with 0,0 coordinates before the first fix. SSIDs are
RFC-4180-quoted so a comma in a network name can't shift the CSV columns.
Channel-hops 1–13 in promiscuous mode.
Does:
- Count frame types (mgmt / ctrl / data / beacon / probe / deauth / EAPOL).
- Build an AP + client-count inventory from beacon and data frames.
- Live packet capture (Wireshark-style). Buffers raw frame prefixes (first 80 bytes) sampled across the sniff window and ships them to the C2, where the dashboard renders a live packet list and a click-to-dissect view with a decoded-802.11 field pane and a hex/ASCII dump — read frames the way you would in Wireshark, with the project's green-on-black vibe.
- Malicious-device / surveillance watchlist. A customizable list of MACs and
OUIs (edited from the dashboard, persisted on the C2, pushed to the node) that
raises a labelled
WATCHLISTalert the moment a matching device is seen. - Harvest directed probe-request SSIDs — the preferred-network lists devices broadcast while hunting for remembered APs. Fully passive (it only listens).
- Raise WIDS alerts: deauth flood (>20/s), beacon flood (>40 unique SSIDs/s), evil-twin (same SSID, new BSSID, ≥25 dBm RSSI delta).
Does not:
- Transmit anything on the monitored band (no deauth, no injection, no rogue AP).
- Store EAPOL/WPA handshake payloads (EAPOL frames are counted only).
- Write PCAPs to flash — the capture view is a bounded, live sample of recent frame prefixes, not a full on-flash packet log.
Passive or active BLE advertisement scan via NimBLE v2.x. To keep the WiFi link rock-solid it interleaves the radios instead of relying on WiFi/BLE coexistence: it scans BLE with WiFi off, then briefly stops the scan, brings WiFi up only long enough to POST, drops it, and resumes scanning — the two radios are never active at the same time, which is what makes association reliable.
Does:
- Per-device inventory: MAC, name, address type, vendor OUI, advertised service UUIDs, manufacturer company ID, best RSSI, hit count.
- GATT service/characteristic enumeration on demand — click a device row in
the dashboard (or send a
gattcommand). Reads service/char UUIDs, derived properties, and short readable values.
Does not:
- Scan Bluetooth Classic (hardware limitation of the ESP32-S3).
- Transmit advertisements or connect to anything without an explicit
gattcommand. - Persist or exfiltrate characteristic values beyond the immediate POST to C2.
The coordinator serves a single-page dashboard with one tab per node. It polls
/api/data every 2 seconds; toolbar buttons queue control commands delivered
to nodes in their next /ingest response.
| Tab | Shows |
|---|---|
| OVERVIEW | Per-node online/offline state, uptime, free heap, aggregate counts |
| WARDRIVER | AP table (BSSID, SSID, channel, encryption, RSSI, vendor, threat flag) + GPS fix |
| WARSNIFFER | Frame-type counters, AP/STA inventory, WIDS alert log, harvested probe SSIDs, a Wireshark-style live capture (packet list + click-to-dissect with a decoded-fields pane and a hex/ASCII dump), and a watchlist editor |
| BLUEDRIVER | BLE device table (+ mfg company ID); click a row to run GATT, result shown below |
The C2's in-RAM stores evict least-recently-seen entries once full, so new
devices keep appearing during long runs, and the CLEAR button wipes the
C2's mirror of a node's data as well as the node's own store — the dashboard
no longer shows stale rows after a clear.
Each headless node drives its onboard WS2812 (GPIO 48 on the DevKitC-1) so you can read its state in the field with no serial console:
| Colour | WarDriver | WarSniffer | BlueDriver |
|---|---|---|---|
| 🟢 green | scanning, GPS fix | sniffing | scanning |
| 🔵 cyan | scanning, no fix | — | — |
| 🔷 blue | reporting to C2 | reporting to C2 | reporting to C2 |
| 🟣 purple | — | — | GATT enum running |
| 🔴 red | — | recent WIDS alert | — |
| 🟠 amber | scan paused | capture stopped | scan paused |
Set STATUS_LED_ENABLED false in a node's config.h to disable it. The LED
uses the Arduino-ESP32 core's built-in neopixelWrite() — no extra dependency.
All endpoints are served by the coordinator on port 80.
| Method & path | Purpose |
|---|---|
GET / |
The dashboard (HTML) |
GET /api/data |
Full aggregate snapshot (JSON) — polled by the dashboard |
GET /api/packets?since=<seq> |
Incremental live-capture feed (WarSniffer frames) |
GET /api/watch / POST /api/watch |
Read / edit the MAC-OUI watchlist |
POST /api/cmd |
Queue a control command for a node |
GET /api/export/wifi |
WarDriver inventory as CSV (incl. GPS lat/lon) |
GET /api/export/ble |
BlueDriver inventory as CSV |
GET /api/export/sniff |
WarSniffer AP inventory as CSV |
POST /ingest |
Node telemetry (see below) |
Each dashboard tab has an EXPORT CSV button that downloads every device
that node has reported. Exports are RFC-4180 and sent with a
Content-Disposition attachment header. The WarDriver CSV columns are
BSSID, SSID, Channel, Frequency, Encryption, RSSI, Latitude, Longitude, Vendor, Flag, AgeSec — a complete, organized network list ready for reporting or import.
The BlueDriver CSV lists every BLE device with address, name, type, vendor,
manufacturer ID, RSSI, hit count, and service UUIDs.
The WarDriver also writes a full WigleWifi-1.4 log to
/wigle.csvon its own LittleFS partition — the canonical, GPS-stamped record for wigle.net uploads in very dense environments that exceed the C2's in-RAM cap.
Examples:
curl -OJ http://192.168.4.1/api/export/wifi # -> wifi.csv
# Stop the WarDriver scan from the CLI
curl -X POST http://192.168.4.1/api/cmd \
-H 'Content-Type: application/json' \
-d '{"target":"wardriver","cmd":"scan","on":0}'
# Enumerate GATT on a BLE device (addrType 1 = random)
curl -X POST http://192.168.4.1/api/cmd \
-H 'Content-Type: application/json' \
-d '{"target":"bluedriver","cmd":"gatt","mac":"AA:BB:CC:DD:EE:FF","addrType":1}'All three nodes POST application/json to http://192.168.4.1/ingest.
Request envelope:
{
"source": "ESP32-WarDriver | ESP32-WarSniffer | ESP32-BlueDriver",
"ver": "1.0.0",
"linkEpoch": 0,
"lastCmdId": 0,
"status": { "uptime": 0, "heap": 0, "...": "node-specific" },
"count": 0,
"devices | aps | alerts | probes": [ ]
}Response envelope:
{
"ok": 1,
"epoch": 3914820157,
"commands": [ { "id": 7, "cmd": "scan", "on": 0 } ]
}Command grammar (entries in the response commands array):
cmd |
Fields | Effect |
|---|---|---|
scan |
on:1/0 |
Start / stop scanning |
clear |
— | Wipe the node's device store |
hop |
on:1/0 |
WarSniffer: channel hopping on/off |
config |
on:<ch> |
WarSniffer: lock to a specific channel |
config |
on:1/0 |
BlueDriver: active(1) vs passive(0) BLE scan |
gatt |
mac:"AA:..", addrType:0/1 |
BlueDriver: connect + enumerate GATT (~8 s) |
Commands carry a monotonic id. A node acks by echoing its highest applied id
as lastCmdId; the coordinator then drops acked commands from the queue. The
linkEpoch / epoch handshake gives exactly-once delivery across a reboot of
either board — a fresh epoch resets the node's ack counter so stale
post-reboot acks can't silently drop new commands, and triggers a node to
re-sync its full inventory so the C2 store (and CSV export) repopulates after a
C2 reboot. The C2 command queue carries the gatt target mac + addrType
through to the node.
Control commands are delivered on the node's next check-in (every few seconds), not instantly. The dashboard shows a "queued" toast on each press so you get immediate confirmation the command was accepted.
ESP32-Net/
├── c2-esp32s2/ Coordinator (SoftAP, dashboard, /ingest, command relay)
│ ├── src/
│ │ ├── main.cpp HTTP server, aggregate store, command queue
│ │ ├── web_ui.h Single-page dashboard (PROGMEM)
│ │ └── config.h AP creds, store caps, timeouts
│ ├── platformio.ini
│ └── partitions.csv
├── wardriver-node/ WiFi survey + GPS + WigleWifi CSV
├── warsniffer-node/ Passive 802.11 monitor + WIDS + probe harvest
├── bluedriver-node/ BLE scanner + GATT enumeration
├── docs/
│ └── esp32-net.7 Project man page (section 7)
└── README.md
A groff man page ships in docs/esp32-net.7.
# View it in place
man ./docs/esp32-net.7
# Install it system-wide (Linux)
sudo cp docs/esp32-net.7 /usr/local/share/man/man7/
sudo mandb
man 7 esp32-netThe nodes share one 2.4 GHz radio between scanning and the C2 uplink, which can wedge the WiFi stack over long runs. Mitigations:
- BlueDriver interleaves the radios — it scans BLE with WiFi off, then drops the scan, brings WiFi up only to POST, and resumes. BLE and WiFi never run at once, so association is reliable (this replaced the earlier coexistence/duty- cycle approach, which was the cause of "BlueDriver won't connect").
- WarSniffer interleaves the same way (promiscuous capture ⇄ brief WiFi POST)
and runs promiscuous mode in
WIFI_STAso the driver is reliably started. - WiFi modem power-save is disabled (
WiFi.setSleep(false)) on every node. - Watchdogs: a node reboots if the C2 has been unreachable for
WIFI_*_MS; the WarDriver also resets the radio after repeatedscanNetworks()failures. - Re-sync on C2 reboot so the dashboard and CSV exports repopulate with every previously-found device, not just new ones.
Control commands are queued on the C2 and re-sent until the node acks, so a
single successful button press is delivered even if the node is mid-cycle; the
dashboard also retries the POST and only polls the heavy live-capture feed while
the WarSniffer tab is open, keeping the single-core C2 responsive. Tune the
thresholds in each node's config.h.
| Symptom | Likely cause / fix |
|---|---|
| Node shows OFFLINE | Wrong C2_SSID/C2_PASSWORD, C2 not flashed/powered, or out of range. Check the node's serial console. |
| Buttons seem to do nothing | Commands apply on the node's next check-in (a few seconds); watch for the dashboard "queued" toast. If a node is OFFLINE its commands can't be delivered — fix connectivity first. |
| Dashboard won't load | Confirm you joined ESP32-NET; try http://192.168.4.1/ directly if mDNS fails. |
| Node drops off after minutes | Addressed by the self-healing above; if it persists, lower the cadence in config.h or check power/antenna. |
| BlueDriver won't connect | Fixed by interleaving (BLE scan and WiFi never run at once). Watch the serial console — each report logs WiFi connected (Nms) or TIMEOUT. |
| Buttons hit-and-miss | The node serial log prints each applied command (cmd #N ...); if you see the press queued on the dashboard but no cmd line, the node isn't checking in — fix connectivity. |
| WarDriver GPS never fixes | Needs clear sky view; verify TX→RX/RX→TX wiring and GPS_BAUD. LED stays cyan until a fix. |
/wigle.csv is empty |
Rows are only logged after a GPS fix. No fix = no rows by design. |
| CSV export incomplete | The C2 holds up to MAX_* of the most-recent devices; raise the caps (RAM permitting) or use the WarDriver's on-flash /wigle.csv. |
| GATT enumeration fails | Device may require bonding, reject the connection, or use a different addrType. The error appears in the dashboard GATT panel. |
| Out-of-memory / resets on C2 | Lower the MAX_* store caps in c2-esp32s2/src/config.h. |
This toolkit is for authorized testing only. Operate it solely on networks and devices you own or have explicit written authorization to assess. Passive 802.11 monitoring, active WiFi/BLE scanning, probe-request collection, and GATT enumeration may be regulated or prohibited in your jurisdiction — you are responsible for compliance. Enable channel 14 or any transmit-related option only where legally permitted.
By design, ESP32-Net does not send deauthentication or injection frames, does not operate a rogue access point, and does not store WPA handshakes (EAPOL frames are counted, never captured; the live capture keeps only short frame prefixes in RAM). The SoftAP is WPA2-protected; change the default credentials before use.
The tool ingests attacker-controllable data (SSIDs, BLE names, raw frame bytes), so the review focused there:
- CSV-injection hardened — exported fields that begin with
= + - @(or tab/CR) are apostrophe-prefixed so a crafted SSID/name can't execute as a formula when the report is opened in a spreadsheet; embedded quotes/commas are RFC-4180 quoted. - XSS — all device-supplied strings are HTML-escaped before rendering, and
the packet dissector/hex view writes via
textContent. Watchlist labels are stripped of quote/backslash/control characters on both entry and load. - Frame parsing is bounds-checked — every read past the 802.11 header is guarded against the captured length, so malformed frames can't over-read.
- Known limitation: the C2 HTTP API has no per-request authentication — it relies on the WPA2-protected SoftAP as the trust boundary. Keep the AP private, change the default password, and don't bridge it to other networks. Adding an API token is left as an opt-in for deployments that need it.