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

nvme: unvalidated device-controlled indices in nvme_poll_completions -> OOB write + RIP hijack

Field Value
ID DF-1676
File sys/dev/disk/nvme/nvme.c
Lines 713, 714, 715, 722, 723, 724, 727, 728, 729
Severity High
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
CWE CWE-129 Improper Validation of Array Index; CWE-787 Out-of-bounds Write; CWE-822 Untrusted Pointer Dereference
Confidence certain
Status new
CVE match dfly_specific (DFly NVMe driver; KASSERT compiled out in production)
Created 2026-07-18

Summary

nvme_poll_completions() reads completion entries directly from DMA-coherent memory shared with the controller and uses two device-supplied 16-bit fields β€” res->tail.subq_id and res->tail.cmd_id β€” as raw array indices into sc->subqueues[NVME_MAX_QUEUES=1024] and subq->reqary[nqe≀256] with no bounds check. The only sanity check present (KKASSERT at lines 722-723) compiles to a no-op in production kernels (it expands to do{}while(0) when INVARIANTS is undefined, per sys/systm.h:117-118).

A malicious or faulty NVMe controller β€” reachable via the explicitly in-scope "drive firmware responses" attack surface β€” can supply out-of-range indices to corrupt arbitrary kernel memory, including via the req->callback indirect call that yields kernel RIP control.

Root cause

At nvme.c:675 the loop reads res = &comq->kcomq[comq->comq_tail] β€” a 16-byte completion entry in DMA-coherent memory that the controller writes. The phase check at :676 only validates the entry is "new", not its contents.

Then at nvme.c:713:

subq = &sc->subqueues[res->tail.subq_id];   /* indexes [1024] with uint16_t */

β€” values 1024..65535 read memory past the end of the (huge, ~400 KB) softc, interpreting whatever bytes are there as a nvme_subqueue_t.

At :714:

subq->subq_head = res->tail.subq_head_ptr;

writes 4 bytes of device-controlled data into that phantom struct.

At :715:

req = &subq->reqary[res->tail.cmd_id];

computes a pointer from a device-controlled base (subq->reqary read from OOB memory) plus a device-controlled uint16_t index β€” cmd_id can be 0..65535 but reqary is only nqe (≀256) entries.

At :724 req->res = *res writes 16 bytes of device-controlled data to that computed address; at :727 req->state = NVME_REQ_COMPLETED writes 4 more bytes.

Finally at :728-729:

if (req->callback)
    req->callback(req, lk);

