tworjaga/ESP32Gotchi
GitHub: tworjaga/ESP32Gotchi
Stars: 4 | Forks: 0
# ESP32Gotchi
[](https://www.espressif.com/en/products/socs/esp32)
[](LICENSE)
[](https://www.arduino.cc/)
[](https://github.com/tworjaga)
## Overview
ESP32Gotchi is a self-contained passive Wi-Fi handshake sniffer inspired by the Pwnagotchi project. It runs on a ~10 EUR hardware stack, requires no host computer, and writes standard PCAP files directly to a microSD card. All operation is autonomous from power-on.
The firmware uses FreeRTOS with four independent tasks, a promiscuous-mode Wi-Fi callback, IEEE 802.11-2020 compliant EAPOL parsing, and a dedicated SD write task to prevent I/O from stalling packet processing.
## v1.2.1 — Correctness & Safety Audit + RSSI Filtering
This release combines a full correctness and safety audit (v1.2.0) with one targeted feature addition (v1.2.1). The audit identified and resolved thirteen issues across four severity tiers — ranging from a deadlock-class mutex misuse to silent partial writes and cross-core memory visibility violations. The new feature implements threshold-based RSSI filtering directly inside the promiscuous callback, eliminating weak-signal packets before they consume any static pool resources.
**Hardware: no changes from v1.1.0. No pin changes. No BOM changes. Flash the new `.ino`, nothing else required.**
### Fixes Applied (v1.2.0)
| Fix | Description |
|-----|-------------|
| F1 | `chunk_buf` size derived from `MAX_PKT_LEN` via named constant `CHUNK_BUF_SIZE`; `static_assert` + runtime `configASSERT` bounds checks added |
| F2 | `g_ap_table` hash table now protected by `portMUX_TYPE` spinlock; ISR-safe critical section wraps the entire probe loop |
| F3 | `pcap_write()` slot release uses `portMAX_DELAY` instead of 20 ms timeout; slot always cleared, no silent leak |
| F4 | `configASSERT(xPortGetCoreID() == 1)` added as first statement in `task_ui`; core-isolation enforced rather than implied |
| F5 | `g_hs_mutex` converted to recursive mutex (`xSemaphoreCreateRecursiveMutex`); deadlock path via `hs_expire()` eliminated |
| F6 | `g_channel`, `g_led`, `g_face`, `g_last_sd_retry` converted from `volatile` to `std::atomic` with `.load()` / `.store()` |
| F7 | `f.write()` return value now checked; partial writes logged and propagated as failure to OLED error state |
| F8 | `mac_hash()` replaced djb2 with Murmur3 finaliser mix for uniform distribution over OUI-heavy captures |
| F9 | `task_proc` and `task_write` stack sizes increased to 6 144 words; named `STACK_PROC` / `STACK_WRITE` constants in tuning block |
| F10 | `btn_tick()` poll-rate dependency on `vTaskDelay` documented in comment |
| F11 | `static_assert` guards added adjacent to `POOL_NONE` definition to prevent sentinel collision if pool depth approaches 255 |
| F12 | `SPI.begin()` now called exactly once across retries via `static bool spi_started`; repeated calls on active bus eliminated |
| F13 | WDT reset on SD write stall documented as intentional recovery mechanism |
### Feature Added (v1.2.1)
**RSSI threshold filter in `promisc_cb()`** — weak-signal packets are dropped before touching the static pool. The filter fires as the very first operation after the packet-type check, before any `memcpy` or pool slot claim. A rejected packet costs one signed comparison and one atomic increment.
#define RSSI_THRESHOLD (-80) /* dBm — tune to environment */
Tune to your environment:
| Threshold | Use case |
|-----------|----------|
| –70 dBm | Dense RF, many nearby APs |
| –80 dBm | Default; good for typical indoor environments |
| –85 dBm | Open air, long-range targets |
Two new atomics (`g_last_rssi`, `g_rssi_drops`) are updated in the ISR and consumed by `task_ui` and `task_proc`. The OLED bottom line and serial log now show live RSSI and drop telemetry:
RSSI:-67 D:142
[STAT] pkt/s=23 rssi=-67dBm drops=142 thr=-80dBm
## v1.1.0 — Previous Release Summary
### Hardware (one wire change required from v1.0.0)
**The button must be moved from GPIO0 to GPIO4.**
GPIO0 is the ESP32 boot-mode strapping pin. A user who keeps the button pressed while the device reboots will land in Download Mode. GPIO4 has no strapping function. Move the single button wire from DevKit pin `IO0` to `IO4`. No other hardware changes.
### Firmware Fixes in v1.1.0
| Fix | Description |
|-----|-------------|
| FIX-1 | Button pin: `GPIO0` → `GPIO4` (strapping-pin hazard) |
| FIX-2 | Task priorities rebalanced: `task_hop` raised to 6, `task_write` raised to 4 |
| FIX-3 | Zero-allocation packet path: `promisc_cb` uses a static pool of 32 fixed-size blocks |
| FIX-4 | `hs_slot_t` no longer embeds raw frame data; slots hold pool indices instead |
| FIX-5 | `write_item_t` is now `uint8_t` (slot index); write queue allocates 8 bytes instead of 51 440 bytes |
| FIX-6 | `g_ap_mutex` removed; `g_ap_count` reads are naturally atomic on Xtensa LX6 |
| FIX-7 | O(N) AP linear scan replaced with 256-bucket open-addressing hash table |
| FIX-8 | EAPOL slot-exhaustion DoS mitigated: new slot creation rate-limited to 1 per 100 ms; `MAX_HS_SLOTS` reduced to 16; `HS_EXPIRE_MS` reduced to 15 s |
## Hardware
### Bill of Materials
| Component | Specification | Approx. Cost |
|-----------|--------------|--------------|
| MCU | ESP32 DevKit V1, 30-pin, ESP32-WROOM-32 | ~5 EUR |
| Display | 0.96" SSD1306 OLED, 128x64, I2C (4-pin) | ~3 EUR |
| Storage | MicroSD SPI module, 3.3V compatible | ~1 EUR |
| Button | Tactile push button | <0.50 EUR |
| LED | 3mm or 5mm LED + 220 ohm resistor | <0.50 EUR |
| Power (portable) | LiPo 3.7V + TP4056 USB-C charging module | ~2 EUR |
**Total: ~10-12 EUR**
### Wiring
**OLED — I2C**
ESP32 GPIO21 -> SDA
ESP32 GPIO22 -> SCL
ESP32 3.3V -> VCC
ESP32 GND -> GND
**MicroSD — SPI**
ESP32 GPIO18 -> SCK
ESP32 GPIO23 -> MOSI
ESP32 GPIO19 -> MISO
ESP32 GPIO5 -> CS
ESP32 3.3V -> VCC
ESP32 GND -> GND
**Button — GPIO4 (changed from GPIO0 in v1.1.0)**
ESP32 GPIO4 -> Button -> GND
(internal pull-up enabled in firmware)
**LED (optional)**
ESP32 GPIO2 -> 220 ohm resistor -> LED anode
LED cathode -> GND
### Power Options
USB only (development / bench use):
USB -> ESP32 DevKit V1
Portable (battery operation):
LiPo 3.7V -> TP4056 -> ESP32 VIN
## Hardware Architecture
ESP32-WROOM-32
|-- OLED SSD1306 (I2C: GPIO21/22)
|-- MicroSD module (SPI: GPIO18/19/23/5)
|-- Tactile button (GPIO4, active-low)
|-- Status LED (GPIO2, optional)
|-- LiPo + TP4056 (optional, portable)
## Firmware
### Architecture
Four FreeRTOS tasks with explicit core pinning:
| Task | Core | Priority | Stack | Function |
|------|------|----------|-------|----------|
| `task_hop` | 0 | 6 | 2 KB | Cycles channels 1–11, 200 ms dwell |
| `task_proc` | 0 | 5 | **6 KB** | Pulls packets from queue, parses 802.11/EAPOL, manages handshake slots |
| `task_write` | 0 | 4 | **6 KB** | Receives completed handshakes by slot index, writes PCAP to SD |
| `task_ui` | 1 | 1 | 4 KB | Updates OLED every 200 ms, handles LED and button |
### Memory Layout
All packet storage is statically allocated at boot. No `malloc()` or `free()` at runtime.
| Region | Size | Purpose |
|--------|------|---------|
| `pkt_pool_mem[32][1600]` | 51 200 B | In-flight packet buffers |
| `hs_raw_pool_mem[32][1600]` | 51 200 B | Handshake frame storage |
| `g_hs[16]` metadata | ~640 B | Handshake slot state (pool indices only) |
| `g_ap_table[256][6]` | 1 536 B | AP hash table |
| **Total user static** | **~104 KB** | Well within the ~200 KB available after the Wi-Fi stack |
### EAPOL Detection
Implements IEEE 802.11-2020 §12.7.2 key_info bit field:
| Message | Pairwise | ACK | MIC | Install | Secure |
|---------|----------|-----|-----|---------|--------|
| Msg 1 | 1 | 1 | 0 | 0 | 0 |
| Msg 2 | 1 | 0 | 1 | 0 | 0 |
| Msg 3 | 1 | 1 | 1 | 1 | 1 |
| Msg 4 | 1 | 0 | 1 | 0 | 1 |
All four messages must be captured to mark a handshake as complete. Incomplete slots expire after 15 seconds.
### RSSI Filtering
The promiscuous callback drops packets below `RSSI_THRESHOLD` (default –80 dBm) before any pool resource is claimed. Signals weaker than this threshold cannot reliably complete the four-message EAPOL exchange and would consume pool slots that expire without producing a capture.
The OLED and serial output report the last accepted RSSI and cumulative drop count in real time. If the drop counter grows significantly faster than `pkt/s`, the threshold is more aggressive than necessary for the current environment.
### PCAP Output
Files are written to `/handshakes/` on the SD card.
Naming: `hs__.pcap`
Example: `hs_aa_bb_cc_dd_ee_ff_3721.pcap`
Format: standard libpcap (magic `0xa1b2c3d4`), network type 105 (IEEE 802.11 + radiotap header). Files open directly in Wireshark without conversion.
### OLED Display Layout
(o_o) <- face (changes with state)
HS: 12 <- handshakes captured this session
CH: 6 <- current Wi-Fi channel
AP: 34 <- unique BSSIDs seen
PKT: 128 <- packets processed per second
RSSI:-67 D:142 <- last accepted RSSI / cumulative dropped packets
Face states:
- `(o_o)` — scanning normally
- `(^o^)` — EAPOL frames being collected
- `(X_X)` — error (SD missing, low space)
- `(-_-)` — idle
### LED Patterns
| Pattern | Meaning |
|---------|---------|
| Slow blink (1 Hz) | Normal scanning |
| Fast blink (5 Hz) | Handshake capture in progress |
| Single short flash | Handshake saved to SD |
| 3 × long flash (2 s) | SD error — repeating |
### Button Behaviour
| Press duration | Action |
|---------------|--------|
| Short (50 ms – 3 s) | Reset channel hopper to CH 1 |
| Long (> 3 s) | `ESP.restart()` |
## Build & Flash
### Requirements
- Arduino IDE 2.x or PlatformIO
- ESP32 board package by Espressif, version 2.0.x or later
- U8g2 library (install via Arduino Library Manager)
### Arduino IDE
1. Install board package: `File -> Preferences -> Additional Boards Manager URLs`
Add: `https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json`
2. Install U8g2: `Tools -> Manage Libraries -> search "U8g2"`
3. Board settings:
Board : ESP32 Dev Module
Partition scheme : Default 4MB with spiffs
CPU Frequency : 240 MHz
Flash mode : QIO
Upload speed : 921600
4. Open `Cheapagotchi.ino`, compile, and flash.
### PlatformIO
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
lib_deps = olikraus/U8g2
board_build.partitions = default.csv
## SD Card
- Format: FAT32
- Minimum recommended size: 2 GB
- The firmware creates `/handshakes/` automatically on first boot
- Minimum free space check: 1 MB before each write; if below threshold the device continues sniffing but skips saving
- If SD is absent or fails, the device retries initialisation every 10 seconds and displays `SD: ERR`
## Serial Debug Output
Connect at 115200 baud. Example output:
[BOOT] ESP32 Cheapagotchi v1.2.1
[SD] OK
[WIFI] promiscuous active
[BOOT] tasks started
[MEM] free heap: 152340 bytes
[HS] aa:bb:cc:dd:ee:ff -> 11:22:33:44:55:66 msg1
[HS] aa:bb:cc:dd:ee:ff -> 11:22:33:44:55:66 msg2
[HS] aa:bb:cc:dd:ee:ff -> 11:22:33:44:55:66 msg3
[HS] aa:bb:cc:dd:ee:ff -> 11:22:33:44:55:66 msg4
[HS] saved /handshakes/hs_aa_bb_cc_dd_ee_ff_3721.pcap total=1
[STAT] pkt/s=23 rssi=-67dBm drops=142 thr=-80dBm
## Repository Structure
ESP32Gotchi/
|-- Cheapagotchi.ino # Full firmware source
|-- README.md
|-- LICENSE
|-- hardware/
| └── BOM.md # Bill of materials
└── docs/
└── pcap_analysis.md # Notes on opening captures in Wireshark
## Troubleshooting
| Symptom | Likely cause | Fix |
|---------|-------------|-----|
| `SD: ERR` on boot | SD not inserted, wrong wiring, not FAT32 | Check SPI wiring, reformat to FAT32 |
| OLED blank | I2C address mismatch or wiring fault | Verify SDA/SCL, confirm 0x3C with I2C scanner |
| No handshakes captured | No WPA2 4-way exchanges occurring nearby | Use a test AP; deauth-based capture is outside scope of this firmware |
| Drop counter rising fast | RSSI_THRESHOLD too aggressive for environment | Lower threshold (e.g. –85 dBm) in tuning block and reflash |
| Device reboots repeatedly | Watchdog trigger — task hang (note: WDT reset on SD stall is intentional) | Check serial output for last log line; report via Issues |
| PCAP not opening in Wireshark | Corrupt write (power cut during save) | Delete partial file; ensure stable power supply |
| Black screen after long-press restart (v1.0.0 only) | Button was on GPIO0 strapping pin | Upgrade to v1.1.0+ and move button wire to GPIO4 |
## Technical Specifications
| Parameter | Value |
|-----------|-------|
| MCU | Xtensa LX6 dual-core, 240 MHz |
| RAM | 520 KB SRAM |
| Wi-Fi | 802.11 b/g/n, 2.4 GHz |
| Channels scanned | 1 – 11 |
| Channel dwell time | 200 ms |
| Packet queue depth | 32 items |
| Packet pool blocks | 32 × 1 600 B (static) |
| HS raw-frame pool blocks | 32 × 1 600 B (static) |
| Max concurrent handshake slots | 16 |
| Max tracked APs | 192 (hash table, 256 buckets) |
| Handshake slot timeout | 15 s |
| New slot rate limit | 1 per 100 ms |
| RSSI threshold (default) | –80 dBm (configurable) |
| PCAP network type | 105 (802.11 + radiotap) |
| Watchdog timeout | 30 s |
| Min SD free space | 1 MB |
| Runtime heap allocations | **0** |
## Future Improvements
- Custom PCB with LiPo connector and integrated charging
- Battery level monitoring via ADC
- Rotary encoder for menu navigation
- Buzzer feedback on handshake capture
- ESP32-S3 port (USB OTG for live PCAP streaming)
- PMKID capture (message 1 only)
## Legal Notice
This tool is intended for **authorised security research and educational use only**.
Capturing Wi-Fi handshakes on networks you do not own or have explicit written permission to test is illegal in most jurisdictions.
The author assumes no liability for misuse.
## License
MIT — see [LICENSE](LICENSE).
## Contact
Author: [@tworjaga](https://github.com/tworjaga)
Telegram: [@al7exy](https://t.me/al7exy)
Issues: [github.com/tworjaga/ESP32Gotchi/issues](https://github.com/tworjaga/ESP32Gotchi/issues)