QEMU NVMe SGL span into BAR0 leftover of CVE-2021-3929
QEMU v11.1.2 still only rejects NVMe mappings whose start sits in BAR0. An SGL data block that begins just before BAR0 and runs into MBAR is accepted. Bounce DMA writes guest disk bytes onto CC and resets the live controller.
- Name
- QEMU NVMe SGL span into BAR0 leftover of CVE-2021-3929
- Series
- QEMU 11.1.2 leftovers
- Phase
- 0
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- QEMU
- Affected
- QEMU v11.1.2 (4fc49f46dc95d4a27de2509e7fceb2931e91faeb). NVMe SGL.
- Published
- 16 Oct 2026
- Updated
- 16 Oct 2026
- Tags
- 0-day, qemu, nvme, uaf, local, cwe-119, cwe-416
The start of the mapping was in RAM. The rest was BAR0.
I am @abraxas_null. The proof of concept is on GitHub: abraxas/qemu-nvme-sgl-bar (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py, start-qemu.sh. Authorized lab only. It talks to loopback qtest.
This is leftover #3 of the QEMU 11.1.2 leftovers series. The other two SUCCESS leftovers on this pin are qemu-xhci-complete-uaf and qemu-colo-l4-oob.
This is QEMU v11.1.2 (4fc49f46dc95d4a27de2509e7fceb2931e91faeb). No CVE yet. CWE-119 / CWE-416. 8.2 High (CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H). Local (guest). Incomplete BAR0 deny-list leftover of CVE-2021-3929.
In 2021 the tree learned to reject an NVMe mapping whose start sits in BAR0. CMB and PMR already check both ends of a range. Identify still advertises unaligned SGL. PRP is page-bounded, so it cannot sneak into a page-aligned BAR from the previous page. An SGL data block can. A guest NVMe READ that begins 0x14 bytes before BAR0 is accepted. Bounce DMA then writes guest disk bytes onto CC. nvme_ctrl_reset runs from the DMA helper, not from a guest MMIO store in the NVMe mem_reentrancy_guard BH, while the live NvmeRequest is still in the completion path.
The lab disk is 1 MiB of zeros. Zeros on CC.EN stop the controller. CSTS.RDY drops. The log prints pci_nvme_mmio_stopped. AddressSanitizer stays quiet. That is the witness. The inferred next step is the same class as 3929: reset of a request that is still on the stack.
Why QEMU
I wanted a hypervisor that still treats the guest as untrusted and still ships a software NVMe controller in process. docs/system/security.rst is honest about the virtualization use case: KVM or HVF, x86_64 pc / q35 / microvm. Guest, VNC/SPICE, NBD, live migration, user-supplied images, PCI/USB passthrough are untrusted. TCG-as-emulator, guest-root asserts, vhost-user backends, and QMP as the attacker are out. This hunt is unpublished Critical/High on the userspace emulator that actually maps guest DMA.
Debian sid was already 1:11.1.2+ds-3. That is a useful signal: the pin is what a current distro hypervisor looks like, not a 2021 tree with a 2021 deny-list story.
I only wanted Critical or High. Guest-to-host. Medium stays on the floor unless it chains. Guest-root assert is hardening. Unbounded host alloc is hardening. Dest-only migration abort without memory corruption is out of process. A leftover had to be a public DMA-reentrancy fix that still has a walk, or a bounds check that looks at the wrong end of a range.
NVMe was the second.
Pinning v11.1.2
Tag v11.1.2. VERSION says 11.1.2. The release commit is 4fc49f46dc95d4a27de2509e7fceb2931e91faeb. GitHub qemu/QEMU and GitLab qemu-project/qemu are the same tree. I cloned the tag and did not chase master. Master is where people move BAR checks. The leftover is what 11.1.2 still ships.
The lab rebuilds qemu-system-x86_64 from that pin with ASan and UBSan, --target-list=x86_64-softmmu, no slirp, no guest-agent, no docs. qtest accel. No KVM. The sink is hw/nvme/ctrl.c. TCG vs KVM does not change whether nvme_map_addr looks at addr or at [addr, addr+len).
The skip list is half the hunt
Before you look for leftovers, you write down what already has a CVE so you do not "discover" a 2026 advisory. On this pin the interesting public rows were already closed:
- CVE-2026-17588 xHCI timer reentrancy. The leftover of that patch is leftover #1, xhci_complete, not this post.
- CVE-2026-77913 VGA text-mode OOB write. The graphic/blank leftover of that patch did not prove in lab.
- CVE-2026-66020 / CVE-2026-6502 / CVE-2026-3886 virtio-gpu blob/2d.
- CVE-2026-93834 / CVE-2026-8348 / CVE-2026-9238 9pfs FID / xattr / Treaddir.
- CVE-2026-16457 packed vring empty. CVE-2026-50626 virtio-mmio OOB.
- CVE-2026-66900 virtio-balloon BH. CVE-2026-5761 virtio-blk zone. CVE-2026-48914 SCSI_CMD size. CVE-2026-5763 virtio-scsi request size.
- CVE-2026-81627 VAPIC. CVE-2026-16271 QXL stride. CVE-2026-12080 qemu-ga. CVE-2026-3890 OHCI.
- Older NVMe: CVE-2021-3929 start-in-BAR0, CVE-2021-3947, CVE-2023-4135, CVE-2023-40360, GitLab #3387 / #4072 Copy and DIF overflow. Closed on this pin.
Also closed and not this: virtio-net RSC/RSS, e1000/igb migrated rx_desc_len recalc, USB timer plus UAS-object UAF, slirp glue (libslirp is out of tree), ACPI ERST, SCSI HBA #4130 already filed. Vghost and PwnEscape are kernel. Do not re-file those.
CVE-2021-3929 is the interesting skip. It is the same class. The local deny-list never covered a spanning SGL.
Twenty-four surfaces, then this range
I split the tree: USB, NVMe, VGA, NBD, virtio-scsi, postcopy discard, virtio-gpu restore, COLO rewriter, 9pfs, ACPI/SMBIOS, audio/chardev, CXL/UEFI/TPM, fw_cfg/VAPIC/SMM, e1000/igb, TAP/socket, PCI/IOMMU, qemu-ga, SCSI HBA, SD/IDE/floppy, slirp, virtio-blk, virtio core, virtio-net, VNC/SPICE.
Most of that is QEMU doing what security.rst said it would do, or a 2026 CVE that already landed.
Five labs on this pin came back FAIL. VGA graphic skipped the resize, no leak, no crash. NBD cookie did not abort (qemu-io and qemu-system timed out). virtio-scsi TMF ran after emulate completed, no nested cancel. Postcopy needs userfaultfd; Docker Desktop linuxkit returns ENOSYS. virtio-gpu restore never came up for QMP. Those stay unpublished. A FAIL lab is not a leftover.
Three SUCCESS. xHCI async complete into the doorbell. COLO parse_packet_early with no L4 floor. And this: NVMe SGL that starts in RAM and ends in MBAR.
CVE-2021-3929 taught the tree to look at the start
nvme_map_addr is the sink. After a length check and a trace, the iomem test is:
if (nvme_addr_is_iomem(n, addr)) {
return NVME_DATA_TRAS_ERROR;
}nvme_addr_is_iomem takes a single hwaddr. It compares that address against [bar0.addr, bar0.addr + bar0.size). The comment is explicit: guard against "local" access to controller registers, currently MBAR plus the MSI-X table/PBA. One address. The start.
CMB and PMR, twenty lines above, already know that a range has two ends:
if (!nvme_addr_is_cmb(n, addr) || !nvme_addr_is_cmb(n, addr + len - 1)) {
return NVME_DATA_TRAS_ERROR;
}Same shape for PMR. BAR0 is the register window you actually do not want DMA to touch. It is the one that still uses a point test.
Identify does not make this theoretical. nvme_init_ctrl still sets:
id->sgls = cpu_to_le32(NVME_CTRL_SGLS_SUPPORT_NO_ALIGN |
NVME_CTRL_SGLS_MPTR_SGL);Unaligned SGL is a feature bit. nvme_map_sgl_data then maps addr / trans_len with an overflow check (UINT64_MAX - addr < dlen) and a call to nvme_map_addr. No iomem range check of its own. If the start is 0x14 bytes before BAR0, nvme_addr_is_iomem is false, and the mapping is a DMA SG that covers CC.
Why SGL and not PRP
nvme_map_prp clips the first entry to the rest of the page:
hwaddr trans_len = n->page_size - (prp1 % n->page_size);
trans_len = MIN(len, trans_len);Later PRP entries are page-aligned. BAR0 on this machine is programmed at 0xe0000000. A PRP that starts in the previous page stops at the page boundary. It cannot cross into MBAR. That is why the 2021 deny-list looked sufficient if you were thinking in PRP.
I built a PRP lab first. It did what the page math said. Then I remembered Identify had already told me SGL does not have that clip.
Admin is the other dead end. nvme_admin_cmd rejects anything that is not PRP:
/* SGLs shall not be used for Admin commands in NVMe over PCIe */
if (NVME_CMD_FLAGS_PSDT(req->cmd.flags) != NVME_PSDT_PRP) {
return NVME_INVALID_FIELD | NVME_DNR;
}Identify and Get Log cannot carry the spanning descriptor. IO READ can. nvme_map_dptr switches on NVME_PSDT_SGL_MPTR_CONTIGUOUS and hands the descriptor to nvme_map_sgl.
CQ and SQ in BAR0 are a different closed walk. nvme_post_cqes is a guarded BH. Nested MMIO from a CQE post is MEMTX_ACCESS_ERROR. The leftover is not "put the completion queue on CC." The leftover is "put the data pointer across the BAR edge, then let bounce DMA write the BAR from a helper that is not that BH."
Bounce DMA writes CC from the helper
An NVMe READ with that SGL goes nvme_read -> nvme_map_data -> nvme_blk_read -> dma_blk_read. MMIO is not RAM. address_space_map bounce-allocates. When the block layer finishes, address_space_unmap in system/physmem.c does:
if (is_write) {
address_space_write(as, bounce->addr, MEMTXATTRS_UNSPECIFIED,
bounce->buffer, access_len);
}That write is the DMA helper callback. It is not an NVMe MMIO store. The TYPE_NVME mem_reentrancy_guard is not set. Nested MMIO into BAR0 is therefore allowed. nvme_write_bar sees NVME_REG_CC. If the bounce puts CC.EN=0 while the controller is enabled:
} else if (!NVME_CC_EN(data) && NVME_CC_EN(cc)) {
trace_pci_nvme_mmio_stopped();
nvme_ctrl_reset(n, NVME_RESET_CONTROLLER);Reset now cancels inflight AIOs. That is the post-3929 hygiene. Cancel/free of the request that is inside the DMA unmap/completion still races the live NvmeRequest, SQ, and CQ. Same class as 3929. The deny-list never covered the span.
The DIF bounce path has the same hole. nvme_tx uses dma_buf_write on the mapped SG from an AIO callback in hw/nvme/dif.c. I did not need DIF for the witness. READ of LBA 0 was enough.
Lab: qtest, a 1 MiB zero disk, and a 0x14 lead
The gadget is not a guest kernel. It is qtest speaking NVMe MMIO the way a guest kernel would. Compose publishes 127.0.0.1:18720. start-qemu.sh makes a 1 MiB zero nvme.img, then:
qemu-system-x86_64 -machine pc,accel=qtest -m 128 -display none
-qtest tcp:0.0.0.0:18720,server=on,wait=off
-drive id=drv0,if=none,file=nvme.img,format=raw,cache=unsafe
-device nvme,drive=drv0,serial=sglbar,addr=04.0Red Hat vendor 0x1b36, device 0x0010, slot 04.0. BAR0 programmed at 0xe0000000. Admin queues at GPA 0x20000 / 0x21000. IO queues at 0x22000 / 0x23000. Identify at 0x24000. IEEE OUI 00:54:52 is the QEMU NVMe fingerprint I used as a sanity check before I touched SGL.
Control first. IO READ, SGL data block, start = BAR0, length 4096. Status 0x4004 (NVME_DATA_TRAS_ERROR | NVME_DNR). CSTS.RDY stays 1. CVE-2021-3929 still works. Then the span: start 0xdfffffec, end 0xe0000fec, lead 0x14. CC lives at BAR0+0x14, so a 4 KiB bounce that begins 0x14 bytes before the BAR puts zeros on CAP, then on CC. CSTS.RDY before 1, after 0. pci_nvme_mmio_stopped in the trace. asan=0. The process stayed up. The controller did not.
SUCCESS qemu-nvme-sgl-bar control-sc=0x4004 span-addr=0xdfffffec span-end=0xe0000fec csts-rdy-before=1 csts-rdy-after=0 mmio-stopped=1 asan=0 QEMU-NVME-SGL-BAR-WITNESSReplay: cd lab && ./run.sh. Target only 127.0.0.1:18720.
I enabled ASan on the rebuild because leftover #1 is a heap UAF and leftover COLO is a heap-buffer-overflow READ. This leftover's first observable is MMIO, not a heap report. Building with ASan and then treating asan=0 as failure would have been a wrong oracle. The oracle is CSTS and the stopped trace.
Wrong turns, in order
PRP. Page-bounded. Cannot span a page-aligned BAR from the previous page. I watched a control-shaped reject and almost wrote "3929 is complete." Identify's SGLS_SUPPORT_NO_ALIGN is the sentence that says it is not.
Admin commands. Identify / Get Log with an SGL. Spec and nvme_admin_cmd both say no. NVME_INVALID_FIELD. The spanning descriptor has to ride an IO opcode.
Expecting ASan on the first READ. The bounce write hits MMIO, not a freed heap chunk. Reset of the live request is the UAF. This run did not free in a way ASan prints. Witness is CSTS.RDY and pci_nvme_mmio_stopped. asan=0 is a fact about this READ of zeros, not a claim that the class is "controller stop only."
What an attacker can do
Who: untrusted guest kernel with an NVMe PCI device. Guest-to-host. Unprivileged guest /dev/nvme* cannot aim an SGL at an arbitrary GPA; the attacker is the guest kernel (or anything that already programs the NVMe controller). That is PR:H. No extra recon. BAR0 is guest-visible.
Integrity (lab-proven)
| Method | Sink | Privilege |
|---|---|---|
NVMe READ SGL addr=0xdfffffec len=0x1000 (starts 0x14 before BAR0 at 0xe0000000) |
nvme_map_addr / nvme_addr_is_iomem start-only |
Mapping accepted. Control IO READ with start-in-BAR0 still returns 0x4004 (DATA_TRAS_ERROR | DNR). |
Bounce unmap writes LBA zeros onto CC |
address_space_write of DMA bounce into BAR0, nvme_write_bar |
CSTS.RDY drops. pci_nvme_mmio_stopped in the log. Controller reset from the DMA helper, not from a guest MMIO store in the NVMe BH. |
Integrity (inferred)
Guest-controlled disk bytes on CC and the rest of BAR0. This lab used zeros so CC.EN becomes 0. A disk image with chosen bytes at LBA 0 would land those bytes on CAP, INTMS, CC, and whatever else the span covers. Reset cancels inflight AIOs, but the request that is inside DMA unmap/completion still races the live NvmeRequest / SQ / CQ. Same class as CVE-2021-3929. DIF bounce (nvme_tx from an AIO callback) has the same hole.
Availability (lab-proven)
Controller stops (CSTS.RDY=0, mmio-stopped=1). Hypervisor process stays up in this lab (asan=0). A nested reset UAF of NvmeRequest is the inferred next step, not the witness this run printed. I am not going to print a heap-grooming recipe. The missing range check is the useful part.
Recon
None. NVMe BAR0 is guest-visible. Identify already reports unaligned SGL support.
Adjacent surface that stays closed
- Start address inside BAR0: blocked. That is CVE-2021-3929. Lab control SC
0x4004. - Admin Identify / Get Log: SGLs rejected. PRP cannot span.
- CQ/SQ-in-BAR0 CQE posts:
nvme_post_cqesBH is guarded. Nested MMIO isMEMTX_ACCESS_ERROR. - CMB/PMR mapping already checks both ends.
- Cross-namespace Copy reset UAF / DIF bounce overflow: public, fixed on this pin (
nvme_sq_cancel_inflight,MAX(sns->lbaf.ms, dns->lbaf.ms)). - UFS PRDT has no iomem deny-list, but SCSI emulate DMA is in a guarded BH. Not this leftover.
The lab does not demonstrate RIP control or a guest-to-host shell. The class is spanning DMA into MMIO plus controller reset of a live request.
The fix
Reject a mapping if any byte of [addr, addr+len) sits in BAR0 (and MSI-X), matching CMB/PMR. nvme_addr_is_iomem should take a range, not a start. Keep the start-in-BAR0 reject as the CVE-2021-3929 control. The one-line version is the CMB test with bar0 substituted.
Until then, a guest kernel that can program NVMe SGL can stop the controller from DMA unmap and race the live request. Operators who already disabled the in-process NVMe device, or who only attach vfio NVMe, are not on this walk. Default -device nvme is.
References
- Proof of concept: abraxas/qemu-nvme-sgl-bar · qemu-nvme-sgl-bar-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · run.sh · poc.py - Series: QEMU 11.1.2 leftovers · qemu-xhci-complete-uaf · qemu-colo-l4-oob
- @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CWE-119 · CWE-416
- Tree: qemu/QEMU v11.1.2 (
4fc49f46dc95d4a27de2509e7fceb2931e91faeb) ·hw/nvme/ctrl.c·system/physmem.c·docs/system/security.rst - Nearby patched (not this span): CVE-2021-3929 start-in-BAR0