Safe Penetration Testing Labs · intermediate · ~15 min

Extract the local name from a BLE advertisement

## What you will learn - Read a Bluetooth Low Energy (BLE) advertising payload as a sequence of **TLV** (type-length-value) records, called *AD structures*. - Walk those records with a **bounds-safe loop** that can never read past the end of the buffer, even when the input is malformed or hostile. - Detect and copy the **Complete Local Name** (type `0x09`) and **Shortened Local Name** (type `0x08`) into a caller-supplied buffer using a bounded copy. - Reject the three classic parser-killers: a length that runs past the buffer, a zero-length record that loops forever, and a value that would overflow the output buffer. - Instrument the parser so a defender can **log** malformed advertisements without logging captured personal data.

Overview

Security objective

Asset protected: the memory of the program doing the parsing — a BLE scanner, a sniffer, or a smart-home hub — and the integrity of whatever it does with the parsed name.

Threat: a nearby device (or a replayed capture) that transmits a malformed advertisement — a length byte that points past the end of the packet, a zero-length record, or a name longer than your buffer. Radio input is fully attacker-controlled: anyone within range can broadcast any bytes they like. A parser that trusts the length fields will read out of bounds or overflow a buffer, turning a passive scan into a crash or worse.

What you will detect / prevent: out-of-bounds reads and buffer overflows in a TLV walker. You will build a parser that treats every length as untrusted and verifies it against the real buffer size before touching a single value byte.

What a BLE advertisement is

When a BLE device is discoverable, it broadcasts small packets called advertisements roughly every 20–1000 ms. The payload (up to 31 bytes in legacy advertising) is a flat list of AD structures. Each AD structure is one TLV record:

[ length ] [ AD type ] [ value bytes ... ]
   1 byte     1 byte      (length - 1) bytes

The length byte counts the AD type plus the value — it does not count itself. So the value is length - 1 bytes long, and the next record starts length + 1 bytes further on. One advertisement typically packs a Flags record, some Service UUIDs, and a name, back to back.

How this builds on your prereqs

This lesson is the payoff for three earlier ones:

  • pointers — you index into a raw const uint8_t * buffer and compute offsets by hand.
  • bounded-copy — copying the name out is exactly a bounded copy: never write more than cap bytes, always NUL-terminate.
  • tlv-walking — the walk-a-length-type-value-loop pattern you learned generically is applied here to a real protocol. The same loop parses DHCP options, ICMPv6/IPv6 extension-header options, and X.509 fields.

We deliberately parse a static buffer handed to us by a test harness. This is not a scanner and it does not touch a radio. That keeps the lesson entirely about safe parsing, which is the transferable skill.

Why it matters

Why this matters in authorized work

Parsing attacker-controlled binary safely is one of the most common real jobs in security engineering, and TLV is everywhere it happens.

  • Wireless tooling. BLE sniffers, asset-tracker gateways, and IoT hubs parse thousands of advertisements per minute from unknown devices. A single unchecked length is a remotely triggerable crash — a denial of service that any passerby can cause.
  • The bug class pays the bills. Historically, the most damaging vulnerabilities in networking stacks are memory-safety bugs in parsers of untrusted input (for example, the BleedingTooth family in the Linux Bluetooth stack). Writing the bounds check correctly is the entire defense.
  • The pattern transfers. Once you can walk BLE AD structures safely, DHCP options, RADIUS attributes, TLS extensions, and ASN.1/DER all fall to the same discipline: validate the length against the remaining buffer before you dereference.
  • Defenders need it too. A blue-team engineer writing a detector for rogue beacons has to parse the same hostile input the attacker sends. The parser is a shared tool; only the intent differs.

Getting this loop right, once, gives you a reusable, review-passing skeleton for every length-prefixed format you will ever touch.

Core concepts

Concept 1 — The AD structure (a TLV record)

Definition. An AD structure is one record: a 1-byte length, a 1-byte AD type, then length - 1 value bytes.

