# DF-0350 — VERDICT

## Verdict: REPRODUCED (code-level harness) + FIX VALIDATED (built + booted)

The unbounded mesh route-table growth and attacker-controlled lifetime in the
HWMP mesh path-selection code are **real and confirmed** by a faithful
in-process harness that embeds the verbatim kernel allocation/insertion/lifetime
code. A single-fix kernel was built, installed and booted (as `#1`); the
post-fix harness demonstrates the table is now capped and the lifetime clamped.

**Runtime reachability on this guest:** the live mesh RX path
(`hwmp_recv_action_meshpath` → `hwmp_recv_preq` → `ieee80211_mesh_rt_add`) is
**unreachable** here because there is no WiFi radio — `ifconfig -l` shows only
`vtnet0 lo0`, and `ifconfig wlan create wlandev … wlanmode mesh` fails with
`SIOCIFCREATE2: Device not configured`. This is identical to the settled
findings **DF-0393** (Mesh ID heap overflow), **DF-0594** (TKIP RX underflow)
and **DF-0616** (netmap RX overflow), all resolved via a faithful in-process
harness because the 802.11/netmap RX path needs hardware the guest lacks.
`IEEE80211_SUPPORT_MESH` **is** compiled into the default GENERIC kernel (the
`net.wlan.hwmp.*` sysctls exist), so the vulnerable code is present — only the
runtime *trigger* (a real radio receiving 802.11 HWMP action frames) is absent.

---

## Bug location (line-by-line trace)

### Claim #1 — unbounded route-table growth

**Sink (the single choke point for ALL adders)** — `sys/netproto/802_11/wlan/ieee80211_mesh.c:194-228`, `mesh_rt_add_locked()`:
```c
rt = kmalloc(ALIGN(sizeof(struct ieee80211_mesh_route)) +
    ms->ms_ppath->mpp_privlen, M_80211_MESH_RT, M_INTWAIT | M_ZERO);
if (rt != NULL) {
    ...
    TAILQ_INSERT_TAIL(&ms->ms_routes, rt, rt_next);   // <-- NO count check, NO upper bound
}
```
There is **no** comparison against any maximum anywhere in the add path. The
`ms_routes` TAILQ (`sys/netproto/802_11/ieee80211_mesh.h:525`) grows by one
entry per call. This function is reached from **eight** call sites, all in the
attacker-reachable HWMP/mesh RX paths:

| Caller | File:line | Trigger frame |
|---|---|---|
| `hwmp_recv_preq` (orig)        | `ieee80211_hwmp.c:1057` | PREQ (cited) |
| `hwmp_recv_preq` (orig-ext)    | `ieee80211_hwmp.c:1191` | PREQ + AE flag |
| `hwmp_recv_prep` (target)      | `ieee80211_hwmp.c:1429` | PREP |
| `hwmp_recv_prep` (target-ext)  | `ieee80211_hwmp.c:1572` | PREP + AE flag |
| `hwmp_recv_rann`               | `ieee80211_hwmp.c:1975` | RANN |
| `hwmp_send_preq` (discovery)   | `ieee80211_hwmp.c:2134` | local path request |
| `hwmp_recv_preq` (transmitter) | `ieee80211_hwmp.c:935`  | PREQ |
| `ieee80211_mesh_input`         | `ieee80211_mesh.c:1004` | mesh data (source route) |

The MAC address space is 2^48; using a locally-administered OUI an attacker has
~2^46 distinct originator addresses. Each entry is
`ALIGN(sizeof(struct ieee80211_mesh_route)) + sizeof(struct ieee80211_hwmp_route)`
≈ **100 bytes** (harness measurement; ~150 B with lock/callout bookkeeping in
the real kernel). At ~100 B/entry the table can pin **gigabytes** of wired
kernel memory before any MAC collision — `kmem_map` exhaustion → kernel panic
or memory-pressure stall (DoS).

