Counting wireless devices on a Raspberry Pi with tcpdump

I was recently a vendor at a large conference and was tasked with keeping track of how many interactions we had at the booth. Rather than use a tedious mechanical clicker to count guests, I set out to create an automated method. Luckily, it was safe to assume most of our visitors were carrying a smartphone so it was feasible to track Wi-Fi beacons.

In a nutshell, a Raspberry Pi with a wireless dongle operating in promiscuous mode tracked particular 802.11 packets via tcpdump and unique MAC addresses were logged and used as a proxy for visitors.

Before I get into the details of this project, I have to say that it is possible to use nearly anything running Linux with a promiscuous-capable Wi-Fi chipset. ARM development boards, laptops, some Android phones, desktops, etc. would all work. I picked a Pi because I had one on hand and I didn't particularly care if it was lost/stolen/broken at the conference. In fact, the lack of a Real Time Clock (RTC) on the Raspberry Pi made the task a little more challenging. Also, Raspbian uses systemd, so systemd is used in this tutorial but there is nothing preventing runit, sysv, upstart, etc. from being used to launch scripts at boot.

On the surface, tracking devices via Wi-Fi is very straightforward. If a device is not currently associated with a wireless access point it will probe for all known networks with a probe request packet. Every Wi-Fi chipset has a unique MAC address that is sent with the probe request. Retail stores used the unique MAC address to track customers across multiple visits and the TfL tracked passengers' movements throughout the London Tube.

Fortunately Unfortunately, it's not as easy to track devices as it once was. Apple introduced MAC randomization in iOS 8) and it was introduced into Android with the 6.0 Marshmallow) release. MAC randomization prevents probe request tracking by sending a random MAC address with each probe request packet. A device using MAC randomization will quickly fill a probe request sniffer with false positives. Recently, an interesting paper emerged from the Naval Academy showing how MAC randomization can be defeated due to a hardware layer flaw. However, the attack requires correctly guessing the real MAC address of the device - a brute force attempt across the entire 248 address space is not feasible to count devices at a conference.

The conference had a dedicated Wi-Fi network which allowed a different flavor of packet to be used for tracking. Due to the nature of the event, it was reasonably safe to assume that most attendees were connected to the network. This assumption allowed association requests, reassociation requests, and null data packets to be logged. When a device first connects to a network it sends an association request and subsequent associations are made with reassociation requests. The null data packet is often used for power management. These packets contain the device's real MAC address - not a randomized one - and can be used to count unique devices and by proxy unique visitors.

Our booth was far removed from the main exhibition, so I didn't use RSSI strength to determine how close devices were to our booth. Our visitor count ended up being a little higher than our gut estimates, so RSSI awareness will be used in future exhibitions.

Required Hardware

  • Raspberry Pi or nearly any other platform running Linux with USB ports
  • Wireless dongle capable of promiscuous mode. I used a Tenda dongle

Setting up promiscuous mode

The first step to observe 802.11 packets is to put a wireless interface into promiscuous mode. The name of the physical interface name can be found by parsing the output of iw list. Usually, the wireless interface will be phy0, but on occasion the wireless card enumerates as phy1 or phy2.

PHY=$(iw list | grep Wiphy | cut -d " " -f 2)

A new monitor interface mon0 is created using the physical interface found above and set to Channel 1.

iw phy $PHY interface add mon0 type monitor
ifconfig mon0 up
iw dev mon0 set channel 1

However, not all devices will be broadcasting on Channel 1. There are 14 802.11 channels, so the monitor interface needs to switch between all of them. A simple while loop in a second terminal accomplishes this:

while true
do
    for channel in {1..14}
    do
        iwconfig mon0 channel $channel
        sleep 0.05s
    done
done

Running tcpdump

tcpdump is a command line packet analyzer that is somewhat similar to Wireshark. If tcpdump is started on a wireless interface, it can be used to analyze and filter 802.11 (Wi-Fi) packets.

tcpdump uses pcap-filter syntax, so filtering for probe requests is straightforward:

/usr/sbin/tcpdump -i mon0 -e -s 0 -l type mgt subtype probe-req

However, probe request packets were rendered nearly useless by MAC randomization. Filtering for packets containing non-randomized MACs is again trivial due to the pcap-filter syntax:

/usr/sbin/tcpdump -i mon0 -e -s 0 -l type mgt subtype assoc-req or type mgt subtype reassoc-req or type data subtype null

Parsing tcpdump output

A single line of tcpdump output contains quite a bit of information:

11:44:51.164198 1.0 Mb/s 2457 MHz 11b -69dBm signal antenna 1 BSSID:Broadcast DA:Broadcast SA:98:9e:64:78:62:ee (oui Unknown) Probe Request (somewifi) [1.0 2.0 5.5 11.0 Mbit]

The relevant portion for counting unique devices is "98:9e:64:78:62:ee" which can be parsed out quiet easily with grep and a PCRE regular expression:

grep -P --line-buffered -o '(?<=SA:)(([a-f]|[0-9]){2}:){5}([a-f]|[0-9]){2}' 

At the end of the day, the power was pulled on the Pi; there was never a graceful shutdown. Rather than open and write the MAC to a database which could be corrupted when the power is pulled, a new file was created and named after each MAC address with touch. To count the number of uniques, ls | wc -l is all that is needed. Additionally, touch will not create duplicate files so a MAC seen multiple times was recorded only once. awk piped the MAC address to touch:

