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

agp: /dev/agpgart cdev outlives chipset detach - dangling si_drv1 UAF on open fds, plus free_cdev unit-vs-minor mismatch leaves a stale node

Summary

make_dev hardcodes minor 0 while agp_free_cdev removes by unit (a no-op for unit!=0, leaving /dev/agpgart surviving detach with dangling si_drv1), and destroy_dev only unlinks - an open fd keeps the cdev and si_drv1 alive past softc free, so the next ioctl/mmap/close does device_get_softc(freed): UAF read+write on as_state and the TAILQ ops. Root-only (0600) and requires AGP hardware (absent on guest, proven). Guard-on-isopen + destroy-the-made-node fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4543 Β· 9 files
FileTypeDescriptionSize
README.md β€” 2.8 KB ↓ raw
VERDICT.md β€” 4.1 KB ↓ raw
poc_agp_liveness.sh β€” 669 B view raw
run.sh β€” 122 B view raw
run.log β€” 410 B view raw
env.txt β€” 583 B view raw
fix.diff β€” 2.5 KB view raw
manifest.json β€” 1001 B view raw
verdict.json β€” 3.4 KB view raw

DF-4543 β€” agp.c device-lifecycle: /dev/agpgart cdev outlives chipset detach; unit/minor mismatch in free path

What the finding is (pass 2, new β€” not any of DF-1685/1686/1687/1688/2397/2398/2399/2400)

sys/dev/agp/agp.c:

  1. agp_generic_attach (agp.c:240-242) creates the userland device with a hardcoded minor 0 and name "agpgart", then stores the backing device_t in the cdev: sc->as_devnode->si_drv1 = dev;
  2. agp_free_cdev (agp.c:248-251) removes it with dev_ops_remove_minor(&agp_ops, device_get_unit(dev)). That helper (sys/kern/kern_device.c β†’ devfs_destroy_dev_by_ops(ops, minor)) matches cdevs by minor, not by unit. It is only correct for unit 0. For any agp device with unit != 0 the removal is a no-op β€” the agpgart node survives detach with si_drv1 pointing at the detached (and soon freed) device. A second simultaneous AGP bridge also collides on the same name/minor (two agpgart cdevs, each overwriting its own si_drv1).
  3. Detach with the device open (any unit): destroy_dev only unlinks the node and drops the link refs (devfs_destroy_dev_worker, sys/vfs/devfs/devfs_core.c:1440-1463). An open file's vnode holds its own cdev reference, so the cdev β€” and si_drv1 β€” survives detach. After the driver finishes detaching, newbus frees the softc; the next ioctl(2)/mmap(2)/close(2) on the still-open fd runs agp_ioctl (agp.c:818-820) / agp_mmap (agp.c:857-858) / agp_close (agp.c:792-794) β†’ kdev->si_drv1 β†’ device_get_softc() β†’ use-after-free read+write on the freed softc (ioctls write sc fields: as_state at agp.c:653-655, TAILQ ops at agp.c:481/496/800).

Trigger requirements (why this is Low)

  • /dev/agpgart is UID_ROOT, GID_WHEEL, 0600 (agp.c:241) β€” root-only (no devfs rule widens it; checked etc/defaults/devfs.conf).
  • Detach requires either a kernel built with agp as a loadable module (stock X86_64_GENERIC compiles it in β€” sys/config/X86_64_GENERIC:170 β€” so kldunload is impossible there) or ACPI/PCI hot-removal of the AGP bridge.

So: root-triggerable UAF, hardware/config-gated. Defense-in-depth value.

Reproduce

  • ./run.sh β€” runs the liveness probe on the audit guest via dfbsd-qemu/vm.sh run_root.
  • Expected on this guest: nothing happens β€” see VERDICT.md: the guest has no AGP hardware (QEMU bochs VGA, 7 PCI devices, no AGP caps, no /dev/agpgart), so the trigger surface does not exist to exercise. The probe output in run.log is the documented proof of hardware absence.

Fix

fix.diff β€” make agp_free_cdev refuse teardown while a client is open (returns EBUSY β†’ detach fails, chip drivers propagate) and destroy the actual stored devnode instead of guessing minor == unit.

VERDICT.md
↓ download raw

DF-4543 VERDICT β€” not_reproduced (attack surface absent on guest; code mechanics verified by trace)

Bottom line

The defect is real in the code (verified line-by-line below), but it is not reproducible on the audit guest: the guest has no AGP-capable hardware, so agp_generic_attach (sys/dev/agp/agp.c:240) never executes, /dev/agpgart does not exist, and no detach lifecycle can even begin. Beyond the hardware gate, the trigger is root-only (0600 root:wheel node) and requires either a module-built agp kernel (kldunload) or PCI/ACPI hot-removal β€” the stock X86_64_GENERIC kernel compiles agp in (sys/config/X86_64_GENERIC:170).

Liveness evidence (run.log, fresh run)

ls: /dev/agpgart: No such file or directory          (ls_rc=1)
kldstat | grep -i agp                                 (empty, rc=1)
vgapci0@pci0:0:2:0: class=0x030000 chip=0x11111234    (QEMU/bochs virtual VGA, vendor 0x1234)
pciconf -lc | grep -i agp                             (empty, rc=1 β€” no AGP capability on any device)
PCI device count: 7

