ESP32-C6 WiFi Dojo: A Pocket WiFi Practice Range

The ESP32-C6 WiFi Dojo, a pocket-sized WiFi practice range on the Nano ESP32-C6 v1.0. Three modes: WPA2/WPA3 practice AP, beacon flood with SSID cloning, and captive portal. Around $13 in parts, runs off any USB power bank.

airodump-ng terminal showing WiFi networks being scanned, demonstrating the practice range in use

Last month I sat in on a DEF CON Group 570 meeting where we watched a live stream of a talk out of DEF CON Group 610 on WiFi hacking, live demos and all. Good idea, I thought. Let's expand on it.

The ESP32-C6 is what makes that worth doing. It does WiFi 6 and native WPA3-SAE, which most ESP32 security projects skip because they run on older variants that can't. I built it into a pocket-sized device that runs off a USB power bank and costs around $13 in parts.

And with that, I bring you the ESP32-C6 WiFi Dojo.

Three modes, one button:

  • Practice AP: A real WPA2 or WPA3 access point with configurable password strength. Capture handshakes, run hashcat, practice the full attack chain against a target you own.
  • Beacon Flood: Hundreds of fake access points flooding every WiFi scanner in range. Clones nearby SSIDs if any are visible, falls back to Norse and Celtic mythology if you're somewhere quiet.
  • Captive Portal: An open AP that serves a page to anyone who connects explaining what just happened to them.

Short press cycles modes. Long press in Practice AP mode cycles the security level: WPA2 with a wordlist-crackable password, WPA2 with a strong one, then WPA3-SAE. Four WS2812B LEDs show what's happening.


What You Need

QtyPartApprox cost
1Nano ESP32-C6 v1.0~$10
1WS2812B strip, 4 LEDs$1.50
16mm tactile button$0.50
1300 ohm resistor<$0.10
1100uF cap across LED 5V/GND<$0.10
140x30mm perfboard$0.50
1USB power bankon hand

The Nano ESP32-C6 v1.0 has two USB-C ports: the CH343 handles programming, the other is direct USB to the ESP32-C6 for native USB. Onboard 3.3V LDO, no external programmer needed. Plug it into a power bank and it runs.


Wiring

Soldered components on a perfboard showing hookup wire and discrete parts
Components on perfboard. Photo: avometrical, CC BY-SA 2.0.

The Nano ESP32-C6 v1.0 exposes both 3.3V and 5V pins. The LED strip runs off the 5V pin; everything else comes from the board's onboard LDO at 3.3V. The GPIO data signal at 3.3V drives the WS2812B cleanly enough in practice. If you get flicker, a 74AHCT125 level shifter between the data pin and the strip will fix it. Put a 100uF cap across the LED strip's 5V and GND to absorb current spikes.

USB power bank
    │
    USB-C ── Nano ESP32-C6 v1.0
                │           │
               5V pin      3.3V pin (internal LDO)
                │
              100uF (to GND)
                │
            WS2812B VCC

ESP32 GPIO6 ── 300 ohm ── WS2812B DATA IN
ESP32 GPIO9 ── button ── GND   (internal pullup enabled in firmware)
GND ──────────────────────────── common GND
GPIO8

GPIO8 controls the ROM boot mode on the ESP32-C6. Leave it floating or tied to 3.3V. I connected something to it during testing. Don't do that.
GPIO9

GPIO9 doubles as the BOOT pin. It needs to be high during boot, which the internal pullup handles. After boot it works fine as a normal input.

Firmware

Full source is on GitHub at [repo link]. Five source files, each with a single job:

Structure

  • modes.h shared enums and extern declarations
  • button.c short and long press detection via GPIO interrupt
  • leds.c WS2812B animations via ESP-IDF RMT driver
  • beacon.c raw 802.11 frame injection and SSID pool management
  • captive_portal.c DNS server and HTTP server for portal mode
  • app_main.c init, task creation, AP configuration and management

Modes

typedef enum {
    MODE_PRACTICE_AP,
    MODE_BEACON_FLOOD,
    MODE_CAPTIVE_PORTAL,
} device_mode_t;

typedef enum {
    AP_SEC_WPA2_WEAK,
    AP_SEC_WPA2_STRONG,
    AP_SEC_WPA3,
} ap_security_t;

current_mode and current_security are volatile globals shared across tasks. The AP manager task polls them at 100ms intervals and reconfigures WiFi when either changes.

Boot Sequence

On power-up the firmware initializes WiFi in APSTA mode, runs a blue LED sweep, and scans for nearby networks. Those scan results become the beacon flood SSID pool.

If it finds fewer than three networks, it loads a fallback list of about 60 Norse and Celtic figures instead: Odin, Fenrir, Surtr, Morrigan, Cernunnos, Dagda, and the rest. This is completely unnecessary and I stand by it.

Button

The ISR fires on both edges. Falling edge records the press time, rising edge computes the duration and sends a short or long press event to a FreeRTOS queue. Anything under DEBOUNCE_MS is ignored. Anything over LONG_PRESS_MS (500ms) is a long press.

