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

for_each_sg_page/_sg_iter_next entry-budget underflow: iteration runs past caller-declared nents across chained chunks; real sg_pcopy_to_buffer processes out-of-budget entries (guest-proven fatal OOB entry deref)

Summary

The maxents==0 break leaves iter->sg at the FIRST out-of-budget entry (which the loop body then executes on) and the next decrement underflows to UINT_MAX, permanently disarming the budget - the walk runs to the physical SG_END across chained chunks. GUEST-PROVEN: budget=2 visited 6/250 entries, and the REAL sg_pcopy_to_buffer with nents=2 processed entry 2 and faulted (Fatal trap 12 va=0x68 in sg_pcopy_to_buffer+0xfc); with non-NULL out-of-budget pages the same path is an OOB DMAP memcpy. No in-tree nents<orig_nents producer yet (DF dma_map_sg never coalesces) but that shape is the Linux-API norm any faithful port arms. Hard-stop-at-budget fix validated on a rebuilt drm.ko (visited=2, panic gone).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5128 Β· 14 files
FileTypeDescriptionSize
sgoverrun.c β€” 6.2 KB view raw
Makefile β€” 239 B ↓ download
build.log β€” 5.6 KB view raw
run.phase1.log β€” 24 B view raw
run.phase1.dmesg.log β€” 795 B view raw
run.phase2.log β€” 0 B ↓ download
panic.txt β€” 1.0 KB view raw
run.fix.phase1.log β€” 630 B view raw
run.fix.phase2.log β€” 286 B view raw
run.fix.stability.log β€” 406 B view raw
buildkernel.fix.log β€” 5.6 MB ↓ download
fix.diff β€” 940 B view raw
env.txt β€” 321 B view raw
VERDICT.md β€” 5.3 KB ↓ raw
VERDICT.md
↓ download raw

DF-5128 β€” VERDICT

Status: reproduced (impact: panic β€” OOB sg-entry processing in the real sg_pcopy_to_buffer(); deterministically repeatable; fix validated) Confidence: certain for the code defect and the OOB-processing primitive.

The bug

sys/dev/drm/include/linux/scatterlist.h (the inline API surface of the audited sys/dev/drm/linux_scatterlist.c):

_sg_iter_next() (scatterlist.h:252-270), the engine behind for_each_sg_page() (scatterlist.h:338-340), decrements the unsigned iter->maxents once per sg entry advanced and breaks when it becomes 0 β€” leaving iter->sg pointing at the out-of-budget entry, which the macro's iter->sg loop condition then executes the body on. On the next call the 0-budget counter underflows to UINT_MAX, permanently disarming the only secondary stop: iteration then runs to the physical SG_END of the table β€” across sg_chain() pointers into other heap chunks β€” no matter what nents the caller declared. Linux semantics (__sg_page_iter_next, which DF even carries as dead code in the same header at lines 279-298, unused by any caller) hard-stop at nents.

Every page-granular consumer of the shim inherits this: sg_pcopy_from_buffer/sg_pcopy_to_buffer (linux_scatterlist.c:165,201), drm_clflush_sg (drm_cache.c:128), radeon_ttm_tt_unpin_userptr (radeon_ttm.c:624).

How it was proven (audit QEMU guest, DragonFly 6.5-DEVELOPMENT #0

Thu Jul 2 06:02:54 UTC 2026, X86_64_GENERIC, drm.ko 5.x-suite loaded via kldload i915)

Phase 1 β€” semantics (safe)

sysctl debug.sgoverrun=1 builds tables with the real __sg_alloc_table() (resolved from drm.ko) and walks them with for_each_sg_page under a caller budget of nents=2:

  • flat 6-entry table: body executed on entries 0,1,2,3,4,5 (visited=6 budget=2 -> OVERRUN); the printed maxents trace shows the underflow exactly: 2 -> 1 -> 0 (entry 2 processed!) -> 4294967295 -> …
  • chained 250-entry table (3 heap chunks): visited=250, entry indices relative to the first chunk reach 2,043,903 units (~78 MB of heap away) β€” the walk crosses chunk boundaries via chain pointers. Linux semantics: exactly 2.

