Building a Suspicious Little Rogue AP Detector Bot

ESP32 bot that spots rogue Wi-Fi

I recently posted about building an evil twin credential harvester, so I thought it would be a good exercise to switch to the blue team's side this time (with the actual code published) and build a device that detects rogue access points.

Since I'm actually using this build in my office, I thought it would be fun to add a bit of personality by turning it into a tiny desktop bot with animated eyes. It's half security tool, half desk buddy.


Parts List & Wiring

  • ESP-WROOM-32
  • 3D printed case
  • Double 0.71” round LCD display
Module Pin Colour ESP32 GPIO
VCC (3V3) Red 3V3 pin
GND Black GND pin
SDA (MOSI) Blue GPIO 23
CLK (SCK) Green GPIO 18
DC White GPIO 21
CS1 Orange GPIO 5
RST1 Brown GPIO 17
CS2 Yellow GPIO 16
RST2 Purple GPIO 4

Note on Display

This build uses a knockoff round LCD display rather than the official Waveshare one. That means the official Waveshare eye library and examples won't work here. Instead, I used the Arduino_GFX library to drive the displays and render the custom eye animations.


Modes of Operation

The bot has three main animation states:

  • Normal mode – eyes blink and wander around naturally.
  • Sleeping mode – the eyes imitate a calm wave pattern.
  • Scanning mode – the eyes squint and dart suspiciously while actively scanning for access points.

To make it feel a little more alive, the bot randomly switches between normal and sleeping modes when idle.


How It Works

  1. Baseline Scan
    • On startup, the ESP32 performs a scan of all available Wi-Fi networks.
    • It records the BSSIDs (unique MAC addresses) of your allowed SSIDs (in my case: TestSSID).
    • This creates a fingerprint of legitimate access points for comparison.
  2. Rogue Detection
    • Every 5 minutes, the device scans again.
    • If it finds an SSID that matches your approved list but is being broadcast by a new, unseen BSSID, it flags it as a potential evil twin AP.
    • When this happens, both eyes turn red for 5 minutes as a visual alert.
  3. Eye Animations
    • All animations run on a dedicated FreeRTOS task, so scanning never causes the display to freeze.
    • The bot blinks randomly and squints when scanning.
    • This keeps it visually interesting even when no threats are detected.

Code

The full Arduino sketch:

#include <Arduino_GFX_Library.h>
#include <WiFi.h>
#include <map>
#include <vector>
#include <algorithm>
#include <math.h>

// ---------- Pins ----------
#define PIN_MOSI 23
#define PIN_SCLK 18
#define PIN_DC   21
#define PIN_CS1  5
#define PIN_RST1 17
#define PIN_CS2  16
#define PIN_RST2 4

// ---------- Displays ----------
Arduino_DataBus *bus1 = new Arduino_ESP32SPI(PIN_DC, PIN_CS1, PIN_SCLK, PIN_MOSI, GFX_NOT_DEFINED, VSPI, true);
Arduino_DataBus *bus2 = new Arduino_ESP32SPI(PIN_DC, PIN_CS2, PIN_SCLK, PIN_MOSI, GFX_NOT_DEFINED, VSPI, true);
Arduino_GFX *eye1 = new Arduino_GC9D01(bus1, PIN_RST1, 0, false);
Arduino_GFX *eye2 = new Arduino_GC9D01(bus2, PIN_RST2, 0, false);
Arduino_Canvas *cv1 = new Arduino_Canvas(160, 160, eye1, 0, 0, false);
Arduino_Canvas *cv2 = new Arduino_Canvas(160, 160, eye2, 0, 0, false);

// ---------- Eye parameters ----------
static const int CX = 80, CY = 80;
static const int SCLERA_R = 70, PUPIL_R = 20, PUPIL_RANGE = 5, PUPIL_BLOCK = 6;

// ---------- Modes ----------
enum EyeMode { MODE_NEUTRAL, MODE_SCAN, MODE_SLEEP };
volatile EyeMode eyeMode = MODE_NEUTRAL;

// ---------- Blink ----------
unsigned long tBlink = 0;
bool closing = false, queuedDouble = false;
float blink = 0.0f;

// ---------- Motion ----------
unsigned long tMove = 0, tScan = 0;
int px = 0, py = 0;
float squint = 0.0f;

// ---------- Rogue AP scanning ----------
const char* ALLOWED_SSIDS[] = { "TestSSID" };
const size_t NUM_ALLOWED = sizeof(ALLOWED_SSIDS) / sizeof(ALLOWED_SSIDS[0]);

// Map SSID -> list of known BSSIDs (UPPERCASE "AA:BB:...").
std::map<String, std::vector<String>> knownBSSIDs;

unsigned long lastScan = 0;
const unsigned long SCAN_INTERVAL = 5UL * 60UL * 1000UL; // every 5 minutes
unsigned long alertUntil = 0; // eyes red until this millis()
bool baselineComplete = false;

