Safe Penetration Testing Labs · intermediate · ~12 min

Classify an 802.11 frame from its control byte

- 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).

Overview

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.

Why it matters

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.

Core concepts

1. The Frame Control field

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.

2. Shift-then-mask extraction

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.

3. Type values and the Reserved case

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.

4. Untrusted input and the trust boundary

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.

  1. What asset does the null-check and length-check protect?
  2. Where is the trust boundary in the diagram, and which side is hdr on?
  3. What insecure assumption would let a truncated frame read past the buffer?
  4. Why must this run only against a bundled fixture in a lab, never against live captured traffic from people who have not authorized you?

Syntax notes

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:

  • Use 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.
  • Parenthesize: (hdr[0] >> 2) & 0x3. Precedence works out here, but explicit parentheses document intent and prevent mistakes when you extend the expression.

Lesson

Why this matters

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.

What the bits look like

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:

  • 0 -> Management (beacon, probe, association)
  • 1 -> Control (RTS, CTS, ACK)
  • 2 -> Data
  • 3 -> Reserved -> return -1

Your job

Implement int classify_frame(const uint8_t *hdr).

It returns:

  • 0, 1, or 2 for the three valid types
  • -1 for the reserved value, or for NULL input

Common mistakes

  • Wrong bit position. Bits 0-1 are the protocol version, not the type.
  • Skipping the mask. Always apply & 0x3 after the shift.
  • Letting NULL through. Check the pointer first.

What this is NOT

  • Not a radiotap parser. Real captures have a radiotap header in front of the 802.11 header. That is a separate module.
  • Not an attack tool. There is no injection here. We only classify.

Code examples