Plain explanation. The length tells you how far to jump to the next record; the type tells you how to read the value. The Bluetooth SIG assigns the type numbers — 0x01 is Flags, 0x09 is Complete Local Name, 0x08 is Shortened Local Name.

How it works. Because length excludes itself, the value length is length - 1, and the next record begins at offset + length + 1.

When / when not. This flat TLV form applies to legacy advertising and scan-response data. Extended advertising and GATT use different framing — do not assume this layout there.

Pitfall. Off-by-one on the length byte. If you compute the value length as length instead of length - 1, or advance by length instead of length + 1, you desynchronize and read garbage.

Concept 2 — Untrusted length fields

Definition. A length field is a number inside the packet that claims how big something is. It is chosen by whoever sent the packet.

Plain explanation. The sender can lie. A 5-byte packet can contain a length byte that says 200. If you trust it, you read 200 bytes from a 5-byte buffer.

How it works. The defense is one comparison, done before any read: does this record actually fit in the bytes I still have? You compare the claimed length against n - offset (bytes remaining), not against a constant.

When / when not. Every time the data crossed a trust boundary — a radio, a socket, a file you did not write. Never skip it for "internal" data that in fact came from outside.

Pitfall. Checking against the total buffer size n instead of the remaining bytes. The record starts partway through; only n - offset bytes are left.

Concept 3 — The bounds-safe walk

Definition. A loop that, at each step, proves the current record fits before reading it, then advances by exactly the record size.

Plain explanation. Look before you leap. Confirm there is a length byte, read it, confirm the whole record fits, only then read the type and value, then jump to the next record.

How it works. Three guards per iteration: (1) at least 1 byte left for the length; (2) length >= 1 (a zero length is malformed and must not advance by zero); (3) the value fits: length - 1 <= n - offset - 1, i.e. offset + 1 + (length - 1) <= n.

When / when not. Always, for length-prefixed formats. The only time you skip a guard is when a stronger one already covers it.

Pitfall. A length == 0 record. offset + length + 1 == offset + 1, so you advance by one and spin near-forever, or on some layouts by zero and hang. Treat length == 0 as end-of-data or as an error.

Concept 4 — Bounded copy of the value

Definition. Copying at most cap - 1 value bytes into out, then writing a NUL terminator.

Plain explanation. Even after the record is proven in-bounds for the input, the value can still be too big for your output. The name could be 29 bytes; your buffer could be 8. Copy only what fits and refuse silently-truncated surprises.

How it works. Compare value length to cap. Here we choose to reject (return -1) if the name would not fit, which is stricter and safer than silent truncation. Then memcpy the exact value length and set out[len] = '\0'.

Pitfall. Forgetting the terminator, or reserving no room for it. A name of exactly cap bytes leaves no space for '\0' — you need value_len < cap.

Threat model

            RF / radio (attacker-controlled)
  ┌──────────────┐   advertising    ┌───────────────────────────┐
  │ Nearby device│ ───────────────► │  Your scanner / sniffer   │
  │ (any bytes)  │   31 raw bytes   │                           │
  └──────────────┘                  │  ┌─────────────────────┐  │
        ▲                           │  │ extract_local_name()│  │
        │  TRUST BOUNDARY  ═════════╪══│  (this lesson)      │  │
        │  (everything left of      │  │  validate length    │  │
        │   here is untrusted)      │  │  bounded copy → out │  │
                                    │  └─────────────────────┘  │
  ENTRY POINT: the adv[] buffer     │        │                  │
  ASSET: process memory + out[]     │        ▼                  │
  THREAT: OOB read, buffer overflow │   app logic / logs        │
                                    └───────────────────────────┘

