Safe Penetration Testing Labs · intermediate · ~15 min
## 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.
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.
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.
This lesson is the payoff for three earlier ones:
const uint8_t * buffer and compute offsets by hand.cap bytes, always NUL-terminate.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.
Parsing attacker-controlled binary safely is one of the most common real jobs in security engineering, and TLV is everywhere it happens.
Getting this loop right, once, gives you a reusable, review-passing skeleton for every length-prefixed format you will ever touch.
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.
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.
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.
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.
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
out buffer (no overflow).adv[] buffer — the moment radio bytes enter your program. Everything in the buffer is attacker-chosen.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.<stdint.h> (uint8_t, SIZE_MAX) and <string.h> (memcpy).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:
0x09 (Complete Local Name)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.
[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.
Implement:
int extract_local_name(const uint8_t *adv, size_t n, char *out, size_t cap);
Steps:
0x09 or 0x08, copy the value bytes into out, bounded by cap.Return -1 if any of these happen:
cap == 0.n (the end of the buffer).cap.len. The next record is at offset + len + 1.len == 0 record can spin the loop forever. Bail out on 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.
#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 */
}
#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.
extract_local_nameadv == NULL || out == NULL || cap == 0 returns -1. A zero-cap output has no room even for a terminator, so it is an immediate reject.off = 0 starts at the first record.while (off < n) — the loop guard is what makes adv[off] safe on the next line. If off ever reaches n, we stop cleanly.len = adv[off] reads the claimed record length. This read is safe because of the loop guard.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.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.type = adv[off + 1] — safe now, because step 6 proved off + 1 < n.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.off += len + 1 advances past length + type + value to the next record.return -1 — loop ended without a name: not found.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 |
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.
1. Checking against total size instead of remaining bytes.
if (len > n) return -1;off, not 0. A len that fits in n can still run past the buffer once you are partway through it.if (off + 1 + (size_t)len > n) return -1;2. Advancing by len instead of len + 1.
off += len;off += (size_t)len + 1;3. Not handling len == 0.
off += 0 + 1 crawls one byte at a time through the whole buffer, or with a different advance formula, hangs.if (len == 0) break;4. Silent truncation instead of rejection.
memcpy(out, val, cap - 1) regardless of the real name length.-1 when name_len >= cap, or return a distinct "truncated" code — but decide deliberately.5. Integer overflow in the fit check.
if (off + 1 + len > n) with off a small type, or adding before widening.off a size_t and cast len to size_t before the addition, as shown.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.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).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.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?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):
LENGTH_PAST_BUFFER, ZERO_LENGTH, NAME_TOO_LONG, NO_NAME.rejected.What to NEVER 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.
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.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.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):
-1) rather than guessing.cap, always NUL-terminate.Best-practice habits (advanced):
extract_local_name in CI, with ASan/UBSan, as a regression gate.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.
All tasks use static, harness-supplied buffers on your own machine — no radio, no live capture. Lab-only. Finish each by remediating and verifying.
int ad_length(const uint8_t *ad, size_t n) returning the length byte at offset 0.-1 if ad == NULL or n == 0; otherwise return ad[0].{0x07,0x09,...} → 7.n >= 1.int ad_type(const uint8_t *ad, size_t n) returning the type byte at offset 1.-1 unless n >= 2.{0x07,0x09,...} → 9.ad[1] when n < 2.int count_records(const uint8_t *adv, size_t n) returning how many well-formed AD structures the buffer holds.len == 0; return -1 if any record runs past n.2.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.*val/*val_len and return 0; return -1 if not found or malformed.want = 0x01 on the GOOD buffer → points at {0x06}, val_len = 1.*val must stay inside adv; validate before setting outputs.off + 2, length is len - 1.extract_local_name to also count and classify rejects, then prove the hardening.LENGTH_PAST_BUFFER, ZERO_LENGTH, NAME_TOO_LONG, NOT_FOUND. Write a fuzz-style test that feeds thousands of random/truncated buffers.cap.-fsanitize=address,undefined; a single ASan hit means a guard is missing.[length][type][value], where length excludes itself, so the value is length - 1 bytes and the next record is at offset + length + 1.off + 1 + len <= n.len == 0 → break; oversized name → reject when name_len >= cap. Always NUL-terminate, always reserve room for the terminator.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.len not len + 1, ignoring zero length, silent truncation, and narrow-integer overflow in the offset math.