static void IRAM_ATTR button_isr(void *arg) {
    uint32_t now = (uint32_t)(esp_timer_get_time() / 1000);
    if (gpio_get_level(BUTTON) == 0) {
        press_start_ms = now;
    } else {
        uint32_t duration = now - press_start_ms;
        if (duration < DEBOUNCE_MS) return;
        btn_event_t evt = (duration >= LONG_PRESS_MS) ? BTN_LONG : BTN_SHORT;
        xQueueSendFromISR(btn_queue, &evt, NULL);
    }
}

Short press cycles modes. Long press in Practice AP mode cycles the security level and flashes blue once, twice, or three times to confirm.

Beacon Flood

The beacon flood sends raw 802.11 beacon frames using esp_wifi_80211_tx(). Each frame gets a freshly randomized BSSID:

static void random_bssid(uint8_t *mac) {
    esp_fill_random(mac, 6);
    mac[0] = (mac[0] & 0xfe) | 0x02;  // locally administered, unicast
}

Setting the locally-administered bit marks the address as not from a real manufacturer. Most scanners show it anyway.

The task runs at 1ms per frame. Because the BSSID randomizes on every frame, each pass through the SSID pool produces a fresh set of phantom APs. A scanner watching during a flood sees the pool SSIDs multiplied across hundreds of unique-looking BSSIDs.

The AP stays running during beacon flood mode. esp_wifi_80211_tx() works alongside the soft-AP interface, so you can flood beacons and still have a real connectable AP active at the same time.

WPA3

WPA3-SAE requires Protected Management Frames. Forget pmf_cfg.required = true and the AP silently falls back to WPA2. Set it explicitly:

case AP_SEC_WPA3:
    strncpy((char *)ap_cfg.ap.password, PASS_STRONG, 64);
    ap_cfg.ap.authmode         = WIFI_AUTH_WPA3_PSK;
    ap_cfg.ap.pmf_cfg.required = true;
    break;

Connect to the WPA3 AP with aircrack-ng or hcxdumptool and observe what happens. Current tooling has limited SAE capture support. The handshake mechanism is fundamentally different from WPA2 and offline dictionary attacks don't work the same way. That's the point.

Captive Portal

The captive portal runs a minimal UDP DNS server on port 53 that answers every query with 192.168.4.1, and an HTTP server that serves the portal page and redirects everything else.

Building the DNS response in place on the receive buffer is a bit ugly but it works: copy the query, flip the response bits, append an A record pointing at 192.168.4.1. Combined with the HTTP redirect, this triggers the sign-in popup on iOS, Android, and Windows without any OS-specific handling.

Anyone who connects sees a page explaining what just happened: you connected to a controlled hacking target, your WPA2 4-way handshake was captured on connect, this is what captive portal interception looks like, no data is logged or stored.

LEDs

StateColorPattern
Boot scanBlueSweep back and forth
Practice AP, no clientGreenDim steady
Practice AP, client connectedGreenSlow breathe
Beacon floodOrange/redFire chase
Captive portalMagentaSlow pulse
Mode transitionWhite3 flashes
Security level changeBlue1, 2, or 3 flashes

The fire chase uses a hardcoded brightness falloff: full orange at the head, stepping down to dim red at the tail. It looks intentional.


Using It

Practice AP Mode

ESP32-C6 board on a red cloth with its WS2812B strip glowing green, USB-connected to a laptop
Practice AP mode. The lit LED count is the security level: two for WPA2 weak, three for strong, four for WPA3.

Power on, wait for the blue boot sweep. The device starts in Practice AP mode with WPA2 weak. SSID is RANGE_TARGET, password is password123.

airodump-ng terminal output showing scanned WiFi networks with BSSIDs and signal strength
airodump-ng scanning nearby networks. RANGE_TARGET shows up here once the device boots. Photo: Christiaan Colen, CC BY-SA 2.0.

From another device, use airodump-ng to capture the 4-way handshake and crack it with hashcat or aircrack-ng. Long press to step up to WPA2 strong and repeat. Long press again for WPA3 and watch your toolchain.

Beacon Flood

ESP32-C6 board with its WS2812B strip glowing red and orange during a beacon flood
Beacon flood mode. The strip runs a red-orange fire chase while it sprays phantom APs.

Short press once; the fire chase starts. Open any WiFi scanner. With real nearby networks visible you'll see clones of every local SSID across dozens of phantom BSSIDs. Somewhere quiet, you get Odin and friends.

Captive Portal

ESP32-C6 board with its WS2812B strip glowing magenta in captive portal mode
Captive portal mode. Magenta pulse while FREE_WIFI is up and serving the portal page.

Short press twice from Practice AP mode. FREE_WIFI appears as an open network. Connect from a phone and the OS popup opens the portal page.


A Few Notes

Beacon flood interference

The beacon flood creates real interference on channel 6. Keep it on the bench or in a Faraday bag anywhere you'd disrupt real networks. Channel is hardcoded to 6 in send_beacon() and is one line to change.
esp_wifi_80211_tx() on the C6

If esp_wifi_80211_tx() returns ESP_ERR_NOT_SUPPORTED, check that CONFIG_ESP_WIFI_ENABLE_WIFI_TX_BUFFER is enabled in menuconfig. The C6 radio architecture is different from the original ESP32 and this option isn't always on by default.
This is a target

It's for attacking devices you own in spaces you control. Taking it somewhere public and running the beacon flood disrupts real networks for everyone in range. Don't do that.