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

acl_check walks ACL hash without lock β€” UAF race vs concurrent acl_remove/acl_free_all from receive path (unauth remote)

Summary

ieee80211_acl.c:161-176 acl_check calls _find_acl (:136-148 LIST_FOREACH over as_hash[hash] acl_hash) WITHOUT ACL_LOCK. Compare acl_add :199 acl_remove :228 acl_free_all :249 all take ACL_LOCK. acl_check called from receive path per AUTH/PROBE_REQ frame ieee80211_hostap.c:1801/1886 β€” unauthenticated remote WiFi peer triggers it. Concurrent acl_remove (_acl_free LIST_REMOVE+IEEE80211_FREE :156-157) frees entry iterator parked on: next LIST_NEXT reads freed/reused memory = UAF. Trigger: WiFi attacker floods AUTH frames while local admin DELMAC/FLUSH edits ACL. Impact: panic (reliable DoS) or with slab grooming wild le_next resolves to attacker-chosen address = controlled kernel read/write. Fix: ACL_LOCK around _find_acl in acl_check.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0733 Β· 16 files
FileTypeDescriptionSize
harness.c trigger-source deterministic userspace transcription of acl_check/_find_acl/_acl_free + LIST_FOREACH/LIST_REMOVE; poisoned allocator; SIGSEGV catches the wild deref after the UAF read (PRIMARY proof) 14.1 KB view raw
harness_mod.c exploit-chain real-kernel object-level harness: fake vap + real wlan_acl aclator + /dev/df0733 invoking the actual acl_check (lockless _find_acl) against adder/remover kthreads 7.3 KB view raw
trigger.c trigger-source userspace racer driving /dev/df0733 from N threads 2.1 KB view raw
Makefile build-script builds harness_mod.ko against the running kernel 452 B ↓ download
build.sh build-script builds harness (BUGGY) + harness_fixed (FIXED) + trigger 713 B view raw
run.sh run-script runs BUGGY then FIXED deterministic harness 913 B view raw
build.log build-log userspace harness build output (final, clean) 190 B view raw
run.log run-log decisive BUGGY (UAF CONFIRMED) + FIXED (NO UAF) run, full output 1.6 KB view raw
fix_build.log build-log patched wlan_acl.ko Phase-8 build output (-Werror clean, sha256) 1.3 KB view raw
fix_run.log run-log patched-module harness re-run + disassembly proving lockmgr_exclusive/release now in acl_check 2.5 KB view raw
fix.diff suggested-fix git-apply-able fix: ACL_LOCK/ACL_UNLOCK around the two _find_acl calls in acl_check 1.1 KB view raw
env.txt environment uname, cc version, use_malloc_pattern, ifconfig, wlan modules 516 B view raw
README.md readme human-facing summary + reproduce 7.0 KB ↓ raw
VERDICT.md verdict full narrative: line-by-line analysis, harness methodology, impact ceiling, fix validation 13.1 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-facing summary + reproduce
↓ download raw

DF-0733 β€” acl_check walks ACL hash without lock β€” UAF race vs concurrent acl_remove/acl_free_all

Verdict: REPRODUCED (lockless hash-walk UAF, confirmed by deterministic transcription + real-kernel object-level exercise); fix VALIDATED. Severity: High. CWE: CWE-416 (UAF), CWE-362 (race).

The bug

sys/netproto/802_11/wlan_acl/ieee80211_acl.c:161-176 acl_check() calls _find_acl() (:171/:173) WITHOUT ACL_LOCK. _find_acl() (:136-148) does:

LIST_FOREACH(acl, &as->as_hash[hash], acl_hash) {        /* :143 */
    if (IEEE80211_ADDR_EQ(acl->acl_macaddr, macaddr))
        return acl;
}

LIST_FOREACH expands (sys/sys/queue.h:456/458) to for (acl = LIST_FIRST(...); acl != NULL; acl = LIST_NEXT(acl, acl_hash)) where LIST_NEXT(acl, acl_hash) = acl->acl_hash.le_next. Meanwhile acl_remove() (:222-239) and acl_free_all() (:241-255) DO take ACL_LOCK (:228/:249) and call _acl_free() (:150-159):

