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

dssize()/diskpsize consume dp->d_slice without ds_token β€” use-after-free walk of struct diskslices/disklabel during forced reprobe, and DSO_RAWPSIZE fallback silently substitutes whole-disk size for partition devices

Field Value
ID DF-2831
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:N/I:L/A:H
CWE CWE-362 (CWE-125 / CWE-672)
File sys/kern/subr_diskslice.c
Lines 849-870 (caller subr_disk.c:1259-1276; dsopen :761-777)
Area kern
Confidence certain
Discovered 2026-08-31
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

dssize() snapshots dp->d_slice, checks slice bounds and the open mask on that snapshot, may re-open the device (letting a reprobe run), reloads ssp, and then dereferences ssp->dss_slices[slice].ds_label/.ds_ops and calls ops->op_getpartbounds() with no ds_token and no revalidation of the reloaded pointer. Its only caller diskpsize() also takes no token. Meanwhile disk_msg_core processes root-forced reprobes (DIOCSYNCSLICEINFO arg!=0, bypassing the open-busy check) by replacing dp->d_slice and dsgone()ing the old struct plus its labels under ds_token. dsopen() additionally lacks the slice<dss_nslices bounds check every other entry point has. On the stock INVARIANTS guest, swapon racing forced reprobes silently installed whole-disk-sized swap (16384 blocks) for a 4096-block partition in 6-14% of cycles across 4 runs, while a same-instant DIOCGDINFO64 probe proved the in-core label was still correct. With tracing, the same race produced a fatal trap 12 in dssize+0x15c with the walked ssp one generation older than the current one β€” a freed struct. Provenance pinned 1:1 (219/219): the reprobe's label re-read fails through the unsynchronized diskstrategy (DF-2742 family), disk_probe_slice clears the label, dssize returns -1, and diskpsize's DSO_RAWPSIZE fallback substitutes the raw whole-disk size for a PARTITION device. Distinct sink and fix site from DF-2742 β€” that fix does not cover the d_psize path.

Threat model & preconditions

Privileged-trigger race (root vs root / forced reprobe vs automated swap management; disk devices require SYSCAP_RESTRICTEDROOT to open): kernel UAF read (panic — observed), silent wrong swap geometry (4× oversized swap on a partition → swap accounting corruption, failed page-out I/O), latent worse via same-size-class recycling feeding arbitrary kernel-object content into partition bounds. No unpriv→root chain.

Proof of contest

