# 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:

```c
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`):

```c
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