/*
 * 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

Line by line

Walking the secure classify_frame and one fixture (data = {0x88, 0x00}):

  1. 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.
  2. 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].
  3. 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
  1. if (type == 3) return -1;type is 2, not 3, so no reject.
  2. 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.

Common mistakes

Mistake 1 — reading the wrong bits.

  • WRONG: type = hdr[0] & 0x3; (no shift).
  • WHY: bits 0-1 are the protocol version, not the type. You would classify by version and mislabel every frame.
  • CORRECTED: type = (hdr[0] >> 2) & 0x3;.
  • RECOGNIZE/PREVENT: sanity-check against a known beacon (0x80 -> should be 0). If a beacon classifies as anything but Management, your bit math is off.

Mistake 2 — dropping the mask.

  • WRONG: type = hdr[0] >> 2;.
  • WHY: the subtype bits ride along, so type can be 0-63, never a clean 0-3.
  • CORRECTED: AND with 0x3 after shifting.
  • RECOGNIZE/PREVENT: if you ever see a type value above 3, you forgot the mask.

Mistake 3 — trusting the pointer and length.

  • WRONG: return (hdr[0] >> 2) & 0x3; with no checks (the insecure version).
  • WHY: NULL or a zero-length frame causes a crash or out-of-bounds read — attacker-triggerable with a crafted short frame.
  • CORRECTED: null-check, then len check, before touching hdr[0].
  • RECOGNIZE/PREVENT: run under a sanitizer (see debugging tips); it flags the bad read instantly.

Mistake 4 — letting Reserved through.

  • WRONG: return type; for all four values.
  • WHY: type 3 has no defined layout; continuing would misparse the rest of the frame.
  • CORRECTED: if (type == 3) return -1;.
  • RECOGNIZE/PREVENT: add a Reserved fixture (0x0C) to your tests and assert it returns -1.

Debugging tips

  • Wrong type value? Print the byte in binary and hand-trace the shift and mask. A quick printf("%02X -> %d\n", hdr[0], type); next to a known fixture reveals off-by-position errors fast.
  • Crash or garbage on some inputs? Build with sanitizers: 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.
  • Signed-char surprises? If you used char instead of uint8_t, a byte >= 0x80 may sign-extend and skew the shift. Switch to uint8_t and recheck.
  • Values above 3? You forgot & 0x3. Add it.
  • Reserved not rejected? Confirm the type == 3 branch runs before the return type.
  • Questions to ask when it fails: Is 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 safety

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:

  • Log: timestamp; capture source (interface or fixture id); frame length; the raw Frame Control byte (hex); the classified type (0/1/2) or the reject reason (Reserved / NULL / short); and a correlation id tying the frame to the capture session.
  • Never log: decrypted payload contents, credentials or PSK/PMK material, full MAC addresses of bystanders beyond what your authorized scope allows, or any personal data you do not need. If you must keep MACs for the assessment, note that they are personal data and handle them per your rules of engagement.
  • Signals of abuse worth alerting on: a burst of Reserved-type frames (often malformed or fuzzing), a flood of malformed/truncated frames (possible denial-of-service or a tool probing your parser), or an unusual ratio of management frames (deauth/beacon floods classify as Management — a spike is worth a look).
  • False positives: RF noise and weak signal legitimately corrupt frames, so a few malformed frames are normal, not an attack. Truncated captures at buffer edges also look malformed. Alert on sustained rates and correlate with signal quality before concluding hostile intent.

Real-world uses

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.

  • Validation: null-check, length-check, and reject Reserved before trusting any downstream layout.
  • Least privilege: run capture and parsing as an unprivileged user where possible; keep monitor-mode capability out of the classifier itself (it only needs bytes).
  • Secure defaults: fail closed — return -1 and stop rather than guessing a layout.
  • Logging & error handling: record the decision and the reject reason; count rejects as a health metric.

Beginner vs advanced.

  • Beginner: classify the 2-bit type from a fixture, with the safety checks above.
  • Advanced: extend to subtype (>> 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.

Practice tasks

Beginner 1 — Extract the type.

  • Objective: implement int frame_type(uint8_t fc0) returning (fc0 >> 2) & 0x3.
  • Input/output: 0x80 -> 0, 0xB4 -> 1, 0x88 -> 2, 0x0C -> 3.
  • Constraints: pure bit work, no I/O.
  • Hints: shift first, then mask. Concepts: shift, mask.

Beginner 2 — Reject Reserved and NULL.

  • Objective: wrap task 1 as int classify(const uint8_t *hdr, size_t len) that returns -1 for NULL, len < 1, or type 3.
  • Requirements: check pointer and length before dereferencing.
  • Constraints: read-only, const parameter.
  • Hints: order the guards NULL -> length -> extract -> reject. Concepts: trust boundary, fail-closed.

Intermediate 1 — Extract the subtype too.

  • Objective: add int frame_subtype(uint8_t fc0) returning (fc0 >> 4) & 0xF, and print type+subtype for a fixture array.
  • Input/output: 0x88 -> type 2, subtype 8 (QoS data).
  • Constraints: still one byte, no capture.
  • Hints: subtype is the upper nibble. Concepts: multi-field extraction.

Intermediate 2 — Fuzz your guards.

  • Objective: write a loop that calls your 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}.
  • Requirements: build with -fsanitize=address,undefined; the run must be clean.
  • Constraints: lab-only, no network.
  • Hints: a table of expected results by (fc0 >> 2) & 0x3 makes assertions easy. Concepts: exhaustive testing, mitigation verification.
  • Defensive conclusion: the passing sanitizer run is your proof the guards hold; document it as the retest evidence.

Challenge — Classify from a fixture with a radiotap prefix.

  • Objective: given a bundled buffer that starts with a radiotap header, read the radiotap length (bytes 2-3, little-endian), bounds-check that len covers it, then classify the 802.11 Frame Control byte that follows.
  • Requirements: every offset must be length-checked before use; return -1 on any short/inconsistent buffer.
  • Constraints: read-only fixture, authorized lab only, no live capture, no injection.
  • Hints: compute off = radiotap_len, verify len > off, then classify buf[off]. Concepts: offset validation, layered parsing, fail-closed.
  • Defensive conclusion: remediate by rejecting any buffer where the radiotap length exceeds 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.

Summary

  • Every 802.11 frame starts with a two-byte Frame Control field; byte 0 holds protocol version (bits 0-1), type (bits 2-3), and subtype (bits 4-7).
  • Extract the type with shift-then-mask: (hdr[0] >> 2) & 0x3. Values map to Management (0), Control (1), Data (2), Reserved (3 -> reject with -1).
  • A capture is untrusted input. Cross the trust boundary safely: null-check the pointer, length-check the buffer, then extract; fail closed by returning -1 on Reserved, NULL, or a short buffer.
  • Common mistakes: reading bits 0-1 instead of 2-3, forgetting the & 0x3 mask, skipping the pointer/length checks, and letting Reserved fall through.
  • Verify the fix with fixtures that both accept the three valid types and reject Reserved/NULL/empty, and build with -fsanitize=address,undefined as the retest evidence.
  • Log the decision (timestamp, source, length, raw FC byte, result/reject reason, correlation id); never log payloads, keys, or unneeded PII. Classify only frames you are authorized to capture — this lesson stays on a bundled fixture, no radio, no injection.

Practice with these exercises