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

virtqueue: used-ring completion ownership never validated at runtime β€” duplicate/foreign (in-bounds) id underflows ndescs, cycles the descriptor free list, aliases in-flight descriptors and mis-attributes driver completion cookies

Summary

dequeue feeds the host-written used-ring id straight into free_chain with only debug-only VQASSERT enforcement: a duplicate completion of an already-freed chain head - a perfectly in-bounds id, distinct from DF-1767's bounds flaw - executes vq_free_cnt += 0 then ndescs-- underflows 0->65535, walks the stale F_NEXT chain, and relinks its tail creating a cycle in desc[].next. INVARIANTS kernels panic deterministically (stock X86_64_GENERIC); production kernels silently corrupt: every later enqueue re-hands in-flight descriptors and overwrites cookies - completions attributed to the wrong driver object (vtblk bio / vtnet mbuf double-completion, UAF or leak at the driver level); a forged F_NEXT cycle makes the free-chain walk spin forever holding the serializer. REPRODUCED on guest via KLD using the kernel's own virtqueue_alloc on the real vtblk0 child (duplicate used-ring entry -> panic with full backtrace); FIX VALIDATED on a rebuilt kernel (bogus entry dropped, legit completions intact). Malicious/buggy host model. Validate-ownership + bound-the-walk fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4623 Β· 14 files
FileTypeDescriptionSize
df4623.c β€” 9.5 KB view raw
Makefile β€” 102 B ↓ download
build.sh β€” 157 B view raw
run.sh β€” 327 B view raw
run.log β€” 2.2 KB view raw
panic.txt β€” 3.9 KB view raw
run.2.log β€” 796 B view raw
fix_run_full.log β€” 796 B view raw
fix_build.log β€” 5.6 MB ↓ download
kmod_build.log β€” 5.6 KB view raw
fix.diff β€” 2.0 KB view raw
env.txt β€” 198 B view raw
README.md β€” 2.8 KB ↓ raw
VERDICT.md β€” 6.1 KB ↓ raw

DF-4623 β€” virtqueue used-ring completion ownership not validated (duplicate/foreign id)

  • File: sys/dev/virtual/virtio/virtio/virtqueue.c
  • Class: memcorrupt (host-trust, same threat model as DF-1767 but a distinct failure mode: an in-bounds duplicate/foreign used-ring id β€” no OOB involved)
  • Kernel: DragonFly 6.5-DEVELOPMENT X86_64_GENERIC (INVARIANTS on), guest boots on virtqueue (vtblk0 root disk, vtnet0)

What it demonstrates

