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

mesh_decap stack OOB write: reserved AE=11 mesh control grows the header copy to 56 bytes into the 50-byte stack buffer b[]

Summary

mesh_input never rejects the reserved AE=11 value of mc_flags&3, so meshdrlen = 6+3*6 = 24 and hdrspace = 56 for a DSTODS QoS frame; mesh_decap then does memcpy(b, mtod(m), hdrlen) into uint8_t b[50]. Six attacker-controlled bytes (the would-be mc_addr6) are written past b[] on the multicast data path, which has no AE validation on any kernel config. Remote unauthenticated-after-peering (victim auto-peers by default) kernel stack overwrite from a single 68-byte multicast mesh data frame. VERIFIED on stock #0: is_mesh_badae increments after the copy (proves the memcpy executed); shipped-binary disassembly proves the 50-byte object at rbp-0x62 receives a 56-byte rep movsb; on this gcc-8.3 INVARIANTS build the 6 bytes land in stack padding so it is silent, but any relayout (or 8 more bytes) reaches saved registers/return address - layout-latent control-flow corruption primitive, unlimited rate. uid0 hard blocker documented (2 bytes short of saved rbx on this build). Fix validated (AE>AE_10 rejected in mesh_input; AE=0 regression-tested).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3286 Β· 11 files
FileTypeDescriptionSize
fwm.c β€” 22.6 KB view raw
Makefile β€” 150 B ↓ download
README.md β€” 3.7 KB ↓ raw
VERDICT.md β€” 3.0 KB ↓ raw
run.log β€” 2.2 KB view raw
env.txt β€” 655 B view raw
build.sh β€” 87 B view raw
run.sh β€” 111 B view raw
fix.diff β€” 3.4 KB view raw
verdict.json β€” 3.2 KB view raw
manifest.json β€” 978 B view raw

DF-3286 β€” mesh_decap stack OOB write: reserved AE=11 mesh control grows header copy to 56 bytes into 50-byte stack buffer b[]

File: sys/netproto/802_11/wlan/ieee80211_mesh.c (pass 2 audit)

What

mesh_input (:1738-1741) computes the mesh-control length from the attacker-supplied Address Extension bits without rejecting the reserved value AE=11 (3):

ae = mc->mc_flags & IEEE80211_MESH_AE_MASK;          /* 0..3, 3 = RESERVED */
meshdrlen = sizeof(struct ieee80211_meshcntl)         /* 6  */
    + ae * IEEE80211_ADDR_LEN;                        /* 3*6 = 18 -> 24 */
hdrspace += meshdrlen;                                /* 32 + 24 = 56 */

mesh_decap (:1281-1298) then copies the full header into a stack buffer sized for the legal maximum only:

uint8_t b[sizeof(struct ieee80211_qosframe_addr4)    /* 32 */
          + sizeof(struct ieee80211_meshcntl_ae10)]; /* 18 -> 50 */
...
memcpy(b, mtod(m, caddr_t), hdrlen);                 /* hdrlen = 56 */

A 4-address (DSTODS) QoS mesh multicast data frame with mc_flags & 3 == 3 from an established mesh peer writes 6 attacker-controlled bytes past b[] (the last 6 bytes of the 24-byte mesh control, i.e. the would-be mc_addr6). The multicast path has no AE validation anywhere (to_me/to_fwrd are unicast-only), so this fires on INVARIANTS and production kernels.

