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

vga_pci_unmap_bios() re-evaluates vga_pci_is_boot_display() and tears down a ROM-BAR mapping with the 128K shadow size: kernel page-table teardown + kernel_map VA free beyond the mapping, plus a leaked live ROM resource

Summary

map_bios picks the mapping kind via the predicate then MUTATES its inputs (sets PCIB_BCR_VGA_ENABLE) and the predicate latches default_unit; unmap RE-evaluates it - when the answer flips, the shadow teardown pmap_unmapdev's 128K over the 64K ROM mapping: neighboring kernel mappings lose PTEs and kernel_map VA is double-assigned; the mismatch KASSERTs sit after the early return and the ROM resource leaks. KLD-proven on the guest: flip -> Fatal trap 12 page-not-present at the ROM kva; stable-predicate controls clean. Live on secondary GPUs with bridge VGA-enable clear + headless default_unit (drm radeon_read_bios map/unmap pair flips it); DF-5468 masks it on bit0-preserving readback. Record-the-kind fix validated on rebuilt kernel.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5467 Β· 16 files
FileTypeDescriptionSize
df5467.c β€” 8.5 KB view raw
Makefile β€” 82 B ↓ download
build.sh β€” 220 B view raw
run.sh β€” 127 B view raw
build.log β€” 822 B view raw
run.log β€” 715 B view raw
run.2.log β€” 239 B view raw
fix_run.log β€” 864 B view raw
fix_build.log β€” 5.0 KB view raw
serial-full.log β€” 2.6 KB view raw
panic.txt β€” 1.4 KB view raw
env.txt β€” 491 B view raw
fix.diff β€” 1.7 KB view raw
VERDICT.md β€” 7.9 KB ↓ raw
manifest.json β€” 1.6 KB view raw
verdict.json β€” 6.6 KB view raw
VERDICT.md
↓ download raw

DF-5467 VERDICT β€” vga_pci_unmap_bios() shadow-path teardown of a ROM-BAR mapping

STATUS: reproduced (kernel panic on the unpatched guest) / impact: kernel-memory-corruption primitive (PTE teardown + kernel_map VA free over a 128 KiB range owning a 64 KiB mapping, plus a leaked live resource). FIX VALIDATED (fixed kernel: identical PoC sequence completes cleanly, guest stays up).

Root cause (line-accurate)

  1. vga_pci_map_bios() chooses the mapping kind by evaluating vga_pci_is_boot_display(dev) at entry β€” sys/bus/pci/vga_pci.c:175. TRUE β‡’ return a fresh pmap_mapbios(0xC0000, 128K) shadow mapping (:185-186). FALSE β‡’ ROM-BAR resource path (:190-274) returning rman_get_virtual(res) of size rman_get_size(res) (here 64 KiB).

  2. The predicate is mutable and latching: - on the ROM path vga_pci_map_bios() itself sets PCIB_BCR_VGA_ENABLE on the parent PCI-PCI bridge (:197-201) β€” one of the exact inputs vga_pci_is_boot_display() consults (:120-124); - vga_pci_default_unit is sticky: the first evaluation that passes all checks latches it (:94-101, :139).

  3. vga_pci_unmap_bios() re-evaluates the predicate (:288) instead of remembering how the BIOS was mapped. When it returns TRUE for a mapping that was created via the ROM path, unmap executes the shadow teardown

    pmap_unmapdev((vm_offset_t)bios, VGA_PCI_BIOS_SHADOW_SIZE) / 128 KiB / (:290)

on the ROM-BAR kva. pmap_unmapdev() clears PTEs via pmap_qremove() (pmap.c:6230, atomic_readandclear_long per page β€” see pmap.c:2177-2192) and frees kernel_map VA via kmem_free(kernel_map, base, size) (pmap.c:6231) for the full rounded 128 KiB β€” over a mapping that owns only 64 KiB. The 16 foreign pages of neighboring kernel mappings lose their PTEs, and their vm_map entries are destroyed by the over-range free (kernel_map VA-space corruption: the freed range is re-handable to future allocations while the true owner still holds it). The KASSERTs that would have caught the mismatch (:314-316) sit after the early return (:292) and never execute on this path.

  1. Secondary effect: the shadow path returns without touching sc->vga_bios β€” the ROM resource stays cached (vr_res != NULL, vr_refs == 1), so the next vga_pci_map_bios()/vga_pci_alloc_resource() hands back the stale kva whose PTEs are gone β†’ kernel page fault inside the kernel's own poll loop (:228).

Reachability / threat model