Phase 2 β€” impact (fatal, deterministic)

sysctl debug.sgoverrun=2 calls the real sg_pcopy_to_buffer() from sys/dev/drm/linux_scatterlist.c with nents=2 on a 6-entry table whose entries β‰₯2 are out-of-budget (page=NULL, length=PAGE_SIZE β€” the shape a coalescing dma_map_sg() leaves behind under Linux semantics):

Fatal user address access from kernel mode from sysctl
Fatal trap 12: page fault while in kernel mode
fault virtual address = 0x68          <- (NULL vm_page)->phys_addr @ +0x68
Stopped at sg_pcopy_to_buffer+0xfc: addq 0x68(%rax),%rcx

Guest dead (DDB). The consumer function dereferenced an entry the caller never declared. With a freed/foreign struct page in the out-of-budget entry (instead of NULL) the same path is an OOB memcpy through the DMAP (read in to_buffer, write in from_buffer) β€” the memory-corruption ceiling, composable with the known multi-page length confusion (DF-2128).

Reachability today (honest)

  • Requires drm.ko/i915.ko/radeon.ko loaded (root kldload β€” standard on any DragonFly desktop with GPU drivers in loader.conf; the shim is the base of all LinuxKPI dma/bo operations).
  • No current in-tree producer creates tables with SG_END beyond the caller-declared nents: DF's dma_map_sg() (dma-mapping.h:73-97) never coalesces, and i915 marks SG_END on the last filled entry (i915_gem.c:2988). The trap is armed for any code written to Linux semantics β€” where dma_map_sg() coalescing makes nents < orig_nents the norm (drm_prime/amdgpu/i915 upstream all rely on it) β€” i.e. every future port or any fix that makes dma_map_sg() Linux-faithful turns this into a live memory-corruption primitive for unprivileged GPU users.

Fix validation (mandatory β€” memory-corruption class)

fix.diff hard-stops the walk at the caller's entry budget (maxents <= 1 β†’ iter->sg = NULL) and removes the underflowing test:

  • Applied inside the guest's /usr/src (byte-identical to the audited tree, md5 verified), incremental make nativekernel (35,726-line build log, 0 errors), rebuilt drm.ko (the iterator is inlined into the real sg_pcopy_to_buffer) + PoC module.
  • Phase 1 post-fix: visited=2 flat and chained, final maxents=1 β€” no overrun, no underflow.
  • Phase 2 post-fix: SGOVER P2: returned 8192 - no out-of-budget entry touched; guest stays up (vm.sh status = up); repeated twice.

Baseline bad behavior: gone on the patched kernel.

Files (decisive evidence)

  • run.phase1.dmesg.log β€” overrun + underflow trace (baseline)
  • panic.txt / run.phase2.log β€” fatal fault in sg_pcopy_to_buffer+0xfc
  • run.fix.phase1.log, run.fix.phase2.log, run.fix.stability.log β€” post-fix
  • buildkernel.fix.log β€” patched kernel build
  • sgoverrun.c β€” PoC module (build: make; run: sysctl debug.sgoverrun=1|2)

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 the guest's byte-identical /usr/src copy; incremental nativekernel rebuild (35,726-line log, 0 errors) rebuilt drm.ko (the macro is inlined into sg_pcopy_to_buffer); re-run of the exact PoC: phase1 visited=2 on flat and chained tables with final maxents=1 (baseline: 6/250 visited + UINT_MAX underflow), phase2 returned 8192 with the guest staying up (baseline: Fatal trap 12 in sg_pcopy_to_buffer+0xfc). Bad behavior gone; repeated twice for stability.

