ieee80211_ageq_append trusts non-monotonic caller ages: attacker-controlled (re)association listen-interval drives the relative age negative β KASSERT panic on INVARIANTS kernels ('panic: age -781' reproduced)
Summary
append() stores each frame's age relative to the queue head and KASSERTs age>=0. The DWDS staging path computes that age from ni->ni_intval, which hostap copies UNVALIDATED from the association request's listen-interval field (attacker-controlled 0..65535). An associated station stages a 4-addr frame with a large listen-interval, then re-associates with listen-interval 1 and stages a second frame: relative age < 0 -> panic on INVARIANTS kernels; the hwmp staging path (fixed age 15) hits the same KASSERT when staged behind a wds head with >15 remaining. Production kernels store the negative age and merely expire the frame one sweep early (no memory unsafety) - Medium/remote DoS. VERIFIED on stock INVARIANTS guest: KLD PoC driving the exported ageq API with exactly the two ages one attacker station controls -> 'panic: age -781', backtrace ieee80211_ageq_append, guest needed hard reset. Fix validated (clamp negative relative age to 0; also validate the assoc listen-interval): identical PoC runs clean on rebuilt kernel. Remote RF-side DoS of any DWDS-capable (or mixed DWDS+mesh) AP on a debug kernel: associate, send one 4-addr frame, re-associate with small interval, send a second - panic.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3466 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| ageqpoc.c | β | 2.1 KB | view raw | |
| Makefile.ageqpoc | β | 371 B | β download | |
| build.sh | β | 91 B | view raw | |
| run.sh | β | 115 B | view raw | |
| build.log | β | 5.9 KB | view raw | |
| run.log | β | 1.3 KB | view raw | |
| run.2.log | β | 1.0 KB | view raw | |
| panic.txt | β | 863 B | view raw | |
| env.txt | β | 387 B | view raw | |
| VERDICT.md | β | 3.6 KB | β raw | |
| verdict.json | β | 3.9 KB | view raw | |
| fix.diff | β | 706 B | view raw |
DF-3466 β VERDICT
Finding: ieee80211_ageq_append() computes a relative age
(age - M_AGE_GET(head), sys/netproto/802_11/wlan/ieee80211_ageq.c:114) and
KASSERTs it is non-negative (:116). The DWDS staging caller
(sys/netproto/802_11/wlan/ieee80211_wds.c:335) derives the requested age from
ni->ni_intval, which the AP copies unvalidated from the association
request's listen-interval field
(ieee80211_hostap.c:1966 lintval = le16toh(...), :2130
ni->ni_intval = lintval). An associated station therefore controls the
ages handed to the queue, and they are not monotonic: staging a frame with a
large listen-interval first, then re-associating with a small one and staging
a second frame, drives the relative age negative.
Reproduced β baseline (stock INVARIANTS kernel)
Guest: DragonFly 6.5-DEVELOPMENT #0 (X86_64_GENERIC, INVARIANTS), up via
dfbsd-qemu/vm.sh. ageqpoc.ko (this directory) drives the exported ageq API
with exactly the two ages a single attacker station produces
(listen-interval 2000 β age 781; then 1 β age 0):
ageqpoc: append #1 age=781 (listen-intval 2000)
ageqpoc: append #1 rc=0 aq_len=1
ageqpoc: append #2 age=0 (reassoc listen-intval 1) -> relative age 0-781 = -781
panic: age -781
Trace: ieee80211_ageq_append+0xc1 <- ageqpoc_modevent <- module_register_init
<- linker_load_file.part.3 <- linker_load_module
Full serial-console capture: panic.txt, run.log. The ssh session died
with the panic (kldload never returned) and the guest required a hard reset β
classic panic reproduction.
Impact
- INVARIANTS/debug kernels: remote denial of service β any associated
station on a DWDS (or mixed DWDS+mesh) vap can panic the AP with two
staged 4-addr frames around a re-association. Not unauthenticated: the
port must be authorized (hostap.c:654-663), but open-network APs authorize
immediately after (re)assoc.
hwmp.c:2236(fixed age 15) hits the same KASSERT whenever it stages behind a wds head with >15 remaining. - production (no INVARIANTS) kernels: the negative age is stored into
m_pkthdr.csum_data(M_AGE_SET) and the frame simply expires one sweep early β no memory unsafety. This bounds severity at Medium.
No escalation chain exists: the primitive is a controlled-constant KASSERT panic, not memory corruption. uid0 is not reachable from this bug.
Fix validation (mandatory for reproduced findings β done)
fix.diff (this directory) clamps the relative age at 0 inside
ieee80211_ageq_append before the KASSERT, protecting all callers (wds,
mesh, hwmp) at once. Applied to the guest's /usr/src copy, kernel rebuilt
with make -j4 nativekernel KERNCONF=X86_64_GENERIC +
make installkernel, rebooted:
- baseline kernel #0:
panic: age -781(see run.log / panic.txt) - patched kernel #1 (uname below): identical PoC β
append #2 rc=0 aq_len=2, no panic, module unloads cleanly, guest stays up (see run.2.log)
fix_kernel_uname: DragonFly dfbsd 6.5-DEVELOPMENT #1: Sun Sep 13 09:29:00
UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64
Guest was reset to the clean-source snapshot after validation.
Recommended hardening (in addition to the clamp)
Validate the association listen-interval at parse time (e.g. reject lintval==0 / clamp to [1,600]) β several consumers trust ni_intval.
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
fixedfix.diff applied to guest /usr/src; make nativekernel + installkernel; reboot into kernel #1; identical PoC produces rc=0 aq_len=2 with no panic and clean module unload (run.2.log), while baseline kernel #0 panicked (run.log/panic.txt).
findings/poc/DF-3466/run.2.log (patched run), findings/poc/DF-3466/run.log + panic.txt (baseline), findings/poc/DF-3466/fix.diff (git apply --check clean against sys/)
Confirmed kernel references
- sys/netproto/802_11/wlan/ieee80211_ageq.c:114
- sys/netproto/802_11/wlan/ieee80211_ageq.c:116
- sys/netproto/802_11/wlan/ieee80211_wds.c:335
- sys/netproto/802_11/wlan/ieee80211_hostap.c:1966
- sys/netproto/802_11/wlan/ieee80211_hostap.c:2130
- sys/netproto/802_11/wlan/ieee80211_hwmp.c:2236
- sys/netproto/802_11/ieee80211_dragonfly.h:189
Detail
Evidence (decisive lines)
["panic.txt: serial-console capture: 'panic: age -781' with ieee80211_ageq_append+0xc1 backtrace from kldload of ageqpoc.ko", 'run.log: baseline session transcript (ssh session dies at kldload; panic on console)', 'run.2.log: fix-validation run on kernel #1 with fix.diff applied: append #2 rc=0 aq_len=2, no panic, clean unload', 'ageqpoc.c: KLD driving the ageq API with exactly the two ages one attacker station controls (listen-interval 2000 then 1)', 'fix.diff: clamp of negative relative age at ieee80211_ageq.c:114-117 (git-apply clean against sys/)', 'VERDICT.md: full narrative incl. reachability chain hostap.c:1966 -> :2130 -> wds.c:335 -> ageq.c:114/116']
PoC changes
None versus the seed sketch concept; the PoC was authored fresh for this run: initial module missed the opt_wlan.h/opt_inet.h generation and the net/if_var.h include chain (mirrored ieee80211_ageq.c's own include order); ages chosen to mirror (ni_intvalic_lintval<<2)/1024 exactly (20001004/1024=781, 1100*4/1024=0).
Verified recommended fix
Clamp negative relative age to 0 in ieee80211_ageq_append (see fix.diff); additionally validate the association listen-interval.
Verdict
ieee80211_ageq_append stores caller ages relative to the head of the queue (ageq.c:114) and KASSERTs non-negativity (:116); the DWDS staging caller (wds.c:335) feeds it ages derived from the unvalidated association listen-interval (hostap.c:1966->2130), so an associated station on a DWDS/mesh AP makes the relative age negative and panics INVARIANTS kernels ('panic: age -781' reproduced on the stock X86_64_GENERIC guest, backtrace ieee80211_ageq_append+0xc1). Production kernels merely expire the frame one sweep early (no memory unsafety), capping severity at Medium/DoS. fix.diff clamps the negative relative age to 0; rebuilt kernel #1 runs the identical PoC with no panic (rc=0, aq_len=2), validating the fix.
No comments yet.