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)
PoC verification
Evidence pack
findings/poc/DF-5128 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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_ENDbeyond the caller-declarednents: DF'sdma_map_sg()(dma-mapping.h:73-97) never coalesces, and i915 marksSG_ENDon the last filled entry (i915_gem.c:2988). The trap is armed for any code written to Linux semantics β wheredma_map_sg()coalescing makesnents < orig_nentsthe norm (drm_prime/amdgpu/i915 upstream all rely on it) β i.e. every future port or any fix that makesdma_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), incrementalmake nativekernel(35,726-line build log, 0 errors), rebuiltdrm.ko(the iterator is inlined into the realsg_pcopy_to_buffer) + PoC module. - Phase 1 post-fix:
visited=2flat and chained, finalmaxents=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 insg_pcopy_to_buffer+0xfcrun.fix.phase1.log,run.fix.phase2.log,run.fix.stability.logβ post-fixbuildkernel.fix.logβ patched kernel buildsgoverrun.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
fixedfix.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
Confirmed kernel references
- sys/dev/drm/include/linux/scatterlist.h:252
- sys/dev/drm/include/linux/scatterlist.h:261
- sys/dev/drm/include/linux/scatterlist.h:264
- sys/dev/drm/include/linux/scatterlist.h:338
- sys/dev/drm/linux_scatterlist.c:165
- sys/dev/drm/linux_scatterlist.c:201
- sys/dev/drm/drm_cache.c:128
- sys/dev/drm/i915/i915_gem.c:2979
- sys/dev/drm/include/linux/dma-mapping.h:73
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.
No comments yet.