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

NULL vap deref in scan_curchan_task: scan state not re-validated after dropping IEEE80211_LOCK across ic_set_channel

Field Value
ID DF-0587
Status new
Severity Low
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:N/I:N/A:H
CWE CWE-476 NULL Pointer Dereference
File sys/netproto/802_11/wlan/ieee80211_scan_sw.c
Lines 741-772, 890-920
Area netproto/802_11 (wlan scanning)
Confidence likely
Discovered 2026-07-02
Reported pending

Summary

scan_curchan_task releases IEEE80211_LOCK at line 741 to run driver callbacks ic_set_channel, ieee80211_radiotap_chan_change, and ic_scan_curchan (sys/netproto/802_11/wlan/ieee80211_scan_sw.c:747-760), which commonly sleep during hardware channel programming. During that window, a concurrent vap detach β€” ieee80211_scan_vdetach in scan.c β€” acquires the same lock, sets ISCAN_ABORT, and nulls ss->ss_vap and ss->ss_ops. When scan_curchan_task re-acquires the lock at :761 it never re-validates ss_vap; control flows through IEEE80211_DPRINTF(ss->ss_vap, …) (:700, :724, :775 β€” unconditional crash on IEEE80211_DEBUG kernels) and, on the aborted-scan path through scan_done, dereferences vap->iv_flags_ext (:916), vap->iv_sta_ps (:917), and ieee80211_notify_scan_done(vap) (:920) unconditionally β€” page-fault panic. The code's own comment at :763 admits it:

/* XXX scan state can change! Re-validate scan state! */

Root cause

  1. The scan borrows ss->ss_vap for the entire scan lifetime with no reference count.

  2. scan_curchan_task holds IEEE80211_LOCK from :695, releases it at :741 to run driver callbacks ic_set_channel (:747), ieee80211_radiotap_chan_change (:748), and ic_scan_curchan (:760), then re-acquires it at :761.

  3. Concurrently, vap destruction runs ieee80211_scan_vdetach (sys/netproto/802_11/wlan/scan.c:137-153): under IEEE80211_LOCK it calls swscan_vdetach (sets ISCAN_ABORT if F_SCAN was set, sys/netproto/802_11/wlan/ieee80211_scan_sw.c:160), then because ss->ss_vap == vap it calls ss->ss_ops->scan_detach, sets ss->ss_ops = NULL, and sets ss->ss_vap = NULL β€” all under the lock that scan_curchan_task is not holding between :741 and :761.

  4. After re-lock at :761 there is no NULL check on ss_vap/ss_ops:

  • :769-773 sees ISCAN_ABORT and does goto end, re-entering the loop at :696-700: IEEE80211_DPRINTF(ss->ss_vap, IEEE80211_MSG_SCAN, …) β€” ieee80211_msg(_vap) expands to (_vap)->iv_debug (sys/netproto/802_11/wlan/ieee80211_var.h), a NULL-vap deref on IEEE80211_DEBUG kernels.
  • On non-DEBUG kernels the same code at :705 routes to scan_end which at :784 takes vap = ss->ss_vap (NULL) and at :789 derefs it again in DPRINTF.
  • scan_done at :890 reads vap = ss->ss_vap (NULL) and, when scandone is true (scan had reached ss_next >= ss_last, :697), dereferences vap->iv_flags_ext (:916) / vap->iv_sta_ps (:917) / ieee80211_notify_scan_done(vap) (:920) β€” page fault.

ic_set_channel is a driver callback that commonly sleeps while programming hardware channel state, so the race window is wide and reliably hittable.