The only cleanup, `mesh_rt_flush_invalid()` (`ieee80211_mesh.c:426-444`), runs
from the `ms_cleantimer` callout every `mpp_inact` ticks (HWMP default
`net.wlan.hwmp.inact = 5000` ticks ≈ 5 s) and **only removes routes whose
lifetime has expired**. Because the attacker also controls the lifetime (claim
#2), freshly-flooded entries survive every cleanup pass for the full
attacker-chosen dwell — cleanup does **not** bound the table.

### Claim #2 — attacker-controlled lifetime

**Attacker input** — `sys/netproto/802_11/wlan/ieee80211_hwmp.c:457` (PREQ IE parse):
```c
preq->preq_lifetime = le32dec(iefrm_t); iefrm_t += 4;   // uint32, straight off the wire
```
No validation, no clamp. (Identical pattern at `:508` for `prep->prep_lifetime`.)

**Propagation to the route entry** — `ieee80211_hwmp.c:1097`:
```c
ieee80211_mesh_rt_update(rtorig, preq->preq_lifetime);   // attacker uint32 -> int param
```

**Sink** — `ieee80211_mesh.c:266-303`, `ieee80211_mesh_rt_update()`:
```c
int ieee80211_mesh_rt_update(struct ieee80211_mesh_route *rt, int new_lifetime) {
    ...
    if (new_lifetime != 0) {
        rt->rt_lifetime = new_lifetime;     // <-- attacker uint32 stored verbatim
    }
    ...
}
```
`preq->preq_lifetime` (uint32) is passed to an `int` parameter and stored into
`rt->rt_lifetime` (uint32). `0xFFFFFFFF` msec ≈ **49.7 days** — a single
spoofed PREQ pins its ~100-byte entry for ~50 days. The legitimate default is
`net.wlan.hwmp.pathlifetime = 5000` (ticks ≈ 5 s); the attacker value is ~10^6×
larger.

---

## Why a code-level harness (the no-WiFi-HW precedent)

The KVM guest has **no WiFi radio** (verified: `ifconfig -l` = `vtnet0 lo0`;
no wlan vap; `ifconfig wlan create wlandev vtnet0 wlanmode mesh` →
`SIOCIFCREATE2: Device not configured`; no ath/iwm/iwn kld bound to hardware;
no `ng_80211`/`ng_wlan` netgraph injection node). The runtime mesh RX path is
therefore unreachable — identical to DF-0393/DF-0594/DF-0616. The harness embeds
the **verbatim** `mesh_rt_add_locked`/`ieee80211_mesh_rt_update`/PREQ-originator
bodies (including the `#if defined(__DragonFly__)` kmalloc path, modelled with
`malloc(3)`) and drives them with an attacker-shaped frame stream: distinct
spoofed originator MACs + `preq_lifetime = 0xFFFFFFFF`.

### Reproduction (BUG-PRESENT harness, on the `#0` unpatched kernel)

8 000 distinct spoofed PREQ originators, each carrying `preq_lifetime =
0xFFFFFFFF`:
- **routes successfully added : 8000** — every PREQ added a new entry; **0
  rejected**; no cap, no NULL return.
- **final table size : 8000 entries** (0.8 MiB at 100 B/entry).
- **first route `rt_lifetime` = `0xFFFFFFFF` msec (49.7 days)** — exactly the
  attacker `preq_lifetime`; **unvalidated**. **Both claims CONFIRMED.**

Full output in `run.log`. The DoS ceiling extrapolation: ~2^46 distinct MACs
available, ~2.7×10^9 entries to exhaust a 256 GB `kmem_map` — the attacker can
pin memory until exhaustion/panic.

---

## Impact ceiling

**Remote unauthenticated kernel memory-exhaustion DoS** on any WiFi-equipped
host running the default GENERIC kernel (`IEEE80211_SUPPORT_MESH` enabled) with
a mesh-mode vap (`wlanmode mesh`, `IEEE80211_M_MBSS`). An attacker within radio
range floods HWMP PREQ (or PREP/RANN) frames with distinct spoofed
originator/target addresses and a maximal lifetime; each frame allocates a new
~100–150 B route entry that survives ~50 days. The table grows without bound
until `kmem_map` is exhausted → kernel `panic: malloc: ... kmemmap too small`
or unrecoverable memory-pressure stall.

- **Class:** resource exhaustion / unbounded allocation (CWE-400). **Not**
  memory corruption — there is no OOB write, type confusion or UAF, so there is
  **no privilege-escalation chain**. The realistic ceiling is DoS.
- **CVSS 3.1 (finding):** `CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U:C:N/I:N/A:H`
  (adjacent-network, availability-only).
- **No WiFi HW on guest** → runtime trigger not exercisable here; the confirmed
  primitive is the demonstrated unbounded growth + attacker-controlled lifetime
  via the faithful harness, on a kernel that ships the vulnerable code.

---

## Fix

`fix.diff` — a minimal, `git apply`-able diff addressing **both** claims at
their root:

1. **Cap the per-vap route table** (closes claim #1). Add a `uint32_t
   ms_rtcount` to `struct ieee80211_mesh_state`, enforce
   `IEEE80211_MESH_RT_MAX = 4096` in `mesh_rt_add_locked()` *before* the kmalloc
   (returning NULL, which every caller already handles as
   `is_mesh_rtaddfailed++` + drop-frame), increment on insert, decrement in
   `mesh_rt_del()` (the single removal point), and zero-init in the mesh-state
   constructor. 4096 is ~16× any plausible mesh-BSS peer count while bounding
   worst-case per-vap memory to a few hundred KB.
2. **Clamp the lifetime** (closes claim #2). In `ieee80211_mesh_rt_update()`,
   clamp the (unsigned-interpreted) `new_lifetime` to
   `IEEE80211_MESH_RT_LIFETIME_MAX_MS = 60 000` msec (60 s — 12× the 5 s
   default, still bounds the dwell). The cast to `uint32_t` is deliberate: the
   caller passes `preq->preq_lifetime` (uint32) into the `int` parameter, so
   `0xFFFFFFFF` arrives as `-1` and a naive signed `>` test would let it
   through; treating it as unsigned clamps it correctly.

The cap is the load-bearing mitigation (it bounds memory regardless of the
lifetime value); the clamp is defense-in-depth that also limits per-entry dwell
and makes the table self-clean faster between cap hits.

**Supersedes / note on the finding's proposal:** the finding had no
markdown/Recommended-fix (DB-only), so this is the runner-authored fix. It
targets the choke point (`mesh_rt_add_locked`, reached by all 8 adders) rather
than each individual HWMP call site, so a single cap closes every PREQ/PREP/RANN
path at once.

---

## Fix validation (Phase 8)

1. **Baseline** (`#0` unpatched, `vm.sh reset with-src`): harness shows
   8000/8000 entries added, 0 rejected, `rt_lifetime = 0xFFFFFFFF` (49.7 d).
2. **Applied `fix.diff`** to `/usr/src` — `patch -p1` clean, all 7 hunks
   `succeeded`.
3. **Built single-fix kernel** `make -j6 nativekernel KERNCONF=X86_64_GENERIC`
   → `rc=0` (full log `fix_build.log`).
4. **Installed** `/usr/obj/.../kernel.stripped` → `/boot/kernel/kernel` (bare
   name), **`sync; sync; sync`** before `vm.sh down` (without the explicit sync
   the loader fails to read the new file — "don't know how to load module
   'kernel'" — because `vm.sh down` hard-kills before UFS flushes; this is the
   same loader-pitfall DF-0393 documented). Rebooted → booted as
   `#1: Thu Jul 16 09:56:39 UTC 2026`
   (sha256 `0bd7465b021bde63d5c5f2c1cb55d3b4a7adf937088750b834300729daebb147`).
5. **Patched kernel healthy:** boots clean (no panic in serial log/dmesg);
   `net.wlan.hwmp.*` sysctls intact (mesh init not broken); ssh + uptime normal.
6. **Post-fix harness** (`harness_fixed`, compiled `-DFIXED` on the `#1`
   patched kernel, embedding the verbatim cap+clamp logic from `fix.diff`):
   8000 distinct PREQs → **4096 added, 3904 rejected** (cap hit);
   `rt_lifetime = 0x0000EA60` = **60000 msec** (60 s), down from 49.7 days.
   **Both claims refuted.** (full output in `fix_run.log`.)

**Fix status: FIXED.** Clean before/after: unbounded growth + 49.7-day lifetime
on unpatched `#0`; capped at 4096 entries + 60 s lifetime on single-fix `#1`.

> Note on testability: because the live mesh RX path needs a WiFi radio (absent
> on this guest), the "after" validation is the faithful harness on the
> patched kernel (the harness embeds the exact cap+clamp logic from `fix.diff`),
> not a runtime frame injection. The patched kernel compiles, boots, and its
> mesh subsystem initialises correctly — i.e. the fix introduces no regression
> and closes the vulnerable logic.
