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

bus_dmamap_destroy() frees a map still queued for deferred load (EINPROGRESS) -> UAF with arbitrary-call/RIP control from freed memory

Summary

destroy's only guard is bpages!=NULL - a map queued on bounce_map_waitinglist (empty bpages, pagesneeded!=0) is kfree'd while linked; bounce release then writes into the freed chunk and busdma_swi executes a full deferred load ending in callback(callback_arg,...) through the freed chunk's function pointer. Trigger is CONVENTIONAL driver error cleanup (if(err){unload;destroy;}), zone pressure groomable via ordinary I/O on bouncing tags (default hw.busdma.bounce_alignment=1 makes every alignment>1 tag bounce). Guest-proven via KLD harness on clean boot: planted callback EXECUTED ('HIJACK PROVEN', arg=0xbaadf00d, twice), callback=0x414141414000 -> Stopped at 0x414141414000 (FULL RIP control), plus NULL-deref and slab-metadata-corruption manifestations. Fix (pending-load EBUSY + unload dequeue/reset + swi drop) validated on rebuilt kernel: hijack and panics gone. No in-tree driver exposes destroy-after-EINPROGRESS unpriv (twa handles it) - High not Critical.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5347 Β· 15 files
FileTypeDescriptionSize
busdmauaf.c β€” 12.4 KB view raw
build.sh β€” 172 B view raw
run.sh β€” 139 B view raw
build.log β€” 800 B view raw
run.log β€” 4.7 KB view raw
panic.txt β€” 939 B view raw
slab_corruption_panic.txt β€” 1.1 KB view raw
getmapwaiting_null_panic.txt β€” 1.4 KB view raw
boot.log.tests123 β€” 17.9 KB ↓ download
dmesg_fixrun.txt β€” 15.1 KB view raw
env.txt β€” 1.2 KB view raw
fix.diff β€” 2.3 KB view raw
VERDICT.md β€” 5.0 KB ↓ raw
manifest.json β€” 1.3 KB view raw
verdict.json β€” 5.2 KB view raw
VERDICT.md
↓ download raw

DF-5347 VERDICT β€” REPRODUCED (kernel UAF β†’ arbitrary-call control-flow hijack)

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

  1. _bus_dmamap_load_buffer() line 645-657: when a blocking load cannot reserve enough bounce pages, the map is queued on dmat->bounce_zone->bounce_map_waitinglist (map->buf/buflen/dmat/callback stashed in the map) and EINPROGRESS is returned. At this moment map->bpages is EMPTY and map->pagesneeded > 0.
  2. bus_dmamap_destroy() line 440-449: the ONLY liveness guard is STAILQ_FIRST(&map->bpages) != NULL β†’ EBUSY. A queued map passes the guard and is kfree(map, M_DEVBUF)d (72-byte chunk) while still linked on the waiting list. Nothing ever validates queue membership.
  3. Any later bounce-page release (free_bounce_page line 1399 β†’ get_map_waiting line 1408) pops the freed chunk: reserve_bounce_pages() line 1279 writes map->pagesreserved into freed memory; the chunk is then threaded onto the global bounce_map_callbacklist (add_map_callback line 1430).
  4. busdma_swi() line 1445 runs bus_dmamap_load(map->dmat, map, map->buf, map->buflen, map->callback, map->callback_arg, 0) on the freed chunk, which ends in callback(callback_arg, segments, nsegs, error) (bus_dma_machdep line 804) β€” an indirect call through a function pointer read from freed, attacker-repopulatable memory.

The destroy-after-EINPROGRESS pattern is ordinary driver error cleanup (if (error) { bus_dmamap_unload(); bus_dmamap_destroy(); }). The only in-tree driver that ever receives EINPROGRESS (twa, sys/dev/raid/twa/tw_osl_freebsd.c:1390) handles it correctly; every other driver using a bouncing tag with conventional error handling would convert bounce-zone pressure into this UAF. Zone pressure is groomable by ordinary I/O on lowaddr-constrained tags (and by default hw.busdma.bounce_alignment=1 makes every tag with alignment>1 and no BUS_DMA_ALIGNED bounce).

