ESP32 Wi‑Fi Packet Sniffer: Promiscuous Mode & Automated OUI Lookup

The ESP32 is an inexpensive microcontroller with on-board Wi‑Fi and Bluetooth. This project uses promiscuous mode so the radio can pass raw 802.11 frames to your code, extracts source MAC addresses, and calls a small HTTP API to show vendor names (OUI lookup). That is useful for labs, inventory, and learning how Wi‑Fi looks on the air.

What this article covers: enabling promiscuous RX on ESP32, deduplicating MACs, batching API calls, and staying on the right side of ethics and law.

The idea in plain language

Normally a Wi‑Fi interface drops frames that are not addressed to it. Promiscuous mode turns that filter off (within chip limits) so a callback receives more of what is in the air. From each buffer you can read bytes that correspond to MAC addresses, build a unique list, and only then query an online database that maps the first bytes of a MAC (OUI) to a manufacturer.

The firmware alternates between “listen for a while” and “pause, resolve vendors, print, clear, repeat” so Serial stays readable and you do not hammer the API.

What you get at the end

  • Layer 2 visibility without joining a network as the main goal (you still use STA mode here because the sketch uses the internet for lookups).
  • Stable MAC list using hash sets so each address is processed once per cycle.
  • Vendor labels from a JSON API (maclookup.app in the example).
  • Predictable timing: sniff window → pause → lookups → resume.

What you need

  • An ESP32 dev board.
  • Arduino IDE with the ESP32 core installed.
  • Libraries: built-in WiFi, HTTPClient, esp_wifi; add ArduinoJson from the Library Manager.

How the sketch is organized

  1. Connect in WIFI_STA and wait for an IP (needed for HTTP in this design).
  2. Call esp_wifi_set_promiscuous(true) and register esp_wifi_set_promiscuous_rx_cb.
  3. In the callback, parse the buffer, format a MAC string, insert into a set if new, print a short line on Serial.
  4. When a timer fires, turn promiscuous mode off, iterate MACs, GET the API, parse JSON, collect matches.
  5. Print a summary, clear the sniff set, restart the timer, enable promiscuous mode again.

tip

802.11 frame layouts differ by type; the sample uses a fixed byte offset to keep the example short. Treat it as a learning aid: validate offsets with real captures if you depend on them.

Firmware (Arduino sketch)

Set your Wi‑Fi credentials before uploading.

#include <Arduino.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <unordered_set>
#include <vector>
#include <string>
#include <esp_wifi.h>

const char* ssid = "YOUR_SSID";
const char* password = "YOUR_PASSWORD";
const char* macLookupURL = "https://api.maclookup.app/v2/macs/";

struct MacEntry {
    std::string mac;
    std::string data;
};

std::unordered_set<std::string> sniffedMacs;
std::unordered_set<std::string> lookedUpMacs;
std::vector<MacEntry> foundMacs;

unsigned long sniffingStartTime;
const unsigned long sniffingDuration = 60000;

std::string lookupMac(const std::string& mac) {
    HTTPClient http;
    http.begin(String(macLookupURL) + mac.c_str());
    int code = http.GET();
    if (code > 0) {
        String response = http.getString();
        http.end();
        return std::string(response.c_str());
    }
    http.end();
    return "";
}

void packetSniffer(void* buf, wifi_promiscuous_pkt_type_t type) {
    wifi_promiscuous_pkt_t* pkt = (wifi_promiscuous_pkt_t*)buf;
    uint8_t* macAddr = pkt->payload + 10;
    char macStr[18];
    snprintf(macStr, sizeof(macStr), "%02X:%02X:%02X:%02X:%02X:%02X",
             macAddr[0], macAddr[1], macAddr[2], macAddr[3], macAddr[4], macAddr[5]);
    std::string macAddress(macStr);
    if (sniffedMacs.find(macAddress) == sniffedMacs.end()) {
        sniffedMacs.insert(macAddress);
        Serial.printf("Intercepted MAC: %s\n", macAddress.c_str());
    }
}

void setup() {
    Serial.begin(115200);
    delay(1000);
    WiFi.begin(ssid, password);
    Serial.printf("\nConnecting to Wi-Fi: %s\n", ssid);
    while (WiFi.status() != WL_CONNECTED) {
        delay(1000);
        Serial.print(".");
    }
    Serial.println("\nWi-Fi connected.");
    WiFi.mode(WIFI_STA);
    esp_wifi_set_promiscuous(true);
    esp_wifi_set_promiscuous_rx_cb(&packetSniffer);
    sniffingStartTime = millis();
    Serial.println("Sniffing started...");
}

void loop() {
    if (millis() - sniffingStartTime >= sniffingDuration) {
        esp_wifi_set_promiscuous(false);
        Serial.println("\nSniffing window closed. API lookups...");

        for (const auto& mac : sniffedMacs) {
            if (lookedUpMacs.find(mac) == lookedUpMacs.end()) {
                Serial.printf("Querying OUI for: %s\n", mac.c_str());
                std::string response = lookupMac(mac);
                if (!response.empty()) {
                    StaticJsonDocument<512> doc;
                    DeserializationError err = deserializeJson(doc, response);
                    if (!err && doc["found"].as<bool>()) {
                        foundMacs.push_back({mac, response});
                        Serial.println("Vendor resolved.");
                    }
                }
                lookedUpMacs.insert(mac);
            }
        }

        Serial.println("\nResolved MAC inventory:");
        for (const auto& entry : foundMacs) {
            Serial.printf("MAC: %s | Data: %s\n", entry.mac.c_str(), entry.data.c_str());
        }

        sniffedMacs.clear();
        sniffingStartTime = millis();
        Serial.println("\nRestarting promiscuous mode...");
        esp_wifi_set_promiscuous(true);
    }
}

What it looks like on Serial

Below is example output; your MACs and JSON payloads will differ.

Typical flow: connect → intercept MACs → query phase → printed inventory.

warning

Use this only for education, your own network, home labs, or authorized security work. Capturing or analyzing radio traffic without permission can be illegal. Do not deploy in offices, schools, or public spaces unless you have clear written authorization and follow local law.

Next steps

If you outgrow the fixed offset, pair this with proper frame parsing or PCAP-style logging. You can also cache OUIs locally to reduce API use. Whatever you build, keep purpose and permission explicit. That is what makes the project genuinely useful instead of risky.

Related Posts

Mastering Apple's App-Site Association (AASA) for Universal Links

How Apple’s AASA file powers Universal Links: where to host the JSON, why HTTPS is non‑negotiable, and how to read it from a security angle, without the myths.

Read More

ESP32 Wi‑Fi Packet Sniffer: Promiscuous Mode & Automated OUI Lookup

Turn an ESP32 into a Layer‑2 Wi‑Fi sniffer: capture MACs in promiscuous mode, dedupe with a hash set, then resolve vendors via a MAC/OUI API (ethics included).

Read More

Hybrid CVE Search & DeepSeek Analysis: A Semantic Security Pipeline

Keyword + FAISS semantic search over CVE text, NVD enrichment, and structured DeepSeek analysis, with validation loops. Full architecture, stack, workflow, and source appendices.

Read More
broMadX

broMadX: notes on app security, engineering, and what I’m learning. Written by achmad (formal résumé: Achmad Firdaus on About).