Knowledge check

  1. What asset is protected here? The parser process's memory (no out-of-bounds read) and the caller's out buffer (no overflow).
  2. Where is the trust boundary? At the adv[] buffer — the moment radio bytes enter your program. Everything in the buffer is attacker-chosen.
  3. What insecure assumption causes the classic bug? Believing the in-packet length byte tells the truth about how many bytes are actually present.
  4. Which logs would detect an attack attempt? A counter/log line for "rejected advertisement: length past buffer" or "name too long" — a spike signals someone is fuzzing your parser.
  5. Why only in an authorized lab? Even passive capture of nearby BLE traffic can collect other people's device identifiers and location data; only do it with data you own or are authorized to handle.

Syntax notes

Key structure

The walk is a single while loop over an offset, with the guards up front. The core skeleton:

size_t off = 0;
while (off < n) {
    uint8_t len = adv[off];          /* safe: off < n proven by loop guard */
    if (len == 0) break;             /* malformed / end: never advance by 0 */
    if (off + 1 + (size_t)len > n)   /* type+value must fit in remaining bytes */
        return -1;                   /* length runs past the buffer -> reject */

    uint8_t type = adv[off + 1];     /* safe: proven in-bounds above */
    const uint8_t *val = adv + off + 2;
    size_t val_len = (size_t)len - 1;

    /* ... inspect type, maybe copy val ... */

    off += (size_t)len + 1;          /* +1 skips the length byte itself */
}

Annotations:

  • off + 1 + (size_t)len > n is the whole-record fit check. Cast len to size_t before adding so the comparison happens in size_t and cannot wrap on a small type.
  • val_len = len - 1 because the length excludes itself but includes the type byte.
  • off += len + 1 advances past length + type + value in one jump.
  • Use <stdint.h> (uint8_t, SIZE_MAX) and <string.h> (memcpy).

Lesson

Why this matters

A BLE advertisement is a stream of TLV records. TLV stands for type-length-value: each record carries a length, a type, and then its data.

Every BLE sniffer parses these records. In this lesson you will parse one too.

We focus on a single field: the Local Name. It comes in two forms:

  • Type 0x09 (Complete Local Name)
  • Type 0x08 (Shortened Local Name)

The code here has the same shape you would write to walk an EXTHDR option list, a DHCP option list, or any TLV-encoded record.

What the bytes look like

[len1] [type1] [val1...] [len2] [type2] [val2...] ...

The len byte counts the bytes in type + value. It does not count itself.

So the next record starts at offset + len + 1. The + 1 skips past the length byte.

Your job

Implement:

int extract_local_name(const uint8_t *adv, size_t n, char *out, size_t cap);

Steps:

  1. Walk the TLV records.
  2. When you hit type 0x09 or 0x08, copy the value bytes into out, bounded by cap.
  3. NUL-terminate the string.
  4. Return the number of value bytes written.

Return -1 if any of these happen:

  • Any input is NULL, or cap == 0.
  • A length would walk past n (the end of the buffer).
  • The name would overflow cap.
  • No name field is found.

Common mistakes

  • Miscounting the length byte. The length byte itself is not part of len. The next record is at offset + len + 1.
  • Infinite loop on a zero length. A len == 0 record can spin the loop forever. Bail out on it.
  • Reading before checking. Always bounds-check the length before you read the value.

What this is NOT

  • It is not a full BLE GAP parser. Other fields (Flags, Service UUIDs, manufacturer data) are out of scope.
  • It is not a scanner. We read a static payload supplied by the test harness.

Code examples

Insecure version — study it, do not ship it

/* WARNING: intentionally vulnerable — use only in a local, isolated,
   authorized lab. Do not deploy. */
#include <stdint.h>
#include <string.h>
#include <stdio.h>

/* BUG 1: trusts len; never checks it against the buffer size n.
   BUG 2: copies val_len bytes into out with no cap check (overflow).
   BUG 3: len == 0 makes off advance by 1 forever on padding. */
