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

CCDIOCSET accepts a negative interleave: negative component transfer size drives an unbounded dispatch loop and a guaranteed kernel panic

Summary

ccio_ileave is a signed int copied into the kernel with no lower-bound validation; ccdconfig(8) rejects ileave<0 but the ioctl does not, and /dev/ccdN is 0640 root:operator. Every later guard tests only >0/==0, so the negative value reaches ccdbuffer: cbc = dbtob(ileave - cboff) is negative, b_bcount goes negative, the start loop grows bcount by |rcount| per iteration and walks b_data backwards out of the user buffer while dispatching unbounded negative-length bios - the first 512-byte read wedges the thread un-killably and the kernel panics (vm_object_hold_shared NULL assertion). REPRODUCED 2/2 with identical signature on the QEMU guest via vn(4) components; fix validated (EINVAL, no panic, control path unregressed). The write variant sprays component content at descending user addresses before the panic. Reject-negative fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4279 Β· 13 files
FileTypeDescriptionSize
trigger.c β€” 2.3 KB view raw
run.sh β€” 103 B view raw
build.sh β€” 98 B view raw
README.md β€” 2.2 KB ↓ raw
VERDICT.md β€” 3.5 KB ↓ raw
panic.txt β€” 2.6 KB view raw
run.2.log β€” 2.0 KB view raw
fix_patched.log β€” 681 B view raw
build.log β€” 951 B view raw
env.txt β€” 284 B view raw
fix.diff β€” 583 B view raw
verdict.json β€” 3.8 KB view raw
manifest.json β€” 930 B view raw

DF-4279 β€” ccd(4): negative interleave β‡’ negative component transfer size β‡’ kernel panic

Build

On the DragonFly guest (as root):

cc -O -o /tmp/df4279 /root/df4279/trigger.c

Run (see run.sh for the full self-contained sequence)

kldload ccd
vnconfig -c -s labels vn0 /root/comp0.img          # 8MB vnode disk
disklabel32 -R -r vn0 <labelfile>                  # partition d: fstype ccd (19)
/tmp/df4279 /dev/ccd0 /dev/vn0s0d 32               # control: config+read OK
/tmp/df4279 /dev/ccd1 /dev/vn1s0d -1               # trigger: negative interleave

Expected (vulnerable kernel)

Control prints CCDIOCSET ok ... ileave=32 + pread returned 512 bytes. Trigger prints CCDIOCSET ok ... ileave=-1 (kernel ACCEPTS the negative interleave), the first 512-byte pread never returns, and the kernel panics within seconds:

panic: assertion "obj != NULL" failed in vm_object_hold_shared at /usr/src/sys/vm/vm_object.c:330
cpuid = 2
vm_object_hold_shared() at vm_object_hold_shared+0x3f
vm_fault() at vm_fault+0x408
trap_pfault() at trap_pfault+0x9a
trap() at trap+0x17c
calltrap() at calltrap+0x9
--- trap 000000000000000c, rip = ... memmove() at memmove+0x10a
Debugger("panic")

Reproduced twice with identical signature (panic.txt, run.2.log).

Expected (patched β€” fix.diff hunk 1)

CCDIOCSET: Invalid argument

No panic, guest stays up, control path (ileave=32) unregressed (fix_patched.log).

Why it happens

  • ccio_ileave is int; ccd.c:1326 stores it with no lower-bound check (ccdconfig(8) rejects ileave<0 at ccdconfig.c:254, but the ioctl is directly reachable; /dev/ccdN is 0640 root:operator).
  • All later guards test sc_ileave > 0 / == 0 only, so the negative value flows into ccdbuffer():
  • ccd.c:975-976 cboff = bn % ileave (≀0), cbn = bn / ileave
  • ccd.c:1068 cbc = dbtob((off_t)(sc_ileave - cboff)) β†’ negative
  • ccd.c:1071 b_bcount = (cbc < bcount) ? cbc : bcount β†’ negative
  • ccd.c:886-888 bcount -= rcount with rcount = cbp[0]->cb_buf.b_bcount negative β‡’ bcount grows every iteration β‡’ the dispatch loop never terminates, and addr += rcount walks b_data backwards out of the user buffer; the resulting OOB transfer faults in memmove β†’ panic.
VERDICT.md
↓ download raw

DF-4279 VERDICT

Status: REPRODUCED (panic). Fixed by fix.diff hunk 1 and re-verified.

Environment

DragonFly 6.5-DEVELOPMENT #0 Thu Jul 2 06:02:54 UTC 2026 (X86_64_GENERIC), QEMU/KVM guest, ccd.ko module, vn(4) components labeled fstype=ccd (19). Trigger executed as root (also reachable by the operator group: /dev/ccdN is 0640 root:operator via make_only_dev defaults, subr_disk.c:699-701).

How it reproduces

  1. kldload ccd; create two 8MB vn disks; disklabel32 each with d: 16368 16 ccd (fstype 19 = FS_CCD β€” required by ccd.c:458).
  2. Positive control: raw ioctl(CCDIOCSET) ileave=32 β†’ config ok ("total blocks 16352" = 16368-16 skip), 512-byte pread returns.
  3. Trigger: same ioctl with ccio_ileave = -1 β†’ accepted; the first pread never returns and the kernel panics within seconds.

Observed (two runs, identical signature β€” panic.txt / run.2.log)

