# DF-0712 — Undersized kmalloc in `ieee80211_alloc_countryie`: 3-byte heap overflow

## Verdict
**REPRODUCED (code-level).** The off-by-3 heap overflow is **real and mathematically
certain**; it is confirmed by a deterministic canary-guarded harness that replicates the
exact allocation + IE-assembly logic of `ieee80211_alloc_countryie`
(`sys/netproto/802_11/wlan/ieee80211_regdomain.c:248-326`). The live in-kernel path is
**unreachable on this guest** (no WiFi radio: only `vtnet0`/`lo0`, no `wlan`/`80211`
modules), so live panic/corruption cannot be observed; the bug is proven at the
code/harness level. The **fix is validated**: a single-fix kernel (`#1`, the `+3` byte
allocation) compiles cleanly, boots, and the before/after harness shows the overflow is
present at the original 257-byte alloc and gone at the fixed 260-byte alloc.

## Mechanism (trigger → primitive → effect), every hop cited

The function `ieee80211_alloc_countryie` allocates a buffer sized **only** for the
country-IE *content* and then writes the IE into `ie_data[]` *plus* the `struct
ieee80211_appie` 2-byte header, overflowing the tail by exactly 3 bytes.

1. **Allocation** — `regdomain.c:248`:
   `aie = kmalloc(IEEE80211_COUNTRY_MAX_SIZE, M_80211_NODE_IE, M_INTWAIT|M_ZERO)`
   - `IEEE80211_COUNTRY_MAX_SIZE` (`ieee80211.h:973`) =
     `sizeof(struct ieee80211_country_ie) + 3*(IEEE80211_COUNTRY_MAX_BANDS-1)` =
     `8 + 3*83 = 257` bytes.
   - So `kmalloc` gets **257 bytes**.

2. **`ie_data[]` lives *inside* that allocation, after the 2-byte header** —
   `regdomain.c:260`: `ie = (struct ieee80211_country_ie *) aie->ie_data`.
   - `struct ieee80211_appie` (`ieee80211_var.h:109`) = `{ uint16_t ie_len; uint8_t ie_data[]; }`
     ⇒ the 2-byte `ie_len` header consumes offsets 0-1, leaving `ie_data` only
     **255 bytes** (valid indices `ie_data[0..254]`, i.e. allocation offsets `2..256`).

3. **Country-IE layout inside `ie_data`** (`ieee80211.h:961`):
   `ie`@ie_data[0], `len`@ie_data[1], `cc[3]`@ie_data[2..4], `band[0]`@ie_data[5..7].
   `frm = &ie->band[0]` = `ie_data[5]` (`regdomain.c:281`).

4. **Each "run" writes 3 bytes and advances `frm += 3`** (`regdomain.c:307-310`).
   `nruns` is capped at `IEEE80211_COUNTRY_MAX_BANDS = 84` (`regdomain.c:300`).
   - run `k` (0-based) writes `ie_data[5+3k], ie_data[6+3k], ie_data[7+3k]`.
   - **run 83 (the 84th)** writes `ie_data[254], ie_data[255], ie_data[256]`.
     - `ie_data[254]` = alloc offset 256 = **last valid byte** (`c->ic_ieee`).
     - `ie_data[255]` = alloc offset 257 = **OOB+1** (constant `1`, `#channels`).
     - `ie_data[256]` = alloc offset 258 = **OOB+2** (`c->ic_maxregpower`,
       **attacker-controlled** via `SIOCS80211` ioctl channel setup).
   - `frm` is now `ie_data[257]`.

5. **Odd-length pad writes OOB+3** (`regdomain.c:318-321`):
   `ie->len = frm - ie->cc = 255` (odd) ⇒ `*frm++ = 0` writes `ie_data[257]` =
   alloc offset 259 = **OOB+3** (constant `0`).

**Net: 3-byte heap write past the requested 257-byte allocation.** The trigger condition
(84 distinct channel power-runs) is exactly reachable at the `IEEE80211_COUNTRY_MAX_BANDS`
cap — feed 84 channels with non-consecutive `ic_ieee` and distinct `ic_maxregpower` and
the loop produces 84 runs. Harness output (`run.log`):

```
OOB+1: alloc[257] = 0x01 (was 0xcd)   # #channels, constant
OOB+2: alloc[258] = 0xf3 (was 0xcd)   # ic_maxregpower of run 83 = 0xA0+83, ATTACKER-CONTROLLED
OOB+3: alloc[259] = 0x00 (was 0xcd)   # odd-IE pad byte, constant
```

## Realistic impact (honest ceiling)

Two independent factors keep this bug **from being exploitable on the default GENERIC
kernel**, both verified:

1. **Slab-bucket rounding absorbs the overflow into intra-object padding.**
   `kmalloc(257)` rounds up via `zoneindex()` (`kern_slaballoc.c:638-656`):
   `n>=256 && n<512` ⇒ `(n+31)&~31` = `(257+31)&~31` = **288-byte slab object**
   (31 bytes of tail padding). The 3 overflow bytes land at allocation offsets
   257/258/259 — **all inside the 288-byte object's unused tail**. No *adjacent* slab
   object is touched, so there is no cross-object corruption, no function-pointer/ucred
   overwrite target reachable. With `INVARIANTS` ON (default GENERIC), `kern_slaballoc.c`
   only poisons/checked *free* chunks (`WEIRD_ADDR` / `chunk_mark_free`); the tail of an
   *allocated-but-oversized* object is neither poisoned nor checked, so the overflow is
   **silent** (no KASSERT, no panic).