int extract_local_name_bad(const uint8_t *adv, size_t n,
                           char *out, size_t cap) {
    size_t off = 0;
    (void)n; (void)cap;                 /* ignored -- that's the bug */
    while (1) {
        uint8_t len  = adv[off];        /* OOB read once off >= n */
        uint8_t type = adv[off + 1];    /* OOB read */
        if (type == 0x09 || type == 0x08) {
            memcpy(out, adv + off + 2, (size_t)len - 1);  /* overflow */
            out[len - 1] = '\0';
            return (int)(len - 1);
        }
        off += (size_t)len + 1;         /* len==0 -> off += 1 spins */
    }
}

Given a 5-byte buffer whose first length byte is 0x1F (31), memcpy reads 30 bytes from a 5-byte array and writes 30 into an 8-byte out: two memory-safety bugs from one trusted length.

Secure version

#include <stdint.h>
#include <stddef.h>
#include <string.h>

/* Returns number of name bytes written (>= 0) on success.
   Returns -1 on any error: NULL/zero args, truncated or oversized
   record, name larger than cap, or no name field found. */
int extract_local_name(const uint8_t *adv, size_t n,
                       char *out, size_t cap) {
    if (adv == NULL || out == NULL || cap == 0)
        return -1;

    size_t off = 0;
    while (off < n) {
        uint8_t len = adv[off];              /* off < n: in bounds */
        if (len == 0)                        /* zero length: stop, never loop */
            break;
        if (off + 1 + (size_t)len > n)       /* whole record must fit */
            return -1;                       /* length runs past buffer */

        uint8_t type = adv[off + 1];
        if (type == 0x09 || type == 0x08) {  /* Complete / Shortened Local Name */
            size_t name_len = (size_t)len - 1;
            if (name_len >= cap)             /* need room for the NUL */
                return -1;                   /* name would overflow out */
            memcpy(out, adv + off + 2, name_len);
            out[name_len] = '\0';
            return (int)name_len;
        }
        off += (size_t)len + 1;              /* +1 skips the length byte */
    }
    return -1;                                /* no name field found */
}

Verify — proves the fix rejects bad input and accepts good input

#include <assert.h>
#include <stdio.h>
#include <string.h>

int extract_local_name(const uint8_t *adv, size_t n, char *out, size_t cap);

int main(void) {
    char out[32];

    /* GOOD: Flags record then Complete Local Name "Beacon". */
    unsigned char good[] = {
        0x02, 0x01, 0x06,                    /* len=2, type=Flags, value */
        0x07, 0x09, 'B','e','a','c','o','n'   /* len=7, type=0x09, 6 chars */
    };
    int r = extract_local_name(good, sizeof good, out, sizeof out);
    assert(r == 6);
    assert(strcmp(out, "Beacon") == 0);

    /* BAD 1: length byte claims 31 bytes in a 3-byte buffer. */
    unsigned char lie[] = { 0x1F, 0x09, 'X' };
    assert(extract_local_name(lie, sizeof lie, out, sizeof out) == -1);

    /* BAD 2: name (6 bytes) larger than a tiny 4-byte out buffer. */
    assert(extract_local_name(good, sizeof good, out, 4) == -1);

    /* BAD 3: zero-length padding, no name -> not found, no hang. */
    unsigned char zero[] = { 0x00, 0x00, 0x00 };
    assert(extract_local_name(zero, sizeof zero, out, sizeof out) == -1);

    /* EDGE: NULL and cap==0 rejected. */
    assert(extract_local_name(NULL, 4, out, sizeof out) == -1);
    assert(extract_local_name(good, sizeof good, out, 0) == -1);

    puts("all checks passed");
    return 0;
}

Expected output: all checks passed. Compile with cc -std=c11 -Wall -Wextra -fsanitize=address,undefined name.c verify.c -o v && ./v. The sanitizers would flag the insecure version on lie[]; the secure version passes clean because every read is proven in-bounds first.

Line by line

