Bluetooth Mesh Security Internals

Bluetooth Mesh Security Internals
Network Obfuscation Β· Replay Protection Β· Mesh Beacons β€” Explained Simply
πŸ“¦ Network Layer
πŸ”’ Obfuscation
πŸ›‘οΈ Replay Attack Defense
πŸ“‘ Mesh Beacons

What This Post Covers

When a Bluetooth Mesh node sends a message over the air, two critical security jobs happen at the network layer: first, sensitive header fields are obfuscated so a passive sniffer cannot track devices, and second, the receiver uses replay protection to discard messages that an attacker tries to re-send later. We will also look at how nodes announce their presence using Mesh Beacons.

This post is based on the Bluetooth Mesh Profile Specification (pages 117–120) and explains each concept using diagrams, BlueZ code snippets, and plain English.

Key Terms

Network PDU Privacy Key IV Index PECB ObfuscatedData PrivacyRandom Replay Attack Sequence Number (SEQ) Replay Protection List Mesh Beacon Unprovisioned Device Beacon Secure Network Beacon

πŸ“˜ Chapter 1 β€” Network Layer Obfuscation

Why Obfuscation Exists

Every Bluetooth Mesh network PDU carries a TTL (time-to-live), a sequence number (SEQ), and a source address (SRC) in its header. Without any protection, a passive sniffer sitting nearby can watch these fields and track which device sent which message β€” a simple privacy leak.

The spec’s answer is obfuscation: XOR those header fields with a pseudo-random pad derived from a Privacy Key, so an eavesdropper sees only scrambled bytes. Note that this is not encryption β€” it is designed only to stop passive tracking, not a determined attacker with the Privacy Key.

The Obfuscation Flow Step by Step

STEP 1
PrivacyRandom
β†’
STEP 2
Privacy Plaintext
β†’
STEP 3
PECB = e()
β†’
STEP 4
XOR β†’ ObfuscatedData
First 7 bytes of the already-encrypted part of the PDU:
(EncDST || EncTransportPDU || NetMIC)[0–6]
0x0000000000 || IV Index || PrivacyRandom
(5 zero bytes + 4-byte IV Index + 7-byte PrivacyRandom = 16 bytes total)
AES-128 block cipher using Privacy Key.
Output = 16-byte PECB.
Only first 6 bytes used.
ObfuscatedData = (CTL||TTL||SEQ||SRC) βŠ• PECB[0–5]
6 header bytes become scrambled.

What the Network PDU Looks Like On-Air

NID/IVI
1 byte
ObfuscatedData
6 bytes (CTL+TTL+SEQ+SRC scrambled)
EncDST
2 bytes
EncTransportPDU
variable
NetMIC
4 or 8 bytes

Green = obfuscated (scrambled header). Blue = encrypted payload. NID/IVI is never obfuscated.

How a Receiver Reverses Obfuscation

The receiver already knows the IV Index from the network and holds the Privacy Key. It just repeats the same steps (PrivacyRandom β†’ Privacy Plaintext β†’ PECB) and XORs the ObfuscatedData again to recover the original CTL, TTL, SEQ, and SRC. XOR is its own inverse.

Why the IV Index Is Part of This

If someone tries to break obfuscation by collecting lots of PDUs, they eventually succeed β€” but only until the IV Index changes. When the network increments the IV Index (a normal mesh event), every obfuscation attempt the attacker has built must restart from zero.

BlueZ Reference β€” Where Obfuscation Happens

In the BlueZ mesh stack, network layer obfuscation is implemented in mesh/net.c:

/* mesh/net.c  β€” BlueZ mesh stack (simplified) */

/* Build the Privacy Plaintext: 5 zero bytes + IV Index + PrivacyRandom */
static void build_privacy_plaintext(uint32_t iv_index,
                                    const uint8_t *privacy_random, /* 7 bytes */
                                    uint8_t *out)                  /* 16 bytes */
{
    memset(out, 0, 5);                           /* octets 0-4: zeros      */
    l_put_be32(iv_index, out + 5);               /* octets 5-8: IV Index   */
    memcpy(out + 9, privacy_random, 7);          /* octets 9-15: PrivRandom*/
}

/* Derive PECB using AES-128 with the Privacy Key */
static void derive_pecb(const uint8_t *privacy_key,   /* 16 bytes */
                         const uint8_t *plaintext,     /* 16 bytes */
                         uint8_t *pecb)                /* 16 bytes out */
{
    aes_ecb_one(privacy_key, plaintext, pecb);
}

