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

bus_dmamap_destroy() frees a map still queued for a deferred load (EINPROGRESS) -> write-after-free with attacker-followed map->dmat pointer and permanent zone wedge (vkernel twin of pc64 DF-5347)

Summary

Same lifecycle hole as pc64 DF-5347, fork never got the fix: destroy's only guard is bpages!=NULL so a queued map (pagesneeded>0, empty bpages) is kfree'd while linked; the next bounce release pops the freed chunk - reserve follows the freed/replanted map->dmat pointer and writes pagesreserved through it, then the ghost reservation wedges the zone forever (every later load EINPROGRESS). Proven IN A LIVE VKERNEL on the guest (driver compiled into the vkernel): destroy returns 0, 72-byte spray reclaims the exact chunk, kernel follows the PLANTED dmat and writes pagesreserved 0->1 through the freed chunk, victim map wedged, vkernel stays alive (deterministic); no-reclaim run captured stale-link writes 2->3; destroy-EBUSY + unload-dequeue fix validated (EBUSY/healthy). Guest-of-guest reach gated on a bus_dma driver in the vkernel (none stock).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5370 Β· 16 files
FileTypeDescriptionSize
dfbusdma.c β€” 13.8 KB view raw
build_kernel.sh β€” 958 B view raw
build_image.sh β€” 1.1 KB view raw
run.sh β€” 118 B view raw
fix.diff β€” 1.4 KB view raw
build.log β€” 934.3 KB ↓ download
base-build.log β€” 24.9 KB view raw
fix-build.log β€” 25.1 KB view raw
fix-build2.log β€” 25.0 KB view raw
run.log β€” 3.6 KB view raw
run.fixed.log β€” 3.2 KB view raw
run.fixed2.log β€” 3.2 KB view raw
env.txt β€” 348 B view raw
VERDICT.md β€” 4.5 KB ↓ raw
manifest.json β€” 1.2 KB view raw
verdict.json β€” 7.5 KB view raw
VERDICT.md
↓ download raw

DF-5370 VERDICT β€” REPRODUCED (vkernel kernel write-after-free + controlled-pointer dereference via freed bus_dma map)

Root cause (path:line into sys/platform/vkernel64/platform/busdma_machdep.c)

  1. _bus_dmamap_load_buffer() :580-593: a blocking load that cannot fully reserve bounce pages queues the map on dmat->bounce_zone->bounce_map_waitinglist (:587-589), stashes map->dmat/buf/buflen (:583-585) and returns EINPROGRESS. Queued state: map->bpages empty, map->pagesneeded > 0, map->pagesreserved = the partial reservation (both observed: 7 / 2 in the run).
  2. bus_dmamap_destroy() :383-392: only guard is STAILQ_FIRST(&map->bpages) != NULL β†’ a queued map is kfreed while still linked. Verified: destroy returned 0.
  3. bus_dmamap_unload() (macro sys/bus_dma.h:313) calls only _bus_dmamap_unload() :891-900, which walks the empty bpages list β€” the queued map is never dequeued (DF-5348 twin), so both orderings of conventional driver error cleanup leave the dangling link armed.
  4. Wake path: free_bounce_page() :1156-1181 β†’ get_map_waiting() :1184-1200 pops the freed chunk β†’ reserve_bounce_pages(map->dmat, map, 1) :1071-1092 follows map->dmat read from freed memory (bz = dmat->bounce_zone, BZ_LOCK(&bz->spin) on the pointed-to address, counter writes at bz+52/+56) and writes map->pagesreserved += pages into freed memory.

Reproduction (DragonFly 6.5-DEVELOPMENT guest, VKERNEL64 vkernel booted with -m 512m)