VERIFIED live (findings/poc/DF-2831/): no-hammer control 2000/2000 correct (size 4096); hammer (150-300k forced reprobes) + swapon loops β†’ ok_WRONG_SIZE 125-287 of 2000 with swapinfo reporting 16384 while the same-instant in-core label says 4096; instrumented kernel dies in dssize+0x15c (panic.txt). Fix (ds_token around diskpsize's dssize walk + never substitute raw size for a partition device + revalidate after re-open reload + the dsopen bounds check) validated on a rebuilt guest kernel: 0/2000 anomalies, clean ENXIO, control 2000/2000, no panic.

Validated fix.diff in findings/poc/DF-2831/.

References

  • DF-2742 (the diskstrategy sibling), DF-0134 (disklabel64 gap β€” impact upgrade recommended: dscheck turns unvalidated p_boffset into an arbitrary absolute bio_offset incl. 64-bit wrap)

Timeline

  • 2026-08-31 Discovered during pass-2 audit of subr_diskslice.c (GLM 5.3); UAF panic + silent wrong-swap-geometry reproduced + fix validated same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2831 Β· 16 files
FileTypeDescriptionSize
README.md β€” 2.5 KB ↓ raw
VERDICT.md β€” 5.4 KB ↓ raw
env.txt β€” 905 B view raw
genimg2831.c β€” 4.1 KB view raw
poc2831.c β€” 2.8 KB view raw
poc2831b.c β€” 5.0 KB view raw
build.sh β€” 295 B view raw
run.sh β€” 596 B view raw
run.log β€” 1.6 KB view raw
fix_run.log β€” 763 B view raw
panic.txt β€” 2.8 KB view raw
instrument.diff β€” 1.2 KB view raw
fixinst.diff β€” 2.2 KB view raw
fix.diff β€” 2.0 KB view raw
manifest.json β€” 1.4 KB view raw
verdict.json β€” 6.3 KB view raw

DF-2831 β€” dssize()/diskpsize consume dp->d_slice without ds_token; DSO_RAWPSIZE fallback then substitutes whole-disk size for partitions

  • Verified 2026-09-01, DragonFly 6.5-DEVELOPMENT x86_64 guest (QEMU/KVM), stock INVARIANTS kernel #0 Thu Jul 2 06:02:54 UTC 2026, then instrumented and fixed kernels built in-guest with make nativekernel.
  • File: sys/kern/subr_diskslice.c (dssize :836-871), consumer wired from sys/kern/subr_disk.c diskpsize :1259-1276 (+ DSO_RAWPSIZE fallback :1270-1274), racing disk_probe replacement :367-368/:494 via disk_msg_core under ds_token.
  • Bucket: memcorrupt. Severity: Medium. Confidence: certain.

TL;DR verdict

REPRODUCED on the stock guest: racing swapon (d_psize -> dssize) against root-forced reprobes (DIOCSYNCSLICEINFO arg=1) makes the kernel install a wrong swap geometry silently: 125-277 of every 2000 swapon cycles returned the whole-disk size (16384 blocks) for partition vn0s1a whose real label says 4096 blocks β€” verified against a same-instant DIOCGDINFO64 probe showing the in-core label was CORRECT (so the value did not come from the label). With kprintf instrumentation between the racy reads (build #1) the same race produced a fatal trap 12 page fault in dssize+0x15c (movq 0x40(%rax),%rax, fault addr 0x40) β€” the ENTER/WALK traces show dssize walking an older generation of struct diskslices (...6a00 vs current ...7c00) whose label/ops pointers were freed β€” the guest died at the ddb prompt. The token fix (diskpsize takes ds_token around dssize + dssize revalidates slice bounds) removes the UAF walk and, together with suppressing the DSO_RAWPSIZE whole-disk substitution for partition devices, drives the wrong-geometry anomalies to 0/2000 and 0/2000 with clean ENXIO failures instead (fix kernel #3).

Evidence

  • run.log / fix_run.log β€” stock vs patched counters
  • panic.txt β€” full serial console trace: DF2831 ENTER/WALK pointer generations, fatal trap 12, Stopped at dssize+0x15c
  • fixinst trace (in VERDICT.md) β€” 219x "blocks=-1 nolabel" matched 1:1 with 219x "RAWPSIZE fallback -> raw whole-disk size" = provenance of the wrong value on the token-fixed kernel (label loss is the DF-2742 diskstrategy family; the fallback misdesign turns it into silent wrong geometry)
  • fix.diff β€” ds_token in diskpsize + dssize/dsopen bounds revalidation + RAWPSIZE fallback restricted to whole-disk/whole-slice devices

Reproduce

see build.sh / run.sh. Root on the guest; ~90 s per 2000-iteration run.

VERDICT.md
↓ download raw

DF-2831 VERDICT β€” full narrative

The bug

dssize() (sys/kern/subr_diskslice.c:836-871) snapshots *sspp (= dp->d_slice), checks slice < ssp->dss_nslices and the open mask on that snapshot, optionally re-opens the device (which can block and let a reprobe run), reloads ssp = *sspp, and then dereferences ssp->dss_slices[slice].ds_label / .ds_ops and calls ops->op_getpartbounds() β€” with no ds_token and no revalidation of the reloaded pointer. Its only caller diskpsize() (sys/kern/subr_disk.c: 1259-1276, the d_psize dispatch) also takes no token. Meanwhile disk_msg_core processes DISK_DISK_REPROBE (forced by root via DIOCSYNCSLICEINFO with arg != 0, which skips the open-busy check at subr_diskslice.c:627-644) by running disk_probe(), which replaces dp->d_slice with a fresh allocation (subr_disk.c:367-368, mbrinit realloc, gptinit realloc) and dsgone()s the old one (:494) β€” freeing the very struct dssize walks, together with its disklabel. dsopen() (subr_diskslice.c:761-777) additionally lacks the slice >= dss_nslices bounds check that every other entry point performs.

Sibling of DF-2742 (diskstrategy, same missing-serialization family) but a distinct sink (d_psize path) with its own fix site; DF-2742's recommended fix does not cover dssize/diskpsize.

How it was proven

  1. Stock kernel #0, fresh vm.sh reset with-src: crafted MBR image (genimg2831.c) vnconfig'd to /dev/vn0 β€” one DFLYBSD slice s1 with a valid disklabel64 whose partition 'a' is 2 MiB (=4096 512-blocks). Racer: swapon("/dev/vn0s1a")+swapoff in a loop; hammer: ioctl DIOCSYNCSLICEINFO(1) on /dev/vn0 (~150-300k reprobes/run). Baseline without hammer: 2000/2000 clean, size always 4096. With hammer (3 independent stock runs): ok_WRONG_SIZE = 206/2000, 285/2000, 277/2000, 125/2000 β€” swapon succeeded with swapinfo reporting 16384 blocks (= whole-disk ds_size) while a same-instant DIOCGDINFO64 probe of /dev/vn0s1 showed the in-core label still says 'a' = 4096 blocks: the size did NOT come from the label.
  2. Instrumented stock kernel #1 (kprintf between the racy reads only): serial console captured the two pointer generations diverging live β€” ENTER ... ssp=0xfffff8008d5d7c00 vs WALK ssp=0xfffff8008d5d6a00 with label=0xfffff8008edb5e80 β€” and then Fatal trap 12: page fault while in kernel mode ... fault virtual address = 0x40 ... Stopped at dssize+0x15c: movq 0x40(%rax),%rax β€” a stale ds_label/ds_ops read from the freed struct. Guest died at db> (panic.txt). This is the use-after-free read, caught mid-race.
  3. Provenance of the silent wrong value (fix+instrumentation build #2, trace counts): 219/1500 anomalies, and dmesg showed exactly 219 DF2831 blocks=-1 nolabel vn0s1a matched 1:1 with 219 DF2831 RAWPSIZE fallback vn0s1a -> raw whole-disk size. Chain: the reprobe's own label re-read goes through diskstrategy (no ds_token β€” the DF-2742 window), hits the transient 2-slice struct, fails, and disk_probe_slice (subr_disk.c:324-332) then clears the slice's label; dssize correctly returns -1 for a labelless partition, and diskpsize's DSO_RAWPSIZE fallback (vn sets DSO_RAWPSIZE, vn.c:579) silently substitutes the raw whole-disk size for a partition device. swapon installs a 4x-oversized swap device on a 2 MiB partition.

Impact

  • Unprivileged reachability: NO β€” both swapon and forced reprobe are root-only (diskopen enforces SYSCAP_RESTRICTEDROOT). This is a privileged-trigger race: root racing root, or root-forced reprobe vs automated swap management.
  • Demonstrated: kernel use-after-free read (fatal trap 12), and silent wrong swap geometry (4x oversized swap on a partition; swap I/O past the real partition then fails/clamps at dscheck β€” corrupted swap bitmap accounting, potential page-loss/DoS). Latent: same-size-class recycling of the freed ssp/label can feed arbitrary kernel-object content into partition bounds (the instrumented trace shows benign recycled-label reads returning 4096 right up until the fault).
  • Severity: Medium (local DoS/integrity with privileged trigger; memory-safety class UAF read).

Fix validation (kernel #3, final fix.diff)

  • make nativekernel in guest, rebooted into #3 (fix_run.log).
  • Same PoC, same churn: ok_WRONG_SIZE=0/2000 and 0/2000 (stock: 125-277), failures became clean, explicit ENXIO ("Device not configured") during the transient labelless windows β€” 703/887 occurrences β€” instead of a silent lie; no panic; no-hammer sanity 2000/2000 swapon OK; guest up throughout.
  • Residual: the transient label loss itself is the DF-2742 diskstrategy family (recommended fix there: ds_token around the dp->d_slice fetch + dscheck, and in disk_invalidate). With DF-2742 fixed, the ENXIO transients disappear too; DF-2831's own fix stands alone and correct regardless.

Kernel references

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff applied to guest /usr/src, make nativekernel + installkernel, reboot into #3. Baseline (stock, fresh reset): ok_WRONG_SIZE 277/2000 and 125/2000, silent. Patched: ok_WRONG_SIZE 0/2000 and 0/2000, swapon failures under race became clean ENXIO (703/887) instead of a silent 4x-oversized swap device; control (no hammer) 2000/2000 OK; no panic; guest stable. UAF walk eliminated (token-serialized build ran equal churn with zero faults vs stock-instrumented fatal trap 12 within 800 iterations).

findings/poc/DF-2831/fix_run.log; findings/poc/DF-2831/fix.diff; VERDICT.md 'Fix validation'
↓ fix.diffDragonFly 6.5-DEVELOPMENT #3: Tue Sep 1 23:37:41 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

root: hammer ioctl(DIOCSYNCSLICEINFO,1) on a disk with an open slice partition; concurrently swapon() that partition -> dev_dpsize -> diskpsize -> dssize snapshots *sspp, reopens (mask gone after reprobe), reloads ssp, dereferences dss_slices[slice].ds_label/.ds_ops while the reprobe's dsgone() freed them -> UAF read (fatal trap 12 observed instrumented) or, via label-clear + DSO_RAWPSIZE fallback, silent whole-disk-sized swap installed on a partition (integrity/DoS). No unpriv->root chain: both triggers are privileged (SYSCAP_RESTRICTEDROOT on open).

Evidence (decisive lines)

['findings/poc/DF-2831/run.log: stock counters 206/285/244/277/125 wrong-size of 2000/2000/1500/2000/2000, control 0', "findings/poc/DF-2831/panic.txt: DF2831 ENTER ssp=0xfffff8008d5d7c00 vs WALK ssp=0xfffff8008d5d6a00 then 'Fatal trap 12 ... Stopped at dssize+0x15c: movq 0x40(%rax),%rax'", "findings/poc/DF-2831/VERDICT.md: fix+instrumentation trace 219x 'blocks=-1 nolabel' == 219x 'RAWPSIZE fallback -> raw whole-disk size' (provenance)", 'findings/poc/DF-2831/fix_run.log: fix kernel #3 ok_WRONG_SIZE=0 twice, swapon_err 703/887 clean ENXIO, no-hammer 2000/2000, guest up']

PoC changes

Wrote PoC from scratch (no seed): genimg2831.c builds an MBR+DFLYBSD-slice+valid-disklabel64 image with a 2MiB FS_SWAP partition 'a' (fstype must be non-zero or devfs creates no s1a node); poc2831.c errno-class racer; poc2831b.c size-checking racer with same-instant DIOCGDINFO64 in-core label probe and dual-disk churn variant; instrument.diff/fixinst.diff for kernel provenance tracing.

Verified recommended fix

Take ds_token around the dssize() call in diskpsize(), revalidate slice < dss_nslices after dssize's re-open reload (and add the same bounds check to dsopen), and restrict the DSO_RAWPSIZE fallback to whole-disk/whole-slice devices so partition geometry is never silently replaced by whole-disk size - see fix.diff.

Verdict

REPRODUCED. dssize() (subr_diskslice.c:849-870) walks dp->d_slice and its per-slice disklabel with no ds_token while disk_msg_core's forced reprobes replace and dsgone() those structures; diskpsize() (subr_disk.c:1259-1276) is its only caller and also takes no token. On the stock INVARIANTS guest, swapon on vn0s1a racing root-forced DIOCSYNCSLICEINFO reprobes silently installed whole-disk-sized swap (16384 blocks) for a 4096-block partition in 6-14% of 2000-cycle runs (4 independent runs, one from a fresh vm.sh reset), while a same-instant DIOCGDINFO64 probe proved the in-core label was correct - the value came from state outside the label. With kprintf tracing between the racy reads the same race produced a fatal trap 12 page fault in dssize+0x15c (movq 0x40(%rax),%rax, fault addr 0x40) with the serial trace showing ENTER ssp=...7c00 vs WALK ssp=...6a00 - dssize dereferencing a freed previous-generation struct diskslices; guest died at db> (panic.txt). Provenance of the silent wrong value was then pinned 1:1 (219/219): reprobe label re-reads fail through the unserialized diskstrategy (DF-2742 family) causing disk_probe_slice to clear the slice label, dssize returns -1, and diskpsize's DSO_RAWPSIZE fallback substitutes the raw whole-disk size for a PARTITION device. Privileged trigger (root races root / forced reprobe vs automated swap management); impact ceiling: UAF read (panic), silent wrong swap geometry (oversized swap on a partition, swap accounting corruption). Fix (ds_token around dssize in diskpsize + slice-bounds revalidation in dssize/dsopen + RAWPSIZE fallback restricted to whole-disk/whole-slice devices) validated on rebuilt kernel #3: wrong-size 0/2000 and 0/2000 vs 125-277 stock, failures become clean ENXIO, control run 2000/2000 OK, no panic, guest stable.