β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-3454

amrr never revalidates stale amn_rix against the active rateset: HT→legacy flip gives OOB reads of node memory (incl. ni_chan kernel pointer bytes) disclosed via ni_txrate/rate_stats, and hands drivers an unbounded/negative rate index

Summary

amrr_node_init computes amn_rix once against whichever rateset amrr_node_is_11n() selects at that moment - the 77-entry ni_htrates for an 11n node (rix up to 76), or -1 when both ratesets are empty. amrr_node_is_11n() is re-evaluated per call and flips to the 15-entry legacy ni_rates whenever ni_chan loses HT (channel switch, e.g. DFS radar CSA) or ni_htrates.rs_nrates is cleared (reassoc without HT IEs) - but amn_rix is never re-clamped. amrr_update, amrr_rate and amrr_node_stats then index rs_rates[76] on the 15-byte legacy array; the byte flows masked (&0x7f) into ni_txrate -> SIOCG80211NODE isi_txrate and the world-readable net.wlanX.rate_stats sysctl. amrr_rate returns the raw rix to drivers: run(4) indexes its 12-entry rt2860_rates[] (static OOB read), bwn reads rs_rates[rix-1], iwm_setrates KASSERT panics INVARIANTS kernels with the -1 tmp-node value. amrr is the DEFAULT ratectl and compiled into the base kernel. REPRODUCED on QEMU guest via a KLD harness driving only the public ratectl API: climb rix to 76 on 11n, flip ni_chan to legacy -> ratectl_rate() returns rix=76 vs rs_nrates=12; failure-driven walk-down reads every OOB byte - all 8 bytes of the ni_chan kernel heap pointer (masked to 7 bits in the sysctl) and planted markers disclosed; empty-rateset tmp node returns rix=-1. Fix validated on rebuilt kernel: rix 76->11 and -1->0, zero OOB reads. Read-only intra-object (12,208-byte node); no write primitive.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3454 Β· 17 files
FileTypeDescriptionSize
amrroob.c β€” 10.7 KB view raw
Makefile β€” 238 B ↓ download
build.sh β€” 153 B view raw
run.sh β€” 166 B view raw
build.log β€” 5.9 KB view raw
run.log β€” 582 B view raw
run.2.log β€” 10.3 KB view raw
fix.diff β€” 2.0 KB view raw
fix_module_only.log β€” 7.4 KB view raw
fix_module_only_attempt1.log β€” 3.7 KB view raw
fix_run.log β€” 1.8 KB view raw
fix_build.log β€” 12.7 KB view raw
env.txt β€” 212 B view raw
VERDICT.md β€” 6.2 KB ↓ raw
README.md β€” 2.3 KB ↓ raw
manifest.json β€” 1.5 KB view raw
verdict.json β€” 6.6 KB view raw

DF-3454 β€” amrr stale rate index (amn_rix) never revalidated against the active rateset

File: sys/netproto/802_11/wlan/ieee80211_amrr.c Class: CWE-125 out-of-bounds read (intra-object, kernel pointer bytes disclosed) Severity: Low Β· Confidence: certain (reproduced on QEMU guest)

Build

scp amrroob.c Makefile dfbsd:/root/amrroob/
guest# cd /root/amrroob && make SYSDIR=/usr/src/sys      # -> amrroob.ko

Run

guest# kldload /root/amrroob/amrroob.ko     # pulls in wlan_amrr (or uses in-kernel amrr)
guest# sysctl amrroob.run=1                 # ~60s; all output to dmesg/console
guest# dmesg | grep amrroob
guest# kldunload amrroob

(see run.sh; output lands in dmesg, captured in run.2.log / run.log)

Expected (vulnerable kernel)

  • PHASE2 ... -> amn_rix=76 β€” 11n rate index driven to 76 through the public ieee80211_ratectl_* API (77-entry ni_htrates table).
  • PHASE3 ... ratectl_rate() returned rix=76; legacy rs_nrates=12 β€” after flipping the node to a legacy channel, the stale index 76 is used against the 15-byte ni_rates.rs_rates array: OOB read at rs_rates[76] (array ends at rs_rates[14]).
  • PHASE4 walk-down: every failure interval reads one more OOB byte; the value lands in ni->ni_txrate (masked & 0x7f) β€” i.e. in SIOCG80211NODE isi_txrate and the world-readable net.wlanX.rate_stats sysctl. rs_rates[18..25] overlay the ni_chan kernel heap pointer (e.g. 0xfffff8008d403a30 β†’ bytes 40 3a 40 8d 00 f8 ff ff read OOB, 0xA5 meshid marker bytes likewise disclosed as 0x25).
  • PHASE5 tmp-node ... amn_rix=-1 ratectl_rate() returned -1 β€” the empty-rateset path (ieee80211_tmp_node): drivers receive rix = -1 (iwm_setrates KASSERT(rix >= 0 && rix < nrates) β‡’ INVARIANTS panic).

Expected (patched kernel, fix.diff applied)

  • PHASE3 returns rix=11 (clamped to rs_nrates-1), no reads past rs_rates[14], walk-down runs 11β†’0 in bounds, no 0xA5/pointer-byte lines, PHASE5 returns 0.

Fix validation

fix.diff applied to guest /usr/src, make nativekernel, installkernel, reboot, same PoC β€” see fix_run.log / VERDICT.md. NOTE: ieee80211_amrr.c is compiled into the base kernel when wlan is built in (sys/conf/files:1630) β€” patching only wlan_amrr.ko has no effect; a kernel rebuild is required.