Attacker = firmware/hardware state (or a malicious PCIe device presenting GPU-class + bridge topology), same trust boundary as the DF-0990/DF-2324 family; no userland privilege is involved:

  • Secondary GPU behind a PCI-PCI bridge whose BCR.VGA_ENABLE is clear (typical for non-boot GPUs on UEFI systems without CSM), with the GPU's own MEM/IO decode enabled, on a system where no display device passed the boot-display checks during probe (vga_pci_default_unit == -1: headless boxes, or firmware that left all VGA decoding unselected).
  • drm attach then runs radeon_read_bios() / amdgpu's analogue (sys/dev/drm/radeon/radeon_bios.c:86-104): vga_pci_map_bios() sees predicate=0 (bridge bit clear at :121), takes the ROM path and sets the bridge bit (:200); the immediately following vga_pci_unmap_bios() sees predicate=1 (first passing evaluation latches :139) and runs the 128 KiB teardown on the 64 KiB ROM mapping.
  • The same mismatch arises from any concurrent latch of vga_pci_default_unit between map and unmap (dual-GPU attach interleave).
  • Note DF-5468: on devices whose ROM BAR readback preserves the enable bit, DF's lazy pci_alloc_map() fails the ROM allocation first, masking this chain; it is live when the readback masks bit0 or the ROM BAR is pre-reserved (hints / FreeBSD-style enumeration). QEMU's std VGA is in the first category β€” the PoC therefore seeds the resource-list entry exactly as FreeBSD's enumeration does, and the CONTROL phases prove the seeded path is balanced whenever the predicate does not flip.

Exploitation ceiling (primitive characterization)