No AGP bridge β‡’ no chipset driver probe/attach β‡’ no make_dev β‡’ no userland device β‡’ no lifecycle to race. Same probe rules out the AMD DCE audio path (no AMD display device: the only class-0x03 device is the bochs VGA).

Code trace (why the bug is real on suitable hardware)

  1. agp_generic_attach β€” sys/dev/agp/agp.c:240-242: sc->as_devnode = make_dev(&agp_ops, 0, UID_ROOT, GID_WHEEL, 0600, "agpgart"); sc->as_devnode->si_drv1 = dev; Minor is hardcoded 0 and the name "agpgart" for every agp unit.

  2. agp_free_cdev β€” sys/dev/agp/agp.c:248-251: dev_ops_remove_minor(&agp_ops, device_get_unit(dev)); dev_ops_remove_minor (sys/kern/kern_device.c) forwards to devfs_destroy_dev_by_ops(ops, minor) which matches cdevs by si_ops+minor. The created cdev has minor 0; the removal uses the unit. - unit 0: matches (correct removal), but only unlinks + release_dev (sys/vfs/devfs/devfs_core.c:1440-1463); an open fd's vnode keeps the cdev (and si_drv1) alive past softc free β†’ UAF on that fd. - unit != 0: matches nothing β†’ node never removed; after detach+softc free, the surviving /dev/agpgart has a dangling si_drv1 and any later open by root enters agp_open (agp.c:777-779) on freed memory.

  3. Consumer derefs of the dangling pointer: agp_open agp.c:777-779, agp_close agp.c:792-794, agp_ioctl agp.c:818-820, agp_mmap agp.c:857-858 β€” all do device_get_softc(kdev->si_drv1).

  4. No chip-driver detach checks for an open client: agp_via.c:226, agp_sis.c:172, agp_amd64.c:251, intel-gtt.c:914 all call agp_free_cdev(dev) blindly and proceed to free the GATT/resources.

Why not the known findings

DF-1685/2397 are races between ioctls on an attached device; DF-1686/2400 is the mmap off-by-one; DF-1687/2399 is the every-close wipe; DF-1688 the signed unbind loop; DF-2398 the bind bounds-check wrap. None of them covers attach/detach device lifecycle or the free_cdev unit-vs-minor mismatch.

Exploit chain

None developed β€” hardware gate. On suitable hardware the primitive would be: root opens /dev/agpgart, keeps fd; root unloads agp.ko (module kernel); ioctl on the fd hits freed softc β†’ UAF write via as_state/TAILQ fields. Purely privileged; escalation value ~none (already root). It is a correctness/robustness UAF worth fixing.

Fix validation

fix.diff authored (agppriv.h + agp.c + 4 chip drivers): refuse agp_free_cdev while sc->as_isopen, propagate EBUSY from all detaches, and destroy_dev(sc->as_devnode) instead of the minor guess. Not testable on this guest: no AGP hardware to attach, and agp is compiled into the guest kernel so no module unload exists to drive the lifecycle.

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

not_testable
baseline no→ patch + rebuild →patched clean

fix.diff authored against the read-only sys/ tree but not buildable/testable on this guest: no AGP hardware means the agp driver never attaches, so neither baseline nor patched lifecycle can be driven. Baseline badness is established by code trace (VERDICT.md), not runtime.

fix.diff (agppriv.h + agp.c + agp_via.c + agp_sis.c + agp_amd64.c + intel-gtt.c hunks); no fix_build.log/fix_run.log because no kernel with the agp lifecycle reachable can run on this QEMU guest.
↓ fix.diffper-fix-DF-4543

Confirmed kernel references

Detail

Evidence (decisive lines)

run.log (ls /dev/agpgart -> ENOENT; pciconf -lc | grep -i agp -> empty; vgapci0 chip=0x11111234 bochs VGA); VERDICT.md section 'Code trace' with path:line for each mechanism step; fix.diff for the proposed guard.

PoC changes

No seed PoC existed; authored a liveness probe (poc_agp_liveness.sh) to prove the attack surface is absent on this guest rather than claim a false run.

Verified recommended fix

Guard agp_free_cdev with sc->as_isopen (return EBUSY, propagate from all chip detaches) and destroy the stored as_devnode via destroy_dev() instead of dev_ops_remove_minor(device_get_unit).

Verdict

Device-lifecycle defect verified by code trace but not exercisable on the audit guest: /dev/agpgart is created with hardcoded minor 0 and name agpgart (agp.c:240-242) while agp_free_cdev removes by device_get_unit (agp.c:248-251), and dev_ops_remove_minor matches by minor (kern_device.c) so for unit != 0 the node survives detach with dangling si_drv1; independently, destroy_dev only unlinks + drops link refs (devfs_core.c:1440-1463) so an open fd keeps the cdev (and si_drv1) alive past softc free, making later ioctl/mmap/close a UAF (agp.c:777/792/818/857). Guest liveness probe (run.log): /dev/agpgart absent, no AGP PCI capability on any of the 7 devices, bochs VGA only - the attach path never runs, and stock X86_64_GENERIC compiles agp in so no kldunload exists. Root-only trigger (0600 root:wheel node) on top of the hardware gate, so impact ceiling is a privileged UAF (robustness), not unpriv escalation.