/* XOR the 6-byte header with PECB[0..5] to produce ObfuscatedData */
static void obfuscate_header(const uint8_t *pecb,
                              uint8_t *hdr,   /* CTL|TTL|SEQ(3)|SRC(2) = 6 bytes */
                              uint8_t *out)
{
    int i;
    for (i = 0; i < 6; i++)
        out[i] = hdr[i] ^ pecb[i];
}

ℹ️ aes_ecb_one() is BlueZ’s thin wrapper around the kernel’s AES-128 ECB. The real production code also handles the PrivacyRandom extraction from the encrypted payload bytes.

πŸ“˜ Chapter 2 β€” Message Replay Protection

What Is a Replay Attack?

Imagine Node A turns on a smart light by sending a “Light ON” message. A nearby attacker silently records this valid, properly encrypted packet. Later, after Node A has turned the light off, the attacker re-transmits the exact same captured packet. The light turns on again β€” the attacker never broke any crypto, they just replayed an old message.

Node A
Sends “Light ON”
SEQ = 42
β†’
valid msg
Node B
Accepts SEQ=42
Stores max SEQ=42
β†’
replay!
Replay Attacker
Re-sends captured
SEQ = 42 packet
β†’
to Node B
⚠️ Without replay protection, Node B cannot tell this is an old replayed packet β€” crypto checks still pass!

The Solution: Sequence Numbers + IV Index Tracking

Every element (a logical endpoint inside a node) increments its sequence number for every new message. The receiver maintains a Replay Protection List β€” a simple table of (source address β†’ last accepted SEQ, last accepted IV Index) pairs.

Incoming Message Condition Action Reason
SEQ > last stored SEQ and IV Index = stored IV βœ… Accept & update list Fresh message from the same IV era
SEQ ≀ last stored SEQ, same IV Index ❌ Discard (likely replay) Old or duplicate packet
IV Index higher than stored IV βœ… Accept & reset stored SEQ Network moved to a new IV era; SEQ counter restarts
IV Index lower than stored IV ❌ Discard Stale from a previous IV era; definitely replayed

Segmented Message Replay β€” The Tricky Case

A large message gets split into numbered segments, each carrying its own SEQ value. The spec says: store the highest SEQ seen for that source. When the attacker re-injects an old segment, the node notices its SEQ is below the stored maximum and drops it. The diagram below maps to Figure 3.43 in the spec.

Node A β†’ Node B Node B Action Attacker β†’ Node B
Msg0 Unseg SEQ=10 Store A β†’ max_seq=10 β€”
Msg1 Seg0/2 SEQ=11
Msg1 Seg1/2 SEQ=12
Msg1 Seg2/2 SEQ=13
Ack sent; max_seq=13 β€”
Msg1 Seg1/2 retransmit SEQ=14 Store A β†’ max_seq=14
Save last Ack (SeqAuth=11, Ack=0,1,2)
β€”
β€” ❌ Drop β€” SEQ=14 already seen, old replay Replays Seg1/2 SEQ=14
Msg1 Seg1/2 new SEQ=15 Store A β†’ max_seq=15; Ack β€”

BlueZ β€” Replay Protection List Lookup

In BlueZ mesh/net.c, replay protection is checked before any access-layer processing:

/* mesh/net.c β€” BlueZ mesh (simplified illustration) */

struct replay_cache_entry {
    uint16_t src;       /* source element address             */
    uint32_t seq;       /* highest accepted sequence number   */
    uint32_t iv_index;  /* IV Index for the above seq         */
};

static bool replay_check(struct mesh_net *net,
                          uint16_t src,
                          uint32_t seq,
                          uint32_t iv_index)
{
    struct replay_cache_entry *entry = find_replay_entry(net, src);

    if (!entry) {
        /* First message from this source β€” always accept */
        add_replay_entry(net, src, seq, iv_index);
        return true;  /* not a replay */
    }

    /* Discard if IV Index is lower */
    if (iv_index < entry->iv_index)
        return false;

    /* New IV Index: accept and reset */
    if (iv_index > entry->iv_index) {
        entry->iv_index = iv_index;
        entry->seq = seq;
        return true;
    }

    /* Same IV Index: accept only strictly higher SEQ */
    if (seq <= entry->seq)
        return false;   /* replay detected β€” discard */

    entry->seq = seq;
    return true;
}