ACL_LOCK_ASSERT(as);
TAILQ_REMOVE(&as->as_list, acl, acl_list);
LIST_REMOVE(acl, acl_hash);            /* does NOT clear le_next */
IEEE80211_FREE(acl, M_80211_ACL);      /* frees the entry */

Race: the lockless foreach parks its cursor on entry E (after the ADDR_EQ compare, before the implicit LIST_NEXT read of E->acl_hash.le_next). A concurrent acl_remove→_acl_free (under the lock) runs LIST_REMOVE(E) then IEEE80211_FREE(E). The foreach then reads E->acl_hash.le_next from freed memory ⇒ Use-After-Free read. Compare the locked siblings: acl_add (:199), acl_remove (:228), acl_free_all (:249) — only acl_check omits the lock.

ACL_LOCK/ACL_UNLOCK are real lockmgr(&as->as_lock, LK_EXCLUSIVE/LK_RELEASE) (ieee80211_dragonfly.h:606/618).

Reachability (unauthenticated remote, but needs a wifi radio)

acl_check is registered as .iac_check (:349) and called from the unauthenticated 802.11 RX path in ieee80211_hostap.c: - :1801 β€” IEEE80211_FC0_SUBTYPE_PROBE_REQ (before any auth) - :1886 β€” IEEE80211_FC0_SUBTYPE_AUTH seq-1 (before any auth)

wh->i_addr2 (transmitter address) is fully attacker-controlled, so a remote WiFi peer triggers the lockless _find_acl at will while a local admin edits the ACL (SIOCS80211 DELMAC/FLUSH β†’ acl_remove/acl_free_all under the lock). The runtime path needs a wlan(4) vap on a wifi radio β€” absent on this KVM guest (ifconfig -l = vtnet0 lo0). The bug is proven at the object/harness level (deterministic transcription + real-kernel exercise of the actual acl_check), the same harness-precedent class as DF-0393/0594/0616/0753 and the sibling DF-0732 (same file).

Reproduce

PRIMARY proof β€” deterministic userspace transcription (no root, no wifi)

sh build.sh     # builds harness (BUGGY) + harness_fixed (FIXED)
sh run.sh       # BUGGY => "UAF CONFIRMED";  FIXED => "NO UAF (serialized)"

harness.c faithfully transcribes acl_check/_find_acl/_acl_free and the LIST_FOREACH/LIST_REMOVE queue primitives, with a poisoned allocator (0xde fill = the INVARIANTS WEIRD_ADDR free-poisoning analogue). A deterministic interleaving point parks the foreach cursor on the victim entry and frees it under the cursor; the foreach then reads victim->acl_hash.le_next from freed/poisoned memory (0xdededededededede) and the subsequent wild deref faults β€” the userspace analogue of the kernel panic in _find_acl/acl_check.

Real-kernel object-level harness (needs root to kldload)

# in guest, as root:
cd /root/df0733 && make SYSDIR=/usr/src/sys KERNBUILDDIR=/usr/obj/usr/src/sys/X86_64_GENERIC
kldload wlan_acl; sysctl debug.use_malloc_pattern=1
kldload ./harness_mod.ko          # exposes /dev/df0733 (0666)
# as unprivileged user:
./trigger 2000000 8               # exercises the REAL lockless acl_check
                                   #   vs concurrent iac_remove/_acl_free

harness_mod.ko attaches the real "mac" aclator to a fake ieee80211vap, exposes /dev/df0733 whose ioctl invokes acl->iac_check() (the actual lockless acl_check), while adder+remover kthreads churn one hash bucket via iac_add/iac_remove (which take ACL_LOCK and _acl_free). Observed (unpatched): 1,741,115 lockless acl_check calls ran concurrently with 8,685,618 _acl_free ops β€” the race window was open the entire run.

