Wiki / Protocols

BLE Replay

Capturing BLE traffic and sending it again, which works cleanly on unencrypted advertising and on static command writes, and fails outright once the link is encrypted or the command carries any freshness.

Does replaying this capture actually work?
A BLE frame you captured
Advertising PDU (never encrypted)
Re-emit the same AD bytes: a real bit-level clone
ATT write, link not encrypted
Reconnect and re-issue the write: works while the command is static
ATT write, link encrypted
Dead end without the LTK: the CCM counter has already moved on
Only the left branch is a replay in the strict sense, the same bytes back on the air. The middle branch is what people actually mean when they say they replayed a BLE unlock: they observed the write, then connected themselves and issued it again. The right branch is where replay stops, and the practical question on any target is which of the three branches it puts you on.

BLE replay is capturing traffic off the air and sending it again to make the target act a second time. On BLE it is the attack people reach for first and the one whose limits are least understood. It works completely on advertising, it works in a weaker sense against unencrypted connections, and it does not work at all against a properly encrypted link. Knowing which of those three you are in front of is the whole skill.

Say what "replay" means first

Two different things get called BLE replay, and conflating them is why write-ups contradict each other.

Bit-level replay is putting the identical radio frame back on the air. That is what you do to an advertisement.

Command re-issue is observing what a client wrote, then connecting yourself and sending the same ATT operation. The bytes on the air are new, from your address, in your own connection; only the application-layer meaning is copied.

Almost every "I replayed the unlock" story is the second one. That is not a criticism, it is usually the only thing that can work, and the reason matters: see below.

Case 1: replaying an advertisement

This one is a true replay and it is easy, because Wiki: ble-advertising has no protection whatsoever. There is no signature, no counter, no timestamp, no session. A receiver that acts on the contents of a broadcast will act again on a copy of that broadcast.

Anything that keys a decision on manufacturer data (a presence badge, an asset tag, a proximity unlock, an occupancy sensor) is cloneable by anyone who can receive the advert once and transmit. This is the whole beacon-spoofing class.

The catch is on the transmit side, and it is not where people expect:

  • Reproducing the payload is easy. BlueZ will advertise arbitrary AD structures for you, via btmgmt add-adv or the LEAdvertisement1 D-Bus API.
  • Reproducing the advertiser address is not. Setting the local public address is a vendor-specific controller command and most adapters simply refuse it; btmgmt public-addr only applies to controllers that support it and only while the adapter is powered down. You can set a random static address (btmgmt static-addr), so you can advertise from an address of your choosing in the random space, but not generally impersonate an arbitrary public address on stock hardware.
  • So test what the receiver checks. If it keys on the manufacturer payload alone, the clone lands. If it also pins the advertiser address, a stock Linux box will not get you there and you need firmware-level control of the radio.
  • Ubertooth One is a receiver in practice. Do not plan the transmit half of an attack around it. Use an nRF52840 with firmware you control, or an ESP32.

Case 2: an unencrypted connection

Here bit-level replay is essentially off the table, and the reason is worth internalising because it is the thing most people get wrong.

A BLE connection is not a broadcast you can shout into. After CONNECT_IND both ends share an access address, a channel map, a hop increment, a CRC initialisation value, and a timing anchor, and they meet on one of 37 data channels per connection event. To put a captured data-channel PDU back on the air and have it accepted you would have to be inside that connection: right channel, right connection event, right CRC init, and correct SN/NESN sequence bits. Retransmitting a recorded frame blindly gets you nothing.

What works instead is command re-issue, and it works because of an application-layer failure, not a radio one:

  1. Sniff the legitimate client doing the thing.
  2. Read the handle and the value out of the capture.
  3. Connect yourself and write the same value to the same characteristic.

Step 3 succeeds when the characteristic's permissions require neither encryption nor authentication, which on cheap devices is the default. See Wiki: ble-att for why permissions and properties are not the same thing.

# Capture with an nRF52840 running Sniffle, then pull the write out of the pcap.
tshark -r capture.pcap -Y 'btatt.opcode == 0x12' \
       -T fields -e btatt.handle -e btatt.value
# 0x0014    01
# Re-issue it. Resolve the UUID to a handle at runtime, never reuse an old handle.
import asyncio
from bleak import BleakClient

async def main():
    async with BleakClient("AA:BB:CC:DD:EE:FF") as client:
        await client.write_gatt_char("0000fff2-0000-1000-8000-00805f9b34fb",
                                     bytes([0x01]), response=True)

asyncio.run(main())

If that works, the finding is not "the device is replayable". It is "the control characteristic has no access control", which is a bigger finding.

Case 3: an encrypted link, and why replay dies there

Once the link is encrypted, three things stop you at once.

You cannot read the capture. The ATT layer is encrypted on the air, so Wireshark shows you ciphertext and no handle or value to copy. Recovering it means recovering the LTK, which is a pairing problem, not a replay problem.

The ciphertext is bound to a position in the stream. BLE link-layer encryption is AES-CCM, and the nonce is rebuilt for every single encrypted PDU from a per-direction packet counter plus an 8-byte initialisation vector agreed during connection setup. The counter only ever increases and the IV changes with the connection. A PDU recorded at one counter value will not authenticate at another: the MIC check fails and the receiver drops it. There is nothing to tune here, it is the design working.

You cannot connect and re-issue either. A characteristic that requires an encrypted link cannot be written by a peer that has not paired, so the re-issue path from case 2 closes as well.

The practical consequence: replay is not a property of BLE, it is a property of the product. A device that encrypts its control path and requires authentication on the characteristic that matters is not replayable, and no amount of radio work changes that.

The narrow cases where it still works anyway

  • Unencrypted control paths on encrypted devices. Very common. The vendor pairs, encrypts the link for the app, and leaves the actuation characteristic with no permission set on it. The product looks encrypted and the interesting handle is not.
  • Legacy pairing you captured at bonding time. If you caught the pairing handshake and the device used LE legacy pairing, crackle can recover the keys from the capture, which puts you back in case 2 with a decrypted view.
  • Static application tokens. A device can encrypt the link perfectly and still accept a fixed secret written by a legitimately paired client. The token is constant, so once you have it, it works forever. That is a replay of the secret rather than of the frame.
  • Advertising, always. No product configuration removes this one.

What actually stops it

Freshness in the message, on the application layer, is the only thing that survives a hostile radio:

  • A challenge from the device that the client must sign, so no recorded exchange is reusable.
  • A counter or timestamp inside the command, with the device rejecting anything not strictly greater than the last accepted value.
  • BLE's own signed writes, which carry a signature and a sign counter, though they are rare in shipping products.

Link encryption stops the frame-level attack. Only freshness stops the token-level one.

Pitfalls

  • "Replay failed, so the device is safe" is wrong. Nine times out of ten the replay failed because you tried to re-send a data-channel PDU. Reconnect and re-issue the operation before you conclude anything.
  • Handles are not stable. A handle read out of a capture belongs to that connection with that firmware. Enumerate and resolve the UUID at runtime; see Wiki: ble-gatt.
  • Write Command is silent. A 0x52 write that the device rejects produces no response at all, which looks exactly like a write that worked. Re-issue as a Write Request (0x12) so the device has to answer.
  • Your host stack may have cached the target. A stale GATT table makes a correct re-issue land on the wrong handle. Remove the device from bluetoothctl and rediscover.
  • Catching the connection setup is the hard part. Following an established connection is far harder than catching CONNECT_IND. If your capture has adverts but no ATT layer, you missed the setup, not the traffic.

Further reading