Skip to content

// 032025

Probe Request Sniffer

Counting devices through MAC randomisation by IE fingerprinting

Role
Sole developer
Stack
Python · scapy
Year
2025
Status
Prototype
Licence
MIT

// Why it exists

I was supervising in a bar and my manager would text asking how busy we were, which meant guessing, or walking the floor and counting, several times a night. I’d just watched a video on 802.11 probe requests and wondered whether the answer could be measured instead.

Phones constantly broadcast probe requests looking for networks they know. Those broadcasts are unencrypted and carry a sender address, so counting distinct senders gives you a rough headcount of the radios nearby — no cameras, and nothing anyone has to opt into.

// The hard part

Counting unique MAC addresses gives a wildly wrong answer. Since iOS 8 and Android 8, phones probe with a randomised, locally-administered MAC and rotate it regularly, so over a few minutes one phone presents a dozen addresses. Naive counting doesn’t overestimate slightly — it produces a number that climbs for as long as you keep listening, whether or not anyone new walked in. It is not a noisy version of the right answer; it is not an answer at all.

The fix is to key on something the phone can’t randomise. A probe request carries a set of information elements — supported rates, capability flags, HT/VHT parameters, and the order they appear in. That combination is a property of the chipset and driver rather than the address, and it survives rotation. So the cluster key is the MAC when it looks universally administered or the probe carried too few IEs to discriminate, and the IE fingerprint when the MAC is locally administered. One phone cycling through twenty addresses collapses to a single device.

// The floor of the technique

Stated because it matters more than the result: two identical handsets on the same OS version fingerprint alike and merge into one. That undercount is inherent to the approach, not a bug in this implementation. The output is roughly how busy is this space, not a census — and the figure above shows that gap rather than hiding it.

// Decisions

pyshark first, then rewritten on scapy.

pyshark came first because it parses 802.11 thoroughly. It was dropped as the default because it wraps tshark, which wraps Wireshark’s dissectors — so a capture passes through several layers before reaching Python, and the bugs that surfaced were mostly in layers I couldn’t reproduce in isolation or fix. scapy parses frames in-process, which made it possible to pull the information elements out directly and actually debug what was happening.

Both backends kept behind a shared interface.

Rather than delete the pyshark work, both sit behind a shared SnifferBackend interface. That abstraction is the residue of switching, not up-front design — but pyshark stayed useful, because tshark parses IEs more thoroughly on malformed frames, which makes it a good second opinion.

More time went into working out what to ignore than what to capture.

Beacons, probe responses, data frames, retransmissions and the adapter’s own traffic all have to go before you have a signal at all.

// Legal and ethical

Probe requests are broadcast in the clear and this tool only listens. That does not make the data harmless. MAC addresses are personal data under UK and EU GDPR — they identify a device, and a device usually identifies a person. The fingerprint clustering here makes that stronger, not weaker: its whole purpose is re-identifying a device across the randomisation introduced specifically to prevent tracking.

So it listens only — never transmitting, associating, deauthenticating or touching packet contents — and it writes nothing to disk, which is a privacy decision rather than an omission; adding persistence needs a lawful basis first. Deployed anywhere the public passes through, this is processing personal data at scale: you need a lawful basis, a privacy notice and most likely a DPIA, and “it’s only a count” is not a defence. I never deployed it in the bar; these questions are a large part of why.

// Current state

Works: live device counting on a monitor-mode adapter, with the clustering doing what it claims, on both backends — plus a --no-fingerprint comparison mode so you can watch the raw MAC count climb while the device count holds steady.

Known rough edges: accuracy is unvalidated against a known headcount over a long period — it is clearly better than raw MAC counting, but how much better is an open question. There are no tests, and the tracker is pure functions over synthetic probe events, so it needs no hardware and is the obvious place to start. Output is terminal only, with no persistence, time series or dashboard, and it needs monitor-mode-capable hardware, which rules out most laptops without a USB adapter.

Read the code and the full README on GitHub