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

sta_entry lifetime: three additional unlocked-use windows beyond DF-0395/0396 β€” PICK1ST match_bss after TABLE_UNLOCK, select_bss result used/joined after unlock, roam/assoc sta_lookup uses

Summary

Beyond the known DF-0395/0396 sites, sta_entry objects are used after the table lock is dropped at: (1) sta_add's PICK1ST tail - TABLE_UNLOCK at :397 then match_bss(se) at :403 runs on EVERY beacon of a PICK1ST scan whose entries keep failing match; (2) select_bss returns selbs after unlocking and sta_pick_bss/adhoc_pick_bss/mesh_pick_bss/sta_roam_check dereference it and pass &selbs->base to ieee80211_sta_join which copies the entry and its se_ies.data blob wholesale into a node; (3) roam/assoc callbacks via sta_lookup. The windows are live cross-thread: rx/ioctl hold wlan_global_serializer while the freeing contexts do not - ieee80211_node_timeout is a raw callout driving adhoc_age entry frees (table lock only) and the sw-scan state machine runs on ic_tq taskqueue threads under ic_comlock. NOT reproduced (180s two-thread race, 171k+ injections vs serializer-free flushes with junk-fill: no panic - sub-microsecond window and mapped-slab reads make runtime observation impractical); windows proven live by lock analysis. Ceiling: stale/garbage entry data steering join decisions, wholesale copy of a freed IE blob into a node, or a crash on a garbage se_chan pointer. Fix validated for window (1) (hold the lock across the PICK1ST tail); full remedy is entry refcounting.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3365 Β· 9 files
FileTypeDescriptionSize
fwscan.c β€” 29.5 KB view raw
Makefile β€” 263 B ↓ download
build.sh β€” 87 B view raw
build.log β€” 5.6 KB view raw
run.log β€” 1.7 KB view raw
README.md β€” 2.8 KB ↓ raw
fix.diff β€” 2.0 KB view raw
verdict.json β€” 3.6 KB view raw
manifest.json β€” 762 B view raw

DF-3365 β€” sta_entry has no reference count: three more unlocked-use windows beyond DF-0395/0396

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

What

struct sta_entry objects are protected only by the scan table lock. Besides the known DF-0395 (sta_lookup callers) and DF-0396 (sta_iterate callback), pass 2 found three additional sites that use an entry after dropping the table lock:

  1. sta_add() PICK1ST tail β€” the lock is released at ieee80211_scan_sta.c:397 and match_bss(vap, ss, se, ...) at :403 then reads se->se_chan, se_capinfo, se_ssid, se_ies.*, se_rates unlocked. Runs on every beacon while a IEEE80211_SCAN_PICK1ST scan is active and the entry keeps failing match (GOTPICK never sets).
  2. select_bss() return value β€” released at :1210; sta_pick_bss (:1269-1272), adhoc_pick_bss (:1658-1669), mesh_pick_bss (:1920-1923) and sta_roam_check (:1353-1366) then dereference selbs->base.se_chan/se_flags and hand &selbs->base to ieee80211_sta_join(), which copies ~the whole entry including se_ies.data/se_ies.len into a fresh node blob (ieee80211_node.c:832) β€” a freed blob would be read wholesale.
  3. sta_roam_check / sta_assoc_fail / sta_assoc_success use sta_lookup() results unlocked (the DF-0395 family, listed for completeness).

These are genuine cross-thread windows on DragonFly, not paper locks: the beacon rx path runs under wlan_global_serializer (ieee80211.c:720: ether_ifattach(ifp, mac, &wlan_global_serializer)), while the freeing contexts do not β€” ieee80211_node_timeout is a raw callout (ieee80211_node.c:2239-2264) that calls ieee80211_scan_timeout -> scan_age -> adhoc_age frees under the table lock only, and the sw-scan state machine runs on ic->ic_tq taskqueue threads under ic_comlock (ieee80211_scan_sw.c:624-699). Nothing serializes rx vs purge/join.

Verification attempt (not reproduced at runtime)

fwscan.ko raced thread A (serializer-held beacon injection loop with PICK1ST armed, rssi=1 so match always fails and the :403 tail runs on every frame) against thread B (serializer-free scan_flush loop, the same table-lock-only discipline as node_timeout). 60s run: 150,000 injections; 120s run with debug.use_weird_array=1 (kfree junk-fill 0xdeadc0de): 21,800 injections. No panic, no observable corruption β€” the window is a few hundred ns inside a multi-Β΅s path and even on a hit the reads land in mapped stale/junked slab memory.

Fix

fix.diff (validated with the DF-3363 kernel build) closes window (1) by keeping the table lock across the PICK1ST match_bss tail. The full remedy for (2) is entry reference counting (upstream-style se_refcnt); the pragmatic hardening in fix.diff documents this.

Fix verification

inconclusive
baseline no→ patch + rebuild →patched clean

the PICK1ST lock reorder shipped in fix.diff built and ran clean across the full DF-3363/3364 PoC matrix (no deadlocks, no regressions), but since the race itself never reproduced at runtime, fix efficacy for the remaining windows cannot be behaviorally demonstrated; the select_bss/join windows need refcounting (recommendation only).

findings/poc/DF-3363/fix_validation.log
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #2 Fri Sep 11 12:44:59 UTC 2026

Confirmed kernel references

Detail

Evidence (decisive lines)

['run.log: race attempts (150000 + 21800 injections survived, guest stable)', 'VERDICT.md lock analysis: wlan_global_serializer (ieee80211.c:720) vs table-lock-only callout/taskq contexts (ieee80211_node.c:2239, ieee80211_scan_sw.c:624)', 'fix.diff hunk: table lock held across the PICK1ST match_bss tail (validated in the DF-3363 fix kernel build - no regressions across the full PoC matrix)']

PoC changes

race threads built into the fwscan harness: injector under wlan_serialize (rx-path discipline) and flusher without the serializer (node_timeout discipline); PICK1ST kept armed by injecting rssi=1 beacons that always fail match so GOTPICK never sets.

Verified recommended fix

hold the table lock across the PICK1ST match_bss tail (done in fix.diff); add reference counting to struct sta_entry for the select_bss/join consumers.

Verdict

Three additional unlocked-use-after-unlock windows exist beyond the known DF-0395/0396: (a) sta_add's PICK1ST match_bss tail at ieee80211_scan_sta.c:403 runs after TABLE_UNLOCK at :397 on every beacon of a PICK1ST scan; (b) select_bss returns selbs after unlocking (:1210) and sta_pick_bss/adhoc_pick_bss/mesh_pick_bss/sta_roam_check dereference it and pass &selbs->base to ieee80211_sta_join which copies the entry and its se_ies blob wholesale; (c) roam/assoc callbacks via sta_lookup. Lock analysis proves the windows are live cross-thread on DragonFly: rx runs under wlan_global_serializer while node_timeout->adhoc_age frees and the taskqueue scan state machine run under the table/ic locks only. A 180s two-thread race (171k+ injections vs serializer-free flushes, INVARIANTS + use_weird_array junk-fill) produced no panic or visible corruption - the sub-microsecond window and mapped-slab reads make runtime demonstration impractical. Verdict: genuine UAF-read race, not reproduced.