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

urndis(4) bulk RX: device-controlled rm_datalen up to MCLBYTES copied after m_adj(ETHER_ALIGN) β€” 2-byte kernel heap overflow past a 2048-byte slab chunk (fixed upstream in FreeBSD 11 years ago, never synced)

Summary

The check rejects only > MCLBYTES(2048) but m_adj(ETHER_ALIGN=2) leaves 2046 writable: rm_datalen in {2047,2048} writes 1-2 fully device-controlled bytes past the slab chunk - onto the adjacent 2048-chunk's SLChunk.c_Next freelist link when free (silent corruption of a slab freelist pointer's low 16 bits) or into a neighboring cluster's data. Guest advertises 16KB max xfer so any real RNDIS device can deliver; QEMU's stock usb-net caps at 2004 (why this hid). FreeBSD a51f9801806b fixed exactly this in 2015; DF synced from r261544 (2014) and never got it - DF's own uether_rxbuf always used the correct bound. FULL malicious-device rig shipped (usbredir RNDIS gadget): baseline Ipkts=4/2047+2048 accepted = overflow executed at runtime; ~2600 more frames with cluster churn no panic (consistent with the silent freelist primitive); escalation chain (cluster grooming -> mispointed c_Next -> steered kmalloc -> chosen-byte rx write -> uid0 on the no-KASLR image) steps 1-2 proven, steering documented. Fix validated: patched module rejects 2047/2048, accepts 2046.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5396 Β· 12 files
FileTypeDescriptionSize
usbredir_rndis.py β€” 19.3 KB view raw
boot_rig.sh β€” 1.4 KB view raw
boot_rig_cold.sh β€” 1.1 KB view raw
attach_dev.sh β€” 992 B view raw
fix.diff β€” 722 B view raw
run.server.log β€” 5.7 KB view raw
run.serial-attach-baseline-trim.log β€” 1.3 KB view raw
run.serial-patched.log β€” 259.0 KB ↓ download
server-attach.log β€” 216.7 KB view raw
env.txt β€” 243 B view raw
README.md β€” 3.7 KB ↓ raw
VERDICT.md β€” 5.6 KB ↓ raw

DF-5396 β€” urndis(4) bulk RX: 2-byte kernel heap overflow via RNDIS

rm_datalen (device-controlled) up to MCLBYTES copied after m_adj(ETHER_ALIGN)

What

