Merge pull request #45 from DeflockJoplin/information-element-fingerprint

Information Element Fingerprint from DeFlock Joplin
This commit is contained in:
Colonel Panic
2026-06-26 07:48:59 -04:00
committed by GitHub
5 changed files with 553 additions and 98 deletions
+48 -33
View File
@@ -5,22 +5,23 @@
**Passive 2.4 GHz promiscuous-mode detector for Flock Safety surveillance infrastructure. Runs standalone or feeds the Flask dashboard over USB for live GPS-tagged wardriving.**
> **Dev note:** This is the `promiscious-dev` branch — adds the
> DeFlockJoplin wildcard-probe tightening and a 31st OUI on top of the
> DeFlockJoplin Information Element research + wildcard probe on top of the
> `promiscious` baseline. See "Further research" below.
---
## Credit
Full credit to @NitekryDPaul for the initial research that sparked my research. His OUI list helps make a strong method rock solid. None of this would be here without his original submission.
All WiFi promiscuous detection research — the **30-OUI target list**, the **promiscuous-mode strategy**, and the **addr1-receiver detection technique** — is the work of **ØяĐöØцяöЪöяцฐ / @NitekryDPaul**. The firmware here is a mod of his original firmware with added SPIFFS persistence and Flask-dashboard integration. Full research writeup: [`datasets/NitekryDPaul_wifi_ouis.md`](datasets/NitekryDPaul_wifi_ouis.md).
Additional credit goes to the researchers that found that information element fields are unique to devices: Lucia Pintor & Luigi Atzori.
Additional research credit to **Michael / DeFlockJoplin** for the **wildcard-probe-request signature** and the 31st OUI (`82:6b:f2`). Field-tested to 11/12 cameras caught with only 2 false positives in Joplin. Source: [DeflockJoplin/flock-you](https://github.com/DeflockJoplin/flock-you).
Pintor, Lucia & Atzori, Luigi. (2022). Analysis of Wi-Fi Probe Requests Towards Information Element Fingerprinting. 3857-3862. 10.1109/GLOBECOM48099.2022.10001618.
---
## What this branch does
Turns a Seeed XIAO ESP32-S3 into a passive WiFi receiver that watches 2.4 GHz management and data frames for Flock Safety MAC OUIs. No AP, no transmit — the radio stays dedicated to sniffing while the device hops channels 1 / 6 / 11 at 350 ms dwell.
Turns a Seeed XIAO ESP32-S3 into a passive WiFi receiver that watches 2.4 GHz management and data frames for Flock Safety MAC OUIs. No AP, no transmit — the radio stays dedicated to sniffing while the device hops channels 11 / 6 / 1 (descending) at 350 ms dwell.
Every detection is:
@@ -32,38 +33,37 @@ The device works standalone (no USB host needed) and plugged in (live dashboard)
---
## Why promiscuous mode, and why `addr1`
## DeFlock Joplin Research Continues - Behavior and IE Fingerprint
Most WiFi sniffers only check the transmitter address (`addr2`). Flock infrastructure spends most of its duty cycle **asleep** — it wakes briefly in bursts, uploads, then sleeps again. During the silence it may never transmit a single frame in your capture window.
Earlier descriptions of Flock behavior are different than what I have observed. This firmware is based on my observations. I do not make claims that others are incorrect or that this behavior has always existed.
But it may still appear on the air as the **destination** (`addr1`) of probe responses or data frames from nearby APs.
**Past Flock Camera Behavior**
In the past, Flock cameras were detectable by the AP they were broadcasting for management. Some time around December 2025, this AP was deactivated. The community began using other detection methods like BLE. Those stopped working some time in the spring.
Checking `addr1` in addition to `addr2` picks those silent stations up. It requires two guards to avoid false positives:
**Current Camera Behavior**
These observations are not meant to cast doubt on anyone else's. These cameras are not managed well and are updated OTA.
- `addr1` is broadcast (`ff:ff:ff:ff:ff:ff`) in beacons and broadcasts — **multicast filter**
- Modern devices use randomised (locally-administered) MACs that can't be fingerprinted by OUI — **randomised-MAC filter** on byte 0 bit 1
Observations:
Flock cameras transmit wildcard probe requests on wi-fi channels in ascending order. These probes are emitted at around .125 second intervals (credit to nsm_barii for orginal observations of both of these). These probes are essentially a WiFi client asking any AP to respond with the SSID of the AP. Hidden SSIDs generally require you send the exact SSID in the probe to generate a response. See [here](https://goodwi.fi/posts/2023/12/hunt-for-hidden-probe/). Flock cameras are also known to use cellular LTE modems to upload to the Flock cloud.
Both are applied before the OUI match. This whole approach, including the 30-OUI list, is **@NitekryDPaul's research**.
Given this, I have disabled all other detection methods in this branch. While they are associated with Flock cameras, they are essentially all echoes of the camera itself, which is only gated by OUI match. The other methods can fire when any OUI matched device is sending wildcard probes because nearby APs can respond and generate a false positive match on addr1 or addr3 methods.
---
The new method relies on IE fingerprinting research done by others in the past such [here](https://www.researchgate.net/publication/367065691_Analysis_of_Wi-Fi_Probe_Requests_Towards_Information_Element_Fingerprinting). According to the linked paper, IE field detection can already be a high confidence identifier, but we combine it with the OUI list collected by @NitekryDPaul for an extremely certain signature. To the point that I have not experience a false postive after hundreds of miles of driving. Other detection methods have trended towards more active methods, but this is entirely passive.
## Further research — the wildcard-probe signature (DeFlockJoplin)
I have also enabled descending channel hopping and reduced dwell time to 250 ms (2x observed hop time) to assist with faster intercept with a Flock signal.
Michael / DeFlockJoplin used the OUI + addr1/addr2/addr3 work above as a starting point and characterised what Flock cameras actually do on the air. His finding:
> The cameras are hopping channels and sending out a wildcard WiFi probe request on every channel. This specific type of request combined with OUI matching has created what seems to be a fairly unique signature.
His drive-test in Joplin caught **11 of 12 cameras** with only **2 false positives**. The 12th camera was doing the same wildcard-probe behaviour but with an OUI (`82:6b:f2`) that wasn't in @NitekryDPaul's original 30 — it's now the 31st entry in our list, credited to him.
Hypothesis on probe behavior:
When the wifi management AP was disabled around December, the devices moved from AP mode to STA mode. The probes likely began at this time. The devices now appear to enumerating networks, perhaps as a default behavior and not something Flock has explicitly set.
The tightened signature that's active on this branch:
1. Frame is 802.11 Management, type=0 subtype=4 (**Probe Request**)
2. SSID Information Element (tag 0) is present with **length 0** (wildcard)
3. `addr2` (transmitter) matches the known-OUI list
4. IE fields match signature collected by Michael / DeFlock Joplin
When all three hit, we emit `detection_method: wifi_wildcard_probe` — the high-precision class. Non-probe frames from the same OUIs still emit `wifi_oui_addr2`, and the `addr1` receiver-side sleeper-catch still runs independently.
When we get a hit, we emit `detection_method: wifi_wildcard_probe_ie_sig`. Broad OUI paths (`wifi_wildcard_probe`, `wifi_oui_addr2`, `wifi_oui_addr1`, `wifi_oui_addr3`) are disabled in firmware — IE fingerprint on uplink probe requests is the active detection path.
His proof-of-concept firmware (different enough we're not just pulling it in wholesale, but the core idea carried over cleanly): [DeflockJoplin/flock-you](https://github.com/DeflockJoplin/flock-you). The wildcard-probe analysis is his; we ported the detection into this firmware and kept our SPIFFS persistence, Flask JSON emission, and audio/LED feedback on top.
---
@@ -160,10 +160,10 @@ The firmware emits one JSON line per detection in the same schema the BLE detect
`detection_method` values:
- `wifi_wildcard_probe`**Probe Request + wildcard SSID from a known OUI** (the DeFlockJoplin high-precision signature). When this fires, the `addr2` broad alert is suppressed for the same frame to avoid double-counting.
- `wifi_oui_addr2` — transmitter-side OUI match on any non-probe frame
- `wifi_oui_addr1`**receiver-side OUI match** (the @NitekryDPaul technique)
- `wifi_oui_addr3` — BSSID OUI match (mgmt frames only; disabled by default)
- `wifi_wildcard_probe_ie_sig`**Probe Request + wildcard SSID + primary Flock IE signature** from a known OUI (PACK method 2 PoC; no rolling-window gates). Supersedes the removed `wifi_wildcard_probe` tier (same wildcard/OUI gates plus IE-field verification).
- `wifi_oui_addr2` *(disabled in firmware)* transmitter-side OUI match on any frame
- `wifi_oui_addr1`*(disabled in firmware)* receiver-side OUI match (the @NitekryDPaul technique)
- `wifi_oui_addr3` *(disabled in firmware)* BSSID OUI on mgmt frames; broad OUI-only filter, false-positive prone
- `wifi_ssid` — SSID keyword match (disabled by default)
### GPS wardriving
@@ -189,7 +189,7 @@ Open `http://localhost:5000`, pick your serial port from the UI, detections star
## Hardware
**Board:** Seeed Studio XIAO ESP32-S3
### Seeed XIAO ESP32-S3 (default: `xiao_esp32s3`)
| Pin | Function |
|-----|----------|
@@ -197,7 +197,19 @@ Open `http://localhost:5000`, pick your serial port from the UI, detections star
| GPIO 21 | Onboard user LED (active low) |
| GPIO 43 | Serial1 TX mirror (115200 baud) |
Boot sound: first 6 notes of Super Mario Bros. World 1-2 (underground).
### LilyGO T-Dongle S3 (`lilygo_t_dongle_s3`). Added because Michael lost his OUI-Spy for testing.
| Pin | Function |
|-----|----------|
| GPIO 15, 38 | ST7735 display (RST, DC, MOSI, CS, SCLK, backlight) |
| GPIO 39 / 40 | APA102 RGB LED (clock / data) — red flash on detection |
| USB CDC | Serial JSON for Flask dashboard |
**Display:** idle screen shows `SCANNING`, current WiFi channel, and unique hit count. On each emitted detection, shows `DETECT`, method, MAC, RSSI, and channel for **5 s**, then returns to idle. Backlight is driven on init (GPIO 38 active-low).
No buzzer on this env.
Boot sound (XIAO only): first 6 notes of Super Mario Bros. World 1-2 (underground).
---
@@ -206,12 +218,15 @@ Boot sound: first 6 notes of Super Mario Bros. World 1-2 (underground).
Requires [PlatformIO](https://platformio.org/).
```bash
pio run # build
pio run -t upload # flash
pio device monitor # serial output
pio run -e xiao_esp32s3 # Seeed XIAO (default)
pio run -e lilygo_t_dongle_s3 # LilyGO T-Dongle S3
pio run -e xiao_esp32s3 -t upload # flash XIAO
pio run -e lilygo_t_dongle_s3 -t upload # flash T-Dongle (hold BOOT if port missing)
pio device monitor
```
`platformio.ini` and `partitions.csv` are at the root (1.9 MB SPIFFS partition, 6 MB app). No extra libraries needed beyond the Arduino-ESP32 core that ships with the espressif32 platform.
`platformio.ini` and `partitions.csv` are at the root (1.9 MB SPIFFS partition, 6 MB app). The T-Dongle env adds **TFT_eSPI** for the onboard display; XIAO needs no extra libraries.
---
@@ -219,12 +234,12 @@ pio device monitor # serial output
| Define | Default | Notes |
|---|---|---|
| `CHANNEL_MODE` | `CHANNEL_MODE_CUSTOM` | `CUSTOM` (1/6/11), `FULL_HOP` (1-11), or `SINGLE` |
| `CHANNEL_MODE` | `CHANNEL_MODE_CUSTOM` | `CUSTOM` (11/6/1 desc), `FULL_HOP` (11-1 desc), or `SINGLE` |
| `CHANNEL_DWELL_MS` | 350 | Time on each channel before hop |
| `RSSI_MIN` | -95 | Drop frames weaker than this |
| `ALERT_COOLDOWN_MS` | 5000 | Per-MAC serial-emit rate limit |
| `CHECK_ADDR1` | 1 | The @NitekryDPaul receiver-side technique |
| `CHECK_ADDR3` | 0 | BSSID fallback (mgmt frames only) |
| `CHECK_ADDR1` | 0 | Receiver-side OUI (disabled — see main.cpp) |
| `CHECK_ADDR3` | 0 | BSSID OUI fallback (disabled — see main.cpp) |
| `ENABLE_SSID_MATCH` | 0 | Substring match against `target_ssid_keywords[]` |
| `PROCESS_MGMT_FRAMES` | 1 | Beacons, probe req/resp, etc. |
| `PROCESS_DATA_FRAMES` | 1 | Data frames (where addr1 catch shines) |
+2 -6
View File
@@ -10,12 +10,9 @@ Flock stations spend most of their duty cycle asleep, waking briefly to upload a
This addr1 technique is @NitekryDPaul's discovery and is the basis of the `promiscuis-flock-you` firmware.
## OUI list (31 prefixes, lowercase, colon-separated)
## OUI list (30 prefixes, lowercase, colon-separated)
@NitekryDPaul contributed the first 30. The 31st (`82:6b:f2`) was contributed
by **Michael / DeFlockJoplin** during follow-up drive-testing in Joplin — it's
the OUI of the 12th camera in his field test, which the original list didn't
catch. See [DeflockJoplin/flock-you](https://github.com/DeflockJoplin/flock-you).
@NitekryDPaul contributed all 30.
```
70:c9:4e
@@ -85,7 +82,6 @@ a4:cf:12
| 64:6e:69 | Flock Safety infrastructure | WiFi 2.4 GHz | @NitekryDPaul |
| 48:27:ea | Flock Safety infrastructure | WiFi 2.4 GHz | @NitekryDPaul |
| a4:cf:12 | Flock Safety infrastructure | WiFi 2.4 GHz | @NitekryDPaul |
| 82:6b:f2 | Flock Safety infrastructure | WiFi 2.4 GHz (wildcard probe) | Michael / DeFlockJoplin |
## Detection strategy
+135
View File
@@ -0,0 +1,135 @@
#ifdef USE_T_DONGLE_DISPLAY
#include "display_dongle.h"
#include <Arduino.h>
#include <TFT_eSPI.h>
#include <string.h>
// LilyGO T-Dongle S3 ST7735 80x160 IPS — backlight active-low on GPIO38.
#define DONGLE_TFT_BL_PIN 38
#define DONGLE_TFT_RST_PIN 1
// 160x80 landscape — matches LilyGO examples/TFT_eSPI/TFT_eSPI.ino
#define DONGLE_TFT_ROTATION 1
static TFT_eSPI tft = TFT_eSPI();
static unsigned long alertUntilMs = 0;
static uint8_t idleCh = 1;
static int idleDetCount = 0;
static bool inAlert = false;
static uint8_t lastDrawnCh = 0xFF;
static int lastDrawnHits = -1;
static void backlightOn() {
pinMode(DONGLE_TFT_BL_PIN, OUTPUT);
digitalWrite(DONGLE_TFT_BL_PIN, LOW);
}
static void tftHardwareReset() {
pinMode(DONGLE_TFT_RST_PIN, OUTPUT);
digitalWrite(DONGLE_TFT_RST_PIN, HIGH);
delay(10);
digitalWrite(DONGLE_TFT_RST_PIN, LOW);
delay(10);
digitalWrite(DONGLE_TFT_RST_PIN, HIGH);
delay(120);
}
static void drawMethodLines(const char* method, int y) {
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setTextSize(1);
char line[32];
const char* p = method;
while (*p) {
size_t n = 0;
const char* seg = p;
while (p[n] && p[n] != '_' && n < 16) n++;
if (p[n] == '_') n++;
size_t copy = n;
if (copy >= sizeof(line)) copy = sizeof(line) - 1;
memcpy(line, seg, copy);
line[copy] = '\0';
tft.setCursor(2, y);
tft.print(line);
y += 10;
p += n;
if (y > 58) break;
}
}
void dongleDisplayInit() {
tftHardwareReset();
tft.init();
tft.setRotation(DONGLE_TFT_ROTATION);
tft.fillScreen(TFT_BLACK);
backlightOn();
dongleDisplayShowIdle(1, 0);
}
void dongleDisplayShowIdle(uint8_t ch, int detCount) {
idleCh = ch;
idleDetCount = detCount;
inAlert = false;
alertUntilMs = 0;
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setTextSize(2);
tft.setCursor(4, 4);
tft.print("SCAN");
tft.setCursor(4, 24);
tft.print("NING");
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setTextSize(1);
tft.setCursor(2, 50);
tft.printf("Ch: %u", (unsigned)ch);
tft.setCursor(2, 64);
tft.printf("Hits: %d", detCount);
lastDrawnCh = ch;
lastDrawnHits = detCount;
}
void dongleDisplayShowAlert(const char* method, const char* mac, int8_t rssi,
uint8_t ch, unsigned long alertMs) {
idleCh = ch;
inAlert = true;
if (alertMs == 0) alertMs = 1;
alertUntilMs = millis() + alertMs;
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_RED, TFT_BLACK);
tft.setTextSize(2);
tft.setCursor(4, 2);
tft.print("DETECT");
drawMethodLines(method ? method : "?", 22);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setTextSize(1);
tft.setCursor(2, 44);
tft.print(mac ? mac : "");
tft.setCursor(2, 56);
tft.printf("RSSI %d", (int)rssi);
tft.setCursor(2, 68);
tft.printf("CH %u", (unsigned)ch);
}
bool dongleDisplayInAlert(unsigned long now) {
return inAlert && alertUntilMs != 0 && (long)(now - alertUntilMs) < 0;
}
void dongleDisplayTick(unsigned long now, uint8_t ch, int detCount) {
idleCh = ch;
idleDetCount = detCount;
if (inAlert && alertUntilMs != 0 && (long)(now - alertUntilMs) >= 0) {
dongleDisplayShowIdle(idleCh, idleDetCount);
return;
}
if (!inAlert && (ch != lastDrawnCh || detCount != lastDrawnHits)) {
dongleDisplayShowIdle(ch, detCount);
}
}
#endif
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <stdint.h>
#ifdef USE_T_DONGLE_DISPLAY
void dongleDisplayInit();
void dongleDisplayShowIdle(uint8_t ch, int detCount);
void dongleDisplayShowAlert(const char* method, const char* mac, int8_t rssi,
uint8_t ch, unsigned long alertMs);
void dongleDisplayTick(unsigned long now, uint8_t ch, int detCount);
bool dongleDisplayInAlert(unsigned long now);
#else
static inline void dongleDisplayInit() {}
static inline void dongleDisplayShowIdle(uint8_t, int) {}
static inline void dongleDisplayShowAlert(const char*, const char*, int8_t, uint8_t,
unsigned long) {}
static inline void dongleDisplayTick(unsigned long, uint8_t, int) {}
static inline bool dongleDisplayInAlert(unsigned long) { return false; }
#endif
+345 -59
View File
@@ -4,37 +4,49 @@
#include <ctype.h>
#include <string.h>
#include <SPIFFS.h>
#include "display_dongle.h"
// ============================================================
// CONFIG
// CONFIG (board defaults; override via platformio build_flags)
// ============================================================
#define BUZZER_PIN 3
#define USE_BUZZER 1
#ifdef BOARD_LILYGO_T_DONGLE_S3
// LilyGO T-Dongle S3: ST7735 display + APA102 RGB (no buzzer).
#define USE_BUZZER 0
#define USE_LED 1
#define USE_APA102_LED 1
#define APA102_DATA_PIN 40
#define APA102_CLK_PIN 39
#define APA102_FLASH_R 255
#define APA102_FLASH_G 0
#define APA102_FLASH_B 0
#define MIRROR_SERIAL 0 // GPIO43 is UART TX on this board
#else
// Seeed XIAO ESP32-S3
#define BUZZER_PIN 3
#define USE_BUZZER 1
#define LED_PIN 21
#define USE_LED 1
#define LED_ACTIVE_HIGH 0
#define MIRROR_SERIAL 1
#define MIRROR_TX_PIN 43
#endif
// Onboard user LED on Seeed XIAO ESP32-S3 is GPIO21 and is ACTIVE LOW
// (driving the pin LOW lights the LED).
#define LED_PIN 21
#define USE_LED 1
#define LED_ACTIVE_HIGH 0
#define LED_FLASH_MS 120
#define MIRROR_SERIAL 1
#define MIRROR_TX_PIN 43
#define MIRROR_BAUD 115200
#define LED_FLASH_MS 120
#define MIRROR_BAUD 115200
#define CHANNEL_MODE_FULL_HOP 0
#define CHANNEL_MODE_CUSTOM 1
#define CHANNEL_MODE_SINGLE 2
#define CHANNEL_MODE CHANNEL_MODE_CUSTOM
#define CHANNEL_DWELL_MS 350
#define CHANNEL_DWELL_MS 250 // Changed to 2 x 125ms to aid in faster detection. 125ms is the observed hop time of the cameras (credit to nsm_barri for the observation).
#define SINGLE_CHANNEL 1
static const uint8_t customChannels[] = {1, 6, 11};
// Channel order reversed to aid in faster detection. Credit to nsm_barri for the observation on the ascending hop order of the cameras.
static const uint8_t customChannels[] = {11, 6, 1};
static const size_t customChannelCount = sizeof(customChannels) / sizeof(customChannels[0]);
static const uint8_t fullHopChannels[] = {1,2,3,4,5,6,7,8,9,10,11};
static const uint8_t fullHopChannels[] = {11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1};
static const size_t fullHopChannelCount = sizeof(fullHopChannels) / sizeof(fullHopChannels[0]);
#define HEARTBEAT_MS 30000
@@ -60,8 +72,8 @@ static const size_t fullHopChannelCount = sizeof(fullHopChannels) / sizeof(full
#define HB_BEEP_GAP_MS 70
#define ENABLE_SSID_MATCH 0
#define CHECK_ADDR1 1 // dst/rx — catches Flock STAs receiving probe responses
#define CHECK_ADDR3 0 // bssid fallback for randomised addr2
#define CHECK_ADDR1 0 // disabled — see wifiSniffer() comment block
#define CHECK_ADDR3 0 // disabled — see wifiSniffer() comment block
static const char* target_ssid_keywords[] = { "flock" };
static const size_t SSID_KEYWORD_COUNT = sizeof(target_ssid_keywords) / sizeof(target_ssid_keywords[0]);
@@ -87,11 +99,8 @@ static const char* target_ouis[] = {
"94:08:53", "e4:aa:ea", "f4:6a:dd", "f8:a2:d6", "24:b2:b9",
"00:f4:8d", "d0:39:57", "e8:d0:fc", "e0:4f:43", "b8:1e:a4",
"70:08:94", "58:8e:81", "ec:1b:bd", "3c:71:bf", "58:00:e3",
"90:35:ea", "5c:93:a2", "64:6e:69", "48:27:ea", "a4:cf:12",
// Contributed by Michael / DeFlockJoplin — discovered via wildcard-probe
// + OUI signature during field testing. The 12th camera in his drive-test
// used this prefix and wasn't in @NitekryDPaul's original 30.
"82:6b:f2"
"90:35:ea", "5c:93:a2", "64:6e:69", "48:27:ea", "a4:cf:12"
};
static const size_t OUI_COUNT = sizeof(target_ouis) / sizeof(target_ouis[0]);
@@ -111,10 +120,10 @@ typedef enum : uint8_t {
ALERT_OUI_ADDR1 = 1,
ALERT_OUI_ADDR3 = 2,
ALERT_SSID = 3,
// Probe Request + wildcard SSID (tag 0, length 0) from a known-OUI addr2.
// Tight signature from Michael / DeFlockJoplin field research:
// https://github.com/DeflockJoplin/flock-you
ALERT_WILDCARD_PROBE = 4,
// Wildcard probe + OUI + primary IE signature (wifi_wildcard_probe_ie_sig).
// wifi_wildcard_probe (OUI + wildcard only) was removed — superseded by this
// path: same wildcard/OUI gates plus IE-field verification.
ALERT_WILDCARD_PROBE_IE_SIG = 4,
} AlertType;
typedef struct {
@@ -166,7 +175,7 @@ static void IRAM_ATTR enqueueAlert(AlertType type, const uint8_t* mac, int8_t rs
typedef struct {
char mac[18];
char method[16]; // "oui_addr2" / "oui_addr1" / "oui_addr3" / "ssid"
char method[24]; // alertTypeToMethod strings (incl. wildcard_probe_ie_sig)
int8_t rssi;
uint8_t channel;
uint32_t firstSeen; // millis() at first hit
@@ -207,6 +216,33 @@ static size_t dedupeIdx = 0;
// LED one-shot pulse timer
static volatile unsigned long ledOffAt = 0;
#if USE_LED && defined(USE_APA102_LED)
static void apa102WriteByte(uint8_t b) {
for (int bit = 7; bit >= 0; bit--) {
digitalWrite(APA102_DATA_PIN, (b >> bit) & 1);
digitalWrite(APA102_CLK_PIN, HIGH);
digitalWrite(APA102_CLK_PIN, LOW);
}
}
static void apa102SetColor(uint8_t r, uint8_t g, uint8_t b) {
for (int i = 0; i < 4; i++) apa102WriteByte(0x00);
apa102WriteByte(0xFF); // global brightness
apa102WriteByte(b);
apa102WriteByte(g);
apa102WriteByte(r);
for (int i = 0; i < 4; i++) apa102WriteByte(0xFF);
}
static void apa102Init() {
pinMode(APA102_DATA_PIN, OUTPUT);
pinMode(APA102_CLK_PIN, OUTPUT);
digitalWrite(APA102_CLK_PIN, LOW);
digitalWrite(APA102_DATA_PIN, LOW);
apa102SetColor(0, 0, 0);
}
#endif
// Heartbeat audio state: last time any target was seen, last time the
// heartbeat beep-pair was played. When nothing has been seen for
// HB_DEVICE_ACTIVE_MS the heartbeat stops until the next new detection.
@@ -256,12 +292,17 @@ static void dualPrintln(const char* str) {
static inline void ledSet(bool on) {
#if USE_LED
#if defined(USE_APA102_LED)
if (on) apa102SetColor(APA102_FLASH_R, APA102_FLASH_G, APA102_FLASH_B);
else apa102SetColor(0, 0, 0);
#else
#if LED_ACTIVE_HIGH
digitalWrite(LED_PIN, on ? HIGH : LOW);
#else
digitalWrite(LED_PIN, on ? LOW : HIGH);
#endif
#endif
#endif
}
static void ledFlash(unsigned ms) {
@@ -308,8 +349,9 @@ static void heartbeatBeep() {
static void startupBeep() {
#if USE_BUZZER
// First 6 notes of SMB World 1-2 (underground). Koji Kondo's descending
// pattern: C5 → C4 → A4 → A3 → G#4 → G#3 (alternating-octave pairs).
static const uint16_t notes[6] = { 523, 262, 440, 220, 415, 208 };
// pattern: C4, C5, A3, A4, B♭3, B♭4). (alternating-octave pairs).
static const uint16_t notes[6] = { 262, 523, 220, 440, 233, 466 };
for (int i = 0; i < 6; i++) {
tone(BUZZER_PIN, notes[i]);
delay((i == 5) ? 160 : 95);
@@ -445,6 +487,9 @@ static void printHeartbeat() {
dualPrintf("[flockyou] scanning (ch=%u mode=%s det=%d)\n",
currentChannel, channelModeName(), fyDetCount);
lastHeartbeat = millis();
if (!dongleDisplayInAlert(millis())) {
dongleDisplayShowIdle(currentChannel, fyDetCount);
}
}
}
@@ -457,9 +502,9 @@ static const char* alertTypeToMethod(AlertType t) {
case ALERT_OUI_ADDR2: return "oui_addr2";
case ALERT_OUI_ADDR1: return "oui_addr1";
case ALERT_OUI_ADDR3: return "oui_addr3";
case ALERT_SSID: return "ssid";
case ALERT_WILDCARD_PROBE: return "wildcard_probe";
default: return "unknown";
case ALERT_SSID: return "ssid";
case ALERT_WILDCARD_PROBE_IE_SIG: return "wildcard_probe_ie_sig";
default: return "unknown";
}
}
@@ -824,6 +869,216 @@ static int IRAM_ATTR isWildcardProbeIE(const uint8_t* body, int len) {
return -1;
}
// --- PACK method 2 PoC: Flock probe IE signature (primary allowlist only) ---
static const char FLOCK_PROBE_IE_SIG_PRIMARY[] =
"2,12,127,221:506f9a16030103,45,191,221:0050f208000000";
static const char FLOCK_LITEON_IE_SIG_PREFIX[] = "221:506f9a16030103";
#define FY_IE_SSID 0
#define FY_IE_VENDOR 221
#define FY_PHANTOM_SKIP_CAP 16
#define FY_TLV_RESYNC_MAX 64
// Encode n raw bytes as lowercase hex pairs (no separator) for vendor IE tokens.
static void IRAM_ATTR fyHexNibbles(char* dst, const uint8_t* b, int n) {
static const char hd[] = "0123456789abcdef";
for (int i = 0; i < n; i++) {
dst[i * 2] = hd[b[i] >> 4];
dst[i * 2 + 1] = hd[b[i] & 0x0f];
}
}
// True when ies[pos] starts vendor IE 221 with OUI 50:6f:9a (LiteON / Flock stack).
// Used to spot real IE boundaries inside corrupted/overflow TLV runs.
static bool IRAM_ATTR fyLiteonVendorAt(const uint8_t* ies, int len, int pos) {
return pos + 9 <= len && ies[pos] == FY_IE_VENDOR && ies[pos + 1] == 7
&& ies[pos + 2] == 0x50 && ies[pos + 3] == 0x6f && ies[pos + 4] == 0x9a;
}
// Scan up to 32 bytes past a bogus TLV header for a real LiteON vendor IE —
// signals a phantom overflow (driver length/FCS skew) rather than end of frame.
static bool IRAM_ATTR fyPhantomLiteonAhead(const uint8_t* ies, int len, int pos) {
int end = pos + 2 + 32;
if (end > len - 1) end = len - 1;
for (int j = pos + 2; j < end; j++) {
if (fyLiteonVendorAt(ies, len, j)) return true;
}
return false;
}
// True when declared IE length extends past the buffer but looks like a phantom
// tag-64/len-128 overflow with LiteON payload still present ahead in the buffer.
static bool IRAM_ATTR fyIsPhantomOverflow(const uint8_t* ies, int len,
uint8_t id, int elen, int i) {
if (i + 2 + elen <= len) return false;
if (elen > 200) return true;
return id == 64 && elen == 128 && fyPhantomLiteonAhead(ies, len, i);
}
// After a TLV parse failure, slide forward up to FY_TLV_RESYNC_MAX bytes to find
// the next plausible IE header (id + len that fits in the buffer).
static int IRAM_ATTR fyTlvResync(const uint8_t* ies, int len, int start) {
int end = start + FY_TLV_RESYNC_MAX;
if (end > len - 1) end = len - 1;
for (int j = start; j < end; j++) {
int elen = (int)ies[j + 1];
if (elen <= 200 && j + 2 + elen <= len) return j;
}
return -1;
}
// Append a comma-separated fragment to the growing IE signature string; fails if cap exceeded.
static bool IRAM_ATTR fySigAppend(char* out, size_t cap, size_t* pos, const char* part) {
size_t plen = strlen(part);
if (*pos != 0) {
if (*pos + 1 >= cap) return false;
out[(*pos)++] = ',';
}
if (*pos + plen >= cap) return false;
memcpy(out + *pos, part, plen);
*pos += plen;
out[*pos] = '\0';
return true;
}
// Append a non-vendor IE as its decimal tag id (e.g. "12", "127", "45").
static bool IRAM_ATTR fySigAppendTag(char* out, size_t cap, size_t* pos, uint8_t id) {
char buf[8];
snprintf(buf, sizeof(buf), "%u", (unsigned)id);
return fySigAppend(out, cap, pos, buf);
}
// Append vendor IE as "221:" + up to 8 payload bytes hex (matches PACK sig format).
static bool IRAM_ATTR fySigAppendVendor(char* out, size_t cap, size_t* pos,
const uint8_t* body, int elen) {
char buf[24];
int take = elen < 8 ? elen : 8;
buf[0] = '2'; buf[1] = '2'; buf[2] = '1'; buf[3] = ':';
fyHexNibbles(buf + 4, body, take);
buf[4 + take * 2] = '\0';
return fySigAppend(out, cap, pos, buf);
}
// Walk 802.11 IE TLVs and build comma-separated fingerprint: skip SSID (tag 0),
// encode vendor 221 payloads, otherwise record tag numbers. Handles phantom
// overflows and resync. Sets *complete when every byte was consumed.
static bool IRAM_ATTR fyBuildFlockIeSigFromIes(const uint8_t* ies, int len,
char* out, size_t cap, bool* complete) {
if (!ies || len < 2 || !out || cap < 2) return false;
size_t pos = 0;
out[0] = '\0';
int i = 0;
uint8_t phantomSkips = 0;
while (i + 2 <= len) {
uint8_t id = ies[i];
int elen = (int)ies[i + 1];
if (i + 2 + elen > len) {
if (phantomSkips < FY_PHANTOM_SKIP_CAP
&& fyIsPhantomOverflow(ies, len, id, elen, i)) {
phantomSkips++;
i += 2;
continue;
}
int j = fyTlvResync(ies, len, i);
if (j > i) {
i = j;
continue;
}
return false;
}
i += 2;
if (id == FY_IE_SSID) {
if (elen == 0) {
while (i + 2 <= len && ies[i] == 0 && ies[i + 1] == 0) i += 2;
} else {
i += elen;
}
continue;
}
if (id == FY_IE_VENDOR && elen >= 4) {
if (!fySigAppendVendor(out, cap, &pos, ies + i, elen)) return false;
} else {
if (!fySigAppendTag(out, cap, &pos, id)) return false;
}
i += elen;
}
if (complete) *complete = (i == len);
return pos > 0;
}
// Normalize signature to "2,12,127,<rest from LiteON anchor>" when the LiteON
// vendor prefix is present but leading tags were truncated by parse skew.
static void IRAM_ATTR fyCanonicalizeFlockIeSig(char* sig, size_t cap) {
if (!sig || cap < 8) return;
if (strncmp(sig, "2,12,127,", 9) == 0
&& strstr(sig, FLOCK_LITEON_IE_SIG_PREFIX) != nullptr) {
return;
}
const char* anchor = strstr(sig, FLOCK_LITEON_IE_SIG_PREFIX);
if (!anchor) return;
char tmp[128];
int n = snprintf(tmp, sizeof(tmp), "2,12,127,%s", anchor);
if (n > 0 && (size_t)n < cap) memcpy(sig, tmp, (size_t)n + 1);
}
// Normalize signature to "2,12,127,<rest from LiteON anchor>" when the LiteON
// vendor prefix is present but leading tags were truncated by parse skew.
static bool IRAM_ATTR fyPickBetterSig(const char* a, bool aComplete,
const char* b, bool bComplete,
char* out, size_t cap) {
if (!a[0] && !b[0]) return false;
if (a[0] && !b[0]) {
strncpy(out, a, cap - 1);
out[cap - 1] = '\0';
return true;
}
if (!a[0] && b[0]) {
strncpy(out, b, cap - 1);
out[cap - 1] = '\0';
return true;
}
const char* pick = a;
if (aComplete && !bComplete) pick = a;
else if (!aComplete && bComplete) pick = b;
else if (strlen(b) > strlen(a)) pick = b;
strncpy(out, pick, cap - 1);
out[cap - 1] = '\0';
return true;
}
// Build fingerprint from full body and from body+2 (skip leading empty SSID IE pair);
// merge, canonicalize, write to out.
static bool IRAM_ATTR fyBuildFlockIeSigFromProbeBody(const uint8_t* body, int bodyLen,
char* out, size_t cap) {
if (!body || bodyLen < 2 || !out || cap < 16) return false;
char sigA[128] = {0};
char sigB[128] = {0};
bool completeA = false, completeB = false;
bool okA = fyBuildFlockIeSigFromIes(body, bodyLen, sigA, sizeof(sigA), &completeA);
bool okB = false;
if (bodyLen >= 2 && body[0] == 0 && body[1] == 0) {
okB = fyBuildFlockIeSigFromIes(body + 2, bodyLen - 2, sigB, sizeof(sigB), &completeB);
}
char merged[128] = {0};
if (!fyPickBetterSig(okA ? sigA : "", completeA, okB ? sigB : "", completeB,
merged, sizeof(merged))) {
return false;
}
fyCanonicalizeFlockIeSig(merged, sizeof(merged));
strncpy(out, merged, cap - 1);
out[cap - 1] = '\0';
return out[0] != '\0';
}
// True when sig exactly matches FLOCK_PROBE_IE_SIG_PRIMARY (drive-tested allowlist entry).
static bool IRAM_ATTR fyFlockIeSigIsPrimary(const char* sig) {
return sig && strcmp(sig, FLOCK_PROBE_IE_SIG_PRIMARY) == 0;
}
static bool IRAM_ATTR fyProbeBodyFlockIeSigPrimary(const uint8_t* body, int bodyLen) {
char ieSig[128];
int len = bodyLen;
if (fyBuildFlockIeSigFromProbeBody(body, len, ieSig, sizeof(ieSig))
&& fyFlockIeSigIsPrimary(ieSig)) {
return true;
}
if (len > 4 && fyBuildFlockIeSigFromProbeBody(body, len - 4, ieSig, sizeof(ieSig))
&& fyFlockIeSigIsPrimary(ieSig)) {
return true;
}
return false;
}
static void IRAM_ATTR wifiSniffer(void* buf, wifi_promiscuous_pkt_type_t type) {
if (!buf || sniffingStopped) return;
@@ -848,15 +1103,13 @@ static void IRAM_ATTR wifiSniffer(void* buf, wifi_promiscuous_pkt_type_t type) {
// --- OUI check: addr2 (transmitter/source) ---
//
// For mgmt Probe Requests (type=0 subtype=4) from a matched OUI, tighten
// to the DeFlockJoplin wildcard-probe signature: SSID IE (tag 0) length
// must be zero. This reduces false positives dramatically (Michael's field
// test: 11/12 true-positive with only 2 false-positives in Joplin).
// Probe requests (type=0 subtype=4) from a matched OUI: wildcard SSID IE
// (tag 0, length 0) plus primary Flock IE signature → wifi_wildcard_probe_ie_sig.
//
// Non-probe frames from the same OUI still emit the broad ADDR2 alert.
// See: https://github.com/DeflockJoplin/flock-you
// wifi_wildcard_probe (OUI + wildcard only, no IE check) was removed here.
// Suggest superseded by the IE fingerprint path: it uses the same wildcard
// and OUI gates and adds verification on probe IE fields.
if (matchOuiRaw(hdr->addr2)) {
bool emitted = false;
if (type == WIFI_PKT_MGMT) {
uint8_t fc0 = hdr->frame_ctrl & 0xFF;
uint8_t ftype = (fc0 >> 2) & 0x03;
@@ -870,34 +1123,50 @@ static void IRAM_ATTR wifiSniffer(void* buf, wifi_promiscuous_pkt_type_t type) {
// ALL (-1). A found-but-nonzero (0) means legit directed probe; do
// not retry — it would mis-classify.
if (r == -1 && bodyLen > 4) r = isWildcardProbeIE(body, bodyLen - 4);
if (r == 1) {
enqueueAlert(ALERT_WILDCARD_PROBE, hdr->addr2, rssi, ch,
if (r == 1 && fyProbeBodyFlockIeSigPrimary(body, bodyLen)) {
enqueueAlert(ALERT_WILDCARD_PROBE_IE_SIG, hdr->addr2, rssi, ch,
nullptr, "probe_req");
emitted = true;
}
}
}
if (!emitted) {
enqueueAlert(ALERT_OUI_ADDR2, hdr->addr2, rssi, ch, nullptr, "addr2");
}
// wifi_oui_addr2 — broad transmitter OUI on any non-fingerprint frame:
// if (!emitted) {
// enqueueAlert(ALERT_OUI_ADDR2, hdr->addr2, rssi, ch, nullptr, "addr2");
// }
}
// --- Disabled: wifi_oui_addr1 (receiver / addr1) ---
//
// Suggest leaving this disabled. Flock cameras are known to channel-hop and
// send wildcard probe requests (addr2 = camera). Nearby APs that hear those
// probes reply with probe responses where addr1 = camera MAC and addr2 = AP.
// This path is the same OUI list again, but matching addr1 (destination) —
// i.e. "is anyone sending *to* a Flock OUI?" — not the camera transmitting.
//
// 802.11 MAC header roles (infrastructure / mgmt):
// addr1 = receiver (DA) addr2 = transmitter (SA) addr3 = BSSID
// On a camera probe request: addr2=camera, addr1 often broadcast.
// On an AP probe response: addr1=camera, addr2=AP, addr3=AP BSSID.
//
// addr1 hits are therefore mostly second-hand fallout from the same probe
// behavior (AP replies), redundant with wildcard+IE detection on the uplink
// probe request itself.
#if CHECK_ADDR1
// addr1 (receiver/destination): catches Flock STAs that appear only as the
// dst of probe responses and data frames — never transmitting in the capture
// window due to their burst-sleep duty cycle. Multicast guard is mandatory
// here since addr1 is broadcast (ff:ff:ff:ff:ff:ff) in beacons/broadcasts.
if (!isMulticast(hdr->addr1) && matchOuiRaw(hdr->addr1)) {
enqueueAlert(ALERT_OUI_ADDR1, hdr->addr1, rssi, ch, nullptr, "addr1");
}
// if (!isMulticast(hdr->addr1) && matchOuiRaw(hdr->addr1)) {
// enqueueAlert(ALERT_OUI_ADDR1, hdr->addr1, rssi, ch, nullptr, "addr1");
// }
#endif
// --- Disabled: wifi_oui_addr3 (BSSID / addr3) ---
//
// Suggest leaving this disabled. Another broad OUI filter on addr3 (BSSID)
// on management frames — intended for randomised addr2 with real OUI in
// addr3, but still OUI-only with no probe/IE behavioral check, so it can
// generate false positives on unrelated mgmt traffic.
#if CHECK_ADDR3
// addr3 fallback: catches cases where addr2 is randomised but addr3
// carries the real BSSID OUI (management frames only).
if (type == WIFI_PKT_MGMT && matchOuiRaw(hdr->addr3)) {
enqueueAlert(ALERT_OUI_ADDR3, hdr->addr3, rssi, ch, nullptr, "addr3");
}
// if (type == WIFI_PKT_MGMT && matchOuiRaw(hdr->addr3)) {
// enqueueAlert(ALERT_OUI_ADDR3, hdr->addr3, rssi, ch, nullptr, "addr3");
// }
#endif
#if ENABLE_SSID_MATCH
@@ -1008,6 +1277,10 @@ static void drainAlertQueue() {
}
ledFlash(LED_FLASH_MS);
char methodLine[40];
snprintf(methodLine, sizeof(methodLine), "wifi_%s", method);
dongleDisplayShowAlert(methodLine, macStr, e.rssi, e.channel, ALERT_COOLDOWN_MS);
#if STOP_ON_OUI_HIT
if (e.type != ALERT_SSID) stopSniffing("OUI hit");
#endif
@@ -1049,6 +1322,10 @@ void setup() {
Serial.setTxTimeoutMs(0);
delay(300);
#ifdef BOARD_LILYGO_T_DONGLE_S3
dongleDisplayInit();
#endif
#if MIRROR_SERIAL
Serial1.begin(MIRROR_BAUD, SERIAL_8N1, -1, MIRROR_TX_PIN); // TX-only on GPIO43
#endif
@@ -1059,8 +1336,12 @@ void setup() {
#endif
#if USE_LED
#if defined(USE_APA102_LED)
apa102Init();
#else
pinMode(LED_PIN, OUTPUT);
ledSet(false);
#endif
#endif
startupBeep();
@@ -1109,6 +1390,10 @@ void setup() {
lastHeartbeat = millis();
fyLastSaveAt = millis();
#ifdef BOARD_LILYGO_T_DONGLE_S3
dongleDisplayShowIdle(currentChannel, fyDetCount);
#endif
}
void loop() {
@@ -1117,6 +1402,7 @@ void loop() {
autosaveTick(); // periodic SPIFFS write if dirty
heartbeatTick(); // audible beep-pair while a target is still in range
ledTick(); // turn off LED after LED_FLASH_MS
dongleDisplayTick(millis(), currentChannel, fyDetCount);
printHeartbeat();
delay(1);
}