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

Mergeable-RX sub-buffer negative device length installs a negative-m_len mbuf chain into the network stack (vtnet_rxeof_merged signed clamp)

Summary

virtqueue_dequeue stores the device-controlled used-ring length through a uint32_t* while vtnet_rxeof_merged owns an int len. The clamp 'if (m->m_len < len) len = m->m_len' is a signed compare: a device length >= 0x80000000 arrives as INT_MIN, skips the clamp, and is written as m->m_len and folded into pkthdr.len. The corrupted chain is delivered to if_input BEFORE any validation - ether_input taps BPF first. Downstream m_copydata computes count as negative then bcopy's (unsigned)0x80000000 bytes -> kernel smash; bpf_mcopy walks into the sub-mbuf copying stale cluster bytes to listeners. Mergeable mode is the live config of the audit guest's own console NIC. VERIFIED: function-level replica with exact types - device len 0x80000000 -> m_len=-2147483648, pkthdr.len=-2147483622, bcopy count (unsigned)0x80000000; after-fix arm shows clamped values. NOT DF-1310 (different mode/site/primitive).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3958 Β· 13 files
FileTypeDescriptionSize
trigger.c trigger-source function-level replica of vtnet_rxeof_merged signed clamp + downstream consumers 5.8 KB view raw
build.sh build-script cc -O2 -Wall -o trigger trigger.c 65 B view raw
run.sh run-script ./trigger 89 B view raw
build.log build-log guest compile output (BUILD_OK) 9 B view raw
run.log run-log baseline-kernel decisive run: negative m_len + 2GiB bcopy count 1.1 KB view raw
run.2.log run-log patched-kernel re-run incl. AFTER-FIX arm 1.1 KB view raw
fix.diff suggested-fix unsigned clamp in vtnet_rxeof_merged (git-apply-able) 728 B view raw
fix_build.log build-log make nativekernel with fix applied, -Werror, rc=0 5.3 KB view raw
fixapply2.log apply-log patch -p1 output, all hunks applied 1.3 KB view raw
env.txt environment guest uname, cc version 213 B view raw
dmesg.txt dmesg vtnet0 attach on virtio_pci0, 3 MSI-X vectors 320 B view raw
VERDICT.md verdict full narrative 4.0 KB ↓ raw
README.md readme reproduce instructions 1.9 KB ↓ raw
README.md readme reproduce instructions
↓ download raw

DF-3958 β€” vtnet mergeable-RX sub-buffer negative device length

vtnet_rxeof_merged() clamps the device-reported sub-buffer length with a SIGNED compare; a used-ring length >= 0x80000000 arrives as a negative int, skips the clamp, and is stored as m->m_len β€” an mbuf with a negative length plus a corrupted m_pkthdr.len is chained and handed to if_input() before any length validation.

Reproduce (on the DragonFly guest)

./build.sh   # cc -O2 -Wall -o trigger trigger.c
./run.sh     # prints BEFORE-FIX corruption signature + AFTER-FIX clamp

Expected (see run.log / run.2.log): with device length 0x80000000 the BEFORE-FIX path yields m2->m_len = -2147483648, m_head.m_pkthdr.len = -2147483622; the m_copydata consumer replica derives a bcopy count of (unsigned)0x80000000 bytes. AFTER-FIX (unsigned clamp) yields m2->m_len = 2048, pkthdr.len = 2074.