The virtqueue engine trusts used-ring completion ids written by the host with only debug-time (VQASSERT/KASSERT) enforcement. A single forged used-ring entry β€” a duplicate of an already-completed chain head (perfectly valid bounds-wise, so DF-1767's index problem does not apply) β€” drives vq_ring_free_chain() into:

  1. dxp->ndescs-- underflow (0 -> 65535, uint16_t) at virtqueue.c:786-787
  2. re-linking the freed chain tail onto the free-list head β†’ cycle in desc[].next (virtqueue.c:803-804)
  3. on INVARIANTS kernels: panic: vq_ring_free_chain: ... failed to free entire desc chain (virtqueue.c:796); on production kernels: silent corruption β€” free-list cycle makes vq_desc_head_idx never advance, every subsequent virtqueue_enqueue() re-hands in-flight descriptors and overwrites vq_descx[head].cookie (completions mis-attributed to the wrong driver object), vq_free_cnt drifts past vq_nentries
  4. with F_NEXT forged on the cycle tail, the while (dp->flags & VRING_DESC_F_NEXT) walk at virtqueue.c:790-794 never terminates β†’ kernel spins forever holding the driver serializer β†’ hard hang.

Build (in guest, as root β€” pack copied to /root/df4623)

sh /root/df4623/build.sh          # generates device_if.h/bus_if.h, kld

Run

Baseline (stock kernel):

kldload /root/df4623/df4623.ko                    # safe mode: sanity + projection
kenv hw.df4623.fire=1
kldload /root/df4623/df4623.ko                    # FIRE -> panic (see panic.txt)

Fixed kernel (after applying fix.diff + nativekernel + installkernel + reboot):

kenv hw.df4623.fire=1
kldload /root/df4623/df4623.ko                    # -> "dropping bogus used entry id=0", NO panic

Expected

  • Baseline fire: panic: vq_ring_free_chain: df4623-probe - failed to free entire desc chain, backtrace virtqueue_dequeue β†’ vq_ring_free_chain, guest wedged in ddb (ssh dies).
  • Patched fire: vtblk0: df4623-probe: dropping bogus used entry id=0, ring state stays consistent (free=64=head=0), legit completions still return the right cookie, module unloads cleanly.

The module uses the kernel's own virtqueue_alloc() on the real vtblk0 child device (feature negotiation is live) with an unregistered queue index, and writes used-ring entries exactly the way the host does via DMA. No real disk I/O is involved.

VERDICT.md
↓ download raw

VERDICT β€” DF-4623

Status: reproduced (kernel panic, deterministic) β€” and the one-line fix is validated (panic gone, corruption rejected, normal operation intact).

Root cause (code trace)

virtqueue_dequeue() reads the completion id straight from device-writable shared memory and hands it to vq_ring_free_chain() with no runtime ownership validation β€” every check on that path is a VQASSERT (KASSERT), which is a no-op on production kernels:

  • sys/dev/virtual/virtio/virtio/virtqueue.c:559 β€” desc_idx = (uint16_t)uep->id;
  • :563 β€” vq_ring_free_chain(vq, desc_idx); runs before the cookie fetch
  • :779 β€” VQ_RING_ASSERT_VALID_IDX (bounds; debug-only) β€” this is the check DF-1767 is about
  • :786-787 β€” vq->vq_free_cnt += dxp->ndescs; dxp->ndescs--; β€” for an already-freed head ndescs == 0, so the decrement underflows to 65535
  • :790-794 β€” the F_NEXT walk decrements further (65535 β†’ 65533 for a 3-desc chain) with no termination bound
  • :796 β€” VQASSERT(dxp->ndescs == 0, "failed to free entire desc chain") β€” live panic on INVARIANTS kernels; silent no-op in production
  • :803-804 β€” dp->next = vq->vq_desc_head_idx; vq->vq_desc_head_idx = desc_idx; β€” the tail of the stale chain is relinked onto the free-list head, producing a cycle (desc[tail].next == head), and vq_free_cnt has already been inflated by the stale ndescs.

A duplicate id is not an out-of-bounds index β€” DF-1767's bounds problem is orthogonal and not re-reported here. This is the missing ownership check: the id refers to a descriptor the guest no longer has in flight.

Post-corruption state (production kernels, from the on-guest projection)

After one duplicate id (chain head 0, 3 descriptors, 64-entry vq):

dxp->ndescs underflows to 65533 after 2 NEXT hops
desc[2].next = vq_desc_head_idx(0) -> CYCLE, head=0 while chain is idle
next virtqueue_enqueue() terminates at desc[2].next == 0 => head NEVER advances
=> every new request is handed desc 0..2 again, vq_descx[0].cookie overwritten
   while the previous request is IN FLIGHT (completion mis-attribution)
with F_NEXT forged on desc[2], vq_ring_free_chain(0) walks >256/64 entries
   without terminating => permanent kernel spin with serializer held (hang)

Driver-level consequences of mis-attributed completions: - virtio_blk.c:914-948 vtblk_vq_intr() immediately dereferences the returned cookie (req->vbr_bio) and finishes + recycles that request; a stale/aliased cookie finishes the wrong bio and re-queues a request object that is still referenced elsewhere (bio double-completion / use-after-free at the block layer), or leaks it forever (channel wedge). - if_vtnet.c:1389/1649/1702 β€” same shape for RX/TX mbufs (leak or stack re-injection of a freed mbuf).

On-guest reproduction (baseline)

Guest: DragonFly 6.5-DEVELOPMENT X86_64_GENERIC (INVARIANTS), boots on vtblk0. The PoC kld (df4623.c) allocates a private virtqueue through the kernel's own virtqueue_alloc() on the real vtblk0 child device (queue index 63 is never registered with the host), runs a normal enqueue/complete/dequeue cycle (healthy: cookie returned, free count restored), then writes a duplicate used-ring entry β€” byte-for-byte what a malicious host DMAs β€” and calls the real virtqueue_dequeue():

[df4623] FIRE: forging DUPLICATE used id=0 and calling the real virtqueue_dequeue()...
panic: vq_ring_free_chain: df4623-probe - failed to free entire desc chain
vq_ring_free_chain() at vq_ring_free_chain+0xed
virtqueue_dequeue() at virtqueue_dequeue+0x53

Guest wedged in ddb; ssh died. Full serial capture: panic.txt.

Fix validation

fix.diff adds (a) a runtime ownership gate in virtqueue_dequeue() β€” reject (warn + drop) any used id that is out of bounds or whose vq_descx[id].cookie == NULL || ndescs == 0 (i.e. not in flight); and (b) a defensive bound on the F_NEXT walk in vq_ring_free_chain() so a host-forged cyclic chain cannot spin forever.

Applied to the guest's /usr/src, make nativekernel && make installkernel, reboot (uname -v β†’ #1: Tue Sep 22 07:43:42 UTC 2026), identical PoC fire:

[df4623] FIRE: forging DUPLICATE used id=0 and calling the real virtqueue_dequeue()...
vtblk0: df4623-probe: dropping bogus used entry id=0
[df4623] dup dequeue RETURNED 0 ..., free=64 nentries=64 head=0     <- state CONSISTENT
[df4623] ... vq_descx[0].cookie now 0xbbbb0002 ...                  <- B's cookie kept,
                                                                      no aliasing with C

No panic, ring state consistent, legit completion path still returns the correct cookie (0xaaaa0001), module unloads cleanly. The baseline panic is gone under the exact same trigger.

Impact ceiling

Triggering requires the host (VMM) to emit a bogus used-ring entry β€” the same trust boundary as DF-1767 (malicious host / buggy device model / compromised VMM; also reachable via any host-side ring corruption). Severity Medium, bucket memcorrupt: guest-kernel DoS is trivial (deterministic panic with INVARIANTS, silent ring corruption or hard hang in production), and the production-mode descriptor aliasing hands drivers wrong completion objects (bio/mbuf lifetime confusion), though full guest code execution from this primitive alone was not pursued given the host already owns guest memory in this threat model.

Artifacts

  • df4623.c β€” PoC kld (safe/fire modes)
  • build.sh, Makefile β€” build
  • run.log β€” baseline run transcript (safe + fire + panic)
  • panic.txt β€” serial console capture of the panic
  • run.2.log / fix_run_full.log β€” fixed-kernel fire run (rejected, no panic)
  • fix_build.log β€” fixed-kernel build log (nativekernel)
  • kmod_build.log β€” kld build log
  • fix.diff β€” the validated fix
  • env.txt β€” guest/kernel/compiler

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

Single-fix kernel (fix.diff: ownership gate in virtqueue_dequeue + bounded chain walk in vq_ring_free_chain) built with make nativekernel, installed, rebooted. Identical fire-mode PoC: no panic; 'vtblk0: df4623-probe: dropping bogus used entry id=0'; ring state consistent (free=64=nentries, head=0, no cookie aliasing across follow-up enqueues); legit completion still returns the correct cookie; module unloads cleanly.

run.2.log / fix_run_full.log (patched-kernel fire run), fix_build.log (kernel build), fix.diff (applied to guest /usr/src only; sys/ in the audit tree untouched)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Tue Sep 22 07:43:42 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

malicious/buggy host DMAs one duplicate used-ring id -> virtqueue_dequeue:563 -> vq_ring_free_chain:786 ndescs underflow -> :803 cycle in desc[].next + vq_free_cnt inflation -> subsequent virtqueue_enqueue re-hands in-flight descriptors and overwrites vq_descx[head].cookie -> vtblk_vq_intr/vtnet receive a completion cookie for the wrong request (bio/mbuf double-completion, UAF or leak) ; with F_NEXT forged on the cycle tail -> unbounded walk at :790 -> permanent kernel spin under serializer -> guest hang. Guest-side escalation not pursued: in this threat model the host already owns guest memory (same ceiling as DF-1767).

Evidence (decisive lines)

panic.txt: 'panic: vq_ring_free_chain: df4623-probe - failed to free entire desc chain' with backtrace virtqueue_dequeue+0x53 -> vq_ring_free_chain+0xed (baseline fire run); run.log full baseline transcript incl. healthy normal-op sanity and the production-mode projection (ndescs 65533, desc[2].next=0 cycle, >256/64-step non-terminating walk); run.2.log/fix_run_full.log: fixed kernel rejects the same forged id ('vtblk0: df4623-probe: dropping bogus used entry id=0') with consistent ring state (free=64 head=0, cookie aliasing absent) and clean unload; fix_build.log: single-fix nativekernel build.

PoC changes

seed was analysis-only; wrote a new kld PoC: allocates a private virtqueue via the kernel's own virtqueue_alloc() on the real vtbl0 child device (unregistered queue index 63), performs a normal enqueue/complete/dequeue sanity cycle, then forges a duplicate used-ring entry exactly as the host would. Fixed two harness bugs found during runs: (1) devclass name is 'vtblk' not 'virtio_blk'; (2) sglist_append_phys() coalesces physically contiguous chunks, breaking the required sg_nseg==readable+writable invariant (use non-adjacent chunks).

Verified recommended fix

Validate the used-ring id in virtqueue_dequeue(): reject (warn+drop) ids that are out of bounds or whose vq_descx[id].cookie==NULL/ndescs==0; bound the F_NEXT walk in vq_ring_free_chain() at vq_nentries steps.

Verdict

virtqueue_dequeue()/vq_ring_free_chain() trust the host-written used-ring completion id with debug-only (VQASSERT) enforcement. A duplicate completion of an already-freed chain head (in-bounds, so distinct from DF-1767's bounds issue) underflows dxp->ndescs (0->65535), relinks the stale chain tail onto the free-list head creating a cycle, and inflates vq_free_cnt. On INVARIANTS kernels this panics deterministically ('failed to free entire desc chain'); on production kernels it silently corrupts the ring: desc heads are re-handed while in flight, vq_descx[head].cookie is overwritten (driver completions mis-attributed to the wrong bio/mbuf -> vtblk/vtnet lifetime confusion), and a host-forged F_NEXT cycle makes free_chain spin forever with the driver serializer held (hard hang). Reproduced on the guest with an in-kernel PoC driving the real virtqueue engine on a private vq bound to vtblk0; fix (runtime ownership gate + bounded walk) validated on a rebuilt kernel: panic gone, bogus entry dropped, normal completions intact.