RattaGATTa: Scalable Bluetooth Low-Energy Survey

Phase 1: Using a pool of collectors to scan and connect to BTLE devices, shedding light on the intricacies of hardware, radio frequency challenges, and the importance of rate-limiting algorithms.



March 1, 2024

On April 18th, 2020 during peak COVID I did my first real foray into Bluetooth Low-Energy (BTLE) privacy and security. A neighbor in my apartment complex lost their Fitbit Charge 2 smartwatch. I succeeded in “cloning” the watch’s Bluetooth profile in such a way that I could observe when the rightful owners phone would attempt to connect, thus indicating and tracking that the owner was in local proximity. This worked and the smartwatch was returned to it’s rightful owner.

In the 4 years since, I’ve done a lot of Bluetooth projects. Most of them involved the higher level protocols and interfaces of Bluetooth Low-Energy. A deeper dive was necessary.

Here’s some conclusions:

  1. The domain expertise needed to work with Bluetooth and radio communications in general is hard.

  2. Measuring the impact of security and privacy implications of Bluetooth is even harder.

  3. The cyber security community primarily takes action once tools are available to provide quantitative and qualitative measures.

And that sucks. If you’ve investigated BTLE in any capacity you will likely find yourself unsettled by what you find. Worse, you will realize the scale at which BTLE operates around you every day and that it should be improbable for such unsettling problems to be found on the first try if BTLE was being done “the right way” in practice. Then a second time. Then a third.

I hope, for everyone’s sake, the scanners do better. Because, he thought, if the scanner sees only darkly, the way I myself do, then we are cursed, cursed again and like we have been continually, and we’ll wind up dead this way, knowing very little and getting that little fragment wrong too. ― Philip K. Dick, A Scanner Darkly