The dfbusdma driver (compiled into the vkernel, SYSINIT at SI_SUB_CONFIGURE) plays a bus_dma driver: private lowaddr=16MB bounce zone seeded with 1 page (+1 per extra map_create β‡’ 3 total), mapA loads 1 page (consumed), mapB loads a 7-page buffer above 16MB β‡’ pagesneeded=7, partial reserve=2 β‡’ queued, EINPROGRESS (DFly EINPROGRESS=36).

  • bus_dmamap_destroy(tag, mapB) β†’ 0 (freed while queued β€” UAF armed).
  • Immediate kmalloc(72) spray reclaims the exact chunk (SAME CHUNK, deterministic; note sizeof(struct bus_dmamap) == 72 β‡’ 128-byte size class β€” a 64-byte spray misses, which cost one iteration to find).
  • Planted body: pagesneeded=0x10000, pagesreserved=0, dmat=<static planted fake tag whose bounce_zone points at the real zone>.
  • Wake via bus_dmamap_unload(tag, mapA): kernel followed the planted dmat (0x1120e20) and wrote pagesreserved 0β†’1 into the freed chunk (write-after-free through an attacker-chosen pointer), read pagesneeded as 65536 through it. The unsatisfiable pagesneeded keeps get_map_waiting returning NULL, so the #ifdef notyet add_map_callback panic stub (DF-1044) is NOT reached β€” the proof isolates this bug and leaves the vkernel ALIVE.
  • Victim mapC load β†’ EINPROGRESS forever: the ghost reservation permanently wedges the zone (one bounce page sits on the free list while free_bpages==0).
  • A separate baseline run without reclaim observed the kernel writing pagesreserved 2β†’3 through the stale link β€” the UAF does not depend on reclaim luck.

