GATT (Generic Attribute Profile)
The BLE data model layered on ATT: services group characteristics, characteristics hold values and properties, and all of it is just named rows in the attribute table.
GATT (the Generic Attribute Profile) is the data model every BLE peripheral exposes once you are connected. It defines how a device presents its features as a tree of services, characteristics, and descriptors. It is a naming and grouping convention layered on top of ATT, and it adds no security of its own.
GATT and ATT, the distinction that matters
This pair confuses people, so state it plainly.
ATT is the wire protocol. It sees a flat, ordered table of attributes, each with a numeric handle, a type UUID, a value, and permissions. Its opcodes read and write handles.
GATT is the profile on top. It says: if you lay out that table in a particular pattern, a client can walk it and recover structure from it. A service declaration marks the start of a group. A characteristic declaration announces a UUID and a set of properties, and the very next handle holds that characteristic's value. Descriptors follow the value.
So GATT is not a different protocol carrying different traffic. Discovering services and characteristics is done by sending ordinary ATT requests (Read By Group Type for services, Read By Type for characteristics, Find Information for descriptors) and interpreting the returned rows. When a tool prints a tidy tree of services, it built that tree client-side out of a flat handle range.
The practical consequence: the structure is a convention, the handle is the truth. You can always go under GATT and address a handle directly.
The three levels
| Level | What it is | What it costs in the table |
|---|---|---|
| Service | A UUID-named group of related characteristics | One handle for the declaration |
| Characteristic | A value plus properties (read, write, write without response, notify, indicate) | At least two handles: declaration, then value |
| Descriptor | Metadata attached to a characteristic | One handle each |
A primary service is a top-level feature; a secondary service exists only to be included by another and is rare in practice. UUIDs are 16-bit for SIG-adopted types and 128-bit for vendor-defined ones. A vendor 128-bit UUID tells you nothing about behaviour, and vendors reuse the same generic 0xFFF0 family constantly, so never assume a UUID means what its name suggests.
The descriptor you will meet most is the CCCD (Client Characteristic Configuration Descriptor, UUID 0x2902). Writing 01 00 to it subscribes to notifications on the parent characteristic; 02 00 subscribes to indications; 00 00 turns it off. If a tool's "subscribe" call fails, that CCCD write is what failed.
Why it matters for security
A characteristic's properties (what the declaration advertises: read, write, notify, and so on) are a description of intended use. They are not permissions. Permissions live on each attribute in the ATT table and are enforced there. The two get confused constantly, and the gap between them is where devices break:
- A characteristic can advertise
writeand require no encryption, no bonding, and no authentication, because nobody set a permission on it. - Removing a characteristic from the app's UI does not remove it from the table. Enumerate the whole table, not the parts the app uses.
- Vendors leave debug, factory-test, and firmware-update services in shipping products. They are frequently the least protected characteristics on the device.
- Because access control is per attribute, a device can protect the interesting characteristic and leave a neighbouring one that reaches the same internal state wide open.
The audit question is therefore never "does this device use GATT correctly" but "for each writable handle, what actually gates it, and what does writing it do".
How to enumerate it
# Full table: services, characteristics, handles, properties.
gatttool -b AA:BB:CC:DD:EE:FF --characteristics
gatttool -b AA:BB:CC:DD:EE:FF --char-desc # descriptors too, CCCDs included
# Interactive, if you want to stay connected.
gatttool -b AA:BB:CC:DD:EE:FF -I
# > connect
# > primary
# > characteristics
# bluetoothctl, on a stack that already knows the device.
bluetoothctl
# > menu gatt
# > list-attributes
# > select-attribute <uuid-or-path>
# > read
In Python, bleak gives you the same tree programmatically: iterate client.services, then each service's characteristics, and print characteristic.handle, characteristic.uuid, and characteristic.properties. Record the handle next to the UUID; the handle is what you will send.
Read every readable characteristic once before touching anything, so you have a baseline. Then correlate: drive the device from its own app while sniffing, see which handle the app writes, and compare that handle against your enumeration.
Pitfalls
- GATT caching. Phones and
bluetoothdcache a device's table and will happily show you a stale one, hiding characteristics that appeared in a firmware update. Remove the device and rediscover, or clear the cache, before you trust an enumeration. - Handles shift between firmware versions. Resolve UUID to handle at runtime rather than reusing a handle from an old note.
- The properties field lies by omission. A characteristic without
readin its properties may still be readable if no permission blocks it, and one withwritemay reject writes. Test, do not infer. - Notifications require the CCCD write. Subscribing at the GATT API level fails silently on some stacks if the CCCD is missing or write-protected; check that the descriptor exists.
- Not every device supports MTU-sized writes. Long values get chunked, sometimes by the stack, sometimes by a vendor scheme layered on top. See Wiki: ble-att for where that boundary comes from.