VERDICT.md
↓ download raw

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.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff applied to guest /usr/src (patch -p1, 3 hunks), make nativekernel (RC=0, fix_build.log), make installkernel, reboot into kernel #1. Identical PoC re-run (fix_run.log): PHASE3 ratectl_rate() returns rix=11 = rs_nrates-1 (was 76); rate_stats prints 'rate: 54 Mbit' = the genuine top legacy rate 108/2 (was leaked OOB bytes); PHASE4 walk-down produces zero OOB reads (loop never reaches the pointer/marker range 18..76); PHASE5 empty-rateset tmp node yields amn_rix=0 and ratectl_rate() returns 0 (was -1, the iwm KASSERT panic trigger). Bad behavior gone; guest stable before/after.

fix_run.log (patched dmesg: 'returned rix=11', 'rate: 54 Mbit', 'amn_rix=0 ... returned 0', no PHASE4 OOB lines) vs run.2.log (baseline: rix=76, pointer bytes 40 3a 40 8d 00 f8 ff ff at rs_rates[18..25], 0xA5 markers as 0x25, rix=-1); fix.diff (git-apply clean); fix_build.log (nativekernel tail, RC=0); fix_module_only.log (module-only patch attempt - no effect, proves in-kernel amrr).
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Sun Sep 13 02:35:55 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

remote 11n peer (or DFS/CSA legacy channel switch) flips node HT status while amn_rix is an htrates index (<=76) -> stale index used against 15-entry legacy rs_rates in wlan core (OOB read, value -> ni_txrate) -> unprivileged local user reads net.wlanX.rate_stats (CTLFLAG_RD) / SIOCG80211NODE isi_txrate -> 7 of 8 bits per disclosed byte incl. ni_chan heap pointer bytes; driver-side: run(4) indexes rt2860_rates[12] with rix<=76 (static OOB read), bwn(4) reads rs_rates[rix-1], iwm(4) KASSERT(rix>=0&&rix<nrates) panics INVARIANTS kernels. Ceiling: KASLR-defeating pointer-byte harvest + driver metadata corruption/panic - no write primitive.

Evidence (decisive lines)

run.2.log: PHASE3 'returned rix=76; legacy rs_nrates=12' + 'rs_rates[76] ... is OOB (array ends at rs_rates[14])'; PHASE4 lines 'rix=25..18 ... [ni_chan pointer byte]' showing bytes ff ff f8 00 8d 40 3a 40 of ni_chan=0xfffff8008d403a30 and 34 x '[0xA5 meshid marker]' lines reading 0xa5 disclosed as 0x25; PHASE5 'amn_rix=-1 ratectl_rate() returned -1'. fix_run.log (patched kernel #1): 'returned rix=11', rate_stats 'rate: 54 Mbit' (=108/2, real top legacy rate), zero PHASE4 OOB lines, 'amn_rix=0 ... returned 0'. fix_module_only.log documents the failed module-only patch attempt (amrr is compiled into the base kernel via sys/conf/files:1630 'optional wlan').

PoC changes

Harness authored fresh (no seed PoC existed). Two build fixes vs first draft (missing net/if_media.h include for ifmedia in ieee80211_var.h; NULL->0 in DEV_MODULE). One harness logic fix after run 1 (walk-down step budget exhausted early - steps counter reuse; run 2 walks the full 76->17 range exposing all 8 ni_chan pointer bytes). Cosmetic: harness 'is OOB' label prints unconditionally for PHASE3 even when the index is in-bounds after the fix (fix_run.log rix=11 vs array end 14 - in bounds). iv_rs setup replicates amrr_init()'s 4 statements because ir_init's sysctl attach requires a fully constructed vap sysctl tree; the vulnerable functions exercised (node_init/update/rate/tx_update/node_stats/setinterval) are the real in-kernel ones, reached through the public wrappers.

Verified recommended fix

Clamp amn_rix/rix against the currently selected rateset at every use (amrr_update, amrr_rate, amrr_node_stats) - see fix.diff; also make drivers treat rix<0 as 'use lowest'.

Verdict

Reproduced on the QEMU guest via a KLD harness that drives only the public ieee80211_ratectl_* API against the in-kernel amrr (the DEFAULT ratectl, ratectl.c:119). After an 11n association is driven to amn_rix=76 through real success intervals, flipping the node to a legacy channel makes amrr_rate()/amrr_update()/amrr_node_stats() index the 15-byte ni_rates.rs_rates with the stale 76 (array ends at 14): all 8 bytes of the ni_chan kernel heap pointer (rs_rates[18..25]) and planted 0xA5 meshid markers are read out of bounds and disclosed 7-bits-masked through ni_txrate -> SIOCG80211NODE isi_txrate and the world-readable net.wlanX.rate_stats sysctl. The empty-rateset tmp-node path yields amn_rix=-1 returned to drivers (iwm_setrates KASSERT -> INVARIANTS panic). Read-only intra-object OOB inside the 12208-byte ieee80211_node allocation: no write primitive, not an escalation candidate. Fix (clamping amn_rix/rix against the active rateset at amrr_update/amrr_rate/amrr_node_stats) validated on a rebuilt kernel: rix 76->11, -1->0, zero OOB reads, identical PoC, guest stable.