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
).