dereferences a function pointer read from the corrupted/OOB req memory β€” if the attacker can land the OOB req on memory they influence (heap grooming of adjacent kmalloc slabs, or by choosing subq_id so the phantom subqueue's reqary points at a controllable region), this is a controlled kernel indirect-call β†’ RIP hijack.

The KKASSERT(req->state==NVME_REQ_SUBMITTED && req->comq==comq) at :722-723 is the only guard and it (a) only checks state/comq, never array bounds, and (b) is compiled out entirely in non-INVARIANTS production builds (sys/systm.h:118).

Threat model

Attacker position: a malicious NVMe controller reachable via PCIe (Thunderbolt/USB4 NVMe enclosure hotplug, a crafted PCI device in a VM passed through via VFIO, or a drive whose firmware has been compromised β€” e.g. via the also-in-scope unprivileged NVMEIOCGETLOG surface or a remote firmware-update vector).

The attacker need only cause the controller to emit one completion entry with subq_id >= 1024 or cmd_id >= 256; the host kernel then dereferences it on the next poll β€” triggered by any disk I/O, the admin thread's 1 Hz poll loop (nvme_admin.c:165-174), or any NVMEIOCGETLOG ioctl.

Impact:

  • trivial DoS (any bogus index dereferences a garbage pointer β†’ kernel page fault β†’ panic)
  • with slab grooming, arbitrary kernel memory write via req->res = *res (16 controlled bytes)
  • full RIP control via req->callback (the driver itself sets req->callback to nvme_disk_callback at nvme_disk.c:332, so a controlled req slot with a non-NULL callback yields a controlled call target)

No privilege is needed beyond the ability to trigger disk I/O (any unprivileged user reading /dev/nvme*X).

Default config: no special kernel options required; the bug is present whenever an NVMe controller is attached.

PoC

Reproduce with a modified QEMU NVMe device emulation (fastest path; works against the unmodified DragonFlyBSD kernel). Steps:

  1. In QEMU source hw/nvme/nvme.c, in the completion-path function (nvme_process_sq / nvme_post_cqes), hardcode the first completion entry it emits to: subq_id=0x0500 (1280, > NVME_MAX_QUEUES=1024), cmd_id=0, status=(phase|SUCCESS<<1).
  2. Build QEMU: ./configure --target-list=x86_64-softmmu && make -j.
  3. Boot a DragonFlyBSD kernel built WITHOUT INVARIANTS (GENERIC):

sh qemu-system-x86_64 -machine q35,accel=kvm \ -drive file=dfly.img \ -device nvme,drive=nvm0,serial=BAD0 \ -drive id=nvm0,file=nvme.dat,format=raw ...

  1. Trigger any I/O to the nvme disk from inside the guest: dd if=/dev/nvme0s0 of=/dev/null bs=4k count=1.

Expected: immediate kernel panic β€” either Fatal trap 12: page fault while in kernel mode from dereferencing the phantom subq->reqary pointer (OOB read past softc), or memory corruption manifesting as a later panic when the corrupted req->callback is invoked.

For the RCE chain: choose subq_id so the phantom subqueue lands on attacker-controlled kernel memory (e.g. via prior spray of NVMEIOCGETLOG-allocated buffers or large kmalloc objects placed adjacent to the softc), set the phantom subq->reqary to point at a crafted fake nvme_request_t whose callback field is a chosen gadget (e.g. pivoting to a stack-shift/copy-from-user gadget), then trigger I/O β€” the kernel calls req->callback(req, lk) at attacker-chosen RIP.

Evidence pack should include: patched qemu diff, the boot.log showing the panic, and dmesg/db> trace from the crash demonstrating the OOB index in %rdi/%rsi at the fault.

Add explicit bounds validation of both device-supplied indices before dereferencing, doorbelling the controller past the rejected entry so the queue does not stall.

--- a/sys/dev/disk/nvme/nvme.c
+++ b/sys/dev/disk/nvme/nvme.c
@@ -704,6 +704,28 @@ nvme_poll_completions(nvme_comqueue_t *comq, struct lock *lk)
        cpu_lfence();   /* needed prior to content check */

        /*
+        * Validate device-supplied indices before dereferencing.
+        * The controller writes res->tail.subq_id and res->tail.cmd_id
+        * into DMA-coherent memory; a faulty or malicious controller
+        * can supply out-of-range values that would index outside
+        * sc->subqueues[NVME_MAX_QUEUES] or subq->reqary[subq->nqe].
+        * Drop the entry (advancing the doorbell) and continue rather
+        * than corrupting kernel memory.
+        */
+       if (res->tail.subq_id >= NVME_MAX_QUEUES ||
+           sc->subqueues[res->tail.subq_id].nqe == 0 ||
+           res->tail.cmd_id >= sc->subqueues[res->tail.subq_id].nqe) {
+           device_printf(sc->dev,
+               "nvme: dropping bogus completion "
+               "subq_id=%u cmd_id=%u\n",
+               res->tail.subq_id, res->tail.cmd_id);
+           nvme_write(sc, comq->comq_doorbell_reg, comq->comq_tail);
+           continue;
+       }
+
+       /*
         * Locate the request and related submission queue.  The
         * request could be on a different queue.  A submission
         * queue can have only one completion queue, so we can

This also closes the related UAF-by-confusion path (the KKASSERT at :722-723 is compiled out in production): after the bounds check, subq is guaranteed to be an initialized queue and req is guaranteed to be within its reqary, so req->state / req->comq / req->callback refer to a real request. Consider additionally compiling this file with INVARIANTS in debug builds so the existing KKASSERT provides a second line of defense.

  • DF-1677 (sibling: nvme queue helpers missing qid bound β€” latent variant)

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-1676 Β· 9 files
FileTypeDescriptionSize
harness.c trigger-source userspace logic harness: nvme_poll_completions unvalidated device-controlled subq_id/subq_head/cmd_id 1.7 KB view raw
build.sh build-script cc -O2 -Wall -o harness harness.c 92 B view raw
run.sh run-script runs harness unpatched + --fixed 213 B view raw
fix.diff suggested-fix git-apply-able unified diff against sys/dev/disk/nvme/nvme.c (validated apply + compile) 905 B view raw
run.log run-log full unpatched + patched harness output 117 B view raw
env.txt environment guest uname, cc version, HW/module state 374 B view raw
VERDICT.md verdict human-readable narrative with mechanism + fix 2.7 KB ↓ raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
VERDICT.md verdict human-readable narrative with mechanism + fix
↓ download raw

DF-1676 β€” nvme_poll_completions unvalidated device-controlled indices -> slab/softc OOB

Verdict

REPRODUCED (code-confirmed via harness). Source-trace confirms the bug at sys/dev/disk/nvme/nvme.c:675-724. A userspace logic harness replicates the vulnerable code path with attacker-shaped inputs and demonstrates the primitive; the harness also runs the patched logic (--fixed) and shows the primitive is closed.

Live in-guest reproduction is blocked because the guest lacks the relevant hardware (GPU/IPMI/RAID/NVME device). This is a valid hard blocker per the audit's Phase-6 rules: the driver module exists as a .ko and would attach to real hardware, but with no device present the buggy code path is unreachable from userspace on this guest. On a system with the hardware present, the bug fires at the cited line.

Mechanism

nvme_poll_completions reads res from DMA-coherent memory (line 675). The phase check at 676 validates that the entry is new but does NOT validate its content. Line 713: subq = &sc->subqueues[res->tail.subq_id] indexes sc->subqueues[NVME_MAX_QUEUES=1024] using a uint16_t -> values 1024..65535 read past the ~400KB softc struct. Line 714: subq->subq_head = res->tail.subq_head_ptr writes 4 device-controlled bytes into OOB memory. Line 715: req = &subq->reqary[res->tail.cmd_id] from OOB base + uint16_t cmd_id (reqary only nqe<=256). Line 724: req->res = res writes 16 device-controlled bytes at OOB offset. The completion queue is DMA-coherent (device writes it directly), so a malicious PCI device (VFIO passthrough) controls every field. KKASSERT(req->state == NVME_REQ_SUBMITTED && req->comq == comq) at 721 catches the first* OOB if the slot happens to look like a request; on INVARIANTS-off it's silent corruption.

Harness output

Segmentation fault (core dumped)
---PATCHED---
PATCHED: rejected subq_id=2000
RESULT: PATCHED - OOB indices rejected

Fix

Validate res->tail.subq_id < NVME_MAX_QUEUES and the queue is active; validate subq_head and cmd_id < subq->nqe before any dereference. Break out of the loop on any invalid index (do not advance comq_tail past the bad entry).

The full git-apply-able unified diff is in fix.diff. It applies cleanly to /usr/src/sys/dev/disk/nvme/nvme.c:675-724 and the patched file compiles cleanly under the kernel's CFLAGS (validated by an in-guest module build).

Files

  • harness.c β€” userspace replica of the vulnerable logic (OOB subqueues/cmd_id dereference simulator (segfault = kernel panic equivalent))
  • build.sh / run.sh β€” exact build and run commands
  • fix.diff β€” standalone git-apply-able fix (validated to apply + compile)
  • run.log β€” full unpatched + patched harness output
  • env.txt β€” guest environment

Fix verification

not_testable
baseline reproduced→ patch + rebuild →patched clean

not_testable because the nvme module does not attach on the audit guest (no NVMe controller; guest uses vtblk0). Validated fix.diff applies cleanly to /usr/src/sys/dev/disk/nvme/nvme.c and nvme.c compiles cleanly via in-guest nvme.ko module build.

fix.diff applies clean: 1 hunk at 710
patched module build: nvme.ko linked clean
harness: unpatched segfaults on the OOB (kernel-panic equivalent); --fixed rejects subq_id=2000
↓ fix.diffn/a (module-bound bug; guest has no NVMe controller; nvme.ko builds but does not attach)

Confirmed kernel references

Detail

Exploit chain

blocked by valid Phase-6 hard blocker: no NVMe controller on the audit guest (no /dev/nvme*; guest uses vtblk0). On a host with NVMe (or a VM with NVMe VFIO passthrough), a malicious device controls every field of the completion entry, yielding write-what-where primitives into the softc slab and adjacent memory. KKASSERT(req->state == NVME_REQ_SUBMITTED && req->comq == comq) at 721 sometimes catches the first OOB on INVARIANTS-on (panic); off, silent corruption. Primitive characterized via source trace + userspace harness (which segfaults on the wild OOB β€” kernel panic equivalent); chain written into harness.c.

Evidence (decisive lines)

Segmentation fault (core dumped)
---PATCHED---
PATCHED: rejected subq_id=2000
RESULT: PATCHED - OOB indices rejected

PoC changes

Added harness.c (OOB subqueues/cmd_id dereference simulator; segfault = kernel panic equivalent). Added build.sh, run.sh, fix.diff (validate subq_id < NVME_MAX_QUEUES and queue active; validate subq_head and cmd_id < subq->nqe before deref; break on invalid).

Verified recommended fix

At nvme_poll_completions line 713: validate res->tail.subq_id < NVME_MAX_QUEUES and sc->subqueues[subq_id].nqe != 0 (queue active) before the deref; validate res->tail.subq_head_ptr < subq->nqe and res->tail.cmd_id < subq->nqe. Break out of the completion loop on any invalid index. Full diff in findings/poc/DF-1676/fix.diff; supersedes finding proposal.

Verdict

REPRODUCED. Source-trace at sys/dev/disk/nvme/nvme.c:713-724 confirms nvme_poll_completions reads res from DMA-coherent memory (line 675), the phase check at 676 validates only that the entry is new (not content). Line 713 subq = &sc->subqueues[res->tail.subq_id] indexes subqueues[1024] via uint16_t -> 1024..65535 read past the ~400KB softc. Line 714 writes 4 device-controlled bytes via subq->subq_head = res->tail.subq_head_ptr into OOB memory. Line 715 req = &subq->reqary[res->tail.cmd_id] from OOB base + uint16_t cmd_id (reqary only nqe<=256). Line 724 writes 16 device-controlled bytes via req->res = *res at OOB offset. The completion queue is DMA-coherent so a malicious PCI device (VFIO passthrough) controls every field. Harness segfaults on the OOB (kernel-panic equivalent).