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)
PoC verification
Evidence pack
findings/poc/DF-4543 Β· 9 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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)
agp_generic_attach(agp.c:240-242) creates the userland device with a hardcoded minor 0 and name "agpgart", then stores the backingdevice_tin the cdev:sc->as_devnode->si_drv1 = dev;agp_free_cdev(agp.c:248-251) removes it withdev_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 β theagpgartnode survives detach withsi_drv1pointing at the detached (and soon freed) device. A second simultaneous AGP bridge also collides on the same name/minor (twoagpgartcdevs, each overwriting its ownsi_drv1).- Detach with the device open (any unit):
destroy_devonly 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 β andsi_drv1β survives detach. After the driver finishes detaching, newbus frees the softc; the nextioctl(2)/mmap(2)/close(2)on the still-open fd runsagp_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 writescfields:as_stateat agp.c:653-655, TAILQ ops at agp.c:481/496/800).
Trigger requirements (why this is Low)
/dev/agpgartisUID_ROOT, GID_WHEEL, 0600(agp.c:241) β root-only (no devfs rule widens it; checkedetc/defaults/devfs.conf).- Detach requires either a kernel built with agp as a loadable module
(stock
X86_64_GENERICcompiles it in βsys/config/X86_64_GENERIC:170β sokldunloadis 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 viadfbsd-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 inrun.logis 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.
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)
-
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. -
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 todevfs_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 (andsi_drv1) alive past softc free β UAF on that fd. - unit != 0: matches nothing β node never removed; after detach+softc free, the surviving/dev/agpgarthas a danglingsi_drv1and any lateropenby root entersagp_open(agp.c:777-779) on freed memory. -
Consumer derefs of the dangling pointer:
agp_openagp.c:777-779,agp_closeagp.c:792-794,agp_ioctlagp.c:818-820,agp_mmapagp.c:857-858 β all dodevice_get_softc(kdev->si_drv1). -
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_testablefix.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.
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.
No comments yet.