The demonstrated fault is the simplest consequence (the ROM mapping's own PTEs). The primitive is stronger: kmem_free(kernel_map, base, 128K) frees a 64 KiB span the kernel still believes allocated (VA double-assignment β†’ two owners for one kernel VA range: a write through the surviving owner and a fresh allocation alias), and pmap_qremove strips PTEs from up to 64 KiB of arbitrary neighboring kernel mappings chosen by allocator adjacency β€” device MMIO windows, kernel heap, KLD text. A weaponized version (choose neighbor, free VA, re-allocate over a live victim object) plausibly reaches uid=0; this requires controlling kernel_map adjacency, which we did not weaponize in this run (trigger is firmware-state-gated, not unpriv-user- gated). Panic + leak + VA-space corruption are proven; privesc is characterized but not demonstrated.

Reproduction record (this pack)

  • serial-full.log β€” full serial console of the baseline run.
  • panic.txt β€” decisive excerpt:

    DF5467: BUG map -> bios=0xfffff8011887c000 size=0x10000 (ROM-BAR path) DF5467: flip: is_boot_display=1 default_unit=0 DF5467: BUG unmap returned β€” shadow-path pmap_unmapdev(ROM kva, 128K) ran DF5467: touching bios[0]=0xfffff8011887c000 β€” expect kernel page fault... Fatal trap 12: page fault while in kernel mode fault virtual address = 0xfffff8011887c000 fault code = supervisor read data, page not present Stopped at df5467_modevent+0x3ff: movzbl (%rbx),%esi

CONTROL phases (predicate consistent) map/unmap/remap/unmap cleanly on the same kernel β€” the only variable is the predicate flip.

  • Baseline kernel: stock snapshot #0: Thu Jul 2 06:02:54 UTC 2026 (env.txt).

Fix validation

  • fix.diff β€” record in the softc how map_bios mapped the BIOS (vga_bios_shadow); unmap tears down that kind. git-apply clean against the repo tree.
  • Applied to the guest /usr/src, kernel rebuilt (make -j6 nativekernel KERNCONF=X86_64_GENERIC, rc=0, -Werror; fix_build.log), installed, rebooted (#1: Tue Oct 6 00:18:55 UTC 2026).
  • Identical PoC on the fixed kernel (fix_run.log): the BUG-phase unmap now performs a proper ROM-path release; the post-bug remap allocates a fresh mapping and reads the ROM signature 0x55/0xaa; unmap clean; kldload rc=0; guest alive. Baseline panic β‡’ baseline_reproduced=1; patched bad-behavior β‡’ patched_reproduced=0.
  • Note: the module's "shadow-path ... ran" kprintf fires unconditionally after vga_pci_unmap_bios() returns; on the fixed kernel the shadow path did NOT run (the remap discriminator proves it). Kept as-is to keep the two runs byte-identical except for the kernel.

PoC changes vs the seeded sketch (honesty log)

  • run_root does not forward stdin β€” initial cat > push produced an empty .c and a vacuous "successful" build; switched to scp -F config.
  • KLD Makefile needed generated headers (device_if.h bus_if.h pci_if.h).
  • DF kernel env accessor is kgetenv, not getenv.
  • Seeded rle size math fixed (32-bit ~mask + 1); stale rle now deleted before re-adding.
  • ROM rle seeding added after the live discovery that DF's lazy pci_alloc_map() cannot allocate ROM BARs at all on this guest (DF-5468) β€” the seed emulates FreeBSD-style pre-reservation.
  • Added CONTROL phases and the remap discriminator (a correct ROM-path release also unmaps the ROM, so "touch bios[0]" alone cannot distinguish fixed from unfixed).

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

fix.diff applied to the guest /usr/src copy, kernel rebuilt with make -j6 nativekernel KERNCONF=X86_64_GENERIC (rc=0, -Werror), installed, rebooted (#1: Tue Oct 6 00:18:55 UTC 2026). Identical PoC and state sequence: BUG-phase unmap now performs a proper ROM-path release; post-bug remap allocates a fresh mapping and reads the ROM signature 0x55/0xaa; unmap clean; kldload rc=0; guest alive. Baseline (stock #0 kernel): Fatal trap 12 kernel page fault, guest down.

fix_run.log (patched run, decisive lines 'post-bug remap -> 0xfffff801185a4000', 'remap reads 0x55/0xaa = 55/aa (clean)', 'alive'); panic.txt (baseline); fix_build.log (fixed-tree build completion rc=0); fix.diff (git apply --check clean against the repo tree)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Tue Oct 6 00:18:55 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

firmware/hardware state (bridge VGA-enable clear + default_unit unset + GPU decoding on, or malicious PCIe device presenting this topology) -> drm attach (radeon_read_bios, radeon_bios.c:86-104) -> vga_pci_map_bios ROM path sets the bridge bit -> vga_pci_unmap_bios re-evaluates predicate, now true -> pmap_unmapdev(rom_kva, 128K) over a 64K mapping -> (a) PTEs of the ROM mapping and up to 16 adjacent kernel pages cleared (demonstrated: kernel page fault/panic), (b) kmem_free of 64K of kernel_map VA the kernel still considers allocated (VA double-assignment primitive), (c) ROM resource leaked with live vr_res -> subsequent map_bios hands out stale kva -> fault in kernel poll loop. Weaponization to uid=0 would require grooming kernel_map adjacency so the freed 64K range re-allocates over a victim object; not executed (trigger is firmware-state-gated rather than unpriv-user-gated).

Evidence (decisive lines)

panic.txt (Fatal trap 12, fault virtual address = ROM kva 0xfffff8011887c000, 'supervisor read data, page not present', stopped at df5467_modevent+0x3ff); serial-full.log lines 30-46 (full CONTROL/BUG sequence; CONTROL phases prove balanced map/unmap when the predicate does not flip); fix_run.log (patched kernel: post-bug remap -> 0xfffff801185a4000, 'remap reads 0x55/0xaa = 55/aa (clean)', kldload rc=0, guest alive); fix.diff; fix_build.log; VERDICT.md

PoC changes

run_root does not forward stdin (first push produced an empty .c and a vacuous green build - switched to scp); KLD Makefile needed device_if.h/bus_if.h/pci_if.h in SRCS; DF kernel env accessor is kgetenv not getenv; seeded ROM rle size math corrected to 32-bit ~mask+1 and stale rle deleted before re-add; ROM resource-list seeding added after discovering live that DF's lazy pci_alloc_map() cannot allocate ROM BARs at all on this guest (filed as DF-5468) - the seed emulates FreeBSD-style pre-reservation/bit0-masking hardware; CONTROL phases and a post-bug remap discriminator added because a correct ROM-path release also unmaps the ROM (touch-after-unmap alone cannot distinguish fixed from unfixed).

Verified recommended fix

Record the mapping kind in struct vga_pci_softc (vga_bios_shadow) when vga_pci_map_bios() creates it, and have vga_pci_unmap_bios() tear down that recorded kind instead of re-evaluating vga_pci_is_boot_display(); full diff in findings/poc/DF-5467/fix.diff

Verdict

vga_pci_unmap_bios() re-evaluates vga_pci_is_boot_display() (vga_pci.c:288) to decide which mapping kind to tear down, but vga_pci_map_bios() itself mutates that predicate's inputs (it sets PCIB_BCR_VGA_ENABLE on the parent bridge, vga_pci.c:197-201, and the predicate latches the sticky vga_pci_default_unit, vga_pci.c:139). When the answer flips between map and unmap β€” the stock case being a secondary GPU behind a bridge with VGA-enable clear and no boot display selected, driven by drm's radeon_read_bios() map/unmap pair β€” unmap executes the shadow-copy teardown pmap_unmapdev(bios, 128K) (vga_pci.c:290) on a ROM-BAR mapping of size rman_get_size() (64K here). pmap_unmapdev clears PTEs and frees kernel_map VA for the full 128K (pmap.c:6223-6232): kernel page-table teardown of up to 64K of neighboring kernel mappings plus a kernel_map VA double-free of unowned space; the KASSERTs at vga_pci.c:314-316 are bypassed by the early return, and the ROM resource leaks so the next map_bios returns stale unmapped kva. Demonstrated on the QEMU guest: CONTROL phases (consistent predicate) clean; BUG phase ends in Fatal trap 12, supervisor read data page not present, at the ROM kva. fix.diff (record the mapping kind in the softc) rebuilt and installed on the guest: identical PoC completes cleanly (fresh remap reads 0x55/0xaa, guest stays up).