run.fix.phase1.log; run.fix.phase2.log; run.fix.stability.log; buildkernel.fix.log; fix.diff
↓ fix.diffper-fix-DF-5128

Confirmed kernel references

Detail

Exploit chain

Primitive (proven): OOB sg-entry processing - the page/length/offset of entries beyond the caller-declared nents are consumed by the real sg_pcopy_from/to_buffer (memcpy through kmap(page)+offset, DMAP) and drm_clflush_sg. Ceiling: with a beyond-budget entry pointing at a freed/reused page (coalesced-table shape), sg_pcopy_* becomes an OOB read/write of unrelated physical memory - composable with DF-2128's per-page length confusion for controlled-size copies. Not driven to uid0 in this run: no in-tree runtime producer of nents<orig_nents tables exists today (DF dma_map_sg does not coalesce; i915 marks SG_END at the last filled entry), so the end-to-end trigger requires a Linux-semantics table producer (any drm_prime/amdgpu-style port or a Linux-faithful dma_map_sg), after which an unprivileged GPU client reaching the consumer (e.g. GuC fw sg_pcopy path) inherits the primitive.

Evidence (decisive lines)

run.phase1.dmesg.log (visited=6/budget=2 + maxents underflow trace 2->1->0->4294967295; chained visited=250); panic.txt (Fatal trap 12 va=0x68, Stopped at sg_pcopy_to_buffer+0xfc after sysctl debug.sgoverrun=2 with nents=2 on a 6-entry table); run.fix.phase1.log + run.fix.phase2.log (post-fix: visited=2 both tables, returned 8192, guest up); buildkernel.fix.log (patched nativekernel build, 0 errors)

PoC changes

Seed sketch rewritten entirely: (1) PoC as a kld module (sgoverrun.ko) using KCFLAGS=-include kconfig.h -I/usr/src/sys/dev/drm/include - bsd.kmod.mk reassigns CFLAGS, plain CFLAGS+= is ignored; (2) module resolves __sg_alloc_table/__sg_free_table/sg_pcopy_to_buffer directly from the loaded drm.ko (kldload i915 first) - no drm header-symbol stubs needed, extern re-declarations removed (-Werror=redundant-decls); (3) trigger via sysctl debug.sgoverrun=1|2 so the panic run and the safe run share one module; (4) out-of-budget entries carry length=PAGE_SIZE with page=NULL so the overrun is fatal and undeniable instead of silent.

Verified recommended fix

In _sg_iter_next(), hard-stop the walk at the caller's entry budget before advancing (if (iter->maxents <= 1) { iter->sg = NULL; return; }) and drop the underflowing `iter->maxents == 0' break test; see fix.diff.

Verdict

for_each_sg_page()'s engine _sg_iter_next() breaks its nents budget at exactly the wrong moment: it leaves iter->sg pointing at the first out-of-budget entry (body executes on it with maxents==0), then underflows the unsigned counter to UINT_MAX and runs to the physical SG_END across chained heap chunks, ignoring the caller-declared nents entirely. Proven on the guest: budget=2 walks visited 6 (flat) and 250 (chained, indices ~2M entries into other chunks); calling the REAL sg_pcopy_to_buffer() from sys/dev/drm/linux_scatterlist.c with nents=2 processes entry 2 and faults on its page pointer (Fatal trap 12, va 0x68, Stopped at sg_pcopy_to_buffer+0xfc) - with a non-NULL foreign page the same path is an OOB memcpy through the DMAP. Gated today on drm.ko being loaded (root kldload / desktop loader.conf) and on a table whose SG_END lies beyond nents - no in-tree producer makes one yet (DF's dma_map_sg never coalesces), but that shape is the Linux-API norm (dma_map_sg coalescing, drm_prime/amdgpu), so every LinuxKPI port walks into it. Fix (hard stop at maxents<=1) validated: overrun and panic gone, sg_pcopy_to_buffer returns 8192, guest stable across repeats.