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

Node teardown dereferences the freed ieee80211vap when the last node reference outlives vap destruction: _ieee80211_free_node AID_CLR wild write + node_free ratectl/cleanup UAF

Summary

ieee80211_node_table_reset() drops a vap's table references at vap detach WITHOUT clearing ni_associd and without performing the vap-dependent cleanup; ic_vap_delete then frees the vap memory. Any node holding other references - any in-flight frame: ic_raw_xmit mgmt output, M_ENCAP data awaiting tx-complete - survives unlinked with ni_associd set and ni_vap dangling. The eventual final ieee80211_free_node() dereferences the freed vap in three places: AID_CLR reads vap->iv_aid_bitmap from freed memory and bit-clear-writes through it; ratectl_node_deinit derefs iv_ratectl; node_cleanup derefs iv_opmode/iv_ps_sta/iv_set_tim. The code's own comment warns 'the vap may be gone if the last reference was held by a driver' - but the derefs happen anyway (same upstream in FreeBSD). VERIFIED on guest (fw3 software radio with async tx-complete + spray reclaiming the freed vap with 0xb0): 'Fatal trap 9 ... Stopped at _ieee80211_free_node+0x2d: andl %esi,(%r8,%rax,4)' - the AID_CLR write through the 0xb0b0.. pointer read from freed memory; one unsprayed variant showed silent corruption. Reproduced twice from clean resets. With heap grooming the primitive is a constrained arbitrary write (clear one bit of one 32-bit word at attacker-chosen base) repeatable per association/destroy cycle; uid=0 assessed not achieved (needs a set-gadget). Any wifi driver with async tx completions (all real 11n radios): destroying/resetting a vap under load (any local user can be the traffic source). Fix validated (VAPGONE flag: full cleanup at table_reset while vap alive + late-free skips vap derefs + belt-and-braces associd clear in reclaim): byte-identical PoC passes, guest up.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3314 Β· 14 files
FileTypeDescriptionSize
fw3.c β€” 21.8 KB view raw
Makefile β€” 254 B ↓ download
build.sh β€” 87 B view raw
run.sh β€” 371 B view raw
build.log β€” 832 B view raw
run.log β€” 4.1 KB view raw
panic.txt β€” 4.7 KB view raw
panic.2.txt β€” 4.7 KB view raw
patched_run.txt β€” 5.0 KB view raw
fix.diff β€” 2.7 KB view raw
env.txt β€” 205 B view raw
README.md β€” 4.9 KB ↓ raw
VERDICT.md β€” 6.6 KB ↓ raw
verdict.json β€” 6.2 KB view raw

DF-3314 β€” node teardown dereferences the freed vap when the last node reference outlives vap destruction

File: sys/netproto/802_11/wlan/ieee80211_node.c (pass-2 audit, GLM 5.3)

What

struct ieee80211_node caches a back-pointer to its owning vap (ni->ni_vap). When a vap is destroyed, ieee80211_node_vdetach() (:203) β†’ ieee80211_node_table_reset() (:1977) drops the table reference of every node of that vap, and the driver then frees the vap memory (ic_vap_delete). Any node that still has other references survives β€” unlinked (ni_table = NULL) but alive β€” and its eventual final ieee80211_free_node() walks a free path that dereferences the freed vap in three places:

  1. _ieee80211_free_node() :1786-1790 β€” vap = ni->ni_vap; if (vap->iv_aid_bitmap != NULL) IEEE80211_AID_CLR(vap, ni_associd) β€” a pointer read from freed memory followed by a bit-clear write through it. (node_reclaim() :1909-1938 unlinks the node without clearing ni_associd; only node_leave() / node_cleanup() clear it, and neither runs for the bss node of a vap being destroyed: table_reset only does ia_node_leave + AID_CLR and leaves the field set.)
  2. node_free() :1117 β€” ieee80211_ratectl_node_deinit(ni) derefs ni->ni_vap->iv_ratectl (proved live: with defect 1 alone patched, the panic moved to node_free+0x18).
  3. node_free() :1118 β†’ node_cleanup() :1033+ β€” vap->iv_opmode (:1041), vap->iv_ps_sta-- (:1042), vap->iv_set_tim (:1082), …

The code's own comment at :1774-1779 admits the hazard ("NB: careful about referencing the vap as it may be gone if the last reference was held by a driver") β€” but the derefs happen anyway. (Same code exists upstream in FreeBSD net80211.)

Who holds the last reference

Any in-flight frame: ic_raw_xmit(ni, …) mgmt output and M_ENCAP data frames carry a node ref that the driver drops at tx-complete. On async radios (ath, iwn, …) completions lag; destroying/resetting a vap under traffic leaves the window open. The PoC emulates exactly this with a software-radio KLD whose raw_xmit parks its node references and "completes" them after ifconfig destroy.