Threat model & preconditions

  • Attacker position: privileged local user. Vap destruction (SIOCIFDESTROY / network-config caps) is gated by caps_priv_check_self(SYSCAP_NONET_WIFI).
  • Privileges gained or impact: kernel panic (local DoS). No code-execution primitive: the fault is a NULL-vap dereference, and map_at_zero is off by default on DragonFlyBSD.
  • Required config or capabilities: any DragonFly system using netproto/802_11 (i.e. any wlan(4) vap with a driver). The race is triggered during ordinary operational reconfiguration (vap destroy/restart, wpa_supplicant/NetworkManager restart, driver reload, shutdown) rather than adversarial input.
  • Reachability: race a vap detach (e.g. ifconfig wlan0 destroy) against an in-progress scan (IEEE80211_IOC_SCAN_REQ).

Proof of concept

PoC source: findings/poc/DF-0587/race.c

Build & run

cc -O2 -Wall findings/poc/DF-0587/race.c -o findings/poc/DF-0587/race
sudo ./findings/poc/DF-0587/race wlan0

Expected output

Kernel panic with the faulting instruction inside scan_done (vap->iv_flags_ext deref at sys/netproto/802_11/wlan/ieee80211_scan_sw.c:916) on non-DEBUG kernels, or inside IEEE80211_DPRINTF(ss->ss_vap,…) at :700 on DEBUG kernels:

Fatal trap 12: page fault while in kernel mode
cpuid = ...; apic id = ...
fault virtual address   = 0x..
[code] scan_done+0x...: mov ...

Reproduces within seconds-to-minutes depending on driver set_channel latency.

Impact

  • Blast radius: any DragonFly system with a wlan(4) vap. The race fires during ordinary operational teardown/reconfiguration β€” including shutdown of a system with an active wireless scan β€” making this a real reliability hazard, not merely an adversarial bug.
  • Severity rationale: Low. Privileged attacker, high race complexity, impact limited to DoS (panic), no code execution.
  • Reliability: race window depends on driver set_channel latency, but that callback commonly sleeps, widening the window.

After re-acquiring IEEE80211_LOCK at scan_curchan_task:761, re-validate ss_vap/ss_ops and, if they were cleared by a concurrent vap detach, bail directly through scan_done() instead of routing through scan_end()/DPRINTF that dereference vap. Additionally harden scan_done to tolerate vap == NULL so any other path that reaches it with a cleared ss_vap cannot fault.

--- a/sys/netproto/802_11/wlan/ieee80211_scan_sw.c
+++ b/sys/netproto/802_11/wlan/ieee80211_scan_sw.c
@@ -760,6 +760,20 @@ scan_curchan_task(void *arg, int pending)
    ic->ic_scan_curchan(ss, maxdwell);
    IEEE80211_LOCK(ic);

+   /* XXX scan state can change! Re-validate scan state! */
+   /*
+    * The vap may have been detached (and ss_vap/ss_ops cleared by
+    * ieee80211_scan_vdetach) while the lock was dropped above for
+    * the driver channel-change callback.  Bail straight through
+    * scan_done() rather than dereferencing a NULL vap in the
+    * scan_end()/DPRINTF/scan_done paths.
+    */
+   if (ss->ss_vap == NULL || ss->ss_ops == NULL) {
+       ss_priv->ss_iflags &= ~ISCAN_RUNNING;
+       ss_priv->ss_iflags |= ISCAN_ABORT;
+       scan_done(ss, 1);
+       return;
+   }
+
    ss_priv->ss_chanmindwell = ticks + ss->ss_mindwell;
    /* clear mindwell lock and initial channel change flush */
    ss_priv->ss_iflags &= ~ISCAN_REP;
@@ -908,9 +922,12 @@ scan_done(struct ieee80211_scan_state *ss, int scandone)
     * the beacon indicates we have frames
     * waiting for us.
     */