ℹ️ In a real device with limited RAM, this list has a fixed maximum size. If it is full and cannot track a new source address, the spec requires discarding the message rather than risking a replay vulnerability.

πŸ“˜ Chapter 3 β€” Mesh Beacons

What Are Mesh Beacons?

Before two Bluetooth Mesh nodes can talk securely, they must find each other and exchange trust. Mesh beacons are small BLE advertising packets that nodes and unprovisioned devices broadcast periodically for this purpose. They are sent as non-connectable, non-scannable undirected advertising events β€” the receiver just passively listens; no connection is formed.

Beacon Packet Structure

Field Size (octets) Notes
Len 1 Standard BLE AD length byte
Type 1 AD Type = Β«Mesh BeaconΒ»
Beacon Type 1 0x00 = Unprovisioned, 0x01 = Secure Network
Beacon Data variable Contents depend on Beacon Type

The Two Beacon Types

0x00 β€” Unprovisioned Device Beacon

Sent by a device that has not yet joined any mesh network. It is shouting “I am here, please provision me!” A Provisioner app (e.g., a phone running nRF Mesh) hears it and starts the provisioning handshake.

Fields in Beacon Data:
β€’ Device UUID (16 bytes) β€” unique per device
β€’ OOB Information (2 bytes) β€” what out-of-band auth methods are available
β€’ URI Hash (4 bytes, optional) β€” hash of a URI with more device info
0x01 β€” Secure Network Beacon

Sent by nodes that are already provisioned members of a mesh network. They use this beacon to keep the network healthy β€” advertising the current IV Index and key refresh state so other nodes stay in sync.

Key info carried:
β€’ Network ID or hash
β€’ Current IV Index
β€’ Key Refresh Flag & IV Update Flag
β€’ Authentication value (MIC)

Beacon Endianness

All multi-byte numeric fields inside mesh beacons are transmitted in big-endian order β€” the most significant byte first. This is consistent throughout the mesh spec.

BlueZ β€” Sending an Unprovisioned Device Beacon

In BlueZ mesh/prov.c, an unprovisioned node starts advertising its beacon like this:

/* mesh/prov.c β€” BlueZ (simplified) */

#define MESH_AD_TYPE_BEACON  0x2B

/* Build the Unprovisioned Device Beacon payload */
static void build_unprovisioned_beacon(struct mesh_node *node,
                                        uint8_t *buf,
                                        size_t *len)
{
    uint8_t *p = buf;

    *p++ = 0x00;                       /* Beacon Type: Unprovisioned (0x00) */

    /* Device UUID β€” 16 bytes, identifies this specific device */
    memcpy(p, node->uuid, 16);
    p += 16;

    /* OOB Information β€” 2 bytes big-endian */
    l_put_be16(node->oob_info, p);
    p += 2;

    /* URI Hash β€” 4 bytes, only present if the URI flag in OOB Info is set */
    if (node->oob_info & OOB_URI_FLAG) {
        l_put_be32(node->uri_hash, p);
        p += 4;
    }

    *len = p - buf;
}

/* Kick off advertising with this beacon */
static void start_unprovisioned_beacon(struct mesh_node *node)
{
    uint8_t buf[23];
    size_t len;

    build_unprovisioned_beacon(node, buf, &len);
    mesh_send_beacon(MESH_AD_TYPE_BEACON, buf, len);  /* BLE adv. non-connectable */
}

ℹ️ mesh_send_beacon() ultimately calls BlueZ’s btd_adapter_set_adv_data() with a non-connectable, non-scannable advertising type (ADV_NONCONN_IND).

Quick Recap

Concept What It Does Key Fields Involved
Network Obfuscation Hides TTL, SEQ, SRC from passive sniffers using XOR + AES PrivacyRandom, PECB, ObfuscatedData, Privacy Key, IV Index
Replay Protection Discards re-sent old messages by tracking last accepted SEQ + IV SEQ, IV Index, Replay Protection List
Mesh Beacons Let devices announce themselves (unprovisioned) or maintain network health (provisioned) Beacon Type, Device UUID, OOB Info, IV Index, Network ID

Keep Learning Bluetooth Mesh

Explore more tutorials at EmbeddedPathashala β€” free embedded systems content for engineers.

Visit EmbeddedPathashala Mesh Series Index

Leave a Reply

Your email address will not be published. Required fields are marked *