As follows is a path to take ~$100 and deploy a setup capable of collecting Bluetooth Low-Energy data with enough distinct attributes to correlate with other datasets to achieve Vendor/Product/Version labeling of arbitrary BTLE GATT devices to facilitate meaningful, numbers-based conversations about BTLE security (see #2 on the list above).

As my friends and colleagues with more specialized experience have told me: “okay, that clearly works, but there’s a better path”. Their feedback and guidance has been invaluable and they are absolutely correct.

For “Phase 1: RattaGATTA”, the subject of this blog, you can find the hardware parts list, firmware source code, and a sample database loader here:

Objectives, Setbacks, and (Un)learning

Functionally I desired tooling to collect a database similar to WiGLE for Bluetooth Low-Energy, but with enough metadata to distinctively identify the device for the upcoming phases 2/3. WiGLE has BTLE data, but with caveats, and therein lies the problem(s). Most notably, the specifications are more what you’d call “guidelines” than actual rules.

If you want to gain a fast operational knowledge about all the things I will touch on in the following document, I highly recommend:

Things that I have observed that defy or contradict defined expectations in the real world are marked with a *.

Bluetooth LE stations announce themselves with a 48-bit “Advertiser Address” (MAC). A bit in the header allows the device to declare its advertiser address as:

  • Public
    • IEEE-assigned MAC *
    • Static *
  • Random
    • Randomly generated *
    • Has the potential to change *

Since the MAC is not useful to uniquely identify a BLE device, we probably want to also capture other advertisement attributes such as:

  • Local Name
    • A short ASCII string *
  • Company Code
    • 16-bit identifier requested from Bluetooth SIG *
  • Member Services
    • 16-bit identifier purchased from Bluetooth SIG *
  • Optional Payload
    • Up to 31 bytes that respect Generic Access Profile (GAP) *
      • Often contains Manufacturer Data

From this point forward I will not mark things with an asterisk. Hopefully it’s understood that my explanations are based on the guidelines and the functions of my tooling are based upon reality, the rest can be attributed to my own misunderstanding.

There is plenty of data in a BLE advertisement to begin curating a uniquely identifiable fingerprint for a device if you have a database of labeled profile attributes. In a bit of a chicken-and-egg scenario, we don’t have that database for the exact reasons listed above. We need to go forward to go back.

Once a Bluetooth advertiser is picked up by a scan they may be able to be “paired” or “connected” to. There is even “fast pairing”, which utilizes device advertisement frames to pair before it pairs so it can… connect?

Regardless of the ever-changing meaning of “connected” / “paired” in the BLE world, we want to establish a synchronized “connected” communications channel with the device being introspected. As long as this communications channel is flowing we can ascertain that the (hopefully) unique metadata extracted from the connected state can be used in combination with the metadata collected from the advertisements.

Glazing over the minutiae of connection parameters, we are presented with the opportunity to speak Generic ATTribute Profile (GATT) with a connected device. There are many such protocols, but GATT presents a device interface as a hierarchical tree of Profiles, Services, and Characteristics which are referenced by their Universally Unique Identifier (UUID).

To us the hierarchical GATT Services/Characteristics UUIDs serve the purpose of uniquely identifiable metadata, ideal for a fingerprint. They also have properties to read, write, and notify (push) data which operate very similarly to a serial connection in practice.

Now that we have a semi-clear list of the bare minimum we need to actually have anything actionable at all, let’s figure out how to do that when…

  • The Bluetooth is Low-Energy
    • Typically short range
    • Low throughput
    • May not always be advertising to save power
  • GATT connections are exclusive
    • Only one BLE peripheral can be connected to one central at any given time

I obviously need some custom hardware.

Radio Reality Problems and Iterative Software Development

A cool warehouse-style store opened near me about 2 years ago. On Saturdays they fill massive wooden slop trough bins with “returned items”. They buy damaged or incomplete items from online retailers in bulk and allow customers to fill a trashbag and purchase items at $4 each. The catch? They rip or cut the printed labels off the boxes which often are the only description of the item available, also you’re not allowed to open the boxes prior to purchase.

Luckily, the Federal Communications Commission mark is something I can recognize anywhere. After nearly every weekend for 2 years, I have a sizable collection of radio emitting cyber-trash to test with.

For my purposes, I wanted a mobile setup I could easily transport to different floors in my house. Believe it or not, a typical cell phone can do everything covered in the rest of this blog! It likely even has done everything in this blog with a remote device without your knowledge. This particular nuance will be left as an exercise to the reader to discover. Due to the exclusitivity of GATT connections it can get slow if you want to interact with multiple remote BTLE devices. For iterative development, I really hate slow things.

To get started, I grabbed an M5Stack Core IoT developer kit I had on my shelf. It’s based on an ESP32 microcontroller, has WiFi/BLE support, and supports a microSD card for data storage.

I flashed the M5Stack with MicroPython and loaded the asyncio Bluetooth module aioble with mpremote connect /dev/ttyUSB0 mip install aioble from:

This allowed me to ignore hardware design, write sloppy python code, and focus on understanding how things actually work in the real world. In practice, this meant 2-3 hours every night for the better part of a year tweaking code, going to sleep, waking up to take note of devices in my house that misbehaved or were possessed by radio gremlins, and trying to address those issues the following night.

These particular gremlins are the ephemeral kind. They manifest in strange and wondrous ways in IoT devices around the house at notably higher volume when working with Bluetooth, but do not present in the exact same way every time. Apart from the unreasonable task to reverse engineer every affected device (which I attempted), there is nothing to do other than do my best to keep track of the overall volume and aim to eliminate as many gremlins as possible.

An exception is the Gen 1 Google Chromecast. That gremlin absolutely was reproducible, did not require a full BTLE connection to reproduce, and after the 3rd reset of the Chromecast it refused to ever boot properly again. Bricked.

Obviously, bricking my collection of IoT cyber-trash devices was not ideal. After many iteration cycles, the majority of the code I wrote during this time period didn’t even end up being for the original purpose of conducting a BTLE survey. Instead, I’d mostly written code to create fake clones of devices around my house with debugging capabilities in order to have something to use for testcases. The nRF52xxx chipset which supports passively sniffing a BTLE connection was immensely helpful for this task:

Shortly after, I flashed the exact same firmware to another M5Stack I had on my shelf and stuffed them in a tupperware container with a USB battery pack. As expected, the resultant data showed that while there was an overlap in observability between the two M5Stack’s, but 2 was better than 1.

While at DefCon 2023 I had the pleasure of meeting up with Lozaning ( https://twitter.com/lozaning / @lozaning@tech.lgbt ) who had been developing hardware similar to my needs for their Wifydra project. We chatted a bit about their design approach and how that might translate to my project. They sold me on the XIAO form factor for ESP-based microcontrollers and I even purchased a few Wifydra PCB’s from them.

Unfortunately, due to admittedly completely valid reasons my wife doesn’t want me soldering in the house, so I needed custom hardware I could assemble/tweak without running afoul of house rules.

Hardware “design”

I am not an electrical engineer and becoming one was not a side-quest I desired to take. Thankfully, doing just power supply isn’t overly complicated. I measured the peak draw of a XIAO ESP32-S3 running my firmware with a USB digital multimeter, did some rough math, and looked up the specifications for an Anker portable USB battery pack with 2 output ports. Concluding that I was well within the safety zone, I ordered two 2x12 OONO screw-based terminal block power distribution modules and a bunch of bulk un-terminated USB-C whips.

A few wire cuts, a few screw turns, and a silicone bottom ice cube tray I cut apart with a hacksaw later: We’re in business and powering 14x XIAO ESP32-S3 microcontrollers without getting to caught up in too much of a side-quest figuring out hardware. And, no soldering required!

The XIAO ESP32-S3 also supports swapping the stock 2.4GHz antenna to a larger, high-gain, omni-directional variant.

Where the ✨✨radio✨✨ happens

In the image before the break, the many small XIAO ESP32-S3’s are what I will refer to as rg-collector’s. The small white square peaking out of the bottom right is an M5Stack Core2 ESP32 which I will refer to as the rg-logger.

When a rg-collector is connected to power it will boot up and do… nothing. Well, nothing Bluetooth related anyways. Each collector will broadcast a WiFi access point on channels spread across the spectrum which are used for communications with the rg-logger. It will do nothing else until the rg-logger discovers it and initializes it.

Both WiFi and Bluetooth operate in the 2.4GHz range. I’m using both WiFi and Bluetooth, 14 times. Explaining the concept of spreading the WiFi AP each rg-collector broadcasts across the available channels as evenly as possible is fairly straightforward. Too many devices on a given WiFi channel may cause interference, much like a modern crowded apartment building might.

However, the 14 rg-collectors are never transmitting data from more than one at a time by design, whereas all 14 people in a crowded apartment building may be streaming YouTube and on Zoom calls. The rg-collectors are only transmitting a small Beacon Frame on each of the 13 WiFi channels at intervals so that the rg-logger knows how to find them.

Below we can see WiFi channels and their associated center frequencies that the Beacon Frames are sent on.

Image Source: Michael Gauthier, Wireless Networking in the Developing World, CC BY-SA 3.0 https://creativecommons.org/licenses/by-sa/3.0, via Wikimedia Commons

Now let’s look at the 40 smaller Bluetooth Low-Energy channels superimposed on top of the 3 non-overlapping WiFi channels 1, 6, and 11. The other WiFi channels are still there, just not pictured.

Image Source: https://community.element14.com/technologies/wireless/b/blog/posts/bluetooth-low-energy

Due to WiFi being a fixed channel you can think of it as tuning your car radio to a specific frequency and leaving it there to listen. BTLE sends advertisements on 3 different channels and transmits data on 37 channels. Rather than using a fixed center frequency like WiFi, Bluetooth uses something called Adaptive Frequency Hopping (AFH) which is a form of Frequency-Hopping Spread Spectrum (FHSS). To put it simply, Bluetooth Low-Energy will very quickly “hops” across frequencies to avoid radio interference. Additionally, if it detect interference, it can issue a channel map update which will change the frequencies it “hops” on.

Pictured below is a “waterfall” view of a spectrum analyzer. The Y-axis is time with the bottom of the chart being the oldest. The X-axis is ascending frequencies in the 2.4GHz range.

Cited with permission, labels added: https://x.com/ea4eoz/status/1761886847234408611

The rest of all of those happy hoppy dots all over the waterfall? That’s Bluetooth Low-Energy. It’s everywhere, if you know how to look for it.

The WiFi Beacon Frames we’re transmitting are small. The BTLE advertisements we want to receive are also small. Despite involving 4 different frequencies, the ESP32 can spin the metaphorical dial on the car radio fast enough to do all of them simultaneously without needing any tricks. Functionally this enables the rg-collector to notify the rg-logger of its existence and also monitor for the existence of BTLE devices at the same time.

But what happens when the rg-collector wants to connect to a BTLE device for GATT?

It can and will!

A BTLE GATT connection involves data and data takes time to transmit. Additionally, rather than the 3 channels used by BTLE advertisements, data channels for GATT will use the remaining 37 channels.

Remember the metaphor about the ESP32 being able to spin the car radio dial fast enough? Well, things just got really complicated. Despite all of this, it keeps up like a champ and the single channel WiFi beacon frame can still be advertised because it is small and does not take much time.

But what happens when the rg-collector is connected over BTLE GATT and the rg-logger also wants to retrieve logs over WiFi?

It can’t and wont!

Or at least my implementation wont. The operations of a BTLE GATT connection and WiFi log retrieval both transmit and receive data, and data takes time. This is the point where simply being able to spin the metaphorical car radio dial fast enough isn’t enough, since you also need to stay on certain channels for a period of time to get the data.

This isn’t to say that pushing past this point is impossible, but rather that there’s a very steep knowledge cliff when getting into the subjects of Adaptive Frequency Hopping (AFH), Transmit Power Control (TPC), and Time Division Multiplexing (TDM).

No, you can’t just blue that

Now that we’ve determined and solved for the constraints need for rg-collector to broadcast a WiFi AP, scan/receive BTLE advertisements, and establish a BTLE GATT connection as well as allow rg-logger to pull logs from rg-collector over WiFi…

Let’s finally talk about collecting BTLE data! Or rather, let’s talk about why you can’t just have 14x rg-collectors scanning/connecting to everything without some sort of rate-limiting and work distribution algorithms in place.

If you have 14x BTLE radios scanning for BTLE advertisements without some sort of work distribution algorithm, you will end up with either:

  • Very large logs files containing mostly duplicate information
  • A bottleneck in your logging pipeline which requires compute time to de-deuplicate

Not to mention that BTLE GATT connections are exclusive. Only one BLE peripheral can be connected to one central at any given time. So 14 BTLE radios attempting to establish BTLE GATT connections to a single remote device without a rate-limiting algorithm will result in 13 connections failing, 1 connection succeeding, and all 14 BTLE radios immediately attempting to establish connections again. This will either:

  • Make the remote device operationally unavailable (DoS)
  • Trigger undefined, unpredictable behavior

Neither of the work distribution problems are ideal when working with microcontrollers with limited compute, memory, and storage. Neither of the rate-limiting problems are ideal when trying to conduct yourself in a responsible and safe manner.

There are even more nuanced problems I wont go into as they are also addressed through work distribution and rate-limiting.

When the rg-logger initializes a rg-collector, it informs the rg-collector:

  • The total rg-collectors there are in the pool it is aware of
  • The index of that rg-collector within the pool

After this initialization, the rg-collector begins scanning BTLE advertisements. When a BTLE advertisement is observed, the following logic is triggered:

uint32_t getRateLimitId(NimBLEAdvertisedDevice *advertisedDevice)
  //We'll use a CRC32 checksum as the key for identifying a device
  uint32_t checksum;
  // Public mac addr type "should" not change, suitable for rate limit key
  if (advertisedDevice->getAddressType() == BLE_ADDR_PUBLIC)
    checksum = CRC32::calculate(advertisedDevice->getAddress().getNative(), 6);
  // Random mac addr type can/do change, unsuitable for rate limit key
  // Use the manufacturer data instead. While man data may *also* change,
  else if (advertisedDevice->getAddressType() == BLE_ADDR_RANDOM)
    checksum = CRC32::calculate(advertisedDevice->getManufacturerData().data(), advertisedDevice->getManufacturerData().length());
    // Default
    checksum = CRC32::calculate(advertisedDevice->getAddress().getNative(), 6);
  return checksum;

This logic generates a uint32_t id to represent the remote device which is the CRC32 checksum of the available attributes which may be subject to change based upon their address type:

  • MAC address for advertised “public” type addresses
  • Manufacturer data for advertised “random” type addresses

By the time a rg-collector observes it’s first BTLE advertisement, it has all of the prerequisite information needed to determine if is responsible for logging and establishing a connection to the remote device.

// Check if we are the owning scanner of the remote address
bool getOwnership(uint32_t id, int scannerIndex, int scannerCount)
  if (id % scannerCount == scannerIndex)
    return true;
  return false;

If the rg-collector is the owner of the remote device advertisement, a record is inserted to track rate-limiting. In the event that the rate limit list is full, the oldest record is evicted and replaced.

// Rate Limiting
// Define the maximum number of allowed MAC addresses.
// Define the expiration time in seconds.

// Structure to store id and its expiration time.
struct RateLimit
  // CRC32 of either mac (public address) or manufacturer data (random address)
  uint32_t id;
  // Time in the future when the rate limit expires
  unsigned long expiration;

// Array to store the id and their expiration times.
RateLimit rateLimitList[MAX_RATE_LIMIT_ITEMS];

In practice, this allows a pool of 14x rg-collectors to scan/connect/log up to 1,400 device fingerprints every 5 minutes, avoiding log duplication, and also ensuring that a remote device is only ever interacted with once, quickly, and gracefully disconnected from every 5 minutes.

I will again repeat: work distribution and rate-limiting algorithms are a must. Even if you have spent months curating a controlled environment within your home, you never know when your wife will walk through the front door one day wearing a BTLE enabled cardiac monitor and companion BTLE Android phone that is distributed as medical equipment.

I was never concerned about disrupting the device thanks to these algorithms, but rather took it as an opportunity to take measured steps to again confirm that my BTLE survey was conducting itself exactly as expected. After 7 days of scrutiny, I returned my BTLE survey setup to normal operation with no issues identified.

It is a device manufacturers responsibility to provide safe and reliable equiptment. However, this does not alleviate you from the abstract consequences to yourself and others that stem from careless conduct.

Show and Tell

The rg-logger will sweep across all available rg-collectors once every 60 seconds and poll for logs to aggregate. If the rg-collector is simultaneously connected to a remote bluetooth device, the rg-collector will time out and simply move on to the next rg-collector. Once the rg-logger completes its polling sweep of all available rg-collectors, those which timed out are prioritized for the next sweep. The resulting aggregated logs are written to the rg-loggers microSD card as log.jsonl.

  "70:52:08:ad:8a:c2": {
    "name": "",
    "rssi": -81,
    "man": "4c001608006dce8f8881c1d1",
    "connectable": false,
    "addr_type": 1
  "72:4f:34:d9:ce:25": {
    "name": "",
    "rssi": -73,
    "man": "4c000c0e002ec5cfde2bcdc3b7a4160c991410067a1d4e81b538",
    "connectable": true,
    "addr_type": 1,
    "tree": [
        "svc": "00001800-0000-1000-8000-00805f9b34fb",
        "chr": "00002a00-0000-1000-8000-00805f9b34fb",
        "prop": 2
        "svc": "00001800-0000-1000-8000-00805f9b34fb",
        "chr": "00002a01-0000-1000-8000-00805f9b34fb",
        "prop": 2
        "svc": "00001801-0000-1000-8000-00805f9b34fb",
        "chr": "00002a05-0000-1000-8000-00805f9b34fb",
        "prop": 32
        "svc": "0000180a-0000-1000-8000-00805f9b34fb",
        "chr": "00002a29-0000-1000-8000-00805f9b34fb",
        "prop": 2

In the rattaGATTa repository, there is a sample loader provided in rg-loader which will take these logs of hierarchical properties and transform them into two tables:

  • logs
    • The logged device tree, converted to dimensional data.
  • records
    • The same as the logs table, but group by mac, name, man, svc, chr, props, val and joined with well known human readable GATT UUID descriptions

At this point, the initial challenges are solved for and a greater view of the bigger picture can be obtained.