awk -Winteractive '{system("touch /logging/path/"$0)}' -

The tcpdump, grep, and awk commands can be chained so a file is created for each unique MAC address.

However, since the Pi doesn't have a Real Time Clock and is being power cycled every day the MACs seen on Day 1 are going to be indistinguishable from the Day 2 MACs. To fix this, when the Pi boots, a new folder was created with an autoincrementing index. The following script checks to see if /home/pi/logs/day0 exists, if it does then /home/pi/logs/day1 is checked, etc. When /home/pi/logs/dayX is not found, dayX is created. The newly created path will be fed to awk so the MACs found between each power cycle are distinct.

rootfolder=/home/pi/logs/day

i=0
while [[ -e $rootfolder$i ]] ; do
	let i++
done
folder=$rootfolder$i

mkdir $folder

Running on Boot with systemd

For the use case of powering on the Pi, throwing it under the table, and forgetting about it for rest of the day, tcpdump and friends need to run on boot.

To make this happen, three systemd services are needed:

tcpdumpsetup.service - setups folder hierarchy and puts the wireless interface in promiscuous mode

tcpdumpchanhop.service - handles the channel hopping loop

tcpdump.service - runs tcpdump and parses output

tcpdumpsetup.service

Create a text file /etc/systemd/system/tcpdumpsetup.service and add the following oneshot systemd service:

[Unit]
Description=Put interface in monitor mode and create folder for saving

[Service]
Type=oneshot
ExecStart=/usr/local/bin/tcpdumpsetup.sh

[Install]
WantedBy=multi-user.target

This service will only one run once every time the Raspberry Pi boots.

Additionally, create and chmod +x /usr/local/bin/tcpdumpsetup.sh

#!/bin/bash

# Put device in monitor mode
PHY=$(iw list | grep Wiphy | cut -d " " -f 2)

iw phy $PHY interface add mon0 type monitor
ifconfig mon0 up
iw dev mon0 set channel 1

# Create a folder for this boot session
rootfolder=/home/pi/logs/day

i=0
while [[ -e $rootfolder$i ]] ; do
	let i++
done
folder=$rootfolder$i

mkdir $folder
tcpdumpchanhop.service

Another systemd service will be created to make sure that the interface is channel hopping.

Create /etc/systemd/system/tcpchanhop.service and add the following oneshot systemd service:

[Unit]
Description=Keeps mon0 channel hopping for tcpdump
After=tcpdumpsetup.service

[Service]
TimeoutStartSec=infinity

ExecStart=/usr/local/bin/tcpdumpchanhop.sh

Restart=always
RestartSec=0

[Install]
WantedBy=multi-user.target

Again, create and chmod +x the script the service calls: /usr/local/bin/tcpdumpchanhop.sh

#!/bin/bash
while true  
do  
    for channel in {1..14}
    do
        iwconfig mon0 channel $channel
        sleep 0.05s
    done
done  
tcpdump.service

Finally, the service that is doing the heavy lifting.

/etc/systemd/system/tcpdump.service

[Unit]
Description=Runs tcpdump
After=tcpdumpsetup.service tcpdumpchanhop.service

[Service]
TimeoutStartSec=infinity

ExecStart=/usr/local/bin/runtcpdump.sh

Restart=always
RestartSec=0

[Install]
WantedBy=multi-user.target

/usr/local/bin/runtcpdump.sh

#!/bin/bash

FOLDER=$(ls -d /home/pi/logs/*/ | tail -1)

/usr/sbin/tcpdump -i mon0 -e -s 0 -l type mgt subtype probe-req or type data subtype null or type mgt subtype assoc-req or type mgt subtype reassoc-req | grep -P --line-buffered -o '(?<=SA:)(([a-f]|[0-9]){2}:){5}([a-f]|[0-9]){2}' | awk -Winteractive -v folder=$FOLDER '{system("touch "folder$0)}' -
Start the systemd services
systemctl daemon-reload
systemctl start tcpdumpsetup.service
systemctl start tcpdumpchanhop.service
systemctl start tcpdump.service

Output

After running the Pi for a few days (or power cycling a few times) a folder hierarchy will be built.

├── day1
│   ├── 1F:CA:C3:67:2B:15
│   ├── 85:4F:99:10:32:B4
│   ├── BD:AA:97:60:9B:54
│   └── ...
├── day2
│   ├── 8B:61:75:DF:7A:A1
│   ├── 98:3B:5F:EC:C2:7F
│   ├── E2:36:4D:35:F8:7A
│   └── ...
├── day3
|   ├── 13:61:14:4D:B9:B4
|   ├── 1A:C8:D1:11:33:C7
|   ├── 61:5C:B9:D9:70:E4
|   └── ...
└── ...

Finding out how many unique MACs came within Wi-Fi range of the booth is as simple as changing into the directory and ls | wc -l.


Was this information useful or thought provoking? Do you appreciate a webpage free of analytics or ads? Say thanks and help keep this site online by using my Amazon Affilliate URL. I'll receive a small kickback from any purchases made within 24 hours of clicking. Or, feel free to donate BTC (1DNwgPQMfoWZqnH78yt6cu4WukJa3h8P1f) or ETH (0xf3c4a78c24D34E111f272Ac2AC72b1f01ba52DF3).