2. **The live path is unreachable on this guest and root-gated even with WiFi.**
   - This guest has no WiFi radio (`ifconfig -l` ⇒ `vtnet0 lo0`; no `wlan`/`80211`
     modules loaded), so no `ieee80211com` exists and `ieee80211_alloc_countryie`
     (called from `ieee80211_add_countryie` while building a hostap beacon,
     `ieee80211_output.c:2153`) can never run.
   - Even with WiFi hardware, setting up the channel list / regdomain that produces 84
     runs goes through `SIOCS80211`, gated by `caps_priv_check_self(SYSCAP_NONET_WIFI)`
     (`ieee80211_ioctl.c:3472`) — a **root** capability. An unprivileged user cannot
     craft the trigger.

**Bottom line:** a confirmed off-by-3 heap write (real correctness/defense-in-depth bug),
but on the current allocator + this guest there is **no demonstrated runtime corruption,
no panic, no info leak, no privilege boundary crossed**. Realistic impact ceiling: latent
correctness bug that would become a real cross-object corruption on a tighter allocator
or if `M_80211_NODE_IE` ever gained a custom zone. Severity Medium is defensible for
defense-in-depth but the practical exploitability on default GENERIC is nil.

## Exploit chain
Not pursued beyond primitive characterization — and **validly so**, because this is a
genuine hard-blocker case (see Phase 6 valid blockers):
- The write is reachable only from a **root-gated ioctl** (`SYSCAP_NONET_WIFI`,
  `ieee80211_ioctl.c:3472`) on hardware this guest lacks — there is **no unprivileged
  path** to the sink, so there is no privilege boundary to cross (root→kernel is
  game-over by definition).
- Independently, on the current slab allocator the 3 bytes land in **intra-object
  padding** (kmalloc(257)→288-byte bucket), so even if reached they cannot corrupt an
  adjacent victim object (function pointer / ucred / refcount) — the corruption target
  required for any escalation does not exist here.
There is therefore no grooming/victim/forge chain to develop; the primitive is confirmed
but inert on this configuration. (No `exploit.c` written — a chain would have no victim
object to corrupt and no unprivileged trigger.)

## PoC changes
- Authored `harness.c` — a deterministic, canary-guarded userspace replication of the
  exact `ieee80211_alloc_countryie` allocation + IE-assembly loop (struct layouts copied
  verbatim from `ieee80211.h:961` / `ieee80211_var.h:109`). Feeds 84 channels forcing 84
  runs and reports exactly which OOB bytes (1..3) are clobbered and with what values.
  Used because the live WiFi path is unreachable on this guest.
- Authored `harness_fixed.c` — identical harness with the allocation changed to the fixed
  size (`sizeof(appie)+MAX_SIZE+1` = 260). Proves 0 OOB bytes after the fix (the
  before/after code-level validation, since live functional testing is impossible).
- Authored `fix.diff` — minimal one-logical-change fix (see below).
- Wrote `build.sh` / `run.sh` repro wrappers.

## Recommended fix (fix.diff — supersedes finding proposal)

`sys/netproto/802_11/wlan/ieee80211_regdomain.c:248` (and the `#else`-branch twin at
:251): allocate room for the `struct ieee80211_appie` 2-byte header **and** the
worst-case odd-IE pad byte, matching how `ieee80211_ioctl.c:2296` sizes appie
allocations (`sizeof(struct ieee80211_appie) + <datasize>`):

```c
-	aie = kmalloc(IEEE80211_COUNTRY_MAX_SIZE, M_80211_NODE_IE,
+	aie = kmalloc(sizeof(struct ieee80211_appie) + IEEE80211_COUNTRY_MAX_SIZE + 1, M_80211_NODE_IE,
 	    M_INTWAIT | M_ZERO);
```

`2 + 257 + 1 = 260` bytes ⇒ `ie_data` gets 258 bytes (indices 0..257); the last write
(the pad byte at `ie_data[257]`) is now in-bounds. The `+1` covers the odd-length pad
written at `regdomain.c:321` in the worst case (84 runs ⇒ `ie->len` odd ⇒ pad).

## Fix validation (Phase 8)

| | kernel | harness alloc | result |
|---|---|---|---|
| **before** | `#0` unpatched baseline (6.5-DEVELOPMENT #0, Jul 2) | 257 | **3-byte OOB write** (OOB+1=0x01, OOB+2=ic_maxregpower attacker-controlled, OOB+3=0x00) |
| **after** | `#1` single-fix kernel (6.5-DEVELOPMENT #1, Jul 8 22:03) | 260 | **0 OOB bytes** |

- `fix.diff` applied to in-guest `/usr/src` (both `#if/#else` branches).
- `make -j6 nativekernel KERNCONF=X86_64_GENERIC` ⇒ `rc=0`, clean build (`fix_build.log`).
- `kernel.stripped` installed to `/boot/kernel/kernel` (sha256 `29ad6efc…`, valid ELF,
  fresh BuildID `ad9e59b1` vs baseline `b18d2eb8`).
- Booted to `6.5-DEVELOPMENT #1 Wed Jul 8 22:03:09 UTC 2026`, guest healthy.
- Functional after-test via harness (live path unreachable: no WiFi) ⇒ fixed-variant
  reports **no overflow**.

`fix_status: fixed` — bad behavior (3-byte OOB) present before, absent after; single-fix
kernel compiles and boots. (Live in-kernel functional test impossible on this guest — no
WiFi radio — but the deterministic harness provides a clean before/after and the patched
kernel source reads the corrected allocation.)