// ---------- Idle switching ----------
unsigned long lastIdleSwitch = 0;
const unsigned long IDLE_SWITCH_INTERVAL = 10000;

// ---------- Helpers ----------
static void rotate90(int x, int y, int *xr, int *yr) { *xr = y; *yr = 159 - x; }
static void rotate270(int x, int y, int *xr, int *yr) { *xr = 159 - y; *yr = x; }

static void drawPixelPupil(Arduino_Canvas *c, int cx, int cy, bool rotateCW) {
  for (int y = -PUPIL_R; y <= PUPIL_R; y += PUPIL_BLOCK) {
    for (int x = -PUPIL_R; x <= PUPIL_R; x += PUPIL_BLOCK) {
      if (x * x + y * y <= PUPIL_R * PUPIL_R) {
        int xr, yr;
        if (rotateCW) rotate90(cx + x, cy + y, &xr, &yr);
        else rotate270(cx + x, cy + y, &xr, &yr);
        c->fillRect(xr, yr, PUPIL_BLOCK, PUPIL_BLOCK, BLACK);
      }
    }
  }
}

static void renderEye(Arduino_Canvas *c, int px, int py, float blinkFrac, float squintBase, bool rotateCW) {
  float lid = (squintBase > blinkFrac) ? squintBase : blinkFrac;
  c->fillScreen(BLACK);
  if (lid >= 1.0f) { c->flush(); return; }
  int shrink = (int)(SCLERA_R * lid);
  int visibleHeight = max(0, SCLERA_R - shrink);
  int xr, yr;
  if (rotateCW) {
    rotate90(CX, CY, &xr, &yr);
    c->fillEllipse(xr, yr, visibleHeight, SCLERA_R, WHITE);
    if (lid < 0.95f) drawPixelPupil(c, CX + px, CY + py, true);
  } else {
    rotate270(CX, CY, &xr, &yr);
    c->fillEllipse(xr, yr, visibleHeight, SCLERA_R, WHITE);
    if (lid < 0.95f) drawPixelPupil(c, CX + px, CY + py, false);
  }
  c->flush();
}

static void renderEyeSleep(Arduino_Canvas *c, bool rotateCW) {
  c->fillScreen(BLACK);
  unsigned long now = millis();
  float breath = 0.6f + 0.4f * sin(now / 1600.0f);
  for (int x = CX - 70; x <= CX + 70; x += 2) {
    float phase = (x + now / 32.0f) * 0.15f;
    int y = CY + (int)(sin(phase) * 6 * breath);
    int xr, yr;
    if (rotateCW) rotate90(x, y, &xr, &yr);
    else rotate270(x, y, &xr, &yr);
    c->drawPixel(xr, yr, WHITE);
  }
  c->flush();
}

static void renderAlert() {
  cv1->fillScreen(RED); cv1->flush();
  cv2->fillScreen(RED); cv2->flush();
}

// ---------- Small utils ----------
static bool isAllowedSSID(const String &ssid) {
  for (size_t i = 0; i < NUM_ALLOWED; ++i) {
    if (ssid == ALLOWED_SSIDS[i]) return true;
  }
  return false;
}

static String toUpperStr(String s) {
  s.toUpperCase();
  return s;
}

static bool vecContains(const std::vector<String> &v, const String &val) {
  return std::find(v.begin(), v.end(), val) != v.end();
}

// ---------- Wi-Fi scanning (rogue detector) ----------
static void baselineScan() {
  eyeMode = MODE_SCAN;
  tScan = millis();

  // Blocking scan, but animation task keeps running on other core
  int n = WiFi.scanNetworks(/*async=*/false, /*show_hidden=*/true);
  if (n <= 0) { baselineComplete = true; return; }

  for (int i = 0; i < n; ++i) {
    String ssid = WiFi.SSID(i);
    if (!isAllowedSSID(ssid)) continue;

    String bssid = toUpperStr(WiFi.BSSIDstr(i));
    auto &list = knownBSSIDs[ssid];
    if (!vecContains(list, bssid)) list.push_back(bssid);
  }
  baselineComplete = true;
  eyeMode = MODE_NEUTRAL;
}

static int checkForRogueAPs() {
  // Show scan animation while scanning
  EyeMode prev = eyeMode;
  eyeMode = MODE_SCAN;
  tScan = millis();

  int rogues = 0;
  int n = WiFi.scanNetworks(/*async=*/false, /*show_hidden=*/true);
  if (n > 0) {
    for (int i = 0; i < n; ++i) {
      String ssid = WiFi.SSID(i);
      if (!isAllowedSSID(ssid)) continue; // only care about your SSIDs

      String bssid = toUpperStr(WiFi.BSSIDstr(i));
      auto it = knownBSSIDs.find(ssid);

      if (it == knownBSSIDs.end()) {
        // If the SSID wasn't seen during baseline (e.g., powered off then on later),
        // treat first sighting as legit and add it.
        knownBSSIDs[ssid] = { bssid };
      } else {
        if (!vecContains(it->second, bssid)) {
          // A new BSSID is broadcasting an approved SSID -> likely rogue AP (evil twin)
          rogues++;
          it->second.push_back(bssid); // remember it so we don't alert repeatedly this session
        }
      }
    }
  }

  // Return to whatever mode we were in before
  eyeMode = prev;
  return rogues;
}

