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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3466 Β· 12 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download 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.

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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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/)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Sun Sep 13 09:29:00 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

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.