Generic selectors
Exact matches only
Search in title
Search in content
Post Type Selectors
/
/
Construindo um Radar Wi-Fi com ESP32: Sniffer de Pacotes em Modo Promíscuo

Construindo um Radar Wi-Fi com ESP32: Sniffer de Pacotes em Modo Promíscuo

Baixe agora a apostila Arduino Iniciante e comece seus projetos

Compartilhe seu conhecimento e receba em dinheiro ou produtos.

Fonte: Autor.

Diagrama dos núcleos da aplicação.

Fonte: Autor.

/**
 * @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();
}

Sobre o Autor


Abraão da Silva

Estudante de Engenharia da Computação, especializado em curiosidades aparentemente aleatórias e desenvolvimento de software. Se eu não estiver pedalando agora, estou estudando ou tentando aproveitar a energia dos raios.

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.

Precisa dos componentes para este projeto?

Encontre tudo na Loja Eletrogate com frete grátis para compras acima de R$ 200