Reproduction (all on DragonFly 6.5-DEVELOPMENT #0, clean snapshot boots)

KLD harness busdmauaf.c (plays the vulnerable driver): - private 2-page bounce zone (lowaddr=16MB), mapB loads both pages, mapA's load β†’ EINPROGRESS (queued), - bus_dmamap_destroy(tag, mapA) β†’ 0 β€” freed while linked, - heap spray reclaims the chunk ("spray chunk 0 == freed mapA" β€” deterministic LIFO on the 72-byte M_DEVBUF zone), - freeing mapB's pages wakes the freed map, - busdma_swi() executes the deferred load on the freed chunk and calls the planted map->callback (hijack_proof) with planted arg 0xbaadf00d:

  BUSDMA-UAF: *** HIJACK PROVEN *** attacker-chosen function
  0xffffffff82600000 executed, arg=0xbaadf00d nsegs=1 error=0

(twice on one boot; see run.log).

  • POC_TEST=3 plants callback=0x414141414000 β†’ kernel jumps there:

    Stopped at 0x414141414000: kernel: type 12 trap, code=0

(see panic.txt) β€” full RIP control from freed memory.

Two additional independent manifestations captured: - getmapwaiting_null_panic.txt β€” NULL-deref panic inside the inlined reserve_bounce_pages when the freed chunk held garbage dmat. - slab_corruption_panic.txt β€” the deferred load's STAILQ_INSERT_TAIL(&map->bpages, ...) wrote the bounce-page pointer at offset 0 of the still-free chunk, corrupting kern_slaballoc's free-chunk linkage; the NEXT unrelated kmalloc(72) panicked in chunk_mark_allocated (kern_slaballoc.c:1659). Reproduced twice.

Primitive / exploit chain

Arbitrary indirect call in ring 0: target = reclaimed map->callback, RDI = map->callback_arg (both fully attacker-set via the spray). With no SMEP/SMAP/KASLR on the audit guest, pointing the callback at any kernel text gadget (e.g. stack pivot → ROP, or a function that grants privileges when called with controlled RDI) is mechanically trivial — the harness demonstrates the controlled call itself, which is the hard part. Escalation to uid=0 additionally requires an unprivileged-reachable driver that destroys/unloads an EINPROGRESS map; no in-tree driver currently does, so end-to-end unpriv→root was not demonstrated and severity is High rather than Critical.

Fix validation

fix.diff (git-apply clean against the sys/ tree; applied inside the guest /usr/src only): destroy returns EBUSY while pagesneeded||pagesreserved, unload dequeues + releases the reservation (includes the one-line add_map_callback(wait_map) typo fix it depends on), busdma_swi drops unloaded maps. Patched kernel (#1 Oct 3 06:29) re-ran all three tests:

bus_dmamap_destroy(queued mapA) -> 16   (EBUSY)   [tests 1 & 3]
RESULT=FAILED (no phantom)                          [test 2]
no panic, guest stays up

Baseline behavior is GONE (dmesg_fixrun.txt).

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 guest /usr/src, kernel rebuilt+installed (nativekernel, X86_64_GENERIC #1). Baseline: destroy=0 + hijack + RIP panic + slab corruption. Patched: destroy returns EBUSY(16) in tests 1 and 3, no hijack, no panic; test 2 phantom callback gone; guest stable across all three runs.

dmesg_fixrun.txt: 'bus_dmamap_destroy(queued mapA) -> 16' (x2) + no HIJACK lines + guest_still_up; fix.diff applies with git apply --check against the pristine sys/ tree
↓ fix.diffper-fix-DF-5347

Confirmed kernel references

Detail

Exploit chain

bouncing tag (lowaddr1 w/ default bounce_alignment=1) -> bus_dmamap_load returns EINPROGRESS under groomed bounce-zone pressure -> driver error-cleanup calls bus_dmamap_destroy -> map kfree'd while linked on bz->bounce_map_waitinglist -> spray reclaims the 72B M_DEVBUF chunk, plants dmat/buf/buflen/callback/callback_arg -> any bounce-page release (free_bounce_page) -> get_map_waiting writes map->pagesreserved in freed memory, dequeues, threads chunk onto bounce_map_callbacklist -> busdma_swi -> bus_dmamap_load on freed chunk -> callback(callback_arg,...) = arbitrary ring-0 call, target+RDI attacker-controlled -> (no SMEP/SMAP/KASLR on guest) stack pivot/ROP -> uid=0; demonstrated up to the controlled call incl. RIP=0x414141414000

Evidence (decisive lines)

run.log: 'bus_dmamap_destroy(queued mapA) -> 0', 'spray chunk 0 == freed mapA (reclaimed!)', '*** HIJACK PROVEN *** attacker-chosen function ... executed, arg=0xbaadf00d'; panic.txt: 'Stopped at 0x414141414000'; slab_corruption_panic.txt + getmapwaiting_null_panic.txt: alternate corruptions; dmesg_fixrun.txt: patched kernel -> destroy=16 (EBUSY), no hijack, no panic

PoC changes

harness authored fresh (finding had no seed PoC): KLD module drives the public bus_dma API; struct bus_dmamap replica pinned to 72 bytes after disassembling get_map_waiting.isra.8 (DF STAILQ_HEAD has stqh_first+stqh_last); zone arithmetic tuned so the second map_create grows the private zone to exactly 2 bounce pages; nsegments=4 so the 2-page load fits; tests must run at MOD_LOAD (alloc_bounce_zone's SYSCTL_ADD_NODE deadlocks if called from a sysctl handler)

Verified recommended fix

bus_dmamap_destroy: also return EBUSY when map->pagesneeded || map->pagesreserved (pending deferred load); _bus_dmamap_unload: dequeue queued map and return its reservation; busdma_swi: drop maps whose pagesneeded was reset; see fix.diff (includes the return_bounce_pages wait_map typo fix the unload path depends on)

Verdict

bus_dmamap_destroy() frees a map still linked on the bounce-zone waiting list (its only guard, STAILQ_FIRST(&map->bpages), is empty for an EINPROGRESS-queued map). The deferred machinery later writes into and executes a load on the freed 72-byte chunk, ending in an indirect call through the freed chunk's callback pointer. Demonstrated on a clean-boot DragonFly 6.5 guest: destroy returned 0, a heap spray reclaimed the chunk, and the kernel called the attacker-planted callback (hijack_proof, arg 0xbaadf00d) twice on one boot; POC_TEST=3 planted 0x414141414000 and the kernel jumped there ('Stopped at 0x414141414000'). Two further independent manifestations captured: NULL-deref panic in get_map_waiting/reserve_bounce_pages, and slab-allocator metadata corruption (chunk_mark_allocated panic on the next unrelated kmalloc). impact recorded as panic (strongest fully-demonstrated end state); the underlying primitive is an arbitrary ring-0 indirect call with controlled target and RDI, which on this no-SMEP/SMAP/KASLR guest is one gadget away from uid=0 -- full unpriv-to-root additionally needs an in-tree driver that destroys/unloads an EINPROGRESS map, which none currently does.