Walkthrough of the secure extract_local_name

  1. Argument guards. adv == NULL || out == NULL || cap == 0 returns -1. A zero-cap output has no room even for a terminator, so it is an immediate reject.
  2. off = 0 starts at the first record.
  3. while (off < n) — the loop guard is what makes adv[off] safe on the next line. If off ever reaches n, we stop cleanly.
  4. len = adv[off] reads the claimed record length. This read is safe because of the loop guard.
  5. if (len == 0) break; kills the zero-length hang. A record can never legitimately be zero, and advancing by len + 1 == 1 on padding would crawl the buffer pointlessly; stopping is correct.
  6. if (off + 1 + len > n) return -1; the fit check. off + 1 is the type byte's position; + len covers type plus value. If that exceeds n, the sender lied about the length — reject. The cast to size_t keeps the arithmetic wide.
  7. type = adv[off + 1] — safe now, because step 6 proved off + 1 < n.
  8. Name branch. For 0x09/0x08, name_len = len - 1 (length excludes itself). if (name_len >= cap) return -1 guarantees room for name_len bytes plus the NUL. memcpy then copies exactly name_len bytes and out[name_len] = '\0' terminates.
  9. off += len + 1 advances past length + type + value to the next record.
  10. Fall-through return -1 — loop ended without a name: not found.

Trace on the GOOD buffer

Buffer: 02 01 06 | 07 09 42 65 61 63 6F 6E, n = 11, cap = 32.

off len fit check (off+1+len ≤ n) type action
0 2 0+1+2=3 ≤ 11 ✓ 0x01 Flags — skip, off += 3
3 7 3+1+7=11 ≤ 11 ✓ 0x09 name_len=6, 6<32 ✓, copy "Beacon", return 6

Trace on the LIE buffer

Buffer: 1F 09 58, n = 3.

off len fit check result
0 0x1F=31 0+1+31=32 ≤ 3? ✗ return -1 (rejected before any value read)

The attacker's oversized length is caught at the guard, before memcpy — no out-of-bounds read happens.

Common mistakes

Real mistakes and their fixes

1. Checking against total size instead of remaining bytes.

  • Wrong: if (len > n) return -1;
  • Why wrong: the record starts at off, not 0. A len that fits in n can still run past the buffer once you are partway through it.
  • Corrected: if (off + 1 + (size_t)len > n) return -1;
  • Recognise: out-of-bounds reads on the last record of a packet, or an AddressSanitizer hit only on certain inputs.

2. Advancing by len instead of len + 1.

  • Wrong: off += len;
  • Why wrong: it never skips the length byte, so you re-read the type as the next length and desynchronize.
  • Corrected: off += (size_t)len + 1;
  • Recognise: names come out as garbage or the loop finds records that were never sent.

3. Not handling len == 0.

  • Wrong: letting a zero-length record fall through the loop.
  • Why wrong: off += 0 + 1 crawls one byte at a time through the whole buffer, or with a different advance formula, hangs.
  • Corrected: if (len == 0) break;
  • Recognise: the parser is slow or hangs on padded packets.

4. Silent truncation instead of rejection.

  • Wrong: memcpy(out, val, cap - 1) regardless of the real name length.
  • Why wrong: the caller gets a wrong, truncated name and no signal that it happened — a correctness and sometimes a security bug.
  • Corrected: reject with -1 when name_len >= cap, or return a distinct "truncated" code — but decide deliberately.
  • Recognise: names that are consistently cut to the same length.

5. Integer overflow in the fit check.

  • Wrong: if (off + 1 + len > n) with off a small type, or adding before widening.
  • Why wrong: narrow arithmetic can wrap and pass a check it should fail.
  • Corrected: keep off a size_t and cast len to size_t before the addition, as shown.

Debugging tips