ccd0-3: Concatenated disk drivers
ccd0: max component iosize is 65536 total blocks 16352   <- control ok
ccd0: max component iosize is 65536 total blocks 16352   <- ileave=-1 ACCEPTED
panic: assertion "obj != NULL" failed in vm_object_hold_shared vm_object.c:330
 vm_object_hold_shared β†’ vm_fault β†’ trap_pfault β†’ trap β†’ calltrap
 --- trap 0xc, rip = memmove+0x10a ---

(Note: every config line says "ccd0" because ccd.c:507 prints cs->sc_unit before ccdinit sets it at ccd.c:593 β€” cosmetic, noted in the audit.)

Root cause chain (path:line)

  • sys/dev/disk/ccd/ccd.c:1326 β€” ccd.ccd_interleave = ccio->ccio_ileave with no <= 0 validation in the kernel (userland ccdconfig.c:254 rejects it, the ioctl does not).
  • sys/dev/disk/ccd/ccd.c:484,514 β€” only > 1 / > 0 guards; negative values reach ccdinterleave/ccdbuffer.
  • sys/dev/disk/ccd/ccd.c:1068-1071 β€” cbc = dbtob(ileave - cboff) is negative for ileave<0 β‡’ cb_buf.b_bcount negative.
  • sys/dev/disk/ccd/ccd.c:886-888 β€” rcount negative β‡’ bcount grows β‡’ unbounded dispatch loop; addr += rcount walks b_data backwards (OOB relative to the user buffer) β‡’ OOB transfer β†’ fault in memmove β†’ vm_fault on a destroyed/absent object β†’ panic.

Interleave-table arithmetic itself stays in bounds for negative ileave (the u64 two's-complement walk in ccd.c:981-985 is self-consistent); the kill shot is the negative transfer size, not the table walk.

Exploit chain

None to uid0. Primitive: privileged local user (root/operator) gets an un-killable kernel thread issuing unbounded negative-length device transfers whose data pointer walks backwards through user VA space, terminating in a guaranteed kernel panic. Class = local DoS/panic. The write variant sprays component-disk content at decreasing user addresses of the caller's own process before the panic.

Fix validation (same run, same guest)

  • fix.diff hunk 1 rejects ccio_ileave < 0 with EINVAL.
  • Patched ccd.ko rebuilt from guest /usr/src (build.log), installed:
  • control ileave=32: config + read OK (no regression);
  • trigger ileave=-1: CCDIOCSET: Invalid argument, no panic, guest stays up (fix_patched.log).
  • Baseline (unpatched): panic Γ—2. Patched: 0/2.

fix.diff hunk 2 (DF-4280) was validated in the same build.

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 hunk 1 (reject ccio_ileave<0 in CCDIOCSET) validated on the guest: ccd.c rebuilt as a module from patched /usr/src, installed, ileave=-1 now returns EINVAL and the previously-guaranteed panic is gone across repeated runs while the ileave=32 control path is unregressed. Hunk 2 (DF-4280 guard) validated in the same build.

findings/poc/DF-4279/fix_patched.log (patched: 'CCDIOCSET: Invalid argument' + guest survives + uptime shown; baseline: panic.txt/run.2.log); build.log (nativekernel RC=0 + module link)
↓ fix.diffper-fix-DF-4279

Confirmed kernel references

Detail

Exploit chain

root/operator user -> open /dev/ccdN (0640 root:operator) -> ioctl(CCDIOCSET, ileave=-1, CCD-typed component) -> pread 512 bytes -> negative b_bcount + backward b_data walk in ccdstart -> OOB transfer -> kernel panic (un-killable spin precedes the crash). No uid0 chain; class is privileged local DoS/panic.

Evidence (decisive lines)

panic.txt (full serial-console panic: 'assertion obj != NULL failed in vm_object_hold_shared', memmove fault); run.2.log (serial sequence: control config ok, ileave=-1 config accepted, immediate panic); README.md/run.sh (self-contained reproduce); fix_patched.log (patched module: ileave=-1 -> 'CCDIOCSET: Invalid argument', ileave=32 control still works, guest survives); build.log (kernel+module rebuild, RC=0)

PoC changes

Seed sketch was author-authored here: raw CCDIOCSET trigger with _KERNEL_STRUCTURES include fix (ccdvar.h is not userland-includable otherwise), vn(4)+disklabel32 fstype=ccd component setup (no mdconfig on guest), positive control included.

Verified recommended fix

Validate ccio_ileave >= 0 in CCDIOCSET (return EINVAL) before configuring; optionally make the ileave guards in ccdinit use unsigned math.

Verdict

REPRODUCED: CCDIOCSET accepts a negative interleave (signed int, no kernel-side lower-bound validation at ccd.c:1326; ccdconfig(8) rejects it but the raw ioctl does not). The negative ileave reaches ccdbuffer() where cbc=dbtob(ileave-cboff) is negative (ccd.c:1068), producing a negative cb_buf.b_bcount (ccd.c:1071); ccdstart() then grows bcount by |rcount| each iteration (ccd.c:886-888) and walks b_data backwards out of the user buffer, issuing unbounded negative-length device transfers that fault in memmove -> vm_fault NULL-object assertion -> kernel panic. Two identical panics on the stock kernel; with fix.diff hunk 1 (reject ileave<0) the ioctl returns EINVAL, no panic, control path unregressed.