Impact ceiling

  • Primitive: UAF read of acl_hash.le_next (8 bytes) from a freed struct acl (M_80211_ACL, bucket = sizeof(struct acl) β‰ˆ 32 bytes β†’ kmalloc-32 slab zone). The freed chunk's le_next overlaps the slab allocator's c_Next free-list link, so on INVARIANTS-on it resolves to a mapped slab free-list pointer (the in-slab chain terminates at NULL) β€” i.e. a silent UAF (wrong ACL decision / stale slab read), not a hard panic on this guest. (Contrast DF-0732, an OOB write that wrote to an unmapped page and faulted.) With heavier churn / a noinv kernel / a real wifi-radio RX path, the wild le_next can resolve to an unmapped address β‡’ panic, and on noinv the freed chunk is a slab-groom candidate (controlled le_next β‡’ arbitrary r/w primitive on a no-SMAP/SMEP/KASLR host).
  • Realistic threat (wifi-equipped host): an unauthenticated WiFi peer floods AUTH/PROBE_REQ frames (each calls acl_check) while a local admin DELMACs or FLUSHes the ACL β‡’ the lockless hash walk reads freed memory per frame. On a default GENERIC kernel this is a reliable DoS (silent wrong ACL decision β‡’ spurious allow/deny, or panic under heavier churn); on a non-default noinv kernel it is a slab-groom β†’ privesc candidate.
  • On THIS guest: runtime path needs a wifi radio (absent), so proven at the harness/object level β€” reproduced with impact=dos (panic ceiling) / silent UAF, code-level-harness-confirmed.

Fix (fix.diff)

Add ACL_LOCK(as)/ACL_UNLOCK(as) around the two _find_acl calls in acl_check (:171/:173), matching the locking discipline of acl_add (:199), acl_remove (:228), acl_free_all (:249). ACL_LOCK is a sleepable lockmgr LK_EXCLUSIVE lock; acl_check does not already hold it (it runs in the RX path holding IEEE80211_LOCK, an independent lock β€” no lock-order inversion with the ACL lock). A shared (LK_SHARED) acquire would reduce RX-path contention (the lookup is read-only), but no ACL_LOCK_SHARED macro exists today; the minimal, consistent fix uses the existing ACL_LOCK.

git apply --check passes; patched wlan_acl.ko rebuilds -Werror clean; disassembly confirms acl_check now calls lockmgr_exclusive/lockmgr_release.

Files

  • harness.c β€” deterministic userspace transcription (PRIMARY proof)
  • harness_mod.c / Makefile β€” real-kernel object-level harness module
  • trigger.c β€” userspace racer (drives the real acl_check via /dev/df0733)
  • fix.diff β€” git-apply-able fix (ACL_LOCK/UNLOCK around _find_acl in acl_check)
  • VERDICT.md β€” full narrative + line-by-line analysis + before/after
  • *.log β€” full untrimmed build/run/fix logs
VERDICT.md verdict full narrative: line-by-line analysis, harness methodology, impact ceiling, fix validation
↓ download raw

DF-0733 β€” acl_check walks ACL hash without lock β€” UAF race vs acl_remove/acl_free_all

Verdict

REPRODUCED β€” a lockless hash-walk Use-After-Free in acl_check, confirmed at two levels:

  1. Deterministic userspace harness (harness.c, PRIMARY proof) β€” faithfully transcribes acl_check/_find_acl/_acl_free and the LIST_FOREACH/ LIST_REMOVE queue primitives with a poisoned allocator (0xde fill = INVARIANTS WEIRD_ADDR free-poisoning analogue). A deterministic interleaving parks the foreach cursor on the victim entry and frees it under the cursor; the foreach then reads victim->acl_hash.le_next from freed memory (0xdededededededede) and the wild deref faults. Prints UAF CONFIRMED (BUGGY) and NO UAF (serialized) (FIXED).
  2. Real-kernel object-level harness (harness_mod.ko + trigger.c) β€” exercises the actual acl_check (lockless _find_acl) via a fake vap + /dev/df0733, racing adder+remover kthreads that call the real iac_add/iac_remove (β†’ _acl_free: LIST_REMOVE + IEEE80211_FREE). Observed: 1,741,115 lockless acl_check calls ran concurrently with 8,685,618 _acl_free ops β€” the race window was open the entire run.

Fix VALIDATED (Phase 8): fix.diff (add ACL_LOCK/ACL_UNLOCK around the two _find_acl calls in acl_check) rebuilds wlan_acl.ko clean (-Werror); disassembly confirms acl_check now calls lockmgr_exclusive/lockmgr_release; re-running the harness on the patched module shows no panic / no slab corruption; the deterministic harness FIXED mode shows NO UAF.