Debugging a TLV parser

  • Turn on the sanitizers first. cc -std=c11 -Wall -Wextra -fsanitize=address,undefined. AddressSanitizer pinpoints the exact out-of-bounds read; UBSan catches integer overflow in the offset math. These find in seconds what code review misses.
  • Print the walk. Temporarily log off, len, and type each iteration. If off jumps by an unexpected amount, your advance formula is wrong; if len looks like an ASCII character, you are reading a value byte as a length (desync).
  • Hexdump the input. xxd or a small loop printing %02x shows you the record boundaries. Verify by hand that off + len + 1 lands on the next length byte.
  • Fuzz it. Feed random and truncated buffers in a loop under ASan; a crash is a bug in your bounds logic, a clean run over millions of inputs is strong evidence the guards are complete.
  • Questions to ask when it fails: Did I check the length before the read? Is my fit check against remaining bytes? Do I stop on len == 0? Is there room for the NUL (name_len < cap, strictly)? Are off and len both widened to size_t before I add them?

Memory safety

Security & safety: detection and logging

This parser sits on a trust boundary, so it is also a detection point. A defender wants to know when it is being fed garbage.

What to log (per rejected advertisement):

  • Timestamp (UTC, monotonic where possible).
  • Source, at the granularity you are authorized to keep — often just "a nearby device" or a rotating/random BLE address; treat it as sensitive.
  • Which check failed: LENGTH_PAST_BUFFER, ZERO_LENGTH, NAME_TOO_LONG, NO_NAME.
  • The security decision: rejected.
  • A correlation id tying it to the capture session.
  • A monotonic counter of rejects; a sudden spike is the signal that someone is fuzzing your stack.

What to NEVER log:

  • The raw advertisement payload beyond a short, redacted length — it can contain other people's device names, which are personal data.
  • Full device identifiers or resolvable private addresses if your authorization does not cover retaining them.
  • Any captured PII, location inference, or secrets. Never dump whole buffers "for debugging" into a shared log.

Events that signal abuse: a burst of LENGTH_PAST_BUFFER or NAME_TOO_LONG rejects from the same session, or oscillating lengths that look like a fuzzer sweeping a value — classic parser-attack fingerprints.

False positives: real-world advertisers do occasionally send slightly malformed or vendor-quirky records; a low, steady trickle of rejects is normal noise, not an attack. Alert on rate and shape, not on a single reject.

C memory-safety notes for this code

  • Every adv[...] read is guarded by a preceding bounds check or the loop condition — no unchecked dereference exists.
  • name_len < cap (strict) reserves the terminator byte; name_len == cap would overflow by one.
  • Cast len to size_t before arithmetic to avoid signed/narrow integer wrap (undefined behavior and a bypass vector).
  • memcpy copies exactly name_len bytes; out is always NUL-terminated on the success path, so callers can treat it as a C string.

Real-world uses

Authorized real-world use

Scenario. You are on a red team engagement with written authorization to inventory BLE devices in a client's warehouse. Your gateway captures advertisements in a controlled area you are cleared to test and extracts device names to map asset trackers. The extraction path is exactly this parser — fed hostile-by-default radio bytes, it must never crash or overflow.

Best-practice habits (beginner):

  • Validate first: bounds-check every length against remaining bytes before any read.
  • Secure defaults: reject malformed input (-1) rather than guessing.
  • Bounded copy: never write past cap, always NUL-terminate.
  • Fail closed: on any doubt, return an error instead of partial data.

Best-practice habits (advanced):

  • Least privilege: run the capture/parse process unprivileged, in a container or with seccomp, so a parser bug cannot escalate.
  • Structured logging of rejects with counters, feeding a detection dashboard (see Security & safety).
  • Continuous fuzzing (libFuzzer/AFL++) of extract_local_name in CI, with ASan/UBSan, as a regression gate.
  • Data minimization & retention limits on captured identifiers, plus a documented authorization scope and cleanup step.

Reset / cleanup after a lab session: stop the capture, delete raw .pcap/buffer files that contain third-party identifiers, clear parser logs of any retained addresses, and confirm no capture process is still bound to the radio.

Practice tasks

Practice tasks

All tasks use static, harness-supplied buffers on your own machine — no radio, no live capture. Lab-only. Finish each by remediating and verifying.

