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

ata_dmaload() mishandles bus_dmamap_load()==EINPROGRESS: busdma defers the load and later fires setprd into a dead stack frame (and re-loads the completed request's freed buffer) β€” kernel memory corruption + permanent channel wedge

Summary

DF busdma strips BUS_DMA_NOWAIT and defers short-bounce-pool loads with the callback armed against a STACK cba - when bounce pages free up anywhere in the shared zone, busdma_swi re-runs the load into the dead frame (stale &dmatab aims an 8*nsegs-byte PRD write) and re-walks the freed request buffer; a load exceeding the whole pool queues forever, wedging the channel permanently. KLD PoC with nata's exact tag geometry: 3/3 demos (PRD written through stale word into dead frame markers, freed-KVA reload, permanent wedge). Private-bounce-zone + fail-stop fix mechanism-validated (16/16 worst-case loads complete synchronously). Sibling of DF-4191 (sdhci).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5083 Β· 17 files
FileTypeDescriptionSize
df5083.c β€” 12.7 KB view raw
Makefile β€” 73 B ↓ download
straddle.c β€” 1.3 KB view raw
build.sh β€” 150 B view raw
run.sh β€” 223 B view raw
build.log β€” 6.4 KB view raw
run.log β€” 2.3 KB view raw
run1.out β€” 2.3 KB view raw
run.2.log β€” 484 B view raw
run.3.log β€” 1.5 KB view raw
panic-run-B.txt β€” 15.4 KB view raw
env.txt β€” 1.2 KB view raw
code_hash.txt β€” 75 B view raw
fix.diff β€” 2.9 KB view raw
VERDICT.md β€” 8.8 KB ↓ raw
manifest.json β€” 1.6 KB view raw
verdict.json β€” 5.5 KB view raw
VERDICT.md
↓ download raw

DF-5083 VERDICT β€” reproduced (kernel memory-corruption primitive + permanent channel DoS)

Finding: sys/dev/disk/nata/ata-dma.c β€” ata_dmaload() calls bus_dmamap_load(..., ch->dma->setprd, &cba, BUS_DMA_NOWAIT) with a stack-allocated struct ata_dmasetprd_args and treats every non-zero return as terminal (ata-dma.c:251-254). DragonFly busdma strips BUS_DMA_NOWAIT for real maps (sys/platform/pc64/x86_64/busdma_machdep.c:776-783, "follow old semantics") and, when the tag's bounce pool is short, QUEUES the map with map->callback/map->callback_arg/map->buf armed and returns EINPROGRESS (busdma_machdep.c:645-656, 793-803). ata_dmaload returns the error to ata_begin_transaction (sys/dev/disk/nata/ata-lowlevel.c:126-134, 182-189), which completes the request with an error β€” the buffer goes back to the issuer β€” and never unloads the map (ATA_DMA_LOADED was never set, ata-dma.c:273). When bounce pages are later freed anywhere in the zone, busdma_swi() (busdma_machdep.c:1437-1450) re-runs bus_dmamap_load(map->dmat, map, map->buf, map->buflen, map->callback, map->callback_arg, 0):

  1. map->callback_arg = &cba β†’ ata_dmasetprd() (ata-dma.c:199-216) reads args->dmatab from the dead stack frame and writes up to 8Γ—nsegs bytes of PRD entries wherever that stale word points, plus 4-byte args->error/args->nsegs writes into the dead frame. Whoever owns that thread-stack memory at fire time governs the write target.
  2. map->buf = the completed request's buffer β†’ the deferred load re-walks freed/reused KVA and produces PRD entries with physical addresses extracted from whatever now owns those pages β€” the disk controller would DMA against stale addresses had the driver armed it.

Bounce zones are shared system-wide between compatible tags (busdma_machdep.c:1081-1096), so nata's 16-page pool (bus_dmamap_create β†’ atop(maxsize) pages, busdma_machdep.c:401-413) is contended by every other 32-bit-limited busdma user; and any single load needing more pages than the whole pool (17-page-straddling 64KB transfer on classic IDE; up to 1MB transfers on nata-ahci/marvell/ promise/siil which override max_iosize after the tags are built) is queued forever, wedging the channel: every subsequent DMA load on that channel returns EINPROGRESS (ata-lowlevel prints "setting up DMA failed") β€” permanent local DoS of that disk/CD channel, reachable by ordinary unprivileged IO once one such transfer occurs.

Verification (QEMU guest, DragonFly 6.5-DEVELOPMENT X86_64_GENERIC, 4608MB)

Kernel module df5083.ko reproducing nata's exact tag geometry and call pattern (private bounce zone + 256MB lowaddr so the trigger is deterministic; nata's real threshold is 32-bit, same code path):

  • DEMO A (deferred write through stale stack word) β€” reproduced 3/3 runs + this final run: victim load returns EINPROGRESS=36 with cba=0xfffff80131d1f798; after the holder releases bounce pages, po_setprd fires in SWI context (current process = Idle) reading args->dmatab from the returned frame; the stale word pointed at the module's marker and the callback wrote real PRD entries (00129000:00001000, 00128000:00001000, … β€” bounce-page bus addresses) through it (run.log). In other runs the stale word held garbage/NULL β†’ Fatal trap 12 … supervisor write … current process = Idle … Stopped at po_setprd+0x48 (panic-run-B.txt, run.3.log) β€” the exact prd[i].addr = htole32(...) write of ata-dma.c:210.
  • DEMO B (freed buffer) β€” reproduced: victim buffer kfreed while queued (request completed with error); deferred load re-ran over the freed KVA and fired the callback into the dead frame (PRD entries zero-phys because the freed PTEs were torn down; with reuse they are the new owner's physical addresses).
  • DEMO C (permanent wedge) β€” reproduced 4/4: 18-page need vs 17-page pool queued forever holding the entire pool; subsequent 1-page load β†’ EINPROGRESS. This is the on-path nata failure mode ("ata%d: setting up DMA failed" forever).
  • On-path attempt: with the install ISO attached (acd0 on the PIIX3 IDE channel) and 3.1GB of low memory drained, misaligned 64KB raw reads from /dev/cd0 completed cleanly β€” this guest's kernel-side disk buffers stay below 4GB (bottom-up allocation), so nata's own 32-bit tag never bounced. The >4GB-buffer precondition is a real-hardware memory-pressure condition (the stock guest's shared boot zone already bounced 2 pages at boot, so >4GB DMA loads do occur in practice), not arrangeable here without hours of memory-pressure grooming; the mechanism itself is proven at the exact nata geometry.

Exploitability (honest ceiling)

  • Certain: permanent per-channel DMA wedge (local DoS of the affected disk/CD) once any single transfer needs more bounce pages than the pool β€” trivially reachable on the nata chipset variants that raise max_iosize to 1MB on a 16-page pool, and reachable on classic IDE via a 64KB transfer straddling 17 pages.
  • Demonstrated primitive: attacker-governed stale-stack word selects the destination of an 8Γ—nsegs-byte PRD write (contents = physical addresses of the re-loaded buffer) plus two 4-byte writes into a live kernel thread's stack. A full uid=0 chain would additionally require controlling the dead frame's contents at fire time (the frame belongs to the ata request-service thread; its reuse is influenced by subsequent ata/callout activity) and was not developed within this run β€” recorded as the next escalation step, not as a completed chain.

Fix validation

fix.diff (authored after verification, never applied to sys/): private bounce zone (BUS_DMA_PRIVBZONE) + one extra page of tag maxsize so the pool always covers a worst-case straddling transfer of max_iosize bytes, making deferral impossible by construction for the single-map-per-channel geometry; defensive fail-stop if EINPROGRESS ever occurs. DEMO F in the module rebuilds exactly this geometry: 16/16 worst-case 17-page-straddling loads completed synchronously, EINPROGRESS 0/16 (run.3.log). A patched-kernel build was not run: the on-path trigger does not manifest on this guest (see above), so a kernel rebuild could not demonstrate anything the geometry demo does not; recorded as fix_status: not_testable with the mechanism-level validation as evidence. Chipset drivers that raise max_iosize after ata_dmainit() (ata-ahci.c:558, ata-marvell.c, ata-promise.c, ata-siliconimage.c) need pool sizing of their own β€” flagged in the fix comment.

Negative space (what was checked and killed in ata-dma.c pass 2)

  • PRD table overflow: sg table is a dedicated MAXTABSZ=PAGE_SIZE (512 entries) bus_dmamem'd page, bus_dmamap_load enforces nsegments=ATA_DMA_ENTRIES=256 (busdma_machdep.c:701-702, 732-733), KASSERT is belt-and-braces β€” killed.
  • prd[i-1] with nsegs==0: count==0 rejected earlier (panic) and busdma always yields β‰₯1 segment for non-empty loads β€” killed.
  • DF-2086 (known, not re-reported): dmaalloc error-path double kfree(ch->dma).
  • dmafree ordering (map/tag destroy order, partial-init walks on M_ZERO'd fields) β€” correct; NULL-deref of ch->dma guarded by callers (ata-pci.c:796-800, 817).
  • alignment/zero-length/oversize checks in dmaload β€” present; count signedness bounded by max_iosize check β€” killed.
  • ATA_DMA_LOADED flag races β€” channel state_mtx held by callers (ata-lowlevel.c:214) β€” killed.
  • sg_map never unloaded in dmaunload β€” intentional persistent mapping β€” killed.

intel_guc_fw.c (second file, clean)

All memory-touching paths are bounded and validated: guc_xfer_rsa copies exactly UOS_RSA_SCRATCH_COUNTΓ—4 = 256 bytes into a fixed stack array via sg_pcopy_to_buffer with the return count checked (intel_guc_fw.c:133-138); header_size/ucode_size/ rsa_offset are validated against the blob size at fetch time (intel_uc_fw.c:87, 99, 106, 110) before upload; version selection is a static platform table (intel_guc_fw.c:54-84); forcewake get/put are balanced including error paths (:245, 266); guc_wait_ucode's status is always initialized by guc_ready before use. The broken skip handling in the underlying sg_pcopy_to_buffer belongs to sys/dev/drm/linux_scatterlist.c and is already filed (DF-2127, DF-2128); the non-propagated guc_xfer_rsa error at intel_guc_fw.c:254-256 is cosmetic (final guc_wait_ucode status catches the failure) β€” no security impact.

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 reproduced→ patch + rebuild →patched clean

fix.diff construction validated at mechanism level in-kernel (module DEMO F: 16/16 worst-case 17-page-straddle loads on the fixed geometry completed synchronously, EINPROGRESS 0/16, deferred callback cannot arm β€” run.3.log). A patched-kernel build was not exercised because the on-path trigger does not manifest on this guest (kernel-heap disk buffers stay below 4GB), so a rebuilt kernel would show nothing the geometry demo does not.

run.3.log: 'DF5083: F: 16x worst-case straddle loads on FIXED geometry: EINPROGRESS=0/16'; fix.diff applies to ata_dmaalloc() data_tag creation + defensive EINPROGRESS panic in ata_dmaload().
↓ fix.diffper-fix-DF-5083

Confirmed kernel references

Detail

Exploit chain

(1) >4GB-RAM nata/IDE box, request pages above the 32-bit bounce threshold (steady state under memory pressure); (2) a >16-bounce-page transfer (17-page-straddling 64KB IO on classic IDE, trivially any large IO on nata-ahci/marvell/promise/siil whose max_iosize overrides outgrow the 16-page pool) gets EINPROGRESS and is queued forever -> permanent channel wedge (demonstrated, DEMO C); (3) a satisfiable queue (any concurrent reservation on the shared bounce zone) fires busdma_swi after the request completed: setprd writes 8*nsegs PRD bytes through the dead frame's dmatab word plus two 4-byte writes into the live thread's stack (demonstrated, DEMOs A/B incl. NULL-target panic) β€” kernel memory corruption whose destination is governed by stale stack contents; (4) [not developed] groom the dead frame (ata request-service thread stack) to aim the PRD write / weaponize the stale-buffer reload into DMA-against-reused-pages for uid=0.

Evidence (decisive lines)

run.log (=run1.out): full clean run β€” 'DEFERRED busdma_swi CALLBACK FIRED into DEAD FRAME', marker[] filled with real PRD entries (00129000:00001000...), 'CHANNEL WEDGED FOREVER'. run.2.log: 3/3 stability. panic-run-B.txt + run.3.log: Fatal trap 12 supervisor write at po_setprd+0x48 (the ata-dma.c:210 write), current process = Idle (SWI fired after frame death), and DEMO F fix-geometry 0/16 deferred. VERDICT.md: full narrative incl. on-path limitation.

PoC changes

Seed concept only; module written from scratch: nata data_tag geometry (alignment 2/boundary 64KB/maxsize 64KB/nseg 256) with BUS_DMA_PRIVBZONE + 256MB lowaddr for a deterministic trigger (real nata: shared zone, 32-bit lowaddr, needs >4GB-resident buffers this guest's bottom-up allocator won't give the kernel heap); holder-map pool pressure via BUS_DMA_ONEBPAGE map create; per-demo tags; DEMO F validates the fix construction. straddle.c (+errno.h fix) is the on-path attempt.

Verified recommended fix

Private bounce zone (BUS_DMA_PRIVBZONE) + one extra page of data_tag maxsize so the pool always covers a worst-case straddling max_iosize transfer, making bus_dmamap_load deferral impossible by construction; fail-stop panic if EINPROGRESS ever occurs.

Verdict

ata_dmaload() (ata-dma.c:251-254) treats bus_dmamap_load()==EINPROGRESS as terminal while DragonFly busdma keeps the map queued with the driver's stack callback arg, the setprd callback and the request buffer armed; busdma_swi() later re-runs the load and fires ata_dmasetprd into the dead stack frame (write target = stale &dmatab word) and over the completed request's freed buffer. Proven on the guest with nata's exact tag geometry: deferred PRD write through the stale word reproduced 3/3 (marker writes and, when the stale word held garbage, Fatal trap 12 supervisor-write at the ata-dma.c:210 PRD instruction in SWI context), freed-buffer reload reproduced, and the unsatisfiable-queue variant wedges the channel permanently (every later DMA load returns EINPROGRESS) β€” the on-path DoS. Full uid=0 chain not developed: needs control of the dead frame's contents at fire time; recorded as primitive-level escalation potential.