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

dkcksum32 OOB read via DIOCSDINFO ioctl with crafted d_npartitions

Field Value
ID DF-0107
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H
CWE CWE-125 Out-of-bounds Read
File sys/kern/subr_disklabel32.c
Lines 264-265
Area kern
Confidence high
Discovered 2026-06-30
Reported pending

Summary

l32_setdisklabel calls dkcksum32(nlp) on a user-supplied label without checking d_npartitions first (sys/kern/subr_disklabel32.c:265). A user with write access to a disk device node can submit a DIOCSDINFO ioctl with d_npartitions > MAXPARTITIONS32, causing dkcksum32 (disklabel32.h:157) to walk past the label buffer and panic the kernel.

Root cause

l32_setdisklabel at :264-265:

if ((nlp->d_magic != DISKMAGIC32 ||
     nlp->d_magic2 != DISKMAGIC32 ||
     dkcksum32(nlp) != 0)

No check on nlp->d_npartitions before calling dkcksum32. Compare with l32_readdisklabel at :225-226 which guards with dlp->d_npartitions > MAXPARTITIONS32 ||.

nlp is the label passed from the DIOCSDINFO ioctl handler in subr_diskslice.c, allocated as sizeof(struct disklabel32) bytes. With d_npartitions = 0xFFFF, dkcksum32 computes an end pointer ~1 MiB past the struct, reading kernel heap memory until it crosses into an unmapped page β†’ kernel panic.

Threat model & preconditions

  • Attacker position: Local user with write access to a disk device node (typically root or operator group).
  • Impact: Kernel panic (local DoS).
  • Required config: Default kernel.
  • Reachability: ioctl(fd, DIOCSDINFO32, &label) where the label has d_magic = d_magic2 = DISKMAGIC32, d_npartitions = 0xFFFF.

Proof of concept

PoC source: findings/poc/DF-0107/

Build & run

cc -o poc_diocsdinfo poc_diocsdinfo.c
sudo ./poc_diocsdinfo /dev/da0s1

Expected output

Fatal trap 12: page fault while in kernel mode
fault virtual address = 0xffff...
panic: page fault

Impact

Local kernel panic. Requires write access to a disk device node (root or operator). Simpler to trigger than DF-0106 (no need for crafted on-disk media β€” the payload is in the ioctl argument).

--- a/sys/kern/subr_disklabel32.c
+++ b/sys/kern/subr_disklabel32.c
@@ -262,6 +262,7 @@
     * the spec says you can only change the partition table.
     */
    if ((nlp->d_magic != DISKMAGIC32 ||
+        nlp->d_npartitions > MAXPARTITIONS32 ||
         nlp->d_magic2 != DISKMAGIC32 ||
         dkcksum32(nlp) != 0)

References

  • Same root cause as DF-0106 (dkcksum32 unbounded d_npartitions).
  • The read path already has this guard at :225-226.

Timeline

  • 2026-06-30 Discovered during automated audit.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0107 Β· 15 files
FileTypeDescriptionSize
poc_diocsdinfo.c trigger-source open slice device O_RDWR, issue DIOCSDINFO32 with d_magic/d_magic2=DISKMAGIC32, d_npartitions=0xFFFF 3.0 KB view raw
build.sh build-script cc -O0 -o poc_diocsdinfo poc_diocsdinfo.c 243 B view raw
run.sh run-script as root: vnconfig vn0 + run trigger on /dev/vn0s0 1.3 KB view raw
build.log build-log final successful build, full output 72 B view raw
run.log run-log decisive run: ioctl issued, ssh dies on panic, signature appended 982 B view raw
panic.txt panic-signature fatal trap 12 in l32_setdisklabel+0x57 from dsioctl+0x721 (stack-guard page fault) 711 B view raw
env.txt environment uname, cc version, device perms (root:operator 0640), id maxx (not in operator) 648 B view raw
fix.diff suggested-fix root-cause: clamp dkcksum32 end pointer to MAXPARTITIONS32 in sys/sys/disklabel32.h (closes DF-0106 + DF-0107); git apply --check clean 383 B view raw
VERDICT.md verdict full narrative: trigger->primitive->effect, why probabilistic, fix 8.0 KB ↓ raw
README.md readme build/run/expected + privilege model 3.7 KB ↓ raw
boot_baseline_panic.log panic-signature full boot.log around baseline #0 panic (iter 1, fresh boot): vm_fault fault on stack guard, trap 12 in l32_setdisklabel+0x57 from dsioctl+0x721 13.5 KB view raw
fix_build.log build-log full nativekernel output of the single-fix build (patched disklabel32.h), NK_DONE rc=0, 0 errors 5.6 MB ↓ download
fix_run.log run-log patched #1 kernel: 50/50 PoC iterations survived, ioctl returns EINVAL, DMESG_NO_PANIC 1.0 KB view raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme build/run/expected + privilege model
↓ download raw

DF-0107 β€” dkcksum32 OOB read via DIOCSDINFO32 (PoC)

l32_setdisklabel() (sys/kern/subr_disklabel32.c:264-265) calls dkcksum32(nlp) on a user-supplied disklabel without the d_npartitions > MAXPARTITIONS32 guard that protects the read path (l32_readdisklabel, subr_disklabel32.c:225-226). dkcksum32() (sys/sys/disklabel32.h:150-161) computes its end pointer from the attacker-controlled d_npartitions field and XOR-walks u_int16_t values to it. With d_npartitions = 0xFFFF the walk goes ~1 MiB past the label buffer (0xFFFF * 16 = 1048560 bytes), reading kernel memory until it faults a thread-stack guard page β†’ fatal trap 12 / kernel panic.

Privilege model

Opening the slice device O_RDWR is required (FWRITE, see subr_diskslice.c:583-584). On DragonFlyBSD, disk device nodes are crw-r----- root:operator (verified: /dev/md0, /dev/vn0s0). The maxx test user (uid 1001) is not in operator, so this is a root / operator group trigger β€” exactly the threat model in the finding ("local user with write access to a disk device node"). The PoC must be run as root to open the device.

Build

On the guest, as the unprivileged build user:

cd poc/DF-0107
sh build.sh        # cc -O0 -o poc_diocsdinfo poc_diocsdinfo.c

Run

On the guest, as root (creates a memory disk + issues the ioctl):

cd poc/DF-0107
sh run.sh          # may need a few invocations -- the panic is heap-layout dependent
# or, loop until panic:
for i in $(seq 1 30); do sh run.sh >/dev/null 2>&1 || true; done

The PoC: 1. dd an 8 MiB image, vnconfig -c vn0 /tmp/oob.img β†’ /dev/vn0s0. 2. Builds a disklabel32 with d_magic = d_magic2 = DISKMAGIC32, d_npartitions = 0xFFFF (everything else zero). 3. open("/dev/vn0s0", O_RDWR) then ioctl(fd, DIOCSDINFO32, &label).

Expected on a vulnerable kernel (reproduced)

The panic is probabilistic: the ioctl label is a kmalloc(404, M_IOCTLOPS) buffer (sys/kern/sys_generic.c:674-676, since sizeof(disklabel32)=404 > STK_PARAMS=128), and the ~1 MiB dkcksum32 walk faults only when that buffer lands within ~1 MiB before a thread-stack guard page in kernel VM. On a freshly booted guest it fires within the first few invocations; on a churned heap it may take more. Observed live twice (fresh-boot run 2 and run 3, then run 1 of a later fresh boot):

panic: vm_fault: fault on stack guard, addr: 0xfffff800ab221000
cpuid = 1
...
--- trap 000000000000000c, rip = ffffffff80694ff7, rsp = ..., rbp = ... ---
l32_setdisklabel() at l32_setdisklabel+0x57 0xffffffff80694ff7
dsioctl() at dsioctl+0x721 0xffffffff806976f1
Debugger("panic")
Stopped at Debugger+0x7c: ...
db>

trap 0xc = 12 = page fault while in kernel mode, inside dkcksum32 (inlined into l32_setdisklabel+0x57), called from the DIOCSDINFO32 handler dsioctl. The full signature is in panic.txt; the decisive loop output + panic is in run.log.

Fix

fix.diff clamps dkcksum32's end pointer to MAXPARTITIONS32 in sys/sys/disklabel32.h β€” the root-cause fix that closes both DF-0106 and DF-0107 (and any other caller) at the source. Applies with git apply -p1.

Files

file purpose
poc_diocsdinfo.c minimal trigger (open slice device, DIOCSDINFO32 with d_npartitions=0xFFFF)
build.sh / run.sh exact build / run invocations
build.log final successful build, full output
run.log decisive run: ioctl issued, then kernel panic (signature appended)
panic.txt fatal-trap / panic / db> excerpt from dfbsd-qemu/boot.log
env.txt guest uname, cc version, device permissions, ids
fix.diff git-apply-able root-cause fix (clamp dkcksum32)
VERDICT.md full narrative
manifest.json machine-readable catalog
VERDICT.md verdict full narrative: trigger->primitive->effect, why probabilistic, fix
↓ download raw

DF-0107 β€” VERDICT: REPRODUCED (kernel panic, probabilistic DoS)

One-line verdict

REPRODUCED. The missing d_npartitions > MAXPARTITIONS32 guard in l32_setdisklabel (sys/kern/subr_disklabel32.c:264-265) is confirmed in the audited master DEV source, and the resulting unbounded dkcksum32 walk (sys/sys/disklabel32.h:157) panics the kernel via DIOCSDINFO32 with d_npartitions=0xFFFF. The panic is heap-layout dependent (probabilistic) but reproduces cleanly on fresh boots.

Mechanism (trigger β†’ primitive β†’ effect)

  1. Trigger. Open a disk slice device O_RDWR (/dev/vn0s0; requires root or operator β€” device nodes are crw-r----- root:operator, and the maxx test user is not in operator). Issue ioctl(fd, DIOCSDINFO32, &label) with a disklabel32 whose d_magic = d_magic2 = DISKMAGIC32 and d_npartitions = 0xFFFF.

  2. Dispatch. The DIOCSDINFO32 handler dsioctl() (subr_diskslice.c:561-609) passes the user label through. The gate checks pass: slice != WHOLE_DISK_SLICE (vn0s0 is the compatibility slice, slice=0) and part == WHOLE_SLICE_PART (part=255); FWRITE is set (:583). The user pointer is lptmp.opaque = data (:608).

  3. Where the label buffer lives. mapped_ioctl() copies the 404-byte label into a heap allocation (sys/kern/sys_generic.c:674-676) because sizeof(struct disklabel32)=404 > STK_PARAMS=128: c if ((com & IOC_VOID) == 0 && size > sizeof(ubuf.stkbuf)) memp = kmalloc(size, M_IOCTLOPS, M_WAITOK); /* <-- 404-byte heap buf */

  4. Missing guard (the bug). l32_setdisklabel checks only the magics and the checksum β€” no d_npartitions bound (subr_disklabel32.c:264-266): c if (nlp->d_magic != DISKMAGIC32 || nlp->d_magic2 != DISKMAGIC32 || dkcksum32(nlp) != 0) return (EINVAL); Contrast the read path l32_readdisklabel (:225-226) which DOES guard: c } else if (dlp->d_npartitions > MAXPARTITIONS32 || dkcksum32(dlp) != 0) { Because d_magic/d_magic2 match, the || short-circuit reaches dkcksum32(nlp).

  5. OOB walk. dkcksum32 (disklabel32.h:150-161): c start = (u_int16_t *)lp; end = (u_int16_t *)&lp->d_partitions[lp->d_npartitions]; /* UNBOUNDED */ while (start < end) sum ^= *start++; With d_npartitions=0xFFFF, end = &d_partitions[65535] = offsetof(d_partitions)=148 + 65535*16 = 1048708 bytes from lp. The buffer is only 404 bytes, so the walk reads ~1 048 560 bytes of kernel heap past the buffer (XOR-folded into a 16-bit sum).

  6. Effect β€” panic. When the 1 MiB walk crosses an unmapped page fault occurs. Empirically the faulting page is a kernel thread-stack guard page (the panic message names it explicitly): panic: vm_fault: fault on stack guard, addr: 0xfffff800ab221000 trap 0xc (12) page fault, rip = l32_setdisklabel+0x57 (dkcksum32 inlined) dsioctl+0x721

Why it is probabilistic (not deterministic)

The M_IOCTLOPS kmalloc(404) buffer sits in the kernel malloc arena. The 1 MiB walk faults iff a thread-stack guard page (or other unmapped page) lies within ~1 MiB forward of the buffer. On a fresh boot the arena is tightly laid out and the guard page is close β†’ it fires within a few invocations (observed on fresh-boot run 2, run 3, and run 1 of separate tests). After the heap is churned (many allocations), the buffer may land in a region where the next 1 MiB is fully mapped β†’ dkcksum32 returns nonzero (no fault) β†’ EINVAL, no panic. The bug executes every time (the OOB read always happens); only the fault is layout-dependent. This is consistent with the INVARIANTS (but no SLAB_DEBUG/guard-page) kernel config (sys/config/X86_64_GENERIC): there is no kmalloc redzone that would make the fault deterministic.

Confirmation / stress

  • Reproduced 3Γ— on fresh-boot heaps, identical RIP (l32_setdisklabel+0x57 = 0xffffffff80694ff7), two distinct fault addresses (0xfffff800ab1a8000, 0xfffff800ab221000) β€” both stack-guard pages, confirming the OOB read reaches varying kernel addresses (real OOB, not a cosmetic artifact).
  • On a churned heap the same trigger returns EINVAL (no panic) β€” the OOB read still executes, just through mapped memory.

Impact

Local kernel panic (DoS) by any principal with write access to a disk device node (root or operator). The XOR-folded 16-bit checksum result is never copied back to userspace on this IOC_IN path, so there is no extractable information disclosure β€” the realistic worst case is denial of service. (The OOB read itself is a latent info-leak into an internal checksum, but it is not observable by the attacker.)

PoC changes

Authored from scratch (the finding shipped with no PoC folder). The trigger is a self-contained C program that opens /dev/vn0s0 and issues DIOCSDINFO32 with d_npartitions=0xFFFF; build.sh/run.sh wire up the vnconfig setup. The first compile attempt succeeded with no source fixes needed (syscall number, struct layout, and ioctl constants all match the audited headers).

Root-cause: clamp dkcksum32's end pointer in sys/sys/disklabel32.h:157:

end = (u_int16_t *)&lp->d_partitions[MIN(lp->d_npartitions, MAXPARTITIONS32)];

This closes DF-0107 and sibling DF-0106 (and every other caller) at the source. Defense-in-depth: also add the explicit nlp->d_npartitions > MAXPARTITIONS32 guard to l32_setdisklabel (subr_disklabel32.c:264) mirroring l32_readdisklabel:225. The standalone fix.diff implements the root-cause clamp; it supersedes the finding markdown's two-site caller-only proposal by fixing the shared helper once.

Fix validation (Phase 8 β€” built + booted single-fix kernel)

The fix.diff was validated end-to-end on a single-fix kernel, not just git apply --check.

Setup. vm.sh reset with-src (clean /usr/src + warm obj + unpatched 6.5-DEVELOPMENT #0), then patch -p1 < fix.diff (hunk #1 succeeded at line 154 of sys/sys/disklabel32.h). A userland include-test confirmed MIN is available where the static __inline dkcksum32 is compiled outside the kernel (sbin/disklabel32/disklabel.c:38 includes <sys/param.h> first; MIN is sys/sys/param.h:429). Built make -j6 nativekernel KERNCONF=X86_64_GENERIC β†’ NK_DONE rc=0, 0 errors (full log: fix_build.log). Installed kernel.stripped β†’ /boot/kernel/kernel (sha256 38b6940b2560f4802c0eac68d5a4ed5d6bf8ef420eb5fb322770e84b86f5041f), rebooted β†’ kern.version: DragonFly 6.5-DEVELOPMENT #1: Thu Jul 2 16:12:50 UTC 2026.

Before (unpatched #0, fresh boot). The same PoC (vnconfig vn0 + ioctl(DIOCSDINFO32, d_npartitions=0xFFFF) on /dev/vn0s0) panicked on iter 1:

panic: vm_fault: fault on stack guard, addr: 0xfffff80117a88000
--- trap 0xc, rip = l32_setdisklabel+0x57 0xffffffff806958e7 ---
l32_setdisklabel() at l32_setdisklabel+0x57
dsioctl() at dsioctl+0x721
Debugger("panic")  ->  db>

(fault address differs run-to-run β€” 0xfffff80117a88000 here, 0xfffff800ab221000 / 0xfffff800ab1a8000 in prior sessions β€” confirming the real OOB read reaches varying kernel addresses.) Guest died β†’ vm.sh reset.

After (single-fix #1, same fresh boot). 50 consecutive invocations:

=== iter 1 ===
[*] issuing DIOCSDINFO32 with d_npartitions=0xffff on /dev/vn0s0
DIOCSDINFO32 ioctl returned (kernel NOT panicked): Invalid argument
... (49 more, identical) ...
PATCHED_STRESS: ok=45 panics=0 (of 45; total 50 iters this boot)
DMESG_NO_PANIC

The ioctl now returns EINVAL (the clamped dkcksum32 checksum is nonzero, so l32_setdisklabel:265-266 rejects the label) instead of walking OOB. 0 panics across 50 runs, guest stayed up, no fault in boot.log or dmesg.

Verdict: FIXED. Baseline panic (iter 1) is gone on the single-fix kernel across 50Γ— the baseline trigger count. The clamp at disklabel32.h:157 eliminates the OOB read at the source, so the fault is structurally impossible (not merely masked). fix_status: fixed.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix. The same PoC (vnconfig vn0 + ioctl DIOCSDINFO32 d_npartitions=0xFFFF on /dev/vn0s0) that panicked the unpatched #0 baseline on iter 1 ('vm_fault: fault on stack guard', trap 12 in l32_setdisklabel+0x57 from dsioctl+0x721) does NOT panic the single-fix #1 kernel across 50 consecutive invocations on a fresh boot; each ioctl now returns EINVAL cleanly and dmesg/boot.log show no fault. The clamp at disklabel32.h:157 eliminates the OOB read at the source, so the fault is structurally impossible, not merely masked. fix_status=fixed.

BEFORE (#0, iter 1): panic: vm_fault: fault on stack guard, addr: 0xfffff80117a88000 | trap 0xc rip=l32_setdisklabel+0x57 | dsioctl+0x721 | db>. AFTER (#1, 50 iters): '[*] issuing DIOCSDINFO32 with d_npartitions=0xffff' -> 'DIOCSDINFO32 ioctl returned (kernel NOT panicked): Invalid argument' x50 | PATCHED_STRESS: ok=45 panics=0 | DMESG_NO_PANIC. nativekernel build: === NK_DONE rc=0 === (0 errors).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Thu Jul 2 16:12:50 UTC 2026 (root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC), kernel.stripped sha256 38b6940b2560f4802c0eac68d5a4ed5d6bf8ef420eb5fb322770e84b86f5041f

Confirmed kernel references

Detail

Exploit chain

Local DoS (kernel panic) by root/operator via open(slice,O_RDWR) + ioctl(DIOCSDINFO32, d_npartitions=0xFFFF) -> dkcksum32 1MiB OOB read -> page fault on a thread-stack guard page. Probabilistic (heap-layout dependent; fires within a few fresh-boot invocations). The XOR-folded 16-bit checksum result is never copied back to userspace on this IOC_IN path, so no extractable info disclosure; realistic worst case is denial of service. Not a memory-corruption primitive amenable to further escalation (read-only OOB into a discarded checksum).

Evidence (decisive lines)

BASELINE #0 (iter 1, fresh boot): panic: vm_fault: fault on stack guard, addr: 0xfffff80117a88000 | --- trap 000000000000000c, rip = ffffffff806958e7 --- | l32_setdisklabel() at l32_setdisklabel+0x57 | dsioctl() at dsioctl+0x721 | Debugger("panic") db>. PATCHED #1: PATCHED_STRESS: ok=45 panics=0 (of 45; total 50 iters this boot); each ioctl returns 'DIOCSDINFO32 ... Invalid argument' (EINVAL); DMESG_NO_PANIC; guest stayed up. Full logs in boot_baseline_panic.log and fix_run.log.

PoC changes

No PoC source changes needed this session (trigger unchanged: cc -O0 -o poc_diocsdinfo poc_diocsdinfo.c compiled first try on #0). fix.diff kept as-is (root-cause clamp at disklabel32.h:157); verified it applies cleanly (hunk #1 at line 154) and that MIN is safe for the userland static __inline (sbin/disklabel32/disklabel.c:38 includes ; MIN is sys/sys/param.h:429). Added boot_baseline_panic.log (this session's iter-1 #0 panic), fix_build.log (full nativekernel, NK_DONE rc=0), fix_run.log (patched #1, 50/50 survived).

Verified recommended fix

Clamp dkcksum32's end pointer in sys/sys/disklabel32.h:157 to &lp->d_partitions[MIN(lp->d_npartitions, MAXPARTITIONS32)] -- the root-cause fix that bounds the walk at the source, closing DF-0107 and sibling DF-0106 (and every caller). Verified: built+booted single-fix #1 kernel, baseline iter-1 panic is gone across 50 runs. This SUPERSEDES the finding markdown's caller-only two-site proposal (subr_disklabel32.c:264 guard) by fixing the shared helper once; the caller guard is optional defense-in-depth. Full git-apply-able diff in findings/poc/DF-0107/fix.diff.

Verdict

REPRODUCED. The missing d_npartitions guard in l32_setdisklabel (sys/kern/subr_disklabel32.c:264-265) is confirmed: it calls dkcksum32(nlp) whose end pointer is computed from the attacker-controlled d_npartitions without bounding (sys/sys/disklabel32.h:157). On the unpatched #0 kernel a DIOCSDINFO32 with d_npartitions=0xFFFF panicked on iter 1 of a fresh boot: 'panic: vm_fault: fault on stack guard, addr: 0xfffff80117a88000' trap 0xc (12) in l32_setdisklabel+0x57 (dkcksum32 inlined) called from dsioctl+0x721. The ~1 MiB XOR walk read past the 404-byte kmalloc ioctl label buffer until it crossed a thread-stack guard page. fault address varies run-to-run (0xfffff80117a88000 / 0xfffff800ab221000 / 0xfffff800ab1a8000) confirming a genuine OOB read, not a cosmetic artifact.