Runtime proof on the stock kernel (X86_64_GENERIC #0, INVARIANTS)

  1. Disassembly of the shipped kernel (mesh_decap inlined into mesh_input): ffffffff80779e62: lea -0x62(%rbp),%r14 # b = rbp-0x62 (50 bytes: .. rbp-0x31) ffffffff80779e73: mov -0x98(%rbp),%rdx ffffffff80779e7a: rep movsb %ds:(%rsi),%es:(%rdi) # count = -0x90(%rbp) = hdrlen = 56 The copy provably runs 56 bytes into the 50-byte object; the 6 extra bytes land at rbp-0x30..rbp-0x2b, which in this build is stack padding (saved registers start at rbp-0x28), so the guest survives β€” a silent, layout-latent kernel stack overwrite.

  2. Reachability: injecting the frame via the harness (sysctl kern.fwm_step=10) on the stock guest leaves the guest up and increments is_mesh_badae β€” the frame provably reaches mesh_decap's post-memcpy AE dispatch (ieee80211_mesh.c:1339), i.e. the memcpy has already executed. Γ—21 frames, still up (padding absorbs it here).

Build / run (repo root)

sh findings/poc/DF-3286/build.sh    # builds /root/fwm/fwm.ko in the guest
sh findings/poc/DF-3286/run.sh      # brings mesh vap up, peers, fires step 10

Harness: fwm.c β€” software net80211 radio + RX frame injector + TX capture (see README in this dir). The peering dance (beacon β†’ OPEN β†’ CONFIRM, all synchronous) puts the victim in IEEE80211_NODE_MESH_ESTABLISHED, after which a single 68-byte crafted multicast data frame triggers the overflow.

Expected output

fwm: wlan0 RUN, beacon allocated
fwm: inject BEACON ... / inject PEER OPEN / inject PEER CONFIRM
fwm: inject MCAST AE=3 data (68 bytes; decap hdr=56 > b[50])
kern.fwm_stats: is_mesh_badae=1 ...      # memcpy executed, frame then discarded
GUEST_STILL_UP                             # 6 bytes hit padding on this build

Patched (fix.diff kernel): the AE=11 frame is discarded at mesh_input before any decap math; same run shows no decap-path activity for the frame and normal delivery for legal AE values.

Threat

Remote, unauthenticated-after-peering (victim auto-peers with any mesh STA by default, ms_flags = AP|FWD at :683). 6 controlled bytes of kernel-stack overwrite per frame; on this exact gcc-8.3 INVARIANTS build they land in padding, on any rebuild/layout where b[] is adjacent to live locals or spill slots this becomes stack corruption (potential control-flow influence). CWE-787/CWE-121.

VERDICT.md
↓ download raw

DF-3286 VERDICT β€” REPRODUCED (memory write proven on stock kernel; silent on this build's stack layout)

Bottom line

The 6-byte out-of-bounds stack write is proven to execute on the stock kernel, by three mutually-independent pieces of evidence:

  1. Source math (certain): meshdrlen = 6 + 3*6 = 24 for AE=11, hdrspace = 32 (DSTODS QoS), so mesh_decap(..., hdrlen=56, ...) is handed 56 bytes for a uint8_t b[50] (sizeof(qosframe_addr4)=32 + sizeof(meshcntl_ae10)=18) at sys/.../ieee80211_mesh.c:1281-1296, 1738-1741.

  2. Shipped-binary disassembly: in /boot/kernel/kernel (mesh_decap inlined at mesh_input+0xae2): lea -0x62(%rbp),%r14 (b base) … rep movsb with count loaded from the hdrlen slot (-0x90(%rbp)) β€” the destination object is 50 bytes (rbp-0x62..rbp-0x31) and the runtime count is 56, so bytes rbp-0x30..rbp-0x2b are written out of object bounds. Those 6 bytes are the frame's would-be mc_addr6 (fully attacker-chosen; 0xCC pattern in the PoC).

  3. Runtime reachability (stock guest, fwm harness): after sysctl kern.fwm_step=10, is_mesh_badae increments β€” that counter is bumped at ieee80211_mesh.c:1339 after the memcpy, proving the copy executed on the live kernel. 21 consecutive frames: guest stays up.

Why no panic on this build

The overflow region rbp-0x30..rbp-0x2b is stack padding in this exact gcc 8.3 -O2 INVARIANTS build (saved registers begin at rbp-0x28, 2 bytes further up; no spill slot or local is addressed in the overflow window β€” verified by scanning the full mesh_input disassembly). The bug is nonetheless a genuine CWE-787 stack OOB write whose impact is compiler-layout-dependent: 6 more bytes would reach saved rbx; any relayout that places a live local or spill immediately above b[] converts this into stack corruption of a pointer/int with attacker-controlled data on a remote unauthenticated-after-peering path.

Exploit chain (honest ceiling)

none on this build (padding). Primitive: 6 controlled bytes at fixed offset above a 50-byte stack array in the RX path of every mesh data frame; rate-unlimited from any established peer.

Fix validation

Combined fix kernel (fix.diff hunk #4: reject ae > IEEE80211_MESH_AE_10 in mesh_input before meshdrlen math) built rc=0, rebooted, re-run: - AE=11 frame now discarded at the new early gate (frame never reaches mesh_decap; disassembly of the fixed kernel shows the new branch); - legal AE=00 data frames still decap and deliver normally (no regression); - guest stays up across the full battery. See fix section of run.log / fix notes in manifest.json.

Reproduce

see README.md; harness in this directory (fwm.c).

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

AE=11 frames rejected at the new mesh_input gate (line-annotated disasm of fixed kernel mesh.c:1766-1770); the 56-byte decap memcpy is unreachable for AE=11; legal AE=0 data still decaps+delivers (no regression); no panic

['fix.log: binary proof + AE3_REJECTED_EARLY / AE0_DELIVERED_NO_CRASH']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Thu Sep 10 05:34:08 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (fix kernel)

Confirmed kernel references

Detail

Exploit chain

attacker mesh STA -> auto-peers with victim (default ms_flags AP|FWD) -> single 68-byte multicast DSTODS QoS data frame with mc_flags AE=11 -> mesh_input computes meshdrlen=24, pulls 56-byte header -> group path reaches mesh_decap -> memcpy overflows b[] by 6 attacker-chosen bytes -> (this build: padding; relayout: stack corruption)

Evidence (decisive lines)

run.log: is_mesh_badae=1 -> =21 after repeated step 10, GUEST_STILL_UP
run.log: objdump excerpt - lea -0x62(%rbp),%r14 / rep movsb with hdrlen=56
VERDICT.md: full disasm + layout analysis

PoC changes

wrote mesh-capable harness from scratch (fwm.c, extends DF-3282 fw.c pattern): MBSS vap, synchronous beacon+OPEN+CONFIRM peering dance, RX injection via ieee80211_input_mimo, TX capture in ic_raw_xmit; learned victim mlpid from captured OPEN frames

Verified recommended fix

reject ae > IEEE80211_MESH_AE_10 in mesh_input before computing meshdrlen (see fix.diff hunk 4)

Verdict

mesh_decap memcpy(b, m_data, hdrlen) copies 56 bytes (DSTODS hdrspace 32 + AE=11 mesh control 24) into the 50-byte stack buffer b[sizeof(qosframe_addr4)+sizeof(meshcntl_ae10)] because mesh_input never rejects the reserved AE=11 bits. The 6-byte OOB write of attacker-controlled bytes (would-be mc_addr6) is proven to execute on the stock INVARIANTS kernel: shipped-binary disassembly shows the 50-byte object at rbp-0x62 and a rep movsb of 56, and the is_mesh_badae counter (bumped after the memcpy) increments on each injected frame. On this exact gcc-8.3 build the 6 bytes land in stack padding (saved registers begin 2 bytes beyond), so no crash is observed - the primitive is a layout-latent remote stack overwrite; 8 more bytes would reach saved rbx.