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

GPT entry #127 maps to slice index 128, which dkmakeminor() silently truncates to 7 bits: duplicate major:minor cdevs and s127<->s0 cross-slice data aliasing (reads AND writes)

Field Value
ID DF-2963
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:N/I:H/A:N
CWE CWE-197
File sys/kern/subr_diskgpt.c
Lines 175, 201-204, 222 (minor: diskslice.h:252-253; probe: subr_disk.c:448-451)
Area kern/disk
Confidence certain
Discovered 2026-09-02
Pass 2 (GLM 5.3 second pass)
Bucket base:kern
Reported pending
Known CVE none
CVE match novel

Summary

gptinit() allocates 130 slice slots and maps GPT entry #N (Nβ‰₯1) to dss_slices[BASE_SLICE+N-1], so entry #127 occupies slice index 128 while dss_nslices=130. But DKMAXSLICES=128 and dkmakeminor() encodes the slice field with only 7 bits β€” slice 128's bit 7 is silently dropped. disk_probe() therefore creates the 'adXs127' whole-slice device with a minor bit-identical to slice 0's; every consumer decodes it back via dkslice() to slice 0. devfs performs no duplicate-(major,minor) check, so two cdevs with the same dev_t coexist. Any GPT with 128 entries β€” the gpt(8)/gdisk DEFAULT, so legitimate media, not just crafted images β€” makes the kernel publish /dev/adXs127 whose reads and writes silently address partition s0 (entry 0's extent). VERIFIED on the guest: st_rdev(vn0s127)== st_rdev(vn0s0)==0x1e100807 on distinct devfs inodes; read via vn0s127 returns entry-0 data; a 512-byte write via vn0s127 lands on absolute LBA 34, corrupting s0's partition; DIOCGPART on vn0s127 reports s0's extent. A user with permission on the s127 node (default root:operator 0640) corrupts a different partition than the one named; devfs/udev/audit keyed on dev_t see two devices with one identity. No memory corruption: all dss_slices accesses are bounds-checked β€” no uid=0 chain from this primitive; escalation ceiling is cross-partition data-integrity violation. Fix validated in-guest (bound to 127 representable entries + loud dmesg): vn0s127 not created, s0/s126 unregressed.

Timeline

  • 2026-09-02 Discovered during pass-2 audit of subr_diskgpt.c (GLM 5.3); cross-slice aliasing reproduced + fix validated.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2963 Β· 15 files
FileTypeDescriptionSize
README.md β€” 1.8 KB ↓ raw
VERDICT.md β€” 5.4 KB ↓ raw
gen_gpt.c β€” 4.8 KB view raw
trigger.c β€” 2.9 KB view raw
build.sh β€” 218 B view raw
run.sh β€” 551 B view raw
build.log β€” 220 B view raw
run.log β€” 1.4 KB view raw
run.2.log β€” 705 B view raw
run.3.log β€” 347 B view raw
run.fixed.log β€” 1.5 KB view raw
env.txt β€” 994 B view raw
fix.diff β€” 1.3 KB view raw
manifest.json β€” 1.5 KB view raw
verdict.json β€” 4.5 KB view raw

DF-2963 β€” GPT entry #127 maps to slice index 128, which the devfs minor encoding silently truncates β†’ duplicate major:minor and s127↔s0 data aliasing

Build

Upload gen_gpt.c, trigger.c, build.sh, run.sh to /root/df2963/ on the guest, then as root:

sh /root/df2963/build.sh     # compiles gen_gpt + trigger, writes /root/df2963.img (64 MB)

Run

sh /root/df2963/run.sh       # as root; attaches vn0, runs trigger, detaches

Expected (vulnerable kernel)

/dev/vn0s0   : st_ino=311 st_rdev=0x1e100807
/dev/vn0s126 : st_ino=317 st_rdev=0xfe1f0807
/dev/vn0s127 : st_ino=321 st_rdev=0x1e100807     <-- identical rdev to vn0s0, different inode
data: vn0s127 first sector = "ENTRY0ZONE_compat_slice0"   <-- slice 0's data, not entry 127's
DIOCGPART vn0s127: media_offset=17408 media_blocks=30     <-- slice 0's extent, not 1000/100
raw LBA 1000 = "ENTRY127ZONE_real_s127_data"              <-- where the name promises to point

