#901

[HIGH] GATT server grants notifications (full mesh broadcast feed) to any unauthenticated peer

Opendiffusionmamopened 2mo ago

Category: auth-bypass / information-disclosure Severity: High Primary location: app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt:285-294 Blast radius: BluetoothPacketBroadcaster.kt:428-448, BluetoothConnectionTracker.kt:176-179


Description

The BLE GATT server adds any device that writes the CCCD (ENABLE_NOTIFICATION_VALUE) to the broadcast subscription list — no authentication, no Noise session, no verified ANNOUNCE, not even a single prior packet from that device is required. Once subscribed, the device receives a live feed of all broadcast mesh traffic via notifyCharacteristicChanged, plus relayed unicast traffic (including encrypted DM ciphertext and its routing metadata). An attacker within BLE range therefore passively harvests identities, social/topology data, public-channel plaintext, and DM metadata with zero cryptographic work.

This is the transport-layer root of THREAT-MODEL threat T-01: "GATT notifications granted to any peer; passive receiver of identity + mesh graph."


code affected

// onDescriptorWriteRequest() — BluetoothGattServerManager.kt:280-299
if (BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE.contentEquals(value)) {
    connectionTracker.addSubscribedDevice(device)          // :286  ← unconditional

    connectionScope.launch {
        delay(100)
        if (isActive) {
            delegate?.onDeviceConnected(device)            // :292  ← peer-connect semantics
        }
    }
}

Subscription bookkeeping accepts duplicates and has no auth gate:

// BluetoothConnectionTracker.kt:176-179
fun addSubscribedDevice(device: BluetoothDevice) {
    subscribedDevices.add(device)                          // CopyOnWriteArrayList — no contains() check
}

Every broadcast/relay path fans out to all subscribers:

// broadcastSinglePacketInternal() — BluetoothPacketBroadcaster.kt:428-448
val subscribedDevices = connectionTracker.getSubscribedDevices()
...
subscribedDevices.forEach { device ->
    if (device.address == routed.relayAddress) return@forEach      // only loop-suppression
    if (connectionTracker.addressPeerMap[device.address] == senderID) return@forEach
    val sent = notifyDevice(device, data, gattServer, characteristic)
    ...
}

Remediation

something similar tosubscribed only after it participation is proved:

// onCharacteristicWriteRequest already gives us inbound packets per device.
// Track first valid signed ANNOUNCE per device address, then allow CCCD grant:

override fun onDescriptorWriteRequest(...) {
    val eligible = connectionTracker.hasVerifiedAnnounce(device.address)   // NEW
    if (ENABLE_NOTIFICATION_VALUE.contentEquals(value)) {
        if (!eligible) {
            sendResponse(device, requestId, GATT_INSUFFICIENT_AUTHORIZATION, 0, null) // fail closed
            return
        }
        connectionTracker.addSubscribedDeviceOnce(device)                  // dedup variant
        ...
    } else if (DISABLE_NOTIFICATION_VALUE.contentEquals(value)) {          // NEW unsubscribe
        connectionTracker.removeSubscribedDevice(device)
    }
    ...
}

Activity

0

No activity yet.