Beginner 1 — Read the length byte

  • Objective: implement int ad_length(const uint8_t *ad, size_t n) returning the length byte at offset 0.
  • Requirements: return -1 if ad == NULL or n == 0; otherwise return ad[0].
  • I/O: input {0x07,0x09,...}7.
  • Constraints: no reads unless n >= 1.
  • Hints: guard before you dereference.
  • Concepts: pointers, bounds check.

Beginner 2 — Read the type byte safely

  • Objective: int ad_type(const uint8_t *ad, size_t n) returning the type byte at offset 1.
  • Requirements: return -1 unless n >= 2.
  • I/O: {0x07,0x09,...}9.
  • Constraints: never read ad[1] when n < 2.
  • Hints: the type lives after the length byte.
  • Concepts: offset arithmetic, bounds check.

Intermediate 1 — Count records safely

  • Objective: int count_records(const uint8_t *adv, size_t n) returning how many well-formed AD structures the buffer holds.
  • Requirements: walk with the fit check; stop on len == 0; return -1 if any record runs past n.
  • I/O: the GOOD buffer from the lesson → 2.
  • Constraints: never read out of bounds; no infinite loop.
  • Hints: reuse the secure walk skeleton; increment a counter each valid record.
  • Concepts: bounds-safe walk, zero-length handling.

Intermediate 2 — Find any record by type

  • Objective: int find_record(const uint8_t *adv, size_t n, uint8_t want, const uint8_t **val, size_t *val_len) that locates the first record of type want and hands back a pointer into adv and its value length.
  • Requirements: no copy; on success set *val/*val_len and return 0; return -1 if not found or malformed.
  • I/O: want = 0x01 on the GOOD buffer → points at {0x06}, val_len = 1.
  • Constraints: *val must stay inside adv; validate before setting outputs.
  • Hints: the value starts at off + 2, length is len - 1.
  • Concepts: returning views safely, bounds checks.

Challenge — Harden and detect

  • Objective: extend extract_local_name to also count and classify rejects, then prove the hardening.
  • Requirements: add an out-param struct tracking counts of LENGTH_PAST_BUFFER, ZERO_LENGTH, NAME_TOO_LONG, NOT_FOUND. Write a fuzz-style test that feeds thousands of random/truncated buffers.
  • Constraints: lab-only; log counts only, never raw payloads or identifiers; never write past cap.
  • Hints: build under -fsanitize=address,undefined; a single ASan hit means a guard is missing.
  • Concepts: detection/logging, fuzzing, bounded copy.
  • Defensive conclusion: after fuzzing, confirm zero sanitizer errors, confirm each malformed class increments the right counter, and document the remediation (which guard catches which attack). Then delete any test buffers containing sample identifiers.

Summary

Summary

  • A BLE advertisement is a flat list of AD structures — TLV records of [length][type][value], where length excludes itself, so the value is length - 1 bytes and the next record is at offset + length + 1.
  • Radio input is fully attacker-controlled. Never trust the length byte. Before any read, prove the whole record fits in the remaining bytes: off + 1 + len <= n.
  • The three parser-killers and their fixes: length past buffer → fit check; len == 0break; oversized name → reject when name_len >= cap. Always NUL-terminate, always reserve room for the terminator.
  • Key syntax/commands: the guarded while (off < n) walk; off += (size_t)len + 1; memcpy bounded by a < cap check; build and test with cc -std=c11 -Wall -Wextra -fsanitize=address,undefined.
  • Common mistakes: checking against total size instead of remaining bytes, advancing by len not len + 1, ignoring zero length, silent truncation, and narrow-integer overflow in the offset math.
  • Detection: log which check rejected an advertisement and a reject counter; never log raw payloads or third-party identifiers. Alert on the rate and shape of rejects, not single events.
  • Remember: the same bounds-first discipline parses DHCP, ICMPv6 options, TLS extensions, and X.509 — get the loop right once and reuse it everywhere. Only ever run captures on data you own or are authorized to test.

Practice with these exercises