Write demonstration (see run.3.log): a 512-byte write via /dev/vn0s127 lands on absolute LBA 34 (slice 0/entry 0's region), verified read-back via /dev/vn0s0 and the raw device; entry 127's true region (LBA 1000) is untouched.

Expected (kernel patched with fix.diff)

ls: /dev/vn0s127: No such file or directory
vn0s0 / vn0s126 unchanged and correct
dmesg: "disk: GPT has 128 entries; only 127 slice devices representable, ignoring entry 127"

Files

gen_gpt.c   image generator (protective MBR + GPT header w/ CRCs + 128 entries + data tags)
trigger.c   stat()/read/DIOCGPART comparison across vn0s0, vn0s126, vn0s127 + raw controls
build.sh    compile + generate image
run.sh      attach β†’ demonstrate β†’ detach
fix.diff    one-hunk fix bounding GPT slice devices to representable minor indices
VERDICT.md
↓ download raw

DF-2963 VERDICT β€” reproduced (deterministic), fix validated

Status: reproduced. Impact: silent cross-slice data aliasing + duplicate major:minor device numbers (integrity/confusion, not memory corruption). Confidence: certain. Fix: validated (fixed).

Root cause (code trace)

  1. sys/kern/subr_diskgpt.c:175 β€” gptinit() replaces the minimal slice struct with dsmakeslicestruct(BASE_SLICE + MAX_GPT_ENTRIES, info) = 130 slice slots (BASE_SLICE=2, MAX_GPT_ENTRIES=128), and sys/kern/subr_diskgpt.c:222 sets ssp->dss_nslices = BASE_SLICE + i (up to 130).
  2. sys/kern/subr_diskgpt.c:201-204 β€” entry #0 maps to dss_slices[COMPATIBILITY_SLICE] (=0); entry #N (Nβ‰₯1) maps to dss_slices[BASE_SLICE+N-1], so entry #127 maps to slice index 128.
  3. sys/sys/diskslice.h:107 β€” DKMAXSLICES 128 (slice indices 0..127); sys/sys/diskslice.h:252-253 β€” dkmakeminor() encodes the slice field with masks slice & 0x0f (β†’bits 16-19) and slice & 0x70 (β†’bits 29-31): 7 bits; bit 7 of slice (0x80) is silently dropped.
  4. sys/kern/subr_disk.c:448-451 β€” disk_probe() creates the whole-slice device for every sized slice via make_dev_covering(..., dkmakewholeslice(dkunit(dev), i), ..., "%ss%d"). For i=128 the minor equals slice 0's minor bit-for-bit: dkmakeminor(unit, 128, 255) == dkmakeminor(unit, 0, 255).
  5. sys/sys/diskslice.h:327-333 β€” every consumer (dsopen subr_diskslice.c:771, dscheck :380/:107, dssize, dsioctl :380) decodes the device with dkslice(dev) β†’ 0 β€” so the node named adXs127 actually addresses dss_slices[0], the compatibility slice holding GPT entry 0's extent.
  6. sys/vfs/devfs/devfs_core.c:2439-2484, 2571-2578 β€” devfs_new_cdev() / devfs_link_dev() perform no duplicate-(major,minor) check, so both cdevs coexist as distinct devfs inodes with identical si_inode.

Reproduction (kernel #0, stock, baseline)

sh run.sh on the stock INVARIANTS kernel (full output in run.log):

/dev/vn0s0   : st_ino=311 st_rdev=0x1e100807
/dev/vn0s126 : st_ino=317 st_rdev=0xfe1f0807
/dev/vn0s127 : st_ino=321 st_rdev=0x1e100807   <-- duplicate dev_t, distinct inode
vn0s127 data read  -> "ENTRY0ZONE_compat_slice0"  (slice 0's region, LBA 34)
vn0s126 data read  -> "ENTRY126ZONE"              (correct, LBA 160)
raw LBA 1000       -> "ENTRY127ZONE_real_s127_data" (where s127 *should* point)
DIOCGPART vn0s127  -> media_offset=17408 media_blocks=30  (slice 0's extent)

Write aliasing (run.3.log): 512-byte write via /dev/vn0s127 β†’ lands at absolute LBA 34; read-back via /dev/vn0s0 and raw /dev/vn0 shows the payload; entry 127's true region unchanged. Reprobe attach/detach cycles x3 survive without panic (stable confusion, not a crash).

Threat model: identical to the file's established crafted-media model (vnconfig by root, supplied VM disk image, physical media swap) β€” AND hits legitimate media: any spec-compliant 128-entry GPT (the gpt(8)/gdisk default) attached to DragonFly gets an s127 device that reads and writes the first partition's data. An unprivileged user needs only read/write permission on the s127 node (default root:operator 0640) to corrupt entry 0's partition through the wrong name.

Why this is not DF-0074/DF-2741 or DF-0228..0231

Those cover the DIOCGSLICEINFO heap overflow (dss_nslices=130 copied into a 16-slice ioctl buffer) and the four header-parsing defects. This finding is a different defect at a different layer: the slice-index-to-devfs-minor truncation (identity/aliasing), which exists even on kernels where none of the other five are exercised.

Exploit-chain assessment

No memory-corruption primitive: every dss_slices access is bounds-checked against dss_nslices (subr_diskslice.c:115/:385), and the alias target is a valid in-array slice. The impact ceiling is silent data-integrity violation across partitions (write to partition named s127 corrupts partition s0) and ambiguous device identity (two live cdevs with the same dev_t; udev events, devfs_find_device_by_devid, and audit/devfs rules key on dev_t). uid=0 is not reachable from this primitive alone.

Fix validation

fix.diff bounds created GPT slice devices to indices representable in the minor encoding (MAX_GPT_SLICES = DKMAXSLICES - BASE_SLICE + 1 = 127 entries: #0..#126 β†’ slice indices 0..127). Applied to clean guest /usr/src, make -j6 nativekernel && make installkernel, reboot into kernel #1 (run.fixed.log):

  • /dev/vn0s127 not created ("No such file or directory")
  • vn0s0 (offset 17408/30 blocks, correct data) and vn0s126 (81920/2, correct) unchanged β€” no regression on representable slices
  • dmesg: disk: GPT has 128 entries; only 127 slice devices representable, ignoring entry 127 (loud, not silent)
  • entry 127's data reachable only via the raw device β€” no aliasing path remains

fix_status: fixed (baseline reproduced on #0, bad behavior absent on #1).

Alternatives considered

Widening the minor encoding is impossible without ABI surgery: bits 8-15 of the kernel minor are reserved (rejected by makeudev, kern_conf.c:156) and all other bits are allocated (see layout comment diskslice.h:233-240), so DKMAXSLICES cannot grow for this minor format. Rejecting the whole 128-entry table (entries > 127 at the :136 validation) would break every legitimate gpt(8)-created disk; skipping only the unrepresentable entry is the minimal-behavior-change fix.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

vm.sh reset with-src; fix.diff applied to guest /usr/src (patch -p0, both hunks clean); make -j6 nativekernel + make installkernel; reboot into kernel #1. Exact PoC re-run: /dev/vn0s127 not created, no duplicate st_rdev, vn0s0 (offset 17408/30, correct data) and vn0s126 (81920/2, correct data) unregressed, dmesg shows 'disk: GPT has 128 entries; only 127 slice devices representable, ignoring entry 127'. Bad behavior (aliasing + duplicate dev_t) fully absent.

run.fixed.log; dmesg line quoted in VERDICT.md
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Fri Sep 4 08:10:58 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

crafted 128-entry GPT image -> vnconfig/boot-time auto-probe -> gptinit writes entry 127 into dss_slices[128] -> disk_probe creates 'vn0s127' with dkmakeminor(unit,128,255) == dkmakeminor(unit,0,255) -> dkslice() decodes 0 for the s127 cdev -> dscheck/dsopen/DIOCGPART all address dss_slices[0] (entry 0's extent) -> reads and writes through /dev/vn0s127 silently read/corrupt partition s0. Ceiling: cross-partition integrity violation + duplicate dev_t in devfs; no kernel memory corruption -> no uid=0.

Evidence (decisive lines)

["run.log: st_rdev vn0s0 == vn0s127 == 0x1e100807 with distinct st_ino (311 vs 321); vn0s127 reads 'ENTRY0ZONE_compat_slice0' while raw LBA 1000 holds 'ENTRY127ZONE_real_s127_data'; DIOCGPART vn0s127 reports offset 17408/30 blocks (slice 0's extent)", "run.3.log: 512-byte write via /dev/vn0s127 lands on abs LBA 34 (verified via /dev/vn0s0 and raw /dev/vn0); entry 127's true region untouched", 'run.2.log: three reprobe attach/detach cycles survive - stable confusion, no panic', "run.fixed.log + dmesg: on fix.diff kernel #1 '/dev/vn0s127: No such file or directory', 'disk: GPT has 128 entries; only 127 slice devices representable, ignoring entry 127', vn0s0/vn0s126 correct", 'fix.diff: bounds loop to MAX_GPT_SLICES (DKMAXSLICES-BASE_SLICE+1=127 entries)']

PoC changes

n/a (finding + PoC authored together in this run)

Verified recommended fix

Bound GPT slice-device creation to minor-representable indices: loop over min(entries, DKMAXSLICES-BASE_SLICE+1) in gptinit() and log when entry #127 is skipped (fix.diff).

Verdict

Deterministic reproduction on the stock kernel: a crafted (or any legitimate 128-entry) GPT makes the kernel create /dev/adXs127 with a minor identical to /dev/adXs0 because dkmakeminor() drops slice bit 7 for slice index 128 (gptinit maps entry #127 to BASE_SLICE+126=128, beyond DKMAXSLICES-1=127). Two live devfs cdevs share one major:minor, and every read AND write through the s127 node silently addresses slice 0's data (proven: read returns entry-0 pattern; 512-byte write lands on abs LBA 34). No memory corruption (all dss_slices accesses bounds-checked), so no uid0 chain; impact is cross-partition data-integrity violation and device-identity confusion. fix.diff (bound created GPT slices to representable minor indices, entries #0..#126) rebuilt and validated on kernel #1: vn0s127 no longer created, s0/s126 unregressed, loud dmesg warning.