Você sabia que smartphones e outros dispositivos costumam enviar “sinais” invisíveis pelo ar procurando por redes conhecidas? Dependendo das configurações e do sistema operacional, essa comunicação acontece periodicamente através de ondas de rádio. Neste artigo, vamos explorar como transformar o ESP32 em um autêntico Radar Wi-Fi utilizando uma técnica avançada conhecida como Modo Promíscuo.
Neste projeto, vamos criar uma ferramenta visual capaz de capturar quadros Probe Request — enviados por dispositivos Wi-Fi durante a busca por redes disponíveis ou já conhecidas. Tudo isso exibido de forma incrível em um painel web dinâmico hospedado no próprio microcontrolador!
Privacidade em 1º Lugar: É fundamental esclarecer que este radar atua de maneira 100% passiva. O ESP32 apenas monitora quadros de gerenciamento Wi-Fi transmitidos no ar livre. Como curiosidade, a linha fina que separa a exploração técnica legal do hacking não-ético está justamente na intenção e na intercepção de dados privados. Nosso projeto não acessa o conteúdo de comunicações, não quebra criptografia e não coleta informações privadas dos usuários. Ele atua unicamente como um “ouvinte” da movimentação eletromagnética.
Como a Mágica Acontece?

Fonte: Autor.
O protocolo Wi-Fi (padrão IEEE 802.11) se baseia na troca de diversos tipos de pacotes invisíveis. O que nos interessa hoje é a família de pacotes de gerenciamento, especificamente o subtipo Probe Request (Solicitação de Sonda).
Em condições normais de uso, a placa de rede do seu computador ou ESP32 (modo Station) ignora qualquer pacote no ar que não seja direcionado exclusivamente ao endereço dela. No entanto, ao ativarmos o Modo Promíscuo (também chamado de Modo Monitor), nós removemos esse filtro da placa de rede física. O rádio do chip passa a encaminhar para o processador absolutamente todos os pulsos de rádio interceptados no ambiente.
Com os pacotes em mãos (em formato de bytes puros), nós escrevemos um algoritmo para inspecionar matematicamente o cabeçalho e verificar se aquilo se trata de um Probe Request. Em seguida, medimos o RSSI (Indicador de Força do Sinal Recebido) para calcular o quão perto o dispositivo está do nosso radar.
Nota sobre Localização: Como o ESP32 possui apenas uma única antena omnidirecional, não conseguimos “triangular” a posição exata ou saber de qual lado o dispositivo está vindo. O RSSI nos dá apenas uma estimativa de proximidade (distância radial), funcionando como uma brincadeira de “tá quente, tá frio”. Para uma triangulação real e exata (saber a direção em X e Y), precisaríamos de pelo menos três antenas separadas fisicamente.
O Desafio dos MACs Randomizados e a Privacidade
Todo chip Wi-Fi sai de fábrica com um endereço físico único (MAC Address), que identifica a fabricante. Antigamente, quando os celulares buscavam redes, eles “gritavam” esse endereço real pelo ar. Com isso, lojas, shoppings e supermercados começaram a usar redes de sensores para capturar esses MACs e rastrear o comportamento dos clientes — criando perfis de visitação (quais corredores andaram, quanto tempo ficaram) sem nenhum consentimento!
Para combater essa invasão de privacidade, fabricantes como Apple (iOS 14+) e Google (Android 10+) adotaram a Randomização de MAC. Agora, quando o seu celular está apenas procurando redes, ele veste um “disfarce”: inventa um endereço temporário e falso. Nosso código está preparado para isso! Ao identificar que um sinal provém de um MAC randomizado (que não bate com nenhuma fabricante real), nosso painel o exibe como “Randomizado (Privado)” ou “Desconhecido”. É incrível poder observar essa barreira de segurança agindo em tempo real pelo ar enquanto rastreamos a densidade de dispositivos.
Materiais Necessários
- 1x Placa ESP32 (Qualquer modelo com Wi-Fi)
- Cabo USB (Para compilar e alimentar a placa)
Entendendo a Arquitetura Dual-Core (O Segredo para não travar)
Nosso ESP32 vai desempenhar duas funções cruciais ao mesmo tempo:
1. Escutar milhares de pulsos de rádio por segundo (no Processador 1).
2. Hospedar um servidor web e entregar um gráfico de radar (no Processador 0).

