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

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

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