Safe Penetration Testing Labs · intermediate · ~12 min
- Read the 802.11 **Frame Control** field from the first byte of a Wi-Fi header. - Use a shift-then-mask (`(byte0 >> 2) & 0x3`) to extract the 2-bit frame **type**. - Map the type value to Management, Control, or Data, and reject the Reserved value and NULL pointers. - Explain why frame classification is step 1 of any Wi-Fi forensics or monitoring pipeline. - Handle attacker-controlled, malformed bytes safely without reading out of bounds. - Log a classification decision defensively (what to record, what never to record).
Security objective. The asset you protect here is the integrity of your parsing pipeline. A Wi-Fi capture is fully attacker-controlled input: anyone within radio range can craft the bytes your program reads. The threat is a malformed or hostile frame that crashes your tool, drives it out of bounds, or fools it into misreading the rest of the frame. In this lesson you learn to detect the frame type safely and refuse to trust anything the type byte does not justify.
Every 802.11 (Wi-Fi) frame begins with a two-byte Frame Control field. Bits 2 and 3 of the very first byte encode the frame type: management, control, or data. A Wi-Fi analyzer cannot know how to read the rest of a frame until it knows the type — the type decides the layout of every byte that follows. So classification is always the first decision.
This builds directly on your prereqs. From pointers you use the idea that const uint8_t *hdr points at raw bytes you must not overrun and must null-check. From bitwise-operators you use right-shift (>>) to discard the low protocol-version bits and bitwise-AND (&) with a mask to keep only the bits you want. Everything here is those two ideas applied to one real byte.
This lesson is read-only and lab-only. It works on a fixture — a pre-captured, bundled frame header — not on a live radio. We never put a network card into monitor mode, never capture other people's traffic, and never inject anything. You are writing a classifier, not a capture tool.
In authorized wireless assessments, blue-team monitoring, and incident response, the first stage of any tool that touches 802.11 is a demultiplexer: it reads the Frame Control byte and routes each frame to the correct parser. Get this wrong and every downstream field is misaligned — you would read a data frame as if it were a beacon and report garbage. Tools like Wireshark, Kismet, and airodump-ng all begin exactly here.
It also matters for robustness. Wireless input is untrusted by definition: the radio hands you whatever bits arrive, including deliberately malformed frames. A parser that indexes hdr[0] without checking the pointer, or that trusts a length it has not validated, becomes the soft spot an attacker aims at. Learning to classify safely — null-check, bounds-check, mask, and treat Reserved as invalid — is the habit that keeps a monitoring tool from becoming the incident. Professionals value a parser that fails closed (rejects) over one that guesses.
Definition. The first two bytes of every 802.11 header. It carries protocol version, frame type, subtype, and a set of flag bits (To/From DS, retry, power management, etc.).
Plain explanation. Think of it as the frame's shipping label: before you open the box you read the label to learn what kind of box it is. Byte 0 alone tells you version, type, and subtype.
How it works. The bits of byte 0 are packed like this (bit 0 is least significant):
byte 0 bit: 7 6 5 4 3 2 1 0
\_____/ \_/ \_/
subtype | protocol version (bits 0-1)
type (bits 2-3)
When / when not. Read Frame Control on every frame — it is mandatory and always first. Do not assume anything past byte 0 exists until you have length-checked the buffer; short or truncated frames are common in real captures.
Pitfall. Confusing bit position with bit value. The type is in bits 2-3, not bits 0-1. Reading the wrong bits silently mislabels every frame.
Definition. A two-step idiom to pull a sub-field out of a byte: right-shift to move the field down to bit 0, then AND with a mask to erase everything above it.
Plain explanation. >> 2 drops the two protocol-version bits off the bottom. & 0x3 (binary 11) keeps only the two lowest remaining bits — the type — and zeroes the subtype bits above them.
How it works.
byte0 = 1 0 0 0 1 0 0 0 (0x88, a QoS data frame)
>> 2 = 0 0 1 0 0 0 1 0
& 0x3 = 0 0 0 0 0 0 1 0 = 2 -> Data
When / when not. Use shift-then-mask for any packed bit-field. Skip the mask only when the field already sits at bit 0 and nothing above it can be set — which is almost never true for real input.
Pitfall. Forgetting the mask. byte0 >> 2 alone still carries the subtype bits, so you would get values far larger than 3.
Definition. The 2-bit type has four possible values; only three are defined.
| Value | Meaning | Examples |
|---|---|---|
| 0 | Management | beacon, probe, association |
| 1 | Control | RTS, CTS, ACK |
| 2 | Data | data, QoS data |
| 3 | Reserved | (undefined -> reject) |
Plain explanation. Three of the four values map to a real frame class. Value 3 is Reserved and has no defined layout — treat it as invalid input and return -1.
How it works. After (byte0 >> 2) & 0x3 you have 0, 1, 2, or 3. Return the value for 0-2; return -1 for 3.
When / when not. Always reject Reserved. A hostile or corrupt frame can carry it; a well-behaved parser must not fall through and guess.
Pitfall. Treating -1 as "error, keep going" and still parsing the rest of the frame. -1 means stop — you do not know the layout.
Definition. Every byte of a captured frame originates outside your program, from a radio you do not control.
Plain explanation. You cannot assume the buffer is long enough, non-NULL, or well-formed. The caller must tell you the length; you must check the pointer.
THREAT MODEL — 802.11 frame classifier
[ RF environment ] attacker can transmit any bytes within range
| crafted / malformed / truncated frames
v
============ TRUST BOUNDARY ============ <- everything below is untrusted
|
v
[ capture fixture / buffer ] const uint8_t *hdr, size_t len
| entry point: classify_frame(hdr)
v
[ your classifier ] null-check -> length-check -> shift+mask -> map
| returns 0/1/2 (valid) or -1 (reject)
v
[ downstream parsers ] ONLY reached for a validated type
Assets protected: parser integrity, no out-of-bounds read, correct routing
Entry point: the single byte hdr[0]
Insecure assumption to avoid: "the buffer is always valid and >= 2 bytes"
Knowledge check.
hdr on?The whole extraction is one shift and one mask. Annotated:
#include <stdint.h> /* uint8_t: exactly 8 unsigned bits, portable */
int type = (hdr[0] >> 2) & 0x3;
/* \____/ \_/ \_/
| | mask 0b11 -> keep the 2 low bits only
| shift right 2 -> drop protocol-version bits 0-1
read byte 0 of the header (the Frame Control low byte) */
Key points:
uint8_t (from <stdint.h>), not plain char, so the byte is unambiguously unsigned 0-255 and the shift behaves predictably.0x3 is the mask for two bits. For the 4-bit subtype you would shift by 4 and mask with 0xF.(hdr[0] >> 2) & 0x3. Precedence works out here, but explicit parentheses document intent and prevent mistakes when you extend the expression.Every 802.11 (Wi-Fi) frame begins with a two-byte Frame Control field.
Bits 2 and 3 of the first byte encode the frame type: management, control, or data.
Reading that field is the first thing any Wi-Fi forensics tool does. It cannot know the layout of the rest of the frame until it knows the type.
This exercise is read-only. It works on a fixture (a pre-captured frame header bundled into the harness). We never put a network card into monitor mode.
byte 0 7 6 5 4 3 2 1 0
^^^^^^^^^^^^^^^^
subtype type protocol-version
The two lowest bits (0-1) are the protocol version. The next two bits (2-3) are the type. The rest is the subtype.
To extract the type, shift right by 2 to drop the version bits, then mask with 0x3 to keep only the two type bits:
type = (byte0 >> 2) & 0x3
The four possible values:
Implement int classify_frame(const uint8_t *hdr).
It returns:
& 0x3 after the shift./*
* classify_frame.c — 802.11 Frame Control TYPE classifier.
* Read-only. Runs on a bundled fixture. No radio, no capture, no injection.
* Build: cc -std=c11 -Wall -Wextra -o classify classify_frame.c
*/
#include <stdint.h>
#include <stddef.h>
#include <stdio.h>
/* ---------------------------------------------------------------
* WARNING: intentionally vulnerable — use only in a local,
* isolated, authorized lab. Do not deploy.
*
* This version trusts its input. It dereferences hdr without a
* null-check and reads hdr[0] without knowing the buffer length.
* A NULL pointer or a zero-length frame causes undefined behaviour
* (crash or out-of-bounds read). It also lets the Reserved type (3)
* fall through as if it were a real, parseable frame.
* --------------------------------------------------------------- */
int classify_frame_insecure(const uint8_t *hdr)
{
return (hdr[0] >> 2) & 0x3; /* no null-check, no length, no reject */
}
/* ---------------------------------------------------------------
* SECURE version: validate the trust boundary, then extract.
* returns 0 = Management, 1 = Control, 2 = Data
* returns -1 for Reserved(3), NULL, or a too-short buffer.
* --------------------------------------------------------------- */
int classify_frame(const uint8_t *hdr, size_t len)
{
if (hdr == NULL) return -1; /* pointer must be valid */
if (len < 1) return -1; /* need at least byte 0 */
int type = (hdr[0] >> 2) & 0x3; /* shift off version, mask 2 bits */
if (type == 3) return -1; /* Reserved -> reject, do not guess */
return type; /* 0, 1, or 2 */
}
/* ---------------------------------------------------------------
* VERIFY: prove the fix REJECTS bad input and ACCEPTS good input.
* These are fixtures — the first byte of a pre-captured header.
* --------------------------------------------------------------- */
static int expect(const char *name, int got, int want)
{
int ok = (got == want);
printf("[%s] %-22s got=%2d want=%2d\n", ok ? "PASS" : "FAIL",
name, got, want);
return ok;
}
int main(void)
{
int fails = 0;
/* ACCEPT good input */
uint8_t mgmt[2] = { 0x80, 0x00 }; /* beacon: type bits = 00 -> 0 */
uint8_t ctrl[2] = { 0xB4, 0x00 }; /* RTS: type bits = 01 -> 1 */
uint8_t data[2] = { 0x88, 0x00 }; /* QoS data: type bits = 10 -> 2 */
fails += !expect("management(0x80)", classify_frame(mgmt, 2), 0);
fails += !expect("control(0xB4)", classify_frame(ctrl, 2), 1);
fails += !expect("data(0x88)", classify_frame(data, 2), 2);
/* REJECT bad input */
uint8_t rsvd[2] = { 0x0C, 0x00 }; /* type bits = 11 -> Reserved */
fails += !expect("reserved(0x0C)", classify_frame(rsvd, 2), -1);
fails += !expect("null pointer", classify_frame(NULL, 0), -1);
fails += !expect("empty buffer", classify_frame(data, 0), -1);
printf("%s\n", fails ? "SOME TESTS FAILED" : "ALL TESTS PASSED");
return fails ? 1 : 0;
}
What it does. The insecure function shows the trap: raw extraction with no checks. The secure classify_frame null-checks the pointer, requires at least one byte, extracts the type with shift-then-mask, and rejects the Reserved value. main runs six fixtures that together prove the classifier accepts the three valid types and rejects Reserved, NULL, and an empty buffer.
Expected output.
[PASS] management(0x80) got= 0 want= 0
[PASS] control(0xB4) got= 1 want= 1
[PASS] data(0x88) got= 2 want= 2
[PASS] reserved(0x0C) got=-1 want=-1
[PASS] null pointer got=-1 want=-1
[PASS] empty buffer got=-1 want=-1
ALL TESTS PASSED
Walking the secure classify_frame and one fixture (data = {0x88, 0x00}):
if (hdr == NULL) return -1; — the trust-boundary check. hdr is 0x88…, not NULL, so we continue. For the classify_frame(NULL, 0) test this line returns -1 immediately.if (len < 1) return -1; — bounds check. len is 2, which is >= 1, so we continue. For the empty-buffer test len is 0 and we return -1 before ever touching hdr[0].int type = (hdr[0] >> 2) & 0x3; — the extraction. Trace it for 0x88:| Step | Binary | Decimal |
|---|---|---|
hdr[0] |
1000 1000 |
136 |
hdr[0] >> 2 |
0010 0010 |
34 |
… & 0x3 (0b11) |
0000 0010 |
2 |
if (type == 3) return -1; — type is 2, not 3, so no reject.return type; — returns 2 = Data.For mgmt = 0x80 (1000 0000): >> 2 gives 0010 0000, & 0x3 gives 0 = Management. For rsvd = 0x0C (0000 1100): >> 2 gives 0000 0011, & 0x3 gives 3, so step 4 returns -1. Notice each fixture's high bits (the subtype) are discarded by the mask — only bits 2-3 survive.
Mistake 1 — reading the wrong bits.
type = hdr[0] & 0x3; (no shift).type = (hdr[0] >> 2) & 0x3;.0x80 -> should be 0). If a beacon classifies as anything but Management, your bit math is off.Mistake 2 — dropping the mask.
type = hdr[0] >> 2;.type can be 0-63, never a clean 0-3.0x3 after shifting.Mistake 3 — trusting the pointer and length.
return (hdr[0] >> 2) & 0x3; with no checks (the insecure version).len check, before touching hdr[0].Mistake 4 — letting Reserved through.
return type; for all four values.if (type == 3) return -1;.0x0C) to your tests and assert it returns -1.printf("%02X -> %d\n", hdr[0], type); next to a known fixture reveals off-by-position errors fast.cc -std=c11 -fsanitize=address,undefined -g classify_frame.c -o classify then run the tests. AddressSanitizer pinpoints an out-of-bounds hdr[0] read; UBSan catches bad shifts. This is the fastest way to prove the insecure version is unsafe and the secure one is not.char instead of uint8_t, a byte >= 0x80 may sign-extend and skew the shift. Switch to uint8_t and recheck.& 0x3. Add it.type == 3 branch runs before the return type.hdr non-NULL? Is len >= 1? Am I shifting by 2 and masking with 0x3? Does a known beacon (0x80) return 0? Does a Reserved byte (0x0C) return -1?Memory & UB safety. The only dereference is hdr[0], and it is guarded by the NULL check and the len < 1 check — so the classifier never reads a byte the caller did not promise exists. Use uint8_t so the value is unsigned 0-255 and >> 2 has no sign-extension surprises (shifting a negative char is a portability landmine). Never index past what len covers; if you later read byte 1 for the subtype flags, add a len < 2 guard first. Keep the parameter const — the classifier reads, never writes, the capture buffer.
Security & safety — detection & logging. A monitoring tool should record each classification decision so abuse is visible:
Authorized real-world use. On a sanctioned wireless assessment or in blue-team monitoring, the classifier is the front door of the analysis pipeline: read Frame Control, route the frame to the management / control / data parser, and tally per-type counts for a dashboard. A sudden surge of Management frames, for instance, is how analysts first notice a deauthentication flood — before decoding a single subtype.
Best-practice habits.
-1 and stop rather than guessing a layout.Beginner vs advanced.
>> 4 & 0xF), decode the To/From-DS flag bits from Frame Control byte 1 (which changes the address-field layout), and skip a preceding radiotap header by reading its length field — all with per-step length guards, still on captures you are authorized to analyze.Authorization & ethics. Classify only frames you are authorized to capture: your own lab, an intentionally-vulnerable test AP you control, or a client network within a signed scope. Never put a card into monitor mode against networks you do not own or have written permission to test.
Beginner 1 — Extract the type.
int frame_type(uint8_t fc0) returning (fc0 >> 2) & 0x3.0x80 -> 0, 0xB4 -> 1, 0x88 -> 2, 0x0C -> 3.Beginner 2 — Reject Reserved and NULL.
int classify(const uint8_t *hdr, size_t len) that returns -1 for NULL, len < 1, or type 3.const parameter.Intermediate 1 — Extract the subtype too.
int frame_subtype(uint8_t fc0) returning (fc0 >> 4) & 0xF, and print type+subtype for a fixture array.0x88 -> type 2, subtype 8 (QoS data).Intermediate 2 — Fuzz your guards.
classify on all 256 values of fc0 (with len = 1) plus the NULL and len = 0 cases, and assert it never crashes and never returns a value outside {-1,0,1,2}.-fsanitize=address,undefined; the run must be clean.(fc0 >> 2) & 0x3 makes assertions easy. Concepts: exhaustive testing, mitigation verification.Challenge — Classify from a fixture with a radiotap prefix.
len covers it, then classify the 802.11 Frame Control byte that follows.-1 on any short/inconsistent buffer.off = radiotap_len, verify len > off, then classify buf[off]. Concepts: offset validation, layered parsing, fail-closed.len; verify with a truncated fixture that must return -1. Reset the lab by discarding the fixture buffer — nothing persists, no capture device is touched.Authorization checklist (for any lab that captures rather than uses a fixture): (1) you own the AP/network or hold written permission and a defined scope; (2) capture is confined to your own hardware / an isolated lab SSID; (3) no third-party traffic is retained; (4) you have a cleanup step. If any box is unchecked, stay on the bundled fixture.
(hdr[0] >> 2) & 0x3. Values map to Management (0), Control (1), Data (2), Reserved (3 -> reject with -1).& 0x3 mask, skipping the pointer/length checks, and letting Reserved fall through.-fsanitize=address,undefined as the retest evidence.