Fonte: Autor.
Se ambas as tarefas tentarem ler ou gravar dados na mesma variável de memória ao exato mesmo tempo, ocorrerá uma falha crítica de hardware chamada Kernel Panic, e o chip reiniciará automaticamente. Para evitar isso, utilizamos um recurso do sistema operacional em tempo real nativo do ESP (FreeRTOS) chamado `portMUX_TYPE`. Ele age como uma fechadura (Spinlock), garantindo que o núcleo do servidor web aguarde pacientemente o núcleo do rádio terminar de anotar um novo dispositivo antes de gerar o gráfico.
Código-Fonte Completo
Abaixo está o código integral para você gravar em seu ESP32. O painel HTML, CSS e JavaScript está embarcado nativamente no C++ utilizando literais crus (`PROGMEM = R”rawliteral(…)rawliteral”`), eliminando a necessidade de usar sistemas de arquivos como LittleFS para este cenário.
/**
* @brief Radar Wi-Fi usando o ESP32 em Modo Promíscuo para detectar Probe Requests de dispositivos próximos.
* @note O ESP32 se conectará à rede Wi-Fi local do usuário, preservando o acesso à internet.
* O painel avançado pode ser acessado no navegador via http://radar.local
* @author Abraão da Silva
*/
#include <WiFi.h>
#include <esp_wifi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
// Definir credenciais da sua rede Wi-Fi local
const char* ssid = "NOME_DA_SUA_REDE";
const char* password = "SENHA_DA_SUA_REDE";
// Servidor Web escutando na porta 80 (padrão HTTP)
WebServer server(80);
/**
* @brief Estrutura de dados para armazenar informações vitais de cada dispositivo detectado no ar.
*/
struct Device {
String mac; // Endereço físico do dispositivo (MAC Address)
int rssi; // Força do sinal recebido (Received Signal Strength Indicator), negativo em dBm
unsigned long last_seen; // Marca de tempo (em milissegundos) da última vez que esse dispositivo "gritou" na rede
};
// Limite máximo de dispositivos simultâneos rastreados na memória para evitar travamentos
#define MAX_DEVICES 50
Device devices[MAX_DEVICES];
int device_count = 0;
// O FreeRTOS usa duas tarefas (tasks) separadas para rodar o Wi-Fi em um núcleo e o Servidor em outro.
// O 'portMUX_TYPE' funciona como uma fechadura (Spinlock) que impede que as duas rotinas tentem ler ou escrever na lista 'devices' exatamente no mesmo milissegundo, o que causaria um congelamento crítico no chip (Kernel Panic).
portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED;
// Constantes base do protocolo Wi-Fi IEEE 802.11
#define WIFI_PKT_MGMT 0x00 // Família de pacotes de Gerenciamento (Management)
#define WIFI_PKT_MGMT_SUBTYPE_PROBE_REQ 0x04 // Subtipo 4: Solicitação de Sonda (Probe Request - quando o celular procura redes salvas)
/**
* @brief O cabeçalho físico obrigatório de qualquer pacote Wi-Fi.
* Usamos a anotação 'packed' para informar ao compilador C++ que esses dados vêm compactados pela placa de rede, byte a byte, sem espaços vazios.
*/
typedef struct {
int16_t fctl; // Controle de frame (tipo e subtipo)
int16_t duration; // Duração da transmissão
uint8_t da[6]; // Destination Address (Normalmente FF:FF:FF:FF:FF:FF para broadcast)
uint8_t sa[6]; // Source Address (MAC do celular da pessoa que queremos detectar)
uint8_t bssid[6]; // Basic Service Set ID
int16_t seqctl; // Controle de sequência
unsigned char payload[]; // O corpo da mensagem
} __attribute__((packed)) wifi_mgmt_hdr_t;
// Dashboard HTML/CSS/JS inteiro colocado em um Literal C++ Raw
const char html_page[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="pt-br">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Monitor Wi-Fi | Scanner</title>
<style>
:root { --primary: #00ff41; --bg: #080808; --surface: #121212; --text: #c8ffc8; }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background-color: var(--bg); color: var(--primary); font-family: 'Courier New', Courier, monospace; padding: 20px; display: flex; flex-direction: column; align-items: center; min-height: 100vh; }
header { text-align: center; margin-bottom: 2rem; border-bottom: 1px solid rgba(0,255,65,0.3); padding-bottom: 15px; width: 100%; max-width: 900px; }
h1 { font-size: 2rem; letter-spacing: 3px; text-shadow: 0 0 10px rgba(0,255,65,0.5); }
.subtitle { font-size: 0.9rem; color: var(--text); opacity: 0.8; margin-top: 5px; }
.dashboard { display: grid; grid-template-columns: 1fr; gap: 20px; width: 100%; max-width: 900px; }
@media(min-width: 768px) { .dashboard { grid-template-columns: 320px 1fr; } }
.radar-box { background: var(--surface); border: 1px solid var(--primary); border-radius: 8px; padding: 30px 20px; display: flex; justify-content: center; align-items: center; flex-direction: column; box-shadow: inset 0 0 20px rgba(0,0,0,0.8), 0 0 15px rgba(0,255,65,0.05); }
.radar-container { width: 250px; height: 250px; border-radius: 50%; border: 2px solid var(--primary); position: relative; background: repeating-radial-gradient(transparent, transparent 24px, rgba(0,255,65,0.1) 25px); margin-bottom: 20px; box-shadow: 0 0 15px rgba(0,255,65,0.2); }
.radar-container::before, .radar-container::after { content: ''; position: absolute; background: rgba(0,255,65,0.3); }
.radar-container::before { width: 100%; height: 1px; top: 50%; left: 0; }
.radar-container::after { height: 100%; width: 1px; left: 50%; top: 0; }
.scanner { position: absolute; top: 50%; left: 50%; width: 125px; height: 125px; background: linear-gradient(45deg, rgba(0,255,65,0.9) 0%, transparent 50%); transform-origin: 0 0; border-radius: 0 0 125px 0; animation: scan 4s linear infinite; z-index: 2; }
@keyframes scan { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.blip { position: absolute; width: 8px; height: 8px; background: #ff003c; border-radius: 50%; box-shadow: 0 0 8px #ff003c; transform: translate(-50%, -50%); z-index: 3; opacity: 1; transition: opacity 1s; }
#status { font-weight: bold; font-size: 1.1rem; text-shadow: 0 0 5px var(--primary); }
.table-box { background: var(--surface); border: 1px solid rgba(0,255,65,0.3); border-radius: 8px; padding: 15px; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px 10px; text-align: left; border-bottom: 1px solid rgba(0,255,65,0.1); }
th { font-size: 0.85rem; color: var(--text); opacity: 0.7; }
td { font-size: 0.95rem; }
.mac { font-weight: bold; letter-spacing: 1px; }
.rssi-bar-bg { width: 100%; height: 6px; background: rgba(0,255,65,0.1); border-radius: 3px; margin-top: 5px; }
.rssi-bar { height: 100%; background: var(--primary); border-radius: 3px; box-shadow: 0 0 8px var(--primary); }
.vendor { font-size: 0.8rem; color: #aaffaa; background: #003300; padding: 2px 6px; border-radius: 4px; margin-left: 5px; }
</style>
</head>
<body>
<header>
<h1>Monitor Wi-Fi: Scanner</h1>
<div class="subtitle">Interceptação Passiva de Probe Requests</div>
</header>
<div class="dashboard">
<div class="radar-box">
<div class="radar-container" id="radar">
<div class="scanner"></div>
</div>
<div id="status">Varrendo sinais...</div>
</div>
<div class="table-box">
<table>
<thead>
<tr><th>Endereço MAC & Fabricante</th><th>Sinal (RSSI)</th></tr>
</thead>
<tbody id="lista">
<tr><td colspan="2" style="text-align:center;">Aguardando dispositivos...</td></tr>
</tbody>
</table>
</div>
</div>
<script>
const vendors = {
"FC:18:3C": "Apple", "00:23:76": "Apple", "04:F7:E4": "Apple", "F8:E6:1A": "Apple",
"18:F0:E4": "Apple", "D4:A3:3D": "Apple", "CC:20:E8": "Apple", "24:F0:94": "Apple",
"FC:C2:DE": "Samsung", "D0:22:BE": "Samsung", "00:12:36": "Samsung", "94:8B:C1": "Samsung",
"88:32:9B": "Samsung", "00:09:2D": "Samsung", "F8:04:2E": "Samsung", "00:15:99": "Samsung",
"F8:CF:C5": "Motorola", "EC:88:92": "Motorola", "E4:8B:7F": "Motorola", "CC:C3:EA": "Motorola",
"64:A2:F9": "Xiaomi", "74:51:BA": "Xiaomi", "28:E3:1F": "Xiaomi", "FC:64:BA": "Xiaomi",
"00:22:F4": "LG", "20:D3:90": "LG", "C4:43:8F": "LG",
"98:0C:82": "Intel", "00:15:00": "Intel", "E0:70:EA": "Intel"
};
const macAngles = {};
const radarCenter = 125;
function getVendor(mac) {
let oui = mac.substring(0,8).toUpperCase();
let char2 = mac.charAt(1).toUpperCase();
if (['2','6','A','E'].includes(char2)) return "Randomizado (Privado)";
return vendors[oui] || "Desconhecido";
}
function updateDash() {
fetch('/dados')
.then(r => r.json())
.then(data => {
let tbody = document.getElementById('lista');
let radarBox = document.getElementById('radar');
document.querySelectorAll('.blip').forEach(el => el.remove());
document.getElementById('status').innerText = data.total + ' DISPOSITIVOS ATIVOS';
if(data.devices.length === 0) {
tbody.innerHTML = '<tr><td colspan="2" style="text-align:center; opacity:0.5;">Nenhum dispositivo na área.</td></tr>';
return;
}
let html = '';
data.devices.forEach(d => {
let pct = Math.max(0, Math.min(100, (d.rssi + 95) * 1.6));
let vendor = getVendor(d.mac);
html += `<tr>
<td><span class="mac">${d.mac}</span> <span class="vendor">${vendor}</span></td>
<td>
<div style="font-size: 0.9rem; margin-bottom: 2px;">${d.rssi} dBm</div>
<div class="rssi-bar-bg"><div class="rssi-bar" style="width: ${pct}%;"></div></div>
</td>
</tr>`;
let radius = Math.max(0, Math.min(125, ((d.rssi * -1) - 30) * 2));
if(!macAngles[d.mac]) {
macAngles[d.mac] = Math.random() * 2 * Math.PI;
}
let angle = macAngles[d.mac];
let x = radarCenter + radius * Math.cos(angle);
let y = radarCenter + radius * Math.sin(angle);
let blip = document.createElement('div');
blip.className = 'blip';
blip.style.left = x + 'px';
blip.style.top = y + 'px';
radarBox.appendChild(blip);
});
tbody.innerHTML = html;
}).catch(e => console.error(e));
}
setInterval(updateDash, 2000);
updateDash();
</script>
</body>
</html>
)rawliteral";
/**
* @brief Converte um array de 6 bytes (MAC Address físico) em uma String formatada.
* Exemplo de saída: "FC:18:3C:A1:B2:C3"
*/
String macToString(uint8_t* mac) {
char buf[18];
snprintf(buf, sizeof(buf), "%02X:%02X:%02X:%02X:%02X:%02X", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
return String(buf);
}
/**
* @brief Função de Callback (Interrupção) chamada automaticamente pelo rádio Wi-Fi sempre que um pacote é interceptado no ar.
* @param buf Ponteiro para os dados crus do pacote recebido.
* @param type Tipo do pacote interceptado.
*/
void sniffer_callback(void* buf, wifi_promiscuous_pkt_type_t type) {
// Filtra apenas pacotes da família de Gerenciamento (Management)
if (type == WIFI_PKT_MGMT) {
wifi_promiscuous_pkt_t *pkt = (wifi_promiscuous_pkt_t *)buf;
// Extrai o tipo e subtipo dos primeiros bytes do payload para descobrir se é um Probe Request
uint8_t frame_control = pkt->payload[0];
uint8_t frame_type = (frame_control & 0x0C) >> 2;
uint8_t frame_subtype = (frame_control & 0xF0) >> 4;
// Verifica se é exatamente um pacote de gerenciamento (tipo 0) do subtipo Solicitação de Sonda (subtipo 4)
if (frame_type == 0 && frame_subtype == WIFI_PKT_MGMT_SUBTYPE_PROBE_REQ) {
wifi_mgmt_hdr_t *mac_hdr = (wifi_mgmt_hdr_t *)pkt->payload;
// Converte o endereço MAC de origem (quem enviou o sinal) para String
String mac_str = macToString(mac_hdr->sa);
// Pega a força do sinal recebido
int rssi = pkt->rx_ctrl.rssi;
// Trava a memória para que o Servidor Web (Core 0) não leia a lista enquanto a atualizamos (Core 1)
portENTER_CRITICAL_ISR(&mux);
bool found = false;
// Procura se o dispositivo já existe na nossa lista de detecção
for(int i = 0; i < device_count; i++) {
if(devices[i].mac == mac_str) {
// Se já existe, atualiza a força do sinal e a hora da última vez visto
devices[i].rssi = rssi;
devices[i].last_seen = millis();
found = true;
break;
}
}
// Se for um dispositivo novo e ainda houver espaço na memória, adiciona à lista
if(!found && device_count < MAX_DEVICES) {
devices[device_count].mac = mac_str;
devices[device_count].rssi = rssi;
devices[device_count].last_seen = millis();
device_count++;
}
// Destrava a memória
portEXIT_CRITICAL_ISR(&mux);
}
}
}
/**
* @brief Rota principal do servidor web (http://radar.local/).
* Retorna todo o conteúdo HTML/CSS/JS do painel armazenado na constante 'html_page'.
*/
void handle_root() {
server.send(200, "text/html", html_page);
}
/**
* @brief Rota da API (http://radar.local/dados) consumida via AJAX pelo frontend.
* Constrói dinamicamente um JSON com a lista de dispositivos ativos detectados nos últimos 15 segundos.
*/
void handle_dados() {
String json = "{\"total\":";
unsigned long now = millis();
int active_count = 0;
String devices_json = "[";
// Trava a memória para o rádio Wi-Fi não alterar os dados enquanto geramos o JSON
portENTER_CRITICAL(&mux);
for(int i = 0; i < device_count; i++) {
// Filtra dispositivos que "gritaram" na rede nos últimos 15000 milissegundos (15s)
if(now - devices[i].last_seen < 15000) {
if(active_count > 0) devices_json += ",";
devices_json += "{\"mac\":\"" + devices[i].mac + "\",\"rssi\":" + String(devices[i].rssi) + "}";
active_count++;
}
}
// Destrava a memória
portEXIT_CRITICAL(&mux);
devices_json += "]";
json += String(active_count) + ",\"devices\":" + devices_json + "}";
// Envia a resposta final para o navegador
server.send(200, "application/json", json);
}
/**
* @brief Função inicial do ESP32 executada apenas uma vez ao ligar.
* Responsável por conectar no Wi-Fi, subir o servidor web e ativar o rádio em modo promíscuo.
*/
void setup() {
// Configura a placa no modo Station (cliente) para conectar ao roteador da sua casa
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, password);
// Aguarda a conexão ser estabelecida
while (WiFi.status() != WL_CONNECTED) {
delay(500);
}
// Inicializa o serviço mDNS para que o painel seja acessado por "http://radar.local" ao invés de IP
MDNS.begin("radar");
// Vincula as rotas às funções que criamos
server.on("/", handle_root);
server.on("/dados", handle_dados);
// Inicia efetivamente o servidor web
server.begin();
// O pulo do gato: Ativa o Modo Promíscuo no rádio do ESP32 e define a função que processará os pacotes
esp_wifi_set_promiscuous(true);
esp_wifi_set_promiscuous_rx_cb(&sniffer_callback);
}
/**
* @brief Loop principal executado infinitamente pelo Core 1.
* Ele precisa ficar constantemente processando e respondendo aos clientes que entram na página web.
*/
void loop() {
server.handleClient();
}
Como testar seu Radar e Conclusão
- Compile e faça o upload do código para sua placa utilizando a Arduino IDE.
- Certifique-se de preencher as variáveis `ssid` e `password` com os dados da sua rede Wi-Fi local.
- Com a placa ligada, certifique-se de que o seu celular ou computador estão conectados na mesma rede Wi-Fi.
- Abra seu navegador web de preferência e digite a URL amigável: `http://radar.local`.
Pronto! Agora pegue outros celulares, desligue e ligue o Wi-Fi deles, e você começará a notar os blips de detecção aparecendo no radar instantaneamente. Aproveite para observar a dinâmica da força do sinal conforme você afasta o aparelho do ESP32!
Conclusão
Muito além de apenas montar um painel, nós acabamos de realizar um verdadeiro sniffing em rede de forma passiva, utilizamos os dois núcleos do processador (Multi-Core) em paralelo protegendo o hardware com o FreeRTOS, e desvendamos na prática como os pacotes de gerenciamento trafegam livres e invisíveis pelas ondas de rádio. O ESP32 provou, mais uma vez, ser uma ferramenta de aprendizado e exploração cibernética incrível!