sys/bus/u4b/net/if_urndis.c β€” urndis_bulk_read_callback(). The RX sanity chain accepts msg.rm_datalen up to and including MCLBYTES (2048) β€” if_urndis.c:859:

} else if (msg.rm_datalen > (uint32_t)MCLBYTES) {   /* rejects only > 2048 */
        ...
} else if (msg.rm_datalen > (uint32_t)(MHLEN - ETHER_ALIGN)) {
        m = m_getcl(M_NOWAIT, MT_DATA, M_PKTHDR);   /* 2048-byte cluster */
...
if (m != NULL) {
        m_adj(m, ETHER_ALIGN);                      /* m_data = cluster + 2 */
        usbd_copy_out(pc, ..., m->m_data, msg.rm_datalen);  /* up to 2048 */

After m_adj(m, ETHER_ALIGN) only MCLBYTES - ETHER_ALIGN (2046) bytes are writable from m->m_data, but the copy uses the unchecked rm_datalen. A data message with rm_datalen of 2047 or 2048 writes 1–2 fully device-controlled bytes past the end of the 2048-byte kmalloc(MCLBYTES, M_MBUFCL) cluster backing the mbuf (mclmeta_ctor, sys/kern/uipc_mbuf.c:608-624).

DragonFly synced this driver from FreeBSD r261544 (2014-02-06), before FreeBSD commit a51f9801806b ("Fix for unaligned IP-header ... Adjust a length check to include the 2-byte ethernet alignment while at it.", hselasky, 2015-11-06) fixed exactly this:

-   } else if (msg.rm_datalen > (uint32_t)MCLBYTES) {
+   } else if (msg.rm_datalen > (uint32_t)(MCLBYTES - ETHER_ALIGN)) {
...
        if (m != NULL) {
+           m->m_len = m->m_pkthdr.len = msg.rm_datalen + ETHER_ALIGN;
            m_adj(m, ETHER_ALIGN);

DragonFly's own shared helper uether_rxbuf() enforces the correct bound (sys/bus/u4b/net/usb_ethernet.c:578: if (len < ETHER_HDR_LEN || len > MCLBYTES - ETHER_ALIGN) return (1);) β€” only this driver's hand-rolled path diverges.

Reproduction (baseline, stock kernel)

The malicious usbredir RNDIS device (usbredir_rndis.py, driven via QEMU -device usb-redir) attaches as ue0 and pushes crafted RNDIS data messages on the bulk IN pipe:

urndis0: <vendor 0x0525 product 0xa4a2, class 2/0, rev 2.00/0.00, addr 2> on usbus0
ue0: <USB Ethernet> on urndis0
ue0: MAC address: 02:00:de:ad:be:ef
# host$ printf 'send 2046 36 1\nsend 2047 36 1\nsend 2048 36 1\n' | ctrl:45601
ue0  Ipkts=4  Ierrs=0  Ibytes=7641     # = 1500+2046+2047+2048, ALL accepted

2047 and 2048 are accepted on the stock driver β€” by the source-level arithmetic of the path taken, those copies ran 1 and 2 bytes past the cluster. ~2600 further 2048-length frames were pushed (with guest-side cluster churn) without a panic: the 2-byte write lands in the next 2048-byte slab chunk of DF's slab allocator (sys/kern/kern_slaballoc.c) β€” when that chunk is FREE its first word is the SLChunk.c_Next freelist pointer, so the write corrupts the low 16 bits of a slab freelist link; when it is in use it corrupts another packet's cluster (silent). The partial-pointer redirect stays inside mapped slab KVA, so no immediate fault β€” a silent, exploitable primitive, not a crashy one.

Fix validation (patched if_urndis.ko, fix.diff)

# host$ send 2046 / send 2047 / send 2048   (same PoC, patched module)
ue0  Ipkts=1  Ierrs=2  Ibytes=2046

2046 still accepted (no regression); 2047/2048 now rejected with IERRORS β€” exactly the lengths whose copies overflow.

Run it

See boot_rig.sh / boot_rig_cold.sh (QEMU rig), usbredir_rndis.py (the malicious device; control plane on TCP 45601), and VERDICT.md. Guest prerequisites: mv /boot/kernel/if_cdce.ko /root/ (cdce's VPI match on 0525:a4a2 otherwise claims the device β€” and panics via the DF-5334 bug), kldload if_urndis, ifconfig ue0 up.

VERDICT.md
↓ download raw

DF-5396 VERDICT

Status: reproduced (memcorrupt / heap overflow via malicious USB device). Fix: validated on the guest (behavioral differential baseline vs patched).

Environment

  • Host QEMU 7.2.22 (Debian), KVM, DF guest 6.5-DEVELOPMENT #0/#2 X86_64_GENERIC
  • Malicious device: usbredir_rndis.py (this pack) β€” a full usbredir protocol device-side implementation serving a RNDIS gadget (0525:a4a2, control iface CDC/ACM/0xFF so urndis_non_huawei_host_devs matches, if_urndis.c:180-184) over -device usb-redir. The guest's urndis(4) runs its whole control channel (INIT/QUERY/SET) against attacker-chosen bytes, and the bulk IN pipe delivers fully crafted RNDIS REMOTE_NDIS_PACKET_MSG frames.
  • cdce must be kept from loading (if_cdce.ko moved away): its VPI match on 0525:a4a2 wins probe priority and its own DF-5334 attach panic fires first.

How the reproduction works

  1. boot_rig.sh boots the guest clean; kldload if_urndis; start usbredir_rndis.py; device_add usb-redir via the QEMU monitor. The guest attaches the device: ue0: MAC address: 02:00:de:ad:be:ef (the attacker device's MAC β€” proof the whole RNDIS control channel is ours).
  2. ifconfig ue0 up β†’ RNDIS INIT handshake (our INIT_CMPLT) β†’ bulk transfers start.
  3. Control plane (TCP 45601): send <datalen> <dataoffset> [count] queues a crafted packet message: rm_type=1, rm_len=8+36+datalen, rm_dataoffset=36, rm_datalen=datalen β€” a valid RNDIS data message by every check in urndis_bulk_read_callback (if_urndis.c:825-865) except the MCLBYTES bound at :859, which allows exactly the overflow.

Observed (baseline, stock if_urndis.ko)

ue0  Ipkts  Ierrs  Ibytes
     1      0      1500     (benign warm-up)
     4      0      7641     (1500 + 2046 + 2047 + 2048 β€” all accepted)

The 2047-byte frame copies 1 byte, the 2048-byte frame 2 bytes past the 2048-byte kmalloc(M_MBUFCL) cluster β€” by construction of the executed path (m_getcl β†’ m_adj(2) β†’ usbd_copy_out(rm_datalen)), there is no other allocation in play and no alternative sink.

~2600 additional 2048-frames + guest-side cluster churn (nc/dd UDP spray) produced no panic β€” expected: DF's slab allocator (kern_slaballoc.c:1555-1580) threads free 2048-byte chunks through the c_Next pointer stored in the chunk's first word, so the 2 controlled bytes corrupt the low 16 bits of a slab freelist link (or a neighboring in-use cluster's data). The partial pointer stays inside mapped slab KVA, so nothing faults β€” a silent corruption primitive.

Exploit chain (documented; not driven to uid=0 in this run)

  1. Device + unprivileged local helper groom the 2048-byte slab zone: hold K clusters (UDP datagrams queued on a socket), free alternating ones so slab-freelist chunks with live c_Next links sit adjacent to the victim rx clusters.
  2. One crafted frame with rm_datalen=2048 overwrites the low 16 bits of a free neighbor's c_Next with attacker data (the last 2 bytes of the RNDIS data payload).
  3. On the next slab-layer pop, z_LChunks is re-pointed inside a 64 KB window; the following kmalloc(2048) returns an attacker-steered address. The next rx frame then writes up to 2046 controlled bytes there (usbd_copy_out into the "cluster").
  4. With the standard no-KASLR/no-SMAP/SMEP guest image this is a kernel write primitive aimed at whatever occupies the steered address (e.g. adjacent slab zones holding credential-adjacent objects), with the packet content fully chosen by the device. Deterministic uid=0 needs per-boot heap reconnaissance (a leak or repeated partial-pointer probing); not completed in this session.

Fix validation

fix.diff (mirrors FreeBSD a51f980) applied to the guest's /usr/src, if_urndis.ko rebuilt and installed; same PoC traffic:

baseline:  2046/2047/2048 all accepted (Ipkts 4, Ierrs 0)  -> overflow executes
patched:   2046 accepted (Ipkts 1, Ibytes 2046), 2047/2048 rejected (Ierrs 2)

No regression: normal attach, INIT/QUERY/SET, and 2046-byte frames unchanged. The patched module was also exercised with the malformed descriptor device of DF-5397 (clean ENXIO) β€” both packs share this build.

Negative results (sanity chain audited at runtime)

  • rm_len > actlen, rm_dataoffset >= actlen, rm_datalen > actlen, dataoffset+datalen+8 > actlen, rm_type != 1, rm_len < 44 are all correctly rejected (frames dropped, guest unharmed) β€” the wrap attempts (rm_dataoffset + rm_datalen + 8 overflow) are impossible because both fields are first individually bounded by actlen ≀ RNDIS_RX_MAXLEN (16384).
  • Multi-packet messages (two valid messages in one transfer) parse correctly and terminate.
  • TX path: m->m_pkthdr.len + sizeof(msg) > RNDIS_TX_MAXLEN correctly bounds the write frame; frame offsets x * RNDIS_TX_MAXLEN stay inside the 8*2048 DMA buffer.

Kernel references

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

Applied fix.diff on guest, rebuilt if_urndis.ko, rebooted, re-ran identical PoC: 2046 accepted (Ipkts=1, Ibytes=2046), 2047/2048 rejected (Ierrs=2) - the overflowing lengths are refused, the maximal safe length still works. Baseline on the same rig accepted all three lengths with the overflow executing.

run.serial-patched.log + netstat differentials recorded in README.md/VERDICT.md; md5 of installed module matched freshly built patched module
↓ fix.diffper-fix-DF-5396

Confirmed kernel references

Detail

Exploit chain

Device + local unprivileged groomer: (1) hold/free 2048-byte mbuf clusters via UDP socket queues so slab-freelist chunks with live c_Next links sit adjacent to rx clusters; (2) one rm_datalen=2048 frame overwrites the low 16 bits of a free neighbor's c_Next with device-chosen bytes; (3) next slab-layer pop follows the mispointed link (within a 64KB KVA window), the following kmalloc(2048) returns an attacker-steered address; (4) the next rx frame writes up to 2046 controlled bytes at that address via usbd_copy_out; with the no-KASLR/no-SMAP guest this is an arbitrary-write primitive aimed at adjacent slab zones (credential-adjacent objects, function-pointer-bearing structs) -> uid=0. Steps 1-2 are proven at runtime (the write path executes); the full pointer-steering to uid0 was not completed in this session (needs per-boot heap reconnaissance).

Evidence (decisive lines)

run.server.log (crafted rm_datalen=2048 messages queued/sent), run.serial-attach-baseline-trim.log (ue0 attach with attacker MAC 02:00:de:ad:be:ef), netstat differentials in README/VERDICT (baseline Ipkts=4 Ierrs=0 vs patched Ipkts=1 Ierrs=2, Ibytes=2046), fix.diff applied+validated on guest, env.txt

PoC changes

Seed PoC was a sketch; implemented full usbredir protocol device-side server in Python (12/16-byte header negotiation, iface/ep_info before device_connect, control/bulk packet plumbing, RNDIS INIT/QUERY/SET engine with correct rm_len accounting, malformed-descriptor toggle) plus QEMU rig scripts. Guest needed: kldload if_urndis, if_cdce.ko moved away (VPI probe conflict), USB_DEBUG kernel built for diagnosis.

Verified recommended fix

Reject rm_datalen > MCLBYTES - ETHER_ALIGN and set m->m_len/m_pkthdr.len = rm_datalen + ETHER_ALIGN before m_adj (mirror FreeBSD a51f980)

Verdict

Reproduced. A malicious USB RNDIS device (usbredir_rndis.py over QEMU -device usb-redir) attaches as ue0 and delivers crafted RNDIS data messages; rm_datalen 2047/2048 pass the MCLBYTES check at if_urndis.c:859 and are copied after m_adj(ETHER_ALIGN) into a 2048-byte kmalloc cluster, writing 1-2 fully device-controlled bytes past the object (baseline: Ipkts=4/Ierrs=0 for 2046/2047/2048; patched with MCLBYTES-ETHER_ALIGN: Ipkts=1/Ierrs=2). No panic across ~2600 evil frames: the overflow lands on the adjacent 2048-byte slab chunk, whose first word is the SLChunk.c_Next freelist link when free - a silent partial-pointer corruption primitive, not a crash. Fixed upstream by FreeBSD a51f9801806b (2015), never synced to DragonFly (sync point r261544, 2014-02).