Verified

  • Function-level harness built and run on the audit QEMU guest (DragonFly 6.5-DEVELOPMENT), both on the baseline kernel and on a kernel built with fix.diff (kernel #1 Thu Sep 17 05:23:00 UTC 2026).
  • fix.diff applies cleanly to /usr/src (patch -p1, all hunks), kernel builds with -Werror rc=0 (fix_build.log), boots, and vtnet0 (the guest's own console NIC) stays UP/RUNNING through a heavy ssh session β€” the fixed clamp path is exercised by every packet of the session.
  • The guest's benign QEMU device never emits insane used-ring lengths, so the corruption itself is demonstrated at the function level (same standard as DF-1310), not by crashing the live console NIC.

Trigger prerequisite

A malicious/compromised virtio host (or virtio device passed through from a compromised host) that negotiates VIRTIO_NET_F_MRG_RXBUF and writes used.ring[].len >= 0x80000000 for a mergeable sub-buffer. QEMU's own virtio-net emulation always writes sane lengths, which is why the audit guest cannot trigger it from inside.

VERDICT.md verdict full narrative
↓ download raw

DF-3958 β€” VERDICT

REPRODUCED at the function level (impact: panic), confidence: certain.

Root cause

virtqueue_dequeue() stores the device-controlled used-ring element length through a uint32_t *lenp (sys/dev/virtual/virtio/virtio/virtqueue.c:544-561, store at :560-561). vtnet_rxeof_merged() owns an int len and passes &len to it (sys/dev/virtual/virtio/net/if_vtnet.c:1642,1649). For every mergeable sub-buffer the driver then clamps and installs that length:

if (m->m_len < len)      /* if_vtnet.c:1663 β€” SIGNED compare   */
        len = m->m_len;
m->m_len = len;          /* if_vtnet.c:1666 β€” m_len := device len */
...
m_head->m_pkthdr.len += len;  /* if_vtnet.c:1669 */
m_tail->m_next = m;           /* if_vtnet.c:1670 β€” chained */

A device length >= 0x80000000 becomes a negative int; m->m_len < len (2048 < INT_MIN) is false, so the clamp is skipped and m->m_len is set negative. The chain is handed to ifp->if_input() at if_vtnet.c:1777.

This is NOT covered by DF-1310 (non-mergeable first buffer, len += VTNET_RX_HEADER_PAD wrap, NULL m_prev deref in vtnet_replace_rxbuf). Here:

  • the first-buffer guards do not protect sub-buffers: the len < hdr_size + ETHER_HDR_LEN drop (if_vtnet.c:1707) and the vtnet_replace_rxbuf() walk (if_vtnet.c:1727) run only on the FIRST buffer of the merged frame;
  • the negative length installs directly into a live mbuf and the packet header instead of NULLing a local.

Impact

  • m2->m_len negative and m_head->m_pkthdr.len negative (proven by the harness: run.log, sweep section).
  • The corrupted chain reaches consumers BEFORE any length validation: ether_input_oncpu() taps bridge/carp/ng_ether/BPF first (sys/net/if_ethersubr.c:1212-1290).
  • m_copydata() on such a chain computes count = min(m->m_len - off, len) β†’ negative β†’ (unsigned)0x80000000 β†’ bcopy of 2 GiB (sys/kern/uipc_mbuf.c:1686-1695) β€” kernel smash / panic. bpf_mcopy() (sys/net/bpf.c:1275-1287) walks into the sub-mbuf and copies stale cluster bytes into listener buffers (minor info exposure to a BPF listener).
  • Plain IP/IPv6 demux drops the frame on m_pkthdr.len < hdr checks, so the practical ceiling is DoS/panic plus the BPF stale-byte exposure β€” consistent with the Medium severity of its non-mergeable twin DF-1310.

Why function-level (not live-panicked)

The audit guest's virtio-net device is QEMU's own benign emulation; it always writes sane used-ring lengths. Crashing the guest's console NIC to prove the primitive was deemed destructive without adding evidence beyond the arithmetic β€” same standard used for DF-1310.

Fix validation

  • fix.diff (unsigned clamp if ((uint32_t)len > (uint32_t)m->m_len)) applies cleanly to /usr/src (fixapply2.log).
  • Kernel rebuilt make -j6 nativekernel KERNCONF=X86_64_GENERIC, rc=0, -Werror (fix_build.log); installed and booted (#1 Thu Sep 17 05:23:00 UTC 2026); vtnet0 UP/RUNNING while the whole verification session ran over it (patched_boot.txt) β€” regression-clean.
  • Harness AFTER-FIX arm shows m2->m_len = 2048, pkthdr.len = 2074 (run.2.log, on the patched kernel).

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

fix.diff (unsigned clamp) applies cleanly; kernel rebuilt with -Werror rc=0; booted into patched kernel #1 (Sep 17 05:23); vtnet0 UP/RUNNING through the entire ssh session (the fixed clamp path carries the console traffic); harness AFTER-FIX arm shows the corrupted-mbuf arithmetic is gone (m2->m_len=2048, pkthdr.len=2074). The live device cannot emit the malicious length, so patched-side 'bad behavior absent' is proven at function level + regression-free live operation.

fixapply2.log (patch -p1 ok); fix_build.log (nativekernel rc=0, -Werror); patched_boot.txt (kernel #1 booted, vtnet0 RUNNING, stats clean); run.2.log (AFTER-FIX arm on patched kernel)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Thu Sep 17 05:23:00 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

malicious/compromised virtio host negotiates VIRTIO_NET_F_MRG_RXBUF (always offered/negotiated on the audit guest) -> device writes used.ring[].len=0x80000000 for a mergeable sub-buffer -> vtnet_rxeof_merged installs m_len=INT_MIN and negative pkthdr.len -> chain reaches bridge/carp/ng_ether/BPF taps before demux -> m_copydata-style consumers bcopy with (unsigned)0x80000000 count -> panic/kernel smash; bpf_mcopy walks into the sub-mbuf copying stale cluster bytes to listeners

Evidence (decisive lines)

run.log lines 'm2->m_len = -2147483648' and 'bcopy of 2147483648 bytes'; run.2.log AFTER-FIX 'm2->m_len=2048 ... pkthdr.len=2074' on the patched kernel; fix_build.log rc=0; fixapply2.log all hunks; VERDICT.md for the full trace

PoC changes

n/a (first-generation evidence pack; harness written to mirror the kernel statements and types exactly, including the uint32_t/int store)

Verified recommended fix

In vtnet_rxeof_merged(), clamp the device length unsigned: if ((uint32_t)len > (uint32_t)m->m_len) len = m->m_len;

Verdict

REPRODUCED at the function level on the audit guest: vtnet_rxeof_merged() clamps the device-controlled mergeable sub-buffer length with a signed compare (if_vtnet.c:1663), so a used-ring length >= 0x80000000 (stored via uint32_t* by virtqueue_dequeue, virtqueue.c:560) arrives as INT_MIN, skips the clamp, and is installed as m->m_len and folded into m_pkthdr.len; the corrupted chain is delivered to if_input (if_vtnet.c:1777) before any length validation. Downstream m_copydata computes an unsigned 2GiB bcopy count (uipc_mbuf.c:1689) => kernel smash/panic; bpf_mcopy exposes stale cluster bytes. Distinct from DF-1310 (non-mergeable first buffer NULL-deref): different mode, different site, different primitive. Live QEMU device is benign, so corruption proven by the exact-arithmetic harness (run.log), house standard of DF-1310. Fix (unsigned clamp) applied, kernel rebuilt -Werror rc=0, booted, vtnet0 (console NIC) UP/RUNNING through the whole session; harness AFTER-FIX arm shows sane chain.