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)
PoC verification
Evidence pack
findings/poc/DF-4646 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 todead_dev_opsβ so new post-teardown reads get EBADF, seenvzombie.cβ but bios already insidenvme_strategy_core()keep running), - bios parked on
nsc->bioqwaiting 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 nvmewith 8-device nvme-nsnamespaces on null-co drives. hw.nvmebang.actiontest 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 KBpread()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 β 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:
nvme_pci_detach()(nvme_attach.c:470-517) never setsNVME_SC_UNLOADING(onlynvme_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.nvme_stop_admin_thread()(nvme_admin.c:110-127) β while the admin thread is still running and IO interrupts are still installed β runsnvme_disk_detach(nsc); kfree(nsc);for every namespace. Bios parked onnsc->bioq(submitted while the submission ring was full, nvme_disk.c:379-389) are orphaned; the admin thread's ownnvme_disk_requeues()(nvme_disk.c:189-213) can holdnsc->lkon a namespace that is freed concurrently.- 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 freednsc(nvme_disk.c:394-430), and an MSI-X interrupt on a freed/bzero'd completion queue hitsKKASSERT(comq_tail < nqe)innvme_poll_completions()(nvme.c:575). - A read/write bio dispatched past the cooked-cdev re-vector (or
re-dispatched by the requeue drain) enters
nvme_get_request()against abzero()'d submission queue:(tail+unsubmitted+1) % nqewithnqe == 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_drv1without guardingnvme_open()producednvme_open+0xb movq (%rax),%rdxNULL-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 clearingNVME_SC_UNLOADINGat 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 (psshows all in nanslp retry loops);dmesgshows 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
- sys/dev/disk/nvme/nvme_disk.c:115-122 (
nvme_disk_detachβ no drain, no exclusion) - sys/dev/disk/nvme/nvme_disk.c:172-181 (
nvme_strategyβ no teardown guard) - sys/dev/disk/nvme/nvme_disk.c:189-213 (
nvme_disk_requeuesβ races kfree) - sys/dev/disk/nvme/nvme_disk.c:379-389 (bioq park path β orphaned at kfree)
- sys/dev/disk/nvme/nvme_disk.c:394-430 (
nvme_disk_callbackβ freed nsc use) - sys/dev/disk/nvme/nvme_attach.c:470-517 (
nvme_pci_detachβ no serialization) - sys/dev/disk/nvme/nvme_admin.c:110-127 (
kfree(nsc)under live I/O) - sys/dev/disk/nvme/nvme_admin.c:185-197 (queue delete/free without final poll)
- sys/dev/disk/nvme/nvme.c:446 (
% queue->nqediv0 site)
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
fixedValidated 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.
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.
No comments yet.