β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-3363

maxrate() heap out-of-bounds read via unvalidated htcap_ie from the verbatim-saved beacon IE blob: attacker-controlled AP selection

Summary

sta_add() saves the beacon's raw IE stream into an exactly-sized kmalloc(ies_len) blob. ieee80211_ies_expand() sets se_ies.htcap_ie for ANY HTCAP IE regardless of declared length ('we don't do any validity checking'), and parse_beacon's HTCAP length check only NULLs sp->htcap which sta_add never consumes. maxrate() then reads htcap->hc_mcsset[12] (IE offset 17), isclr(hc_mcsset,i) for i<=31 (offsets 5-8) and le16dec(hc_cap) (offsets 2-3) with no length check - a TLV-well-formed trailing HTCAP IE with len<16 makes the kernel read up to 16 bytes past the heap allocation, and those bytes feed sta_compare's rate ranking. VERIFIED on stock INVARIANTS guest (fwscan software radio through the production parse->add_scan->sta_add->pick_bss path): with 0xff heap bytes groomed past the 64-byte blob, a 5.5 Mbps-only AP whose beacon ends in a 2-byte HTCAP IE is JOINED over a real 54 Mbps AP (phantom MCS31-HT40 fabricated from OOB bytes); with 0x00 beyond the allocation the correct AP wins. Kernel-heap content oracle with control-flow influence; no direct disclosure or panic; no uid0 route. Fix: length-validate the IE pointer at the consumer.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3363 Β· 15 files
FileTypeDescriptionSize
fwscan.c β€” 29.5 KB view raw
Makefile β€” 263 B ↓ download
build.sh β€” 87 B view raw
run.sh β€” 195 B view raw
build.log β€” 5.6 KB view raw
run.log β€” 3.3 KB view raw
run.v1.log β€” 1.8 KB view raw
run.v2.log β€” 1.8 KB view raw
run.v3.log β€” 1.8 KB view raw
fix_validation.log β€” 1.8 KB view raw
fix.diff β€” 2.0 KB view raw
README.md β€” 2.6 KB ↓ raw
VERDICT.md β€” 3.2 KB ↓ raw
manifest.json β€” 862 B view raw
verdict.json β€” 4.2 KB view raw

DF-3363 β€” maxrate() heap OOB read via unvalidated htcap_ie from the saved beacon IE blob

File: sys/netproto/802_11/wlan/ieee80211_scan_sta.c (pass 2)

What

sta_add() saves the beacon's entire IE stream verbatim into an exactly-sized kmalloc(ies_len) blob (ieee80211_ies_init, ieee80211_node.c:938-964, called from ieee80211_scan_sta.c:380). ieee80211_ies_expand() (ieee80211_node.c:983-1024 β€” "we don't do any validity checking of ie lengths") then sets se->se_ies.htcap_ie for any IEEE80211_ELEMID_HTCAP IE regardless of its declared length. parse_beacon's HTCAP length validation (ieee80211_input.c:733-739) only NULLs sp->htcap, which sta_add never uses.

