# VERDICT — DF-3454 (ieee80211_amrr.c stale amn_rix OOB read)

**Status: reproduced** (2 baseline runs on the QEMU guest, full pointer-byte
disclosure captured) · **Impact: leak** (7 of 8 bits of arbitrary intra-node
bytes, incl. the `ni_chan` kernel heap pointer, via `ni_txrate`/`rate_stats`)
plus an unbounded/negative index handed to drivers (run(4) `rt2860_rates[12]`
static OOB read, bwn(4) `rs_rates[rix-1]`, iwm(4) INVARIANTS KASSERT panic).
No write primitive: **not an escalation candidate** — read-only intra-object
OOB inside a 12 KB `struct ieee80211_node` allocation.

## Root cause (path:line)

* `amrr_node_init` (ieee80211_amrr.c:208-218) computes `amn_rix` once, against
  whichever rateset `amrr_node_is_11n()` selects at that moment. For an 11n
  node that is the 77-entry `ni_htrates` (MCS0..76 across 3 streams), so
  `amn_rix` can legitimately be up to 76; for the empty-rateset tmp-node path
  (ieee80211_node.c ieee80211_tmp_node → ieee80211_ratectl_node_init) it
  becomes `-1`.
* `amrr_node_is_11n()` (ieee80211_amrr.c:145-155) is re-evaluated on **every**
  call: it flips to "legacy" as soon as `ni_chan` loses HT
  (channel switch — e.g. DFS radar CSA to a legacy channel — or reassoc) or
  `ni_htrates.rs_nrates` is cleared (reassoc without HT IEs).
* Nothing ever re-clamps `amn_rix` to the newly selected rateset:
  * `amrr_update` reads `rs->rs_rates[rix]` at :259 (NOTE arg), :280, :301;
  * `amrr_rate` writes `ni->ni_txrate = rs->rs_rates[rix]` at :339 and
    returns `rix` at :350 to the driver (bwn:6304-6308, run:2617/3345-3346,
    iwm:3540→iwm_setrates:4366 KASSERT);
  * `amrr_node_stats` reads `rs->rs_rates[amn->amn_rix]` at :434/:438 and
    prints it — this is exactly what the world-readable
    `net.wlanX.rate_stats` sysctl (ieee80211_ratectl.c:129-135,
    CTLFLAG_RD, attached for every vap) dumps per node.
* amrr is the **default** ratectl (ieee80211_ratectl.c:119-120 forces AMRR
  whenever the algorithm is NONE), and `ieee80211_amrr.c` is built into the
  base kernel (`sys/conf/files:1630 optional wlan`) — no module opt-in needed
  for any vap.
* Under success the stale index never comes down (`rix+1 < rs_nrates` is
  false, so no increment, no reset), under failure it walks down at one
  byte per update interval (500 ms default) — the disclosure is slow but
  deterministic, and the attacker (remote 11n peer controlling its own IEs /
  an HT→legacy channel change) chooses where the 11n era ends, i.e. which
  byte index gets read.

## Reproduction

Harness: `amrroob.c` — a KLD that exercises ONLY the public
`ieee80211_ratectl_set/_node_init/_tx_update/_rate/_node_stats` wrappers on a
synthetic vap/node (iv_rs set up exactly as `amrr_init` does; sysctl attach
replicated because it needs a full vap sysctl tree — everything the vulnerable
functions touch is real). No wireless hardware needed; the guest's in-kernel
amrr is the code under test (verified: ops table at 0xffffffff80d69ea0, inside
the static kernel image).

Decisive output (run 2, `run.2.log`):

```
node=0xfffff80118800400 size=12208  legacy rs_rates[0..14]=0xfffff8011880066e..0xfffff8011880067c
&ni_chan=0xfffff80118800680 (rs_rates +18..+25)   <- pointer overlays rs_rates[18..25]
ni_chan pointer value = 0xfffff8008d403a30
PHASE1 11n node_init -> amn_rix=67 (htrates nrates=77, legacy nrates=12)
PHASE2 after 9 successful intervals -> amn_rix=76
PHASE3 after HT->legacy flip: ratectl_rate() returned rix=76; legacy rs_nrates=12
        rix=76 indexes legacy rs_rates[76] => 0xfffff801188006ba is OOB (array ends at rs_rates[14])
PHASE4   rix=25 -> read ...687 = 0xff  (ni_txrate/sysctl show 0x7f)  [ni_chan pointer byte]
         rix=24 -> read ...686 = 0xff  ...  rix=18 -> ...680 = 0x40  [ni_chan pointer byte]
         rix=68/67/... -> 0xa5 -> 0x25  [0xA5 meshid marker]
PHASE5 tmp-node (empty ratesets): amn_rix=-1 ratectl_rate() returned -1
DONE - OOB reads demonstrated via public ratectl API only; guest still alive
```

All 8 bytes of the `ni_chan` kernel heap pointer were read out-of-bounds
(7 bits each after the `IEEE80211_RATE_VAL` mask) and the `0xA5` marker
planted in `ni_meshid` was disclosed as `0x25` through `ni_txrate` — the same
value `SIOCG80211NODE`/`rate_stats` hand to unprivileged users. Guest stayed
alive both runs (read-only bug).

## Fix validation

* `fix.diff` clamps `amn_rix`/`rix` against the *currently selected*
  rateset at the three consumption sites (`amrr_update`, `amrr_rate`,
  `amrr_node_stats`) and neutralises the empty-rateset `-1`.
* Applied in-guest to `/usr/src` (`patch -p1`, 3 hunks), `make nativekernel`
  (RC=0), `make installkernel`, reboot into kernel
  `#1 Sun Sep 13 02:35:55 UTC 2026`, identical PoC re-run (`fix_run.log`):

  | observation | baseline kernel #0 | patched kernel #1 |
  |---|---|---|
  | `ratectl_rate()` after flip | `rix=76` (OOB) | `rix=11` (= rs_nrates-1) |
  | `rate_stats` output | leaked OOB bytes | `rate: 54 Mbit` (real 108/2) |
  | walk-down OOB reads | 8 pointer bytes + 34 marker bytes | none |
  | tmp-node rix | `-1` | `0` |

  Bad behavior gone; guest stable before and after.
* (Lesson learned the hard way and recorded here: the file is compiled into
  the base kernel via `optional wlan` (sys/conf/files:1630); rebuilding only
  `wlan_amrr.ko` has no effect — see `fix_module_only.log`. Also: this
  guest's `vm.sh down` hard-kills after a timeout, so file installs must be
  `sync`'d or they are lost — the first module install was truncated to 0
  bytes by exactly that.)

## Not an escalation

The OOB read is confined to the 12,208-byte `struct ieee80211_node`
allocation (index ≤ 76 vs array end +61 bytes, struct end +14,535 bytes).
No OOB write, no freed-object access, no cross-object read. Combined with
the 7-bit mask and the 500 ms/byte pacing, the realistic ceiling is a
KASLR-defeating pointer nibble harvest and driver-side garbage metadata
(wrong ack durations on run(4), INVARIANTS panic on iwm(4)) — hence
severity Low, bucket kernleak/memcorrupt-read.

## Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest `make -j6 nativekernel KERNCONF=X86_64_GENERIC` completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
