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

nvme(4) device teardown vs in-flight data-path I/O: use-after-free of the namespace softc and I/O queues -> kernel divide-by-zero panic (Fatal trap 18 in nvme_get_request)

Summary

Teardown neither drains nor excludes the data path: UNLOADING never set (only open checks it), stop_admin_thread kfrees every softc while requeues/interrupts/in-flight bios are live, and the queue free bzeros nqe without a final completion poll - a dispatched bio executes (tail+unsubmitted+1)%nqe with nqe==0: Fatal trap 18 at nvme_get_request+0x34 divl. REPRODUCED 3/3 fresh boots with 16 unpriv raw-pread spammers + a detach module (the exact PCI hot-unplug path) - the data-path twin of DF-4529, reachable with no admin ioctl. Residual: disk_callback writes devstat counters into the freed softc; orphaned bios hang forever. Fix validated by full kernel rebuild (v2: UNLOADING flag + EIO guards + bioq drain + si_drv1 NULL + final comq poll) - baseline 3/3 panic, patched battery clean. Serialize-teardown-against-the-data-path fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4646 Β· 16 files
FileTypeDescriptionSize
README.md β€” 4.2 KB ↓ raw
VERDICT.md β€” 5.7 KB ↓ raw
nvio.c β€” 2.7 KB view raw
nvzombie.c β€” 1.9 KB view raw
nvmebang.c β€” 3.1 KB view raw
build.sh β€” 116 B view raw
run.sh β€” 90 B view raw
build.log β€” 5.8 MB ↓ download
base_build.log β€” 5.8 MB ↓ download
run.log β€” 251.5 KB view raw
panic.txt β€” 289 B view raw
fix.diff β€” 4.9 KB view raw
env.txt β€” 272 B view raw
hashes.txt β€” 225 B view raw
verdict.json β€” 5.7 KB view raw
manifest.json β€” 1.0 KB view raw

PoC DF-4646 β€” nvme(4) device teardown vs in-flight data-path I/O: use-after-free β†’ kernel divide-by-zero panic (REPRODUCED 3/3; fix validated)

What this proves

nvme_pci_detach() (nvme_attach.c:470) frees the namespace softcs and the I/O submission/completion queues while bios can still be in flight on the data path:

  • raw reads/writes already dispatched past the cooked-disk cdev (destroy_dev() re-vectors the cooked ops to dead_dev_ops β€” so new post-teardown reads get EBADF, see nvzombie.c β€” but bios already inside nvme_strategy_core() keep running),
  • bios parked on nsc->bioq waiting for the admin-thread requeue (nvme_disk_requeues, nvme_disk.c:189),
  • completions arriving on queues that the admin-thread exit path deletes and frees without a final drain (nvme_admin.c:188-197).

Nothing blocks new data-path work (the NVME_SC_UNLOADING flag is checked in nvme_open() but never set anywhere in the tree), nothing drains in-flight requests before kfree(nsc) (nvme_admin.c:117-126), and nvme_disk_detach() (nvme_disk.c:115-122) neither drains nsc->bioq nor excludes the strategy path. A bio dispatched or requeued in the window enters nvme_get_request() against a queue that nvme_free_subqueue() has bzero()'d β€” queue->nqe == 0 β€” and the ring-full check