maxrate() (ieee80211_scan_sta.c:773-810, reached from select_bss -> sta_compare whenever two candidates' RSSI are within 5) dereferences that pointer without any length check:

  • htcap->hc_mcsset[12] β€” IE offset 17
  • isclr(htcap->hc_mcsset, i) for i in [0,31] β€” offsets 5-8
  • le16dec(&htcap->hc_cap) β€” offsets 2-3

A beacon whose (TLV-well-formed) HTCAP IE declares len < 16 and is the last IE of the blob therefore makes the kernel read up to 16 bytes past the end of the heap allocation.

Reproduced (stock INVARIANTS kernel #0)

Beacon B advertises only 1/2/5.5 Mbps and ends in a 2-byte HTCAP IE (id 45, len 0) inside an exactly-64-byte blob. Beacon A advertises 54 Mbps with no HT IE. Both at rssi 30. Heap grooming places 0xff-filled chunks after B's blob:

  • groom 0xff past the allocation: B (5.5 Mbps phantom "MCS31 HT40") is selected and joined over A (54 Mbps) β€” maxrate consumed the out-of-bounds bytes as HT capabilities.
  • groom 0x00: A wins (correct).
  • valid 26-byte zero HTCAP: A wins (control).

B.blob and its 0xff neighbor were verified to differ by exactly 0x40 (64) β€” the OOB bytes ARE the neighbor chunk. Kernel-to-kernel differential on identical beacons: only the heap bytes past the allocation changed the association decision.

Threat

Unauthenticated 802.11 beacon/probe-response (attacker within radio range) during any scan; no special vap configuration. Impact: bounded heap OOB read (≀16 bytes) that steers AP selection β€” a kernel-heap content oracle with control-flow influence; no direct disclosure or panic observed.

Build / run (repo root)

sh findings/poc/DF-3363/build.sh     # builds /root/fwscan/fwscan.ko in guest
sh findings/poc/DF-3363/run.sh       # fresh vap per variant; see run*.log

Fix validated in-guest (kernel #2): see fix_validation.log and fix.diff (also covers DF-3364/DF-3365 sites).

VERDICT.md
↓ download raw

DF-3363 VERDICT

Status: reproduced (baseline, stock INVARIANTS kernel #0, DragonFly 6.5-DEVELOPMENT #0 Thu Jul 2 06:02:54 UTC 2026). Fix: validated (kernel #2, Fri Sep 11 12:44:59 UTC 2026).

How it was reproduced

The fwscan.k LD software radio (adapted from the DF-3282 harness) injects fully attacker-controlled beacons through the exact production path a driver uses: ieee80211_parse_beacon() β†’ ieee80211_add_scan() β†’ sta_add() β†’ ieee80211_ies_init() (verbatim 64-byte blob), then ss_ops->scan_end() β†’ sta_pick_bss() β†’ select_bss() β†’ ieee80211_ies_expand() β†’ match_bss() / sta_compare() β†’ maxrate() β€” the victim read.

Decisive observations (stock kernel):

  1. entry mac 02:22:22.. blob ..d240 len 64 and its groom neighbor at ..d280 β€” exactly +0x40. htcap_ie: off 62, ie_len 0 β†’ every byte maxrate reads (IE+2..IE+17 = blob[64..79]) is past the allocation.
  2. With 0xff beyond the blob: iv_bss mac 02:22:22:22:22:22 β€” the 5.5 Mbps AP beats the real 54 Mbps AP.
  3. With 0x00 beyond the blob (identical beacons): iv_bss mac 02:11:11:11:11:11. With a valid zero HTCAP: A wins.
  4. Post-fix (fix.diff, kernel #2): variant 1 β†’ A wins; the OOB-derived selection is gone; valid IE traffic (TDMA v17) still accepted.

Why it happens (path:line)

  • ieee80211_scan_sta.c:380 β€” saves raw IE blob (sp->ies, sp->ies_len).
  • ieee80211_node.c:1009-1010 β€” ies->htcap_ie = ie with no length check.
  • ieee80211_scan_sta.c:787,797,799 β€” hc_mcsset[12] (off 17), isclr(hc_mcsset, i) (off 5-8), le16dec(hc_cap) (off 2-3).
  • ieee80211_input.c:733-739 β€” parse-time HTCAP validation only NULLs sp->htcap, unused by sta_add.

Exploit chain (as far as the class permits)

Remote-unauth beacon β†’ OOB read of ≀16 heap bytes past a small kmalloc β†’ values steer sta_compare β†’ attacker-visible effect (which BSS the station joins). It is an information oracle on adjacent heap content (e.g. "is the byte at blob_end+15 nonzero"), not a disclosure channel or write primitive; no uid0 route exists for a read-only, oracle-grade primitive. Attempts: multiple groom strategies (LIFO-reuse, D-neighbor, post-spray) until deterministic.

Notes on PoC repairs vs the seed

The harness was written fresh for this finding (no seed existed). Iterations that mattered: vap creation MUST go through the ifconfig clone path (in-kernel ic_vap_create from a sysctl deadlocks β€” serializer discipline); the fake radio needs exactly one channel so ic_curchan never moves under DSPARMS; kmod opt_wlan.h generation is broken on this bsd.kmod.mk (empty file) so the TDMA/MESH defines live in the source; background scans bump se_notseen β†’ candidates are re-injected immediately before scan_end; scan_signal() asserts the ic lock is NOT held, so the lock is taken only around scan_end.

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

with fix.diff applied (in-guest nativekernel build #2) the identical PoC no longer shows OOB-dependent behavior: variant 1 now selects the correct 54 Mbps AP (A) like the controls; short HTCAP/TDMA IEs are rejected as malformed; a full-length valid TDMA IE (v17) is still accepted. fix.diff is git-apply-able against the read-only sys/ tree and was never applied there.

findings/poc/DF-3363/fix_validation.log
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #2 Fri Sep 11 12:44:59 UTC 2026 root:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

unauthenticated 802.11 beacon during scanning -> sta_add saves raw IE blob -> ies_expand exposes htcap_ie for any-length HTCAP IE -> select_bss/sta_compare -> maxrate reads hc_cap/hc_mcsset up to 17 bytes into a 2-byte trailing IE = 16 bytes past the heap allocation -> OOB bytes fabricate HT rates -> sta station joins the wrong/attacker AP. Impact ceiling: kernel-heap content oracle influencing association; no write primitive, no uid0 route for this class.

Evidence (decisive lines)

['run.log: variant differentials (0xff groom -> B joined / 0x00 -> A joined / valid-HTCAP -> A joined)', 'run.log: entry blobs at ..d240 (victim) and ..d280 (groom) differ by exactly 0x40; htcap_ie off 62 ie_len 0 -> all 18 maxrate bytes past the 64-byte allocation', 'fix_validation.log: post-fix matrix - phantom selection eliminated, controls unchanged']

PoC changes

harness written fresh (fwscan.ko software radio, adapted from the DF-3282 pattern); vap creation via the ifconfig clone path (in-kernel creation deadlocks without the wlan serializer); single-channel radio so curchan never moves; IE defines in-source (kmod opt_wlan.h generation yields an empty file); candidate re-injection before scan_end to defeat background-scan se_notseen bumps; ic lock only around scan_end (scan_signal asserts it is not held).

Verified recommended fix

validate htcap_ie length (>= sizeof(struct ieee80211_ie_htcap)-2) in maxrate(); see fix.diff (also length-validates the TDMA IE consumers and clamps tdma slotcnt).

Verdict

maxrate() dereferences se_ies.htcap_ie without checking the IE's declared length; a beacon whose trailing HTCAP IE is shorter than 16 bytes makes the kernel read up to 16 bytes past the end of the exactly-sized kmalloc(ies_len) heap blob, and the out-of-bounds bytes demonstrably steer AP selection: with 0xff groomed beyond the allocation a 5.5 Mbps phantom-'MCS31 HT40' AP beat a real 54 Mbps AP and was joined (iv_bss 02:22:22:22:22:22); with 0x00 the correct AP won. Reproduced on the stock INVARIANTS kernel with byte-level proof of adjacency (victim blob and groom chunk differ by exactly 0x40); fixed in-guest and re-verified (fix.diff: length check in maxrate; phantom selection gone, valid traffic unaffected).