// ---------- Animation Task ----------
TaskHandle_t animTaskHandle;

void animTask(void *param) {
  for (;;) {
    unsigned long now = millis();

    // If we’re in alert window, override visuals to red, but keep timing going
    if (now < alertUntil) {
      renderAlert();
      vTaskDelay(33 / portTICK_PERIOD_MS);
      continue;
    }

    // Blink logic (unchanged)
    if (eyeMode != MODE_SLEEP) {
      if (blink == 0.0f && !closing && now - tBlink > (unsigned long)random(3000, 7000)) {
        closing = true; tBlink = now; queuedDouble = (random(5) == 0);
      }
      if (closing) {
        blink += 0.4f;
        if (blink >= 1.0f) { blink = 1.0f; closing = false; tBlink = now; }
      } else if (blink > 0.0f) {
        if (!(blink == 1.0f && now - tBlink < 40)) {
          blink -= 0.2f;
          if (blink < 0.0f) { blink = 0.0f; if (queuedDouble) { closing = true; queuedDouble = false; } }
        }
      }
    } else blink = 0.0f;

    // Render modes (unchanged)
    if (eyeMode == MODE_NEUTRAL) {
      squint = 0.0f;
      if (now - tMove > 800) {
        px = random(-PUPIL_RANGE, PUPIL_RANGE + 1);
        py = random(-PUPIL_RANGE, PUPIL_RANGE + 1);
        tMove = now;
      }
      renderEye(cv1, px, py, blink, squint, true);
      renderEye(cv2, px, py, blink, squint, false);
    } else if (eyeMode == MODE_SCAN) {
      squint = 0.85f;
      float phase = (now - tScan) / 2500.0f * 2.0f * PI;
      px = (int)(sin(phase) * (PUPIL_RANGE + 12)); py = 0;
      renderEye(cv1, px, py, blink, squint, true);
      renderEye(cv2, px, py, blink, squint, false);
    } else { // MODE_SLEEP
      renderEyeSleep(cv1, true); renderEyeSleep(cv2, false);
    }

    vTaskDelay(33 / portTICK_PERIOD_MS); // ~30 FPS
  }
}

// ---------- Setup ----------
void setup() {
  eye1->begin(); eye2->begin();
  cv1->begin();  cv2->begin();
  randomSeed(analogRead(0));

  // Start animation task FIRST so visuals never freeze
  xTaskCreatePinnedToCore(
    animTask, "AnimTask", 16384, NULL, 2, &animTaskHandle, 0
  );

  // Boot sleep animation (same visuals, timed via the anim task)
  eyeMode = MODE_SLEEP;
  vTaskDelay(3000 / portTICK_PERIOD_MS);

  // Wi-Fi in STA mode; not connecting to any network is fine for scanning
  WiFi.mode(WIFI_STA);
  WiFi.disconnect(); // ensure not connected; scanning works regardless

  // Baseline known BSSIDs for your approved SSIDs
  baselineScan();
  lastScan = millis();

  // Idle mode after boot
  eyeMode = MODE_NEUTRAL;
  lastIdleSwitch = millis();

  // Immediate rogue check at boot as requested
  int rogues = checkForRogueAPs();
  if (rogues > 0) {
    alertUntil = millis() + (5UL * 60UL * 1000UL); // 5 minutes
  }
}

// ---------- Loop ----------
void loop() {
  unsigned long now = millis();

  // Periodic rogue check
  if (now - lastScan >= SCAN_INTERVAL) {
    int rogues = checkForRogueAPs();
    if (rogues > 0) {
      alertUntil = millis() + (5UL * 60UL * 1000UL); // 5 minutes
    }
    lastScan = now;
  }

  // Idle mode switching (unchanged)
  if (eyeMode != MODE_SCAN && now - lastIdleSwitch > IDLE_SWITCH_INTERVAL) {
    eyeMode = (random(2) == 0) ? MODE_NEUTRAL : MODE_SLEEP;
    lastIdleSwitch = now;
  }

  vTaskDelay(50 / portTICK_PERIOD_MS); // be nice to the scheduler
}

Final Thoughts

This project was a fun little crossover between red team concepts and blue team defenses. I may update this post when the next steps are implemented:

  • Sending rogue AP alerts over MQTT to integrate with Home Assistant or a SIEM.
  • Expanding detection logic for things like sudden signal strength changes or SSID spoof floods.

For now, I'm happy to have a little guardian watching over the network, even if it sometimes looks like it’s judging me.