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)
PoC verification
Evidence pack
findings/poc/DF-4623 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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:
dxp->ndescs--underflow (0 -> 65535, uint16_t) at virtqueue.c:786-787- re-linking the freed chain tail onto the free-list head β cycle in
desc[].next(virtqueue.c:803-804) - 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 makesvq_desc_head_idxnever advance, every subsequentvirtqueue_enqueue()re-hands in-flight descriptors and overwritesvq_descx[head].cookie(completions mis-attributed to the wrong driver object),vq_free_cntdrifts pastvq_nentries - with
F_NEXTforged on the cycle tail, thewhile (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, backtracevirtqueue_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 β 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 headndescs == 0, so the decrement underflows to 65535:790-794β theF_NEXTwalk 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), andvq_free_cnthas already been inflated by the stalendescs.
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β buildrun.logβ baseline run transcript (safe + fire + panic)panic.txtβ serial console capture of the panicrun.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 logfix.diffβ the validated fixenv.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
fixedSingle-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)
Confirmed kernel references
- sys/dev/virtual/virtio/virtio/virtqueue.c:559
- sys/dev/virtual/virtio/virtio/virtqueue.c:563
- sys/dev/virtual/virtio/virtio/virtqueue.c:779
- sys/dev/virtual/virtio/virtio/virtqueue.c:786
- sys/dev/virtual/virtio/virtio/virtqueue.c:790
- sys/dev/virtual/virtio/virtio/virtqueue.c:796
- sys/dev/virtual/virtio/virtio/virtqueue.c:803
- sys/dev/virtual/virtio/block/virtio_blk.c:914
- sys/dev/virtual/virtio/net/if_vtnet.c:1649
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.
No comments yet.