Reproduction (stock INVARIANTS guest, kernel #0)

Software radio fw3.c (DF-3282/3286/3312 harness pattern):

  1. kldload fw3.ko; STA-mode vap up: ifconfig wlan create wlandev fw3_0 wlanmode sta ssid fw3sta mode 11g channel 1 up.
  2. sysctl kern.fw3_sta=1 β€” the vap's bss node gets ni_associd = 0xc001 (exactly what ieee80211_recv_assoc, sta.c:1683, assigns after a completed MLME association) and one+ driver-held refs are parked (in the wild: any in-flight frame).
  3. ifconfig wlan0 destroy β€” vap teardown runs; the bss node survives with ni_associd == 0xc001, ni_table == NULL; the 2640-byte vap block is kfree'd.
  4. sysctl kern.fw3_spray=1 β€” reclaim the freed vap block with 0xb0 markers (proves the stale read pulls attacker-influenced memory).
  5. sysctl kern.fw3_release=1 β€” the late "tx completions" drop the refs; the final ieee80211_free_node() executes:
fw3.release: late tx-complete of ni 0xfffff80118740400 (associd 0xc001, ni_vap 0xfffff80118718d00)
Fatal trap 9: general protection fault while in kernel mode
Stopped at      _ieee80211_free_node+0x2d:      andl    %esi,(%r8,%rax,4)

The faulting instruction is precisely IEEE80211_AID_CLR's aid_bitmap[aid/32] &= ~bit with %r8 = iv_aid_bitmap read from the freed (reused) vap. Reproduced twice from clean resets; one non-sprayed variant showed the silent form (stale pointer still mapped β†’ bit-clear write into other freed memory, no trap) β€” the UAF occurs on every run, the fault depends on reuse.

Fix (validated)

fix.diff: at vap detach (ieee80211_node_table_reset), while ni_vap is still valid, run the full vap-dependent cleanup (ic_node_cleanup + ieee80211_ratectl_node_deinit) on every surviving node and mark it IEEE80211_NODE_VAPGONE; node_free() skips those calls for marked nodes (plus a belt-and-braces associd clear in node_reclaim()). Patched kernel (#1, 15:53:57): the exact PoC completes (RELEASE_RC=0), release log shows associd 0x0, guest stays up, dmesg clean. Stock kernel: fatal trap 9.

An earlier one-line fix (associd clear only) was insufficient β€” the panic moved to node_free+0x18 (ratectl deref), which is how defects 2 and 3 were proved.

Build / run

  • sh findings/poc/DF-3314/build.sh β€” compiles the KLD in the guest (/root/fw3, make SYSDIR=/usr/src/sys).
  • Baseline: load module β†’ STA vap up β†’ sysctl kern.fw3_sta=1 β†’ ifconfig wlan0 destroy β†’ sysctl kern.fw3_spray=1 β†’ sysctl kern.fw3_release=1 β†’ expect fatal trap 9 at _ieee80211_free_node+0x2d.
  • Fix check: patch -p1 < fix.diff in guest /usr/src, make nativekernel && make installkernel, reboot, same sequence β†’ no trap, guest survives.
VERDICT.md
↓ download raw

DF-3314 VERDICT β€” reproduced (panic / kernel UAF wild write), fix validated

Status

  • reproduced: 1 β€” Fatal trap 9 (general protection fault) at _ieee80211_free_node+0x2d (andl %esi,(%r8,%rax,4) = the IEEE80211_AID_CLR write through iv_aid_bitmap read from the freed vap), stock INVARIANTS kernel #0, from a fresh vm.sh reset with-src boot. Guest down at db> after the trap (vm.sh status => down).
  • fix_status: fixed β€” with fix.diff (v2) applied in the guest and a rebuilt kernel (#1 15:53:57), the byte-identical PoC sequence completes: RELEASE_RC=0, associd 0x0 at every late completion, guest stays up, zero traps in dmesg, module unloads cleanly.

How the reproduction works

Threat model: a wifi driver whose tx completions can lag vap destruction (every async radio: mgmt output through ic_raw_xmit and M_ENCAP data frames each carry a node reference, dropped at tx-complete). The software-radio KLD (fw3.c) parks the references its raw_xmit receives and drops them on demand, i.e. a perfectly legal driver behaviour, just late.

  1. STA vap up; its bss node is given ni_associd = 1|0xc000 β€” the exact value ieee80211_recv_assoc (sta.c:1683) assigns a station that completed MLME association. This is the one artificial step: it substitutes for driving the full AUTH/ASSOC exchange with a fake AP. Everything downstream is the genuine teardown path. (The AUTH/ASSOC exchange itself was implemented and exercised during the run β€” see "negative results" β€” proving the assoc state is normally reachable over the air; only the AP side had to be faked.)
  2. ifconfig wlan0 destroy: ieee80211_vap_destroy β†’ iv_vdetach β†’ ieee80211_node_table_reset drops the table ref; the bss node survives (driver refs), ni_associd stays 0xc001; ic_vap_delete kfree's the 2640-byte vap block.
  3. Marker spray reclaims the freed vap block with 0xb0 bytes.
  4. Late tx-complete drops the last ref β†’ _ieee80211_free_node reads ni_vap->iv_aid_bitmap = 0xb0b0b0b0b0b0b0b0 (non-canonical) β†’ the bit-clear write GP-faults. Deterministic given reclamation of the freed block; without reclamation the same UAF write lands in whatever now occupies the stale target (silent corruption β€” observed once: 35 refs dropped, no trap, guest up).

Why the panic is not the whole story (primitive characterization)

  • The faulting write is aid_bitmap[word] &= ~bit where the base pointer is read from freed-and-reclaimable vap memory. An attacker who can groom the 2640-byte M_80211_VAP-class heap so the stale iv_aid_bitmap field contains a chosen value gets a constrained arbitrary write: clear one bit of one 32-bit word at base + 4*(aid/32), aid ∈ [1, iv_max_aid) β€” i.e. a small fixed offset window set by the AID the vap allocated (attacker-influenced via association order). Repeatable per association/destroy cycle.
  • Escalation to uid=0 from a bit-clear primitive was not achieved this run: candidates (credential cr_uid clear β€” wrong direction, needs bit SET; refcount zeroing β€” clear-only; pointer tag clearing β€” plausible but target-specific) all need a separate write or a set-gadget. The panic + demonstrated controlled-base write is the honest ceiling here. No KASLR/SMEP/SMAP on this guest, so the limit is the clear-only primitive, not hardening.
  • Trigger preconditions keep severity at Medium: needs (a) a vap destroy/reset while (b) frame completions are still outstanding β€” routine admin under load on async-driver hardware; not directly forceable by an unprivileged local user, though any local user can be the traffic source.

Negative results recorded during verification (valuable)

  • The "remote rates-IE heap overflow via node.c" candidate is DEAD. ieee80211_parse_beacon ends with IEEE80211_VERIFY_ELEMENT(rates, IEEE80211_RATE_MAXSIZE=15, …) (input.c:667) and every ieee80211_add_neighbor/ieee80211_init_neighbor caller checks the status (adhoc.c:697, mesh.c:1904, tdma.c:336); hostap.c:2061-2064 and sta.c:1666-1670 VERIFY_ELEMENT directly before setup_rates. Live proof on the guest: injecting a beacon with a 200-byte RATES IE into a running IBSS cell yields discard beacon frame, bad scan->rates len 200 and no overflow. DF-0581's "latent" classification stands.
  • The AUTH/ASSOC injection harness worked on hostap (associd 0xc001 assigned to the attacker node), but the hostap destroy path clears ni_associd via node_leave β†’ sta_leave β†’ node_cleanup before the last ref can drop, so the hostap variant of this UAF does not fire; the STA-vap bss node is the vulnerable case (no node_leave ever runs for it β€” node_leave KASSERTs opmode != STA).

Kernel refs

Fix narrative

  • v1 (associd clear in node_reclaim only): insufficient β€” rebuilt kernel trapped at node_free+0x18 (movq 0x30(%rax),%rax, the ratectl ni_vap deref). This usefully proved defects 2/3.
  • v2 (fix.diff, validated): IEEE80211_NODE_VAPGONE flag; full vap-dependent cleanup at table_reset while the vap is alive; node_free skips cleanup for flagged nodes. Exact PoC passes; guest survives; module unloads. Recommend upstream also audits ic_node_cleanup callers-under-lock semantics for HT nodes (the PoC's node was legacy; node_cleanup is now additionally invoked under IEEE80211_NODE_LOCK from table_reset).

Guest

  • baseline: DragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 (X86_64_GENERIC) β€” stock INVARIANTS.
  • patched: DragonFly 6.5-DEVELOPMENT #1: Thu Sep 10 15:53:57 UTC 2026 (fix.diff applied in-guest only; host sys/ untouched).
  • guest left in clean state (vm.sh reset with-src, status up).

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

fix.diff v2 applied in guest /usr/src, make nativekernel + installkernel + reboot (#1 15:53:57); byte-identical PoC re-run: RELEASE_RC=0, associd cleared at detach (0x0 in release log), guest up, dmesg trap-free, module unloads. Baseline on stock #0 traps at _ieee80211_free_node+0x2d. v1 associd-only fix insufficient (trap moved to node_free+0x18 ratectl deref) -- documented in VERDICT.md.

findings/poc/DF-3314/patched_run.txt; findings/poc/DF-3314/panic.txt; findings/poc/DF-3314/VERDICT.md (fix narrative incl. v1 failure)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Thu Sep 10 15:53:57 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

STA vap associated (associd set at sta.c:1683) + driver-held node ref in flight -> ifconfig destroy: table_reset drops table ref, node survives unlinked with ni_associd intact, vap kfree'd -> late tx-complete -> ieee80211_free_node -> _ieee80211_free_node reads iv_aid_bitmap from freed vap -> bit-clear write through stale/reclaimed pointer -> fatal trap (or silent corruption of whatever occupies the stale bitmap target). Grooming the freed vap block yields a constrained arbitrary bit-clear write; full uid=0 chain not developed (clear-only primitive).

Evidence (decisive lines)

["panic.txt: 'Fatal trap 9 ... Stopped at _ieee80211_free_node+0x2d: andl %esi,(%r8,%rax,4)' (baseline run 1, with 0xb0 spray of the freed vap block)", 'panic.2.txt: same trap/IP reproduced from a fresh vm.sh reset with-src (deterministic spray variant)', "run.log: full command sequence incl. the negative-evidence rates-IE run ('discard beacon frame, bad scan->rates len 200') and the fw3.sta/spray/release transcript (associd 0xc001, ni_vap freed address)", 'patched_run.txt: fix-v2 kernel, exact PoC, RELEASE_RC=0, associd 0x0 at every late completion, guest up', 'fix.diff: NODE_VAPGONE fix (node.c + node.h), applied in-guest only', 'fw3.c: software-radio KLD (beacon injector, AUTH/ASSOC injector, async-driver ref parking, exact-reclaim spray)']

PoC changes

Seed concept (from pass-2 analysis) was a remote rates-IE overflow via init_neighbor; live testing killed it (parse_beacon VERIFY_ELEMENT guard) and the effort pivoted to the vap-lifetime UAF, which static pass-2 analysis had flagged from the code's own hazard comment. fw3.c written from scratch on the DF-3282/3312 harness pattern; the associd state is set directly on the bss node (substitutes for a full MLME dance with a fake AP; the AUTH/ASSOC injector was also built and used to prove over-the-air associability on hostap). An associd-only fix was tried first and failed (trap moved to node_free+0x18), driving the NODE_VAPGONE v2 fix.

Verified recommended fix

At vap detach, run all vap-dependent node cleanup while ni_vap is still valid (ic_node_cleanup + ratectl deinit in ieee80211_node_table_reset) and mark nodes IEEE80211_NODE_VAPGONE so node_free() skips those calls; clear ni_associd in node_reclaim's unlink branch (fix.diff).

Verdict

Use-after-free of the ieee80211vap when the last node reference outlives vap destruction: ieee80211_node_table_reset() (node.c:1977) unlinks a vap's nodes without clearing ni_associd or performing vap-dependent cleanup, the vap memory is then freed by ic_vap_delete, and the eventual final ieee80211_free_node() dereferences ni->ni_vap in three places (_ieee80211_free_node:1786 AID_CLR bit-clear write through a pointer read from the freed vap; node_free:1117 ratectl deinit; node_cleanup:1041-1082). Reproduced deterministically on the stock INVARIANTS guest with a software-radio KLD emulating an async driver whose tx completions arrive after ifconfig destroy: 'Fatal trap 9 ... Stopped at _ieee80211_free_node+0x2d: andl %esi,(%r8,%rax,4)' -- exactly the AID_CLR write, with the base pointer read from marker-sprayed (0xb0) freed vap memory. A one-line associd-only fix moved the trap to node_free+0x18 (ratectl deref), proving the broader free-path deref family; the validated fix.diff (NODE_VAPGONE flag + full cleanup at table_reset while the vap is alive) makes the byte-identical PoC pass cleanly (RELEASE_RC=0, guest up, no traps). Primitive beyond the panic: a clear-one-bit-of-one-32bit-word write at a small attacker-influenced offset through an attacker-groomable base pointer (freed 2640-byte vap block is reclaimable); uid=0 escalation from a clear-only write was assessed and not achieved this run. Precondition (vap destroy/reset racing outstanding tx completions -- routine admin under load on async-driver hardware) keeps severity at Medium.