if ((queue->subq_tail + queue->unsubmitted + 1) % queue->nqe ==   /* nvme.c:446 */
    queue->subq_head) {

divides by zero β†’ Fatal trap 18 (integer divide fault):

nvmebang: DETACH method on nvme0...
Fatal trap 18: Stopped at      nvme_get_request+0x34:  divl    0x40(%r13),%eax
db>

0x40(%r13) = queue->nqe (offset 0x40 in struct nvme_subqueue, nvme.h:118-155); divl is the modulo above β€” the only division in nvme_get_request(). Same signature as DF-4529 (which raced the admin-ioctl path); this finding is the data path (read/write bios), reproduced with plain pread() spam β€” no ioctls at all.

Threat model

The detach side is an administrative/hardware event (PCI hot-unplug, surprise removal, VM device_del, driver detach) that must be safe while the device is under I/O; the I/O side can be any filesystem or raw-disk activity on the nvme namespaces (unprivileged). Even when the div0 window is missed, the freed nsc is dereferenced by nvme_disk_callback() (devstat_end_transaction_buf(&nsc->stats, bp), nvme_disk.c:410) and the requeue drain β€” silent writes into freed memory.

Environment

  • Private QEMU/KVM guest (dfbsd-qemu/vm_nvme.sh): stock virtio-boot disk plus -device nvme with 8 -device nvme-ns namespaces on null-co drives.
  • hw.nvmebang.action test module (nvmebang.c, from the DF-4529 pack) invokes the driver's own attach/detach driver-methods on unit 0 on demand β€” a deterministic stand-in for PCI hot-unplug.
  • nvio (nvio.c): N reader processes spamming synchronous 128 KB pread()s across the 8 namespace devices.

Build (in guest, as root)

cc -O2 -o /root/nvio nvio.c
cd bang && make && kldload ./nvmebang.ko   # Makefile: KMOD=nvmebang
                                           # SRCS=nvmebang.c device_if.h bus_if.h

Run (exact rig)

sysctl hw.nvmebang.action=2       # ensure device attached (fresh boot: already)
(nohup /root/nvio 16 >/root/nvio.log 2>&1 &)
sleep 7                           # 16 readers, ~7k reads/s total
sysctl hw.nvmebang.action=1       # detach under load  -> panic

Expected / observed

Kernel panic: Fatal trap 18 ... Stopped at nvme_get_request+0x34: divl 0x40(%r13),%eax, guest drops to DDB (db>), ssh dies. Reproduced 3/3 on fresh boots (panic.txt; two earlier runs had the trap banner partially interleaved on the serial console but the identical Stopped at signature).

With the fix (fix.diff, validated by full kernel rebuild): no panic; readers receive EBADF/ENODEV for new I/O after destroy, in-flight bios fail with EIO, no readers stuck in biowait, detach cycles cleanly Γ—3.

Negative result kept for the record

nvzombie.c proves a post-teardown open descriptor is NOT exploitable by itself: disk_destroy() β†’ destroy_dev() re-vectors the cooked cdev to dead_dev_ops, so a fresh pread() after teardown returns EBADF. The vulnerability is strictly the in-flight window, not a persistent zombie device.

VERDICT.md
↓ download raw

VERDICT β€” DF-4646

Status: reproduced (impact: panic β€” use-after-free-driven kernel divide-by-zero). Reproduced 3/3 on fresh boots. Fix validated by full kernel rebuild (baseline panics 3/3 β†’ patched: 0 panics across a 16-reader detach, three attach/detach cycles, and concurrent opens).

Root cause

The teardown of an nvme controller frees everything the data path still touches, with no serialization against it:

  1. nvme_pci_detach() (nvme_attach.c:470-517) never sets NVME_SC_UNLOADING (only nvme_open() checks it, nvme_disk.c:132 β€” dead flag, as DF-4529 documented), never drains in-flight I/O, and never takes a lock the strategy path honors.
  2. nvme_stop_admin_thread() (nvme_admin.c:110-127) β€” while the admin thread is still running and IO interrupts are still installed β€” runs nvme_disk_detach(nsc); kfree(nsc); for every namespace. Bios parked on nsc->bioq (submitted while the submission ring was full, nvme_disk.c:379-389) are orphaned; the admin thread's own nvme_disk_requeues() (nvme_disk.c:189-213) can hold nsc->lk on a namespace that is freed concurrently.
  3. The admin thread's exit path (nvme_admin.c:185-197) deletes and frees the I/O queues without a final completion poll; a late completion delivers nvme_disk_callback() β†’ devstat_end_transaction_buf() on the freed nsc (nvme_disk.c:394-430), and an MSI-X interrupt on a freed/bzero'd completion queue hits KKASSERT(comq_tail < nqe) in nvme_poll_completions() (nvme.c:575).
  4. A read/write bio dispatched past the cooked-cdev re-vector (or re-dispatched by the requeue drain) enters nvme_get_request() against a bzero()'d submission queue: (tail+unsubmitted+1) % nqe with nqe == 0 β†’ integer divide fault (nvme.c:446).

Observed fatal manifestation (3/3):

nvmebang: DETACH method on nvme0...
Fatal trap 18: Stopped at      nvme_get_request+0x34:  divl    0x40(%r13),%eax
db>

0x40(%r13) = queue->nqe. This is the data-path twin of DF-4529 (admin-ioctl path): the trigger rig here uses only pread() β€” no admin ioctls β€” so the two findings are independently reachable manifestations of the same missing teardown serialization. DF-4529's validated fix guarded open/close/ioctl/strategy against a NULLed si_drv1 but did not address in-flight bios, the requeue race, or the queue teardown order β€” that residual class is this finding.

Reproduction

Rig: 16 reader processes (nvio) issuing continuous synchronous 128 KB raw pread()s across nvme0..7 (null-co-backed, ~7k reads/s total); sysctl hw.nvmebang.action=1 invokes the driver's own detach driver-method (the exact code PCI hot-unplug runs). Run 1 and Run 2: panic on first detach (Run 2 banner captured in full β€” panic.txt shows the Run 3 recapture on a pristine-rebuilt kernel; all three identical). nvzombie.c additionally documents the NEGATIVE result that a purely post-teardown dispatch is absorbed by dead_dev_ops (EBADF) β€” the bug is the in-flight window.

Exploit-chain assessment

The reliable primitive is the divide-by-zero panic plus orphaned-bio hangs (unkillable-style blocked I/O). The UAF-write side (nvme_disk_callback into freed nsc via devstat_end_transaction_buf, fixed offsets inside the freed chunk, counter-like values) and the freed-queue lockmgr() use offer heap-corruption potential under heap pressure, but with fixed offsets/limited value control we assess the realistic ceiling as denial of service (panic / hung I/O), matching the DF-4529 precedent. No uid=0 chain was pursued beyond this characterization: the deterministic outcome is a fault, not a controllable allocation.

Fix validation (fix.diff β€” v2, final)

  • v1 (strategy/requeue/bioq guards + UNLOADING flag + final comq poll) killed the original panic but introduced its own: NULLing si_drv1 without guarding nvme_open() produced nvme_open+0xb movq (%rax),%rdx NULL-deref on a concurrent open of the dying cdev β€” caught by the validation battery, fixed in v2 by adding the open/close/ioctl guards and clearing NVME_SC_UNLOADING at attach (nvme_attach.c:115).
  • v2 battery on the rebuilt kernel (make nativekernel): 16 readers Γ— detach β€” no panic, all readers survived with EBADF/ENODEV on new I/O; three further attach/detach cycles with concurrent opens and reads β€” clean; no readers stuck in biowait (ps shows all in nanslp retry loops); dmesg shows normal chip shutdown each time.
  • Baseline re-verified after reverting the guest source to pristine and rebuilding: same panic signature on the first detach under load (panic.txt).

Build logs: build.log (patched v2 build), base_build.log (pristine rebuild). Run logs: run.log (patched-battery reader log), nvio*.log.

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

Validated by full kernel rebuild on the guest. Baseline (pristine): panic 3/3 under the exact rig. Patched v2: identical rig plus three attach/detach cycles with concurrent opens and reads -- no panic, clean EBADF/ENODEV for new I/O, no readers stuck in biowait, clean chip shutdown each cycle. Intermediate v1 (missing open/close/ioctl guards) was itself caught by the validation battery panic'ing in nvme_open on the dying cdev -- fixed in v2 before the final validated run.

findings/poc/DF-4646/fix.diff (v2 final); build.log (patched build, nativekernel); base_build.log (pristine revert build); run.log (patched battery: 'pread error Bad file descriptor/Device not configured after ~7-8k iters', all 16 readers alive in nanslp); panic.txt (baseline recapture on pristine-rebuilt kernel); VERDICT.md 'Fix validation' section.
↓ fix.diffper-fix-DF-4646

Confirmed kernel references

Detail

Exploit chain

16 reader processes spam synchronous 128KB pread()s on /dev/nvme0..7 -> bios in flight / parked on nsc->bioq; sysctl hw.nvmebang.action=1 invokes the driver's own detach driver-method (identical to PCI hot-unplug) -> nvme_stop_admin_thread frees nsc under live I/O and the admin-thread exit frees+bzeros the I/O queues without a final completion poll -> in-flight/requeued bio enters nvme_get_request -> (tail+unsubmitted+1) % nqe with nqe==0 -> integer divide fault -> kernel panic (Fatal trap 18), DDB, system down. Residual silent side: nvme_disk_callback writes devstat counters into the freed softc; orphaned bios hang their owning threads forever.

Evidence (decisive lines)

panic.txt (Fatal trap 18 ... Stopped at nvme_get_request+0x34: divl 0x40(%r13),%eax -- pristine-kernel recapture, 3rd of 3 reproductions); VERDICT.md (full narrative incl. fix-v1 nvme_open NULL-deref lesson); run.log (patched-battery reader log: EBADF after destroy, 16/16 readers alive); build.log/base_build.log (patched + pristine kernel builds); nvio.c (rig), nvzombie.c (negative result: post-teardown read absorbed by dead_dev_ops); fix.diff (validated v2)

PoC changes

Wrote the rig from scratch for this finding: nvio.c (16-process dynamic-discovery raw pread spammer; earlier draft used hardcoded /dev/nvme0..7 which broke after re-attach renumbered units to nvme8+), plus reused the DF-4529 nvmebang detach-trigger module unchanged. fix.diff authored in two iterations: v1 omitted the open/close/ioctl guards and the attach-side UNLOADING clear (fix validation caught nvme_open NULL-deref panic on the dying cdev); v2 is final and validated.

Verified recommended fix

Serialize teardown against the data path: set NVME_SC_UNLOADING at nvme_pci_detach entry (and clear it at attach); fail bios with EIO in nvme_strategy_core once unloading; drain nsc->bioq in nvme_disk_detach before destroy; NULL si_drv1 and guard open/close/ioctl/strategy; final completion-queue poll in the admin-thread exit before queue delete/free (validated: fix.diff).

Verdict

nvme device teardown races the data path: nvme_pci_detach frees the namespace softcs and I/O queues with no serialization against in-flight bios (no UNLOADING flag set, no drain, no strategy guard; nvme_disk_detach neither drains nsc->bioq nor excludes nvme_disk_requeues/nvme_disk_callback). A bio dispatched past the cooked-cdev dead_dev_ops re-vector, parked on nsc->bioq, or re-dispatched by the admin-thread requeue enters nvme_get_request against a bzero'd queue (nqe==0) and divides by zero. Fatal trap 18 at nvme_get_request+0x34 (divl 0x40(%r13)) reproduced 3/3 on fresh boots with 16 unprivileged-looking raw pread() spammers (no admin ioctls -- the data-path twin of DF-4529, which was the admin-ioctl path and whose validated fix did not cover in-flight bios). Fix (fix.diff v2) validated by full kernel rebuild: baseline 3/3 panic; patched battery (16-reader detach + 3 attach/detach cycles + concurrent opens) clean, readers get EBADF/ENODEV, no biowait hangs. Exploit ceiling assessed as panic/hang: the UAF-write side (devstat counters into freed nsc at fixed offsets) has limited control; no uid0 chain pursued beyond characterization.