-   if (scandone) {
+   /*
+    * vap may be NULL if we got here via the scan_curchan_task
+    * re-validation bail-out after the owning vap was detached.
+    */
+   if (scandone && vap != NULL) {
        if ((vap->iv_flags_ext & IEEE80211_FEXT_SCAN_OFFLOAD) == 0)
            vap->iv_sta_ps(vap, 0);
        if (ss->ss_next >= ss->ss_last)

Longer-term the right fix is to acquire a reference on ss_vap for the duration of the scan (mirroring ieee80211_node refcounts) so the borrowed pointer cannot be invalidated under the task; the guard above closes the immediate NULL-deref window with minimal change.

References

  • FreeBSD r288725 / similar scan-state-vs-teardown races in the wlan stack (historical context; DragonFly's netproto/802_11 derives from FreeBSD sys/net80211).
  • The XXX scan state can change! comment at sys/netproto/802_11/wlan/ieee80211_scan_sw.c:763 β€” original author acknowledgment of the hazard.

Timeline

  • 2026-07-02 Discovered during automated DragonFlyBSD kernel security audit.
  • 2026-07-02 Reported to DragonFlyBSD security contact (pending).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0587 Β· 14 files
FileTypeDescriptionSize
race.c trigger-source 4-thread scanner + 4-thread vap-destroy race driver; fixed include path and -lpthread 3.2 KB view raw
build.sh build-script cc -O2 -Wall race.c -o race -lpthread 239 B view raw
run.sh run-script sudo ./race <wlanN> (requires real wlan vap) 553 B view raw
VERDICT.md verdict Full code-level trace confirming bug; explanation of guest reachability limit 9.6 KB ↓ raw
fix.diff suggested-fix NULL-check ss_vap/ss_ops after re-lock at scan_curchan_task:774 + harden scan_done:931 to 'scandone && vap != NULL' 1.5 KB view raw
build.log build-log PoC build output (final successful build, including -lpthread fix) 13 B view raw
run.log run-log Decisive run on unpatched #0 baseline: PoC times out, no panic, no wlan vap 1.2 KB view raw
fix_build.log fix-build-log Full single-fix kernel build (make nativekernel, rc=0, 35707 lines) 5.6 MB ↓ download
fix_run.log fix-run-log PoC run on patched #1 kernel: same outcome (unreachable), kernel healthy 932 B view raw
dmesg.txt dmesg Baseline dmesg excerpt β€” no wlan/80211 messages, no panic 858 B view raw
env.txt environment uname, cc version, IEEE80211_DEBUG config line, wlan vap absence 475 B view raw
README.md readme human reproduce doc 1.7 KB ↓ raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme human reproduce doc
↓ download raw

DF-0587 β€” PoC: race scan_curchan_task against concurrent vap detach

Privileged local race (root required: vap destroy). Drives scan_curchan_task's unlock/re-lock window against a concurrent vap detach so that ss->ss_vap / ss->ss_ops get nulled by ieee80211_scan_vdetach while the task is unlocked inside ic_set_channel, producing a NULL-vap page fault on re-lock.

Files

  • race.c β€” minimal reproducer (4 scan-request threads + 4 vap-destroy threads).
  • (added by per-PoC verifier) build.sh, run.sh, build.log, run.log, VERDICT.md, manifest.json.

Build & run

cc -O2 -Wall race.c -o race
sudo ./race wlan0

Expected first outcome

Kernel panic (Fatal trap 12: page fault while in kernel mode) with the faulting instruction inside scan_done (vap->iv_flags_ext deref at sys/netproto/802_11/wlan/ieee80211_scan_sw.c:916) on non-DEBUG kernels, or inside IEEE80211_DPRINTF(ss->ss_vap,…) at :700 / :724 / :775 on DEBUG kernels.

Notes for the per-PoC verifier

  • Requires root (SIOCIFDESTROY is privilege-gated). This is a privileged DoS / reliability bug, not an unprivileged-escalation bug.
  • Requires a wlan(4) vap with a driver loaded; verify with ifconfig wlan0 and dmesg | grep wlan.
  • The race fires during ordinary operational teardown β€” wpa_supplicant / NetworkManager restarts, system shutdown with active scan, driver reload all hit the same path; a reproduced panic is the success criterion.
  • If destroy toggles the vap faster than the scan can arm, try inserting a short usleep between scan-request and destroy to widen the channel-change window. The race is fundamentally an unlocked ic_set_channel sleep, so any driver with a non-trivial set_channel path is a reliable target.
VERDICT.md verdict Full code-level trace confirming bug; explanation of guest reachability limit
↓ download raw

DF-0587 β€” VERDICT

Verdict: INCONCLUSIVE (runtime) β€” code-level trace CONFIRMS the bug is real. Status: not_reproduced (impact: panic if reachable) β€” bug is unreachable on this guest.

The PoC cannot trigger on the audit guest because the QEMU/KVM guest has no IEEE 802.11 radio hardware β€” there is no wlan(4) vap and no parent ieee80211com device to create one against, so the cited code path (scan_curchan_task ↔ ieee80211_scan_vdetach race) is never reached at runtime. The wlan framework is compiled into the default GENERIC kernel (options IEEE80211_DEBUG, device wlan β€” verified in sys/config/X86_64_GENERIC:255-258), so the vulnerable code is present and would be live on any DragonFly system with a real wireless adapter; it is just not exercisable on this particular VM.

A line-by-line trace of the cited source confirms the bug is genuine β€” this is classification (d) "genuinely not reachable on this guest at runtime", not a false positive.


Mechanism (code-level trace)

The finding's race is exactly as described. Every hop verified against the audited sys/ tree:

  1. scan_curchan_task (sys/netproto/802_11/wlan/ieee80211_scan_sw.c:686) acquires IEEE80211_LOCK(ic) at :695, then at :741 drops it to call driver callbacks ic_set_channel (:747), ieee80211_radiotap_chan_change (:748), and ic_scan_curchan (:760) β€” all of which commonly sleep while the radio is re-tuned. It re-acquires the lock at :761.

  2. Concurrent vap detach runs ieee80211_scan_vdetach(vap) (sys/netproto/802_11/wlan/ieee80211_scan.c:137-155) β€” verified present (the finding cites scan.c but the function actually lives in ieee80211_scan.c; the body is identical to the description). Under IEEE80211_LOCK(ic) it: - calls ic->ic_scan_methods->sc_vdetach(vap) = ieee80211_swscan_vdetach (ieee80211_scan_sw.c:151-161), which does scan_signal_locked(ss, ISCAN_ABORT) if IEEE80211_F_SCAN is set β€” setting the abort flag in ss_priv->ss_iflags (:602). - then, because ss->ss_vap == vap, calls ss->ss_ops->scan_detach(ss), sets ss->ss_ops = NULL, and sets ss->ss_vap = NULL (ieee80211_scan.c:147-153). All under the same lock the task is not holding between :741 and :761.

  3. No re-validation after re-lock. At scan_curchan_task:761 the lock is reacquired and the comment /* XXX scan state can change! Re-validate scan state! */ sits at :763 β€” but no re-validation follows. Control flows to :769 where the freshly-set ISCAN_ABORT triggers goto end (:772), looping back to the end: label at :696. The next statement is:

c IEEE80211_DPRINTF(ss->ss_vap, IEEE80211_MSG_SCAN, "%s: loop start; ...");

On IEEE80211_DEBUG kernels (which the default X86_64_GENERIC is β€” sys/config/X86_64_GENERIC:255, verified), IEEE80211_DPRINTF expands to if (((_vap)->iv_debug & (_m))) ieee80211_note(...) (sys/netproto/802_11/ieee80211_var.h:956-960). With ss->ss_vap == NULL this is a NULL page-fault β€” panic.

  1. On non-DEBUG kernels the DPFIFs at :700/:724/:775 are no-ops (ieee80211_var.h:1028: #define IEEE80211_DPRINTF(...) do { } while (0)), so the code instead falls through :705 (ISCAN_ABORT set) to scan_end(ss, scandone) (:709), which reads vap = ss->ss_vap (NULL) at :784. scan_end sees ISCAN_ABORT (:791) and routes to scan_done(ss, scandone) (:792). scan_done (:886) reads vap = ss->ss_vap (NULL) at :890, and if scandone is true (which the loop set from ss->ss_next >= ss->ss_last at :697) it dereferences vap->iv_flags_ext (:916), vap->iv_sta_ps(vap, 0) (:917), and calls ieee80211_notify_scan_done(vap) (:920) β€” page fault, panic.

The race window is wide because ic_set_channel is a driver callback that commonly sleeps while programming the radio. The bug admits NULL-deref β†’ panic only; map_at_zero is not a thing on DragonFly (vm.map_at_zero does not exist as a sysctl; the zero page is unmapped), so there is no path from this primitive to code execution. Impact ceiling = local DoS / panic. Severity Low is appropriate (privileged attacker β€” vap destroy is gated by caps_priv_check_self(SYSCAP_NONET_WIFI) β€” and a narrow race window).

Exploit chain

Not applicable β€” this is a NULL-deref / CWE-476. No memory-corruption primitive is acquired; the only effect is a kernel panic (DoS). No escalation chain to develop.

Why the PoC cannot trigger on this guest

  • The guest is QEMU/KVM with virtio-net (vtnet0) and loopback (lo0) only.
  • ifconfig wlan0 β‡’ "interface wlan0 does not exist".
  • Creating a vap requires ifconfig wlan create wlandev <parent> where <parent> is a WiFi radio driver (run0, ath0, iwm0, …). The guest has no such parent device (SIOCIFCREATE2: Device not configured).
  • All wifi driver modules in /boot/kernel/ are firmware blobs or hardware drivers that require real PCI/USB adapters; none bind to emulated QEMU HW.
  • Result: scan_curchan_task is never scheduled, ss->ss_vap never becomes non-NULL inside a real scan, the unlock/lock window never opens. The PoC's ioctl loops return ENXIO/ENODEV silently and time out.

This is a runtime reachability limitation of the audit guest, not a property of the bug. On any DragonFly system with a wlan vap (which is the default deployment target of the wlan stack β€” wpa_supplicant/NetworkManager restarts, system shutdown with active scan, vap destroy/recreate), the race fires during ordinary operational teardown.

PoC changes

  • race.c:18 β€” fixed include path <netproto/802_11/wlan/ieee80211_ioctl.h> β†’ <netproto/802_11/ieee80211_ioctl.h> (the header lives one level up from wlan/; verified at /usr/include/netproto/802_11/ieee80211_ioctl.h and /usr/src/sys/netproto/802_11/ieee80211_ioctl.h).
  • build line β€” added -lpthread (DragonFly's cc does not auto-link pthreads; without it the link fails with undefined reference to 'pthread_create').
  • build.sh, run.sh β€” added (exact runnable commands).
  • fix.diff β€” added (authored post-verification; supersedes/refines the finding markdown's ## Recommended fix proposal β€” see below).

fix.diff

Authored against the actual file (the finding's diff context did not match the on-disk layout, which has the /* XXX scan state can change! */ comment between the re-lock and ss_chanmindwell). The verified fix:

  1. scan_curchan_task:774 (after re-lock at :761) β€” add a NULL check on ss->ss_vap / ss->ss_ops. If either was cleared by a concurrent vap detach during the unlocked ic_set_channel window, clear ISCAN_RUNNING, set ISCAN_ABORT, and bail straight through scan_done(ss, 1) β€” which only manipulates ic->ic_flags and ss_priv->ss_iflags, never touching vap. This avoids the loop-top DPRINTF at :700 (DEBUG kernels) and the scan_end/scan_done vap-deref path (any kernel).
  2. scan_done:931 β€” defense-in-depth: harden if (scandone) to if (scandone && vap != NULL) so that any other path that reaches scan_done with a cleared ss_vap cannot fault at :916/:917/:920.

This matches the finding proposal in intent and structure, with the diff context corrected to apply cleanly (git apply --check passes; patch -p1 --forward applies both hunks: "Hunk #1 succeeded at 762.", "Hunk #2 succeeded at 924.").

Fix validation (Phase 8)

  • git apply --check on host: PASS.
  • Applied to in-guest /usr/src, both hunks applied cleanly.
  • Built single-fix kernel make -j6 nativekernel KERNCONF=X86_64_GENERIC β†’ rc=0, no errors (fix_build.log, 35707 lines).
  • Installed /usr/obj/.../kernel.stripped β†’ /boot/kernel/kernel (sha256 fe5df37c792efe5437521e6e60d8cf3b799661c91d2ac2454e1f29c752365ee1), booted kern.version = DragonFly 6.5-DEVELOPMENT #1: Mon Jul 13 01:25:53 UTC 2026.
  • fix_status = not_testable: empirical before/after re-test is impossible because the bug is unreachable on this guest (no WiFi hardware). Validated that the diff applies, compiles, and the patched source contains the expected guards (grep confirms 774: if (ss->ss_vap == NULL || ss->ss_ops == NULL) and 931: if (scandone && vap != NULL) in /usr/src/sys/netproto/802_11/wlan/ieee80211_scan_sw.c). The static trace shows the patched code closes the cited path.

References (verified path:line)

Fix verification

not_testable
baseline no→ patch + rebuild →patched clean

NOT_TESTABLE empirically (no WiFi HW -> bug unreachable). Fix validated statically: git apply --check PASS; nativekernel rc=0; patched source contains NULL-check guards at cited lines.

Both #0 and #1: ifconfig wlan0 -> does not exist; timeout -> RUN_EXIT=124. Empirical before/after impossible (path unreachable). Static trace confirms guards close the cited path.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Mon Jul 13 01:25:53 UTC 2026 (sha256 fe5df37c792efe5437521e6e60d8cf3b799661c91d2ac2454e1f29c752365ee1)

Confirmed kernel references

Detail

Exploit chain

none (NULL deref / CWE-476 -- no memory-corruption primitive). Impact ceiling: local DoS/panic (privileged attacker; map_at_zero doesn't exist on DragonFly).

Evidence (decisive lines)

Baseline #0: ifconfig wlan0 -> 'does not exist'; timeout 5 ./race wlan0 -> RUN_EXIT=124, guest up, no panic. Code trace: scan_curchan_task:761 re-lock has no NULL check despite XXX at :763; ieee80211_scan.c:152 sets ss->ss_vap=NULL under same lock; IEEE80211_DPRINTF(ss->ss_vap,...) at :700 panics on NULL.

PoC changes

Fixed include path and added -lpthread. Added build.sh, run.sh, fix.diff (NULL check after re-lock + scan_done guard), VERDICT.md, manifest.json, full logs.

Verified recommended fix

After re-acquiring IEEE80211_LOCK at scan_curchan_task:761, add: if (ss->ss_vap == NULL || ss->ss_ops == NULL) { scan_done(ss, 1); return; }. Plus defense-in-depth: harden scan_done from 'if (scandone)' to 'if (scandone && vap != NULL)'. Matches finding proposal in intent. Full git-apply-able diff in findings/poc/DF-0587/fix.diff.

Verdict

NOT_REPRODUCED at runtime but CODE-LEVEL TRACE CONFIRMS the bug is real (classification (d) -- unreachable on this guest, NOT false-positive). scan_curchan_task drops IEEE80211_LOCK at :741 to call driver callbacks, re-acquires at :761 with NO re-validation despite the XXX comment at :763. Concurrent vap detach nulls ss->ss_vap/ss->ss_ops. On re-lock the NULL-vap deref at DPRINTF/scan_end/scan_done panics. No WiFi hardware on guest (vtnet0/lo0 only); scan_curchan_task never scheduled. NULL-deref only (CWE-476), no escalation.