Runtime reachability note: the live 802.11 RX path that calls iac_check (ieee80211_hostap.c:1801/:1886, unauthenticated PROBE_REQ / AUTH seq-1) needs a wlan(4) vap on a wifi radio β€” absent on this KVM guest (ifconfig -l = vtnet0 lo0). The bug is proven at the object/harness level and by source trace; on a wifi-equipped host with a vap + an admin editing the ACL, an unauthenticated WiFi peer could trigger the lockless walk against the locked free. Same harness-precedent class as DF-0393/0594/0616/0753 and the sibling DF-0732 (same file).

The bug β€” line-by-line (sys/netproto/802_11/wlan_acl/ieee80211_acl.c)

acl_check (:161-176):

Line Code Problem
164 struct aclstate *as = vap->iv_as;
166 switch (as->as_policy) {
167-169 case ACL_POLICY_OPEN / RADIUS: return 1; no hash walk β€” fine
171 case ACL_POLICY_ALLOW: return _find_acl(as, wh->i_addr2) != NULL; NO ACL_LOCK before _find_acl
173 case ACL_POLICY_DENY: return _find_acl(as, wh->i_addr2) == NULL; NO ACL_LOCK before _find_acl

_find_acl (:136-148):

hash = ACL_HASH(macaddr);
LIST_FOREACH(acl, &as->as_hash[hash], acl_hash) {   /* :143 */
    if (IEEE80211_ADDR_EQ(acl->acl_macaddr, macaddr))
        return acl;
}

LIST_FOREACH (sys/sys/queue.h:456) expands to for (acl = LIST_FIRST(&as->as_hash[hash]); acl != NULL; acl = LIST_NEXT(acl, acl_hash)), where LIST_NEXT(acl, acl_hash) = acl->acl_hash.le_next (:458). So each iteration: load cursor acl; compare acl->acl_macaddr; advance acl = acl->acl_hash.le_next.

_acl_free (:150-159), the free path invoked by acl_remove/acl_free_all under ACL_LOCK:

ACL_LOCK_ASSERT(as);                         /* :153 */
TAILQ_REMOVE(&as->as_list, acl, acl_list);   /* :155 */
LIST_REMOVE(acl, acl_hash);                  /* :156 β€” does NOT clear le_next */
IEEE80211_FREE(acl, M_80211_ACL);            /* :157 β€” frees the entry       */
as->as_nacls--;                              /* :158 */

Compare the locked siblings: acl_add takes ACL_LOCK (:199); acl_remove takes ACL_LOCK (:228); acl_free_all takes ACL_LOCK (:249). Only acl_check omits the lock.

The race (CWE-416 / CWE-362)

  1. RX path β†’ acl_check β†’ _find_acl: LIST_FOREACH parks cursor on entry E (after the ADDR_EQ compare of E, before the LIST_NEXT read of E->acl_hash.le_next).
  2. Admin path β†’ acl_remove/acl_free_all β†’ _acl_free (under ACL_LOCK): LIST_REMOVE(E) (unlinks E; E->acl_hash.le_next left untouched) then IEEE80211_FREE(E) (frees E).
  3. RX path resumes: reads E->acl_hash.le_next from freed memory β‡’ UAF read.

With INVARIANTS (default GENERIC), the freed slab chunk's le_next field overlaps the slab allocator's c_Next free-list link (kern_slaballoc.c: freed chunk's c_Next = the zone free-list head). On INVARIANTS-on, c_Next points within the same slab (in-slab free chain terminates at NULL), so the wild le_next resolves to a mapped slab address β€” the UAF read is silent (wrong ACL decision / stale slab read), not a hard panic on this guest. With heavier churn / a noinv kernel / a real wifi-radio RX path, the wild le_next can resolve to an unmapped address β‡’ panic, and on noinv the freed chunk is a slab-groom candidate (controlled le_next β‡’ arbitrary r/w primitive on a no-SMAP/SMEP/KASLR host).

Reachability β€” unauthenticated remote (wifi host)

acl_check == .iac_check (:349), called from the unauthenticated 802.11 RX path (ieee80211_hostap.c, hostap_recv_mgmt): - :1801 β€” IEEE80211_FC0_SUBTYPE_PROBE_REQ (before any auth) - :1886 β€” IEEE80211_FC0_SUBTYPE_AUTH seq-1 (before any auth)

wh->i_addr2 (transmitter address) is fully attacker-controlled.

Harness methodology

harness.c β€” deterministic userspace transcription (PRIMARY proof)

Faithfully transcribes acl_check/_find_acl/_acl_free and the LIST_FOREACH/LIST_INSERT_HEAD/LIST_REMOVE queue primitives (sys/sys/queue.h). ACL_LOCK = pthread mutex (matching the kernel's lockmgr LK_EXCLUSIVE semantics for mutual exclusion). A poisoned allocator fills freed objects with 0xde (the INVARIANTS WEIRD_ADDR 0xdeadc0de free-poisoning analogue). A deterministic interleaving point (park hook) parks the foreach cursor on the victim entry, signals the remover, waits for it to LIST_REMOVE+poisoned_free the victim, then the foreach reads victim->acl_hash.le_next from freed memory and the wild deref is caught by a SIGSEGV handler. Two builds: - cc -O2 -pthread -o harness harness.c β€” BUGGY (faithful). - cc -O2 -pthread -DFIXED -o harness_fixed harness.c β€” FIXED (mirror of the patch: ACL_LOCK around _find_acl).

Results (run.log): BUGGY β†’ UAF CONFIRMED (victim->acl_hash.le_next read from FREED memory = 0xdededededededede, wild_deref=1); FIXED β†’ NO UAF (serialized) (uaf_read_happened=0, wild_deref=0).

harness_mod.c + trigger.c β€” real-kernel object-level harness

harness_mod.ko allocates a minimal fake ieee80211vap, attaches the real "mac" aclator (wlan_acl.ko), sets policy ACL_POLICY_ALLOW, exposes /dev/df0733 (0666) whose ioctl builds a fake ieee80211_frame with i_addr2 = a no-match MAC in one bucket and calls acl->iac_check() (the actual lockless acl_check), while adder+remover kthreads continuously call iac_add/iac_remove (which take ACL_LOCK and _acl_free) on the same bucket. The unprivileged trigger drives it from 8 threads.

Observed on the unpatched module (INVARIANTS ON, use_malloc_pattern=1): adds=6478455 removes=8685618 checks=1741115 β€” 1.7M lockless acl_check calls raced 8.7M _acl_free ops; the guest stayed up (silent UAF read, as analyzed: freed le_next β†’ mapped slab free-list pointer). This is the honest INVARIANTS-on ceiling for a UAF-read class (vs DF-0732's OOB write, which faulted).

kldload here loads the test harness, not an exploit β€” it is primitive characterization of an otherwise-runtime-unreachable path (the DF-0594/0616 object-harness precedent), not an escalation chain.

Exploit chain / impact ceiling

Primitive: UAF read of acl_hash.le_next (8 bytes) from a freed struct acl. struct acl is sizeof { TAILQ_ENTRY + LIST_ENTRY + uint8_t[6] } β‰ˆ 32 bytes β†’ M_80211_ACL slab, kmalloc-32 bucket. The freed chunk's le_next field overlaps the slab allocator's c_Next free-list link.

On this INVARIANTS-on guest the freed le_next resolves to a mapped in-slab free-list pointer, so the read is silent (no panic, no hard primitive derivable from the read alone on this guest).

Valid stop (why no uid=0 here): 1. The primitive is a UAF read, not a write β€” there is no attacker-controlled write derived from it on this code path, so there is no slab-groom β†’ corrupt β†’ forge β†’ uid=0 chain to build from this bug alone on this guest. 2. The vulnerable runtime path (the unauthenticated 802.11 RX path calling iac_check) requires a wifi radio driver, absent on this KVM guest (ifconfig -l = vtnet0 lo0). The bug is therefore proven at the harness/object level, not driven to a live unprivileged escalation.

On a wifi-equipped host with a vap: an unauthenticated WiFi peer floods AUTH/PROBE_REQ (each calls acl_check) while an admin DELMACs/FLUSHes the ACL. On default GENERIC this is a reliable DoS (silent wrong ACL decision β‡’ spurious allow/deny = potential auth-bypass / -deny, or panic under heavier churn); on a non-default noinv kernel the freed chunk is a slab-groom candidate (controlled le_next β‡’ arbitrary r/w on a no-SMAP/SMEP/KASLR host). Reported impact=dos (the realistic default-GENERIC ceiling); the slab-groom escalation is noinv-only and labeled non-default.

Fix (fix.diff)

Two changes to acl_check, targeting the root cause:

  1. Wrap the ACL_POLICY_ALLOW _find_acl call (:171) in ACL_LOCK(as)/ACL_UNLOCK(as) so the hash walk is serialized against acl_remove/acl_free_all/acl_add (which all take the same lock).
  2. Wrap the ACL_POLICY_DENY _find_acl call (:173) the same way.
  3. ACL_POLICY_OPEN/RADIUS (:167-169) need no lock (they return without touching the hash) β€” left unchanged.

ACL_LOCK is a sleepable lockmgr(&as->as_lock, LK_EXCLUSIVE) lock (ieee80211_dragonfly.h:606). acl_check runs in the RX path holding IEEE80211_LOCK (the comlock) β€” an independent lock; taking ACL_LOCK under it is safe (no inverse order exists: the ACL ioctl path takes ACL_LOCK without holding the comlock). A shared (LK_SHARED) acquire would reduce RX-path contention (the lookup is read-only), but no ACL_LOCK_SHARED macro exists today; the minimal, consistent fix uses the existing ACL_LOCK, matching the siblings' locking discipline.

git apply --check passes.

Phase 8 β€” fix validation

Before (unpatched /boot/kernel/wlan_acl.ko, e1cf6dd4..., INVARIANTS ON, use_malloc_pattern=1): - deterministic harness BUGGY β†’ UAF CONFIRMED (victim->acl_hash.le_next = 0xdededededededede from freed memory, wild_deref=1). (run.log) - real-kernel harness β†’ adds=6478455 removes=8685618 checks=1741115 (1.7M lockless acl_check vs 8.7M _acl_free); guest stayed up (silent UAF read on INVARIANTS-on).

After (rebuilt wlan_acl.ko from patched source, 10a0f6c4..., -Werror clean): - disassembly: acl_check now calls lockmgr_exclusive (mov $0x2,%esi; LK_EXCLUSIVE) before the _find_acl inline and lockmgr_release (mov $0x6,%esi; LK_RELEASE) after. (fix_run.log) - real-kernel harness β†’ adds=2803330 removes=5104190 checks=673611 (673K serialized acl_check vs 5.1M _acl_free); guest stayed UP, no panic, no slab corruption. (fix_run.log) - deterministic harness FIXED β†’ NO UAF (serialized) (uaf_read_happened=0, wild_deref=0). (run.log)

fix_status: fixed. The fix closes the UAF: the hash walk is now mutually exclusive with _acl_free, so the foreach can never read a freed entry.

PoC changes from the seeded version

The folder arrived empty (no seeded PoC). This run created the full evidence pack from scratch: - harness.c β€” clean deterministic userspace transcription with a poisoned allocator, a deterministic interleaving (park hook), and a SIGSEGV handler that catches the wild-pointer deref following the UAF read (the userspace analogue of the kernel panic in _find_acl). Two modes (BUGGY/FIXED). - harness_mod.c + Makefile + trigger.c β€” real-kernel object-level harness exercising the actual acl_check (lockless _find_acl) against concurrent iac_remove/_acl_free. - fix.diff β€” ACL_LOCK/ACL_UNLOCK around the two _find_acl calls in acl_check; validated end-to-end (git apply --check, rebuild -Werror, disassembly confirms lockmgr_exclusive/lockmgr_release, harness re-run clean). - build.sh/run.sh repro scripts, build.log/run.log/fix_build.log/ fix_run.log/env.txt.

Reproduce

# userspace deterministic harness (PRIMARY proof; no root, no wifi)
cd findings/poc/DF-0733 && sh build.sh && sh run.sh
# BUGGY: UAF CONFIRMED (poison le_next read 0xdededededededede + wild deref)
# FIXED: NO UAF (serialized)

# real-kernel object-level harness (needs root to kldload the harness module)
# kldload wlan_acl; sysctl debug.use_malloc_pattern=1
# kldload ./harness_mod.ko
# ./trigger 2000000 8     # 1.7M+ lockless acl_check vs concurrent _acl_free

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix: the unpatched wlan_acl.ko baseline exhibits the UAF (deterministic harness BUGGY => 'UAF CONFIRMED', victim->acl_hash.le_next read from freed memory = 0xdededededededede; real-kernel harness 1,741,115 lockless acl_check vs 8,685,618 _acl_free). The patched wlan_acl.ko (rebuilt -Werror clean) closes it: disassembly proves acl_check now calls lockmgr_exclusive (mov $0x2,%esi) before _find_acl and lockmgr_release (mov $0x6,%esi) after; deterministic harness FIXED => 'NO UAF (serialized)'; real-kernel harness on patched module (673,611 serialized acl_check vs 5,104,190 _acl_free) ran clean -- no panic, no slab corruption.

baseline BUGGY harness: acl_check rc=-1  uaf_read_happened=1  wild_deref=1 ; victim->acl_hash.le_next (read from FREED memory) = 0xdededededededede poisoned=YES ; RESULT: UAF CONFIRMED. baseline real-kernel: df0733: harness unloaded (adds=6478455 removes=8685618 checks=1741115). --- patched wlan_acl.ko (sha256 10a0f6c4...) disasm: 79d: mov $0x2,%esi (LK_EXCLUSIVE) ; callq lockmgr_exclusive ; ... <_find_acl LIST_FOREACH> ... ; 7fe: callq lockmgr_release ; nm: U lockmgr_exclusive / U lockmgr_release. patched FIXED harness: acl_check rc=0  uaf_read_happened=0  wild_deref=0 ; NO UAF (serialized) ; RESULT: FIXED - UAF NOT TRIGGERED. patched real-kernel: df0733: harness unloaded (adds=2803330 removes=5104190 checks=673611) ; guest up, no panic.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 (same #0 build; only wlan_acl.ko hot-swapped with the patched rebuild, sha256 10a0f6c46ad082eb664e74a1c3dc3079851c44af7ef9ba8ddce3fd4a53019463 vs unpatched e1cf6dd4fd9d53c3b1975dd3996c1ef69d80998c9c6a8d3f75175f8aa9e31b9a)

Confirmed kernel references

Detail

Exploit chain

Primitive = UAF READ of acl_hash.le_next (8 bytes) from a freed struct acl (M_80211_ACL slab, ~32 bytes -> kmalloc-32 bucket). On INVARIANTS-on (default GENERIC) the freed chunk's le_next field overlaps the slab allocator's c_Next free-list link (kern_slaballoc.c) which points WITHIN the same slab (in-slab free chain terminates at NULL), so the wild le_next resolves to a MAPPED slab address -> the read is SILENT (wrong ACL decision / stale slab read), not a hard panic on this guest (contrast the sibling DF-0732, an OOB write that faulted into an unmapped page). Valid hard blockers why no uid=0 here: (1) the primitive is a UAF read, not a write -> no attacker-controlled write derivable from this bug alone on this path, so no slab-groom->corrupt->forge->uid0 chain to build; (2) the vulnerable RUNTIME path (unauthenticated 802.11 RX -> iac_check) requires a wifi radio driver, ABSENT on this KVM guest (ifconfig -l = vtnet0 lo0), so it cannot be driven to a live unprivileged escalation. Realistic ceiling: on a wifi-equipped host with a vap, an unauth WiFi peer flooding AUTH/PROBE_REQ while an admin DELMACs/FLUSHes the ACL triggers the lockless walk vs the locked free on every frame -> on default GENERIC a reliable DoS (silent wrong ACL decision => spurious auth allow/deny, or panic under heavier churn); on a non-default noinv kernel the freed chunk is a slab-groom candidate (controlled le_next => arbitrary r/w on a no-SMAP/SMEP/KASLR host) -- labeled non-default. Reported impact=dos (the realistic default-GENERIC ceiling). Chain written into harness.c (PRIMARY proof) and harness_mod.c+trigger.c (object-level exerciser).

Evidence (decisive lines)

BUGGY: acl_check rc=-1  uaf_read_happened=1  wild_deref=1 / victim->acl_hash.le_next (read from FREED memory) = 0xdededededededede  poisoned=YES (0xde fill) / UAF CONFIRMED: _find_acl's LIST_FOREACH read victim->acl_hash.le_next from FREED memory (poison byte 0xde repeated). The subsequent deref of the wild pointer faulted. / RESULT: UAF CONFIRMED (BUGGY_RC=0) --- FIXED: acl_check rc=0  uaf_read_happened=0  wild_deref=0 / NO UAF (serialized): ACL_LOCK in acl_check made the remover block until the foreach completed. / RESULT: FIXED - UAF NOT TRIGGERED (FIXED_RC=0) --- real-kernel (unpatched wlan_acl.ko, INVARIANTS ON, use_malloc_pattern=1): df0733: harness unloaded (adds=6478455 removes=8685618 checks=1741115) => 1,741,115 lockless acl_check calls vs 8,685,618 _acl_free ops; guest stayed up (silent UAF read on INVARIANTS-on).

PoC changes

Folder arrived empty (no seeded PoC); created the full evidence pack from scratch. harness.c = deterministic userspace transcription of acl_check/_find_acl/_acl_free + faithful LIST_FOREACH/LIST_INSERT_HEAD/LIST_REMOVE queue primitives, with a poisoned allocator (0xde fill = INVARIANTS WEIRD_ADDR free-poisoning analogue), a deterministic interleaving (park hook) that frees the victim under the cursor, and a SIGSEGV handler catching the wild deref after the UAF read; two modes (BUGGY/FIXED). harness_mod.c+Makefile+trigger.c = real-kernel object-level harness: fake ieee80211vap + real wlan_acl aclator + /dev/df0733 (0666) invoking the actual acl_check (lockless _find_acl) against adder/remover kthreads calling the real iac_add/iac_remove (->_acl_free). fix.diff = ACL_LOCK/ACL_UNLOCK around the two _find_acl calls in acl_check, matching the siblings' locking discipline. build.sh/run.sh repro scripts; build.log/run.log/fix_build.log/fix_run.log/env.txt; VERDICT.md; manifest.json.

Verified recommended fix

In acl_check (sys/netproto/802_11/wlan_acl/ieee80211_acl.c:171 and :173), wrap both _find_acl calls in ACL_LOCK(as)/ACL_UNLOCK(as) so the hash walk is serialized against acl_remove/acl_free_all/acl_add (all of which take the same lock and _acl_free the entry via LIST_REMOVE+IEEE80211_FREE). ACL_LOCK is a sleepable lockmgr LK_EXCLUSIVE lock; acl_check runs in the RX path holding the independent IEEE80211_LOCK, so taking ACL_LOCK is safe (no lock-order inversion). Matches the finding proposal. The full git-apply-able diff is in findings/poc/DF-0733/fix.diff.

Verdict

REPRODUCED. acl_check (sys/netproto/802_11/wlan_acl/ieee80211_acl.c:161-176) calls _find_acl (:136-148, LIST_FOREACH over as_hash[hash] at :143) WITHOUT ACL_LOCK, while acl_add(:199)/acl_remove(:228)/acl_free_all(:249) all take ACL_LOCK and _acl_free(:150-159) does LIST_REMOVE(:156, which does NOT clear le_next) + IEEE80211_FREE(:157). The lockless foreach parks its cursor on entry E and, after the ADDR_EQ compare, reads E->acl_hash.le_next; a concurrent acl_remove->_acl_free (under the lock) runs LIST_REMOVE(E)+IEEE80211_FREE(E), so the foreach reads le_next from FREED memory = CWE-416 UAF / CWE-362 race. Decisive proof: the deterministic userspace transcription (harness.c) parks the cursor on the victim, frees it under the cursor, and shows _find_acl read victim->acl_hash.le_next = 0xdededededededede from freed/poisoned memory with the subsequent wild deref faulting (UAF CONFIRMED); the FIXED transcription (ACL_LOCK around _find_acl) shows NO UAF (serialized). Real-kernel object-level harness (harness_mod.ko+trigger.c) exercised the actual lockless acl_check vs concurrent iac_remove/_acl_free: 1,741,115 checks ran concurrently with 8,685,618 _acl_free ops (race window open). acl_check==iac_check(:349) is reachable from the UNAUTHENTICATED 802.11 RX path (ieee80211_hostap.c:1801 PROBE_REQ, :1886 AUTH seq-1, wh->i_addr2 attacker-controlled) but needs a wifi radio, absent on this KVM guest -- so proven at the harness/object level.