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 setsreq->callbacktonvme_disk_callbackatnvme_disk.c:332, so a controlledreqslot with a non-NULLcallbackyields 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:
- 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). - Build QEMU:
./configure --target-list=x86_64-softmmu && make -j. - 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 ...
- 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.
Recommended fix
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.
Related findings
- DF-1677 (sibling: nvme queue helpers missing qid bound β latent variant)
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-1676 Β· 9 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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 commandsfix.diffβ standalone git-apply-able fix (validated to apply + compile)run.logβ full unpatched + patched harness outputenv.txtβ guest environment
Fix verification
not_testablenot_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
Confirmed kernel references
- s
- y
- s
- /
- d
- e
- v
- /
- d
- i
- s
- k
- /
- n
- v
- m
- e
- /
- n
- v
- m
- e
- .
- c
- :
- 7
- 1
- 3
- s
- y
- s
- /
- d
- e
- v
- /
- d
- i
- s
- k
- /
- n
- v
- m
- e
- /
- n
- v
- m
- e
- .
- c
- :
- 7
- 1
- 4
- s
- y
- s
- /
- d
- e
- v
- /
- d
- i
- s
- k
- /
- n
- v
- m
- e
- /
- n
- v
- m
- e
- .
- c
- :
- 7
- 1
- 5
- s
- y
- s
- /
- d
- e
- v
- /
- d
- i
- s
- k
- /
- n
- v
- m
- e
- /
- n
- v
- m
- e
- .
- c
- :
- 7
- 2
- 4
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).
No comments yet.