Escalation ceiling: identical code to pc64 DF-5347 (High, arbitrary ring-0 call via the freed map's callback). On the vkernel the deferred callback is the notyet panic, so the chain stops at controlled write + controlled pointer dereference + zone wedge; reaching the stub instead yields the DF-1044 panic. No stock VKERNEL64 driver uses bus_dma, so guest-of-guest reachability requires a bus_dma driver kldloaded into the vkernel β€” hence Medium severity (memcorrupt bucket) rather than the pc64 twin's High.

Fix validation

fix.diff (destroy EBUSY guard on pagesneeded/pagesreserved; unload dequeues queued maps and returns their reservation):

  • baseline run.log: destroy=0, planted-dmat UAF write (0β†’1), wedge PROVEN.
  • run.fixed.log: bus_dmamap_destroy(queued mapB) = 16 (EBUSY), unload(mapA)+unload(mapB) drain the queue, mapC load err=0 nsegs=1 β€” zone healthy. No UAF, no wedge. Vkernel ALIVE.

Negative/notes

  • No panic in the decisive runs (by design; deterministic).
  • EINPROGRESS is 36 on DragonFly (not 115).
  • vkernel pmap interplay: _bus_dma_extract (:503-510) feeds pmap_extract/pmap_kextract, whose empty-PTE garbage return is DF-5290; not re-verified here.

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 (destroy EBUSY guard + unload dequeue/return-reservation) applied to guest /usr/src, VKERNEL64 rebuilt. Baseline: destroy=0, planted-dmat write-after-free (pagesreserved 0->1), zone wedged (mapC EINPROGRESS). Patched: bus_dmamap_destroy(queued mapB)=16 (EBUSY), unload(mapA)+unload(mapB) drain the waiting list, mapC load err=0 nsegs=1 'zone healthy'; no UAF, no wedge, vkernel ALIVE.

run.fixed.log: 'DF5370: bus_dmamap_destroy(queued mapB) = 16 (EBUSY - fix active, UAF not armed)' and 'DF5370: fixed path: mapC load err=0 nsegs=1 - zone healthy'; fix-build.log full rebuild log; fix.diff is git-apply-able against the pristine sys/ tree (hunk offsets verified)
↓ fix.diffper-fix-DF-5370

Confirmed kernel references

Detail

Exploit chain

lowaddr-constrained bouncing tag (private zone, 3 pages) -> mapA load consumes 1 page -> mapB WAITOK load of a 7-page kernel buffer under zone pressure -> reserve_bounce_pages partial (2/7) -> map queued, EINPROGRESS -> driver error cleanup bus_dmamap_destroy(mapB) returns 0: kfree while linked (map_count--, chunk in 128B M_DEVBUF class) -> kmalloc(72) spray reclaims the chunk and plants pagesneeded=0x10000 / pagesreserved=0 / dmat=planted fake tag (bounce_zone -> real zone) -> bus_dmamap_unload(mapA) frees its bounce page -> free_bounce_page -> get_map_waiting pops the freed chunk -> reserve_bounce_pages dereferences the planted dmat (BZ_LOCK on attacker-chosen address, bz->free_bpages/reserved_bpages writes) and writes pagesreserved 0->1 into freed memory -> reservation stays partial so get_map_waiting returns NULL, avoiding the notyet add_map_callback panic -> zone permanently wedged (ghost reservation), all later loads on the zone EINPROGRESS forever. With a satisfiable planted pagesneeded the chain instead continues to dequeue + add_map_callback, which on this vkernel is panic('uncoded') (DF-1044); with the callback machinery implemented (as on pc64) this same chain yields the arbitrary ring-0 call of DF-5347.

Evidence (decisive lines)

run.log (pristine-source baseline): 'load B ret=36 (EINPROGRESS - mapB queued...)', 'mirror check mapB pagesneeded=7 pagesreserved=2 ... OK', 'bus_dmamap_destroy(queued mapB) = 0 (FREED WHILE STILL QUEUED - UAF armed)', 'reclaim: freed mapB=0x800e966068 fake=0x800e966068 SAME CHUNK - planted ... dmat=0x1120e20 (fully controlled)', 'after wake: freed chunk pagesneeded=65536 pagesreserved=1', 'VERDICT PROVEN - kernel wrote pagesreserved (0->1) into the reclaimed chunk after following its PLANTED dmat pointer', 'victim mapC load err=36 (EINPROGRESS forever...)', 'VERDICT wedge PROVEN'. run.fixed.log (fix.diff): 'bus_dmamap_destroy(queued mapB) = 16 (EBUSY - fix active, UAF not armed)' + 'fixed path: mapC load err=0 nsegs=1 - zone healthy'. An earlier baseline also captured the no-reclaim variant: 'after wake: freed chunk pagesneeded=7 pagesreserved=3' (stale 2->3 write through the queued link).

PoC changes

no seed PoC existed; driver authored fresh. Fixes vs first draft: (1) EINPROGRESS is 36 on DragonFly, and bus_dmamap_load returns 0 on non-EINPROGRESS errors (error only reaches the callback) - had to read cb_err; (2) large kmallocs are page-quantized so a +2048 interior offset forces uniform non-zero pmap_kextract offsets; (3) BOUNCE_ALIGN tags only bounce the first (partial) page of a linear buffer - the count loop samples pages at aligned starts - so the demo uses a lowaddr=16MB BOUNCE_LOWADDR tag where every heap page bounces; (4) DFly STAILQ_HEAD carries stqh_first AND stqh_last: sizeof(bus_dmamap)=72 and mirror offsets are pagesneeded=16/pagesreserved=20/dmat=24 - the 72-byte chunk lives in the 128-byte class, so the spray must kmalloc(72), not 64 (64-byte spray never reclaims); (5) vkernel needs /var/vkernel to exist and the mountroot prompt answered (ufs:vkd0) for a clean boot.

Verified recommended fix

bus_dmamap_destroy: also return EBUSY when map->pagesneeded || map->pagesreserved (pending deferred load); _bus_dmamap_unload: dequeue the map from the bounce-zone waiting list and return its reservation; see fix.diff (validated: EBUSY + zone healthy)

Verdict

bus_dmamap_destroy() in sys/platform/vkernel64/platform/busdma_machdep.c:383-392 frees a bus_dmamap that is still linked on bounce_zone->bounce_map_waitinglist after an EINPROGRESS deferred load (queued at :587-589); bus_dmamap_unload is equally a no-op for a queued map (macro calls only _bus_dmamap_unload:891-900, which walks the empty bpages list - the DF-5348 twin). Proven in a live VKERNEL64 vkernel on the guest with a compiled-in test driver: destroy of the queued map returned 0 (freed while queued), an immediate kmalloc(72) spray reclaimed the exact chunk, and the next bounce-page release (free_bounce_page:1156 -> get_map_waiting:1184 -> reserve_bounce_pages:1071) followed the PLANTED map->dmat pointer (attacker-chosen address, spinlock+counter writes through it) and wrote map->pagesreserved 0->1 into the freed chunk. The freed map's ghost reservation also permanently wedged the zone: a victim map's load returns EINPROGRESS forever while a bounce page sits on the free list (free_bpages==0) - recorded impact dos is the strongest fully-demonstrated end state; the underlying primitive is a vkernel-kernel write-after-free with controlled pointer dereference, the vkernel twin of pc64 DF-5347 (High, arbitrary ring-0 call). On the vkernel the deferred chain is cut off by the #ifdef notyet add_map_callback panic stub (pass-1 DF-1044, not re-reported here) unless the attacker keeps the reservation partial, as the PoC deliberately does to stay crash-free. Guest-of-guest reachability requires a bus_dma-using driver inside the vkernel (none in stock VKERNEL64; the file is compiled into every vkernel), hence Medium rather than the pc64 twin's High.