Divide-by-zero kernel panic via CCDIOCSET with ccio_ndisks=0
Summary
CCDIOCSET only validates ccio->ccio_ndisks against upper bound (CCD_MAXNDISKS 65536) never rejects 0. With 0 disks ccdinit component loop runs zero times so maxsecsize remains 0 and subsequent pseudo-geometry setup performs integer division by zero - either at :515 (ileave>0: sc_ileave%(maxsecsize/DEV_BSIZE) => x%0) or :580 (1048576/ccg_secsize where ccg_secsize=maxsecsize=0). On x86-64 raises #DE kernel panics. kmalloc(0) returns non-NULL ZERO_LENGTH_PTR so all intervening allocations bail out check pass. Local DoS kernel panic. Attacker: open /dev/ccdN for write issue CCDIOCSET with ccio_ndisks=0. 100% reliable.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2432 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| poc.c | trigger-source | CCDIOCSET with ccio_ndisks=0 -> divide-by-zero in ccdinit | 2.9 KB | view raw |
| build.sh | build-script | cc -O2 poc.c | 80 B | view raw |
| run.sh | run-script | kldload ccd && ./poc /dev/ccd0 (as root) | 423 B | view raw |
| panic.txt | panic-signature | Fatal trap 18 integer divide fault at ccdinit+0x578 divl | 2.0 KB | view raw |
| run.log | run-log | fixed-module run: CCDIOCSET rejected EINVAL, guest up | 204 B | view raw |
| run.2.log | run-log | determinism re-run #2: EINVAL, guest up | 84 B | view raw |
| run.3.log | run-log | determinism re-run #3: EINVAL, guest up | 84 B | view raw |
| fix.diff | suggested-fix | reject ndisks==0 at CCDIOCSET + guard maxsecsize==0 in ccdinit | 892 B | view raw |
| fix_build.log | build-log | standalone ccd.ko build, compiles clean | 662 B | view raw |
| fix_env.txt | environment | kern.version, fixed ccd.ko sha256, kldstat | 385 B | view raw |
| env.txt | environment | uname, cc, /dev/ccd0 perms (root:operator 0640), maxx groups | 469 B | view raw |
| VERDICT.md | verdict | full narrative + before/after validation | 4.3 KB | β raw |
DF-2432 β Divide-by-zero kernel panic via CCDIOCSET with ccio_ndisks=0
Verdict: REPRODUCED (local DoS / kernel panic), then FIXED and VALIDATED.
Mechanism (confirmed, path:line)
CCDIOCSET (sys/dev/disk/ccd/ccd.c:1309, sys/sys/ccdvar.h:238) only
validates the component count against its upper bound:
if (ccio->ccio_ndisks > CCD_MAXNDISKS) { /* ccd.c:1319 β no lower bound! */
ccdunlock(cs);
return (EINVAL);
}
It never rejects ccio_ndisks == 0. With zero disks:
kmalloc(0 * sizeof(char *))andkmalloc(0 * sizeof(struct vnode *))return the non-NULLZERO_LENGTH_PTR, so the allocation checks pass (ccd.c:1350-1353);copyin(..., 0)is a no-op (ccd.c:1355); and the component-lookup loop runs zero times (ccd.c:1372).ccdinit()then runs:cs->sc_nccdisks = ccd->ccd_ndev = 0(ccd.c:402);kmalloc(0 * sizeof(struct ccdcinfo))again yieldsZERO_LENGTH_PTR(ccd.c:405); and the per-component loop (ccd.c:418) runs zero times, somaxsecsizestays0(ccd.c:416).- The pseudo-geometry setup divides by it:
c ccg->ccg_secsize = maxsecsize; /* = 0 */ ccg->ccg_nsectors = 1024 * 1024 / ccg->ccg_secsize;/* 1048576 / 0 -> #DE */(ccd.c:578-580). Ifccio_ileave > 0the earlier dividesc_ileave % (maxsecsize / DEV_BSIZE)==x % 0(ccd.c:515) faults first. Either way the CPU raises a divide error (#DE, trap 18) and the kernel panics.
Reproduction (unpatched #0 baseline)
poc.c opens /dev/ccd0 (O_WRONLY, satisfying the FWRITE check at
ccd.c:1313) and issues ioctl(fd, CCDIOCSET, &ccio) with ccio_ndisks=0,
ccio_ileave=0, ccio_flags=0. Run as root after kldload ccd.
Observed (panic.txt, serial console):
Fatal trap 18: integer divide fault while in kernel mode instruction pointer = 0x8:0xffffffff82600e28 Stopped at ccdinit+0x578: divl %esi,%eax db>
Guest went down (ssh died). 100% reliable.
Impact ceiling (realistic)
This is a divide-by-zero DoS, not memory corruption β no escalation
chain. The trigger requires:
- kldload ccd (root), and
- opening /dev/ccd0 with O_WRONLY β /dev/ccd0 is
crw-r----- root:operator, so the caller needs root or membership in the
operator group. The unprivileged test user (maxx, uid 1001) is not
in operator, so it is a rootβkernel DoS. (A real deployment that
pre-loads ccd and grants an account operator would expose it as an
unprivileged local DoS; that is a deployment choice, not the default.)
The fix (fix.diff)
Root-cause: reject a zero-component configuration at the CCDIOCSET layer, and
add a defense-in-depth guard against maxsecsize == 0 in ccdinit():
ccd.c:1330βif (ccio->ccio_ndisks == 0 || ccio->ccio_ndisks > CCD_MAXNDISKS)returnsEINVAL.ccd.c:515βif (maxsecsize == 0) { error = EINVAL; goto fail; }before the divides at:519/:580.
Fix validation (rebuilt + reloaded ccd.ko, same #0 kernel base)
ccd is a loadable kld module, so the fix was validated without a kernel
reboot: rebuilt ccd.ko standalone from /usr/src/sys/dev/disk/ccd, copied
it to /boot/kernel/ccd.ko, and kldload-ed it on the running kernel.
| result | |
|---|---|
| before (unpatched ccd.ko, #0 kernel) | Fatal trap 18: integer divide fault β¦ Stopped at ccdinit+0x578: divl %esi,%eax β guest down |
| after (fixed ccd.ko, same #0 kernel) | poc: CCDIOCSET rejected: Invalid argument (FIXED behavior) β guest up, Γ3/3 runs |
Fixed ccd.ko SHA256: 5189556a9a0aee32ac340f408e3a088d84a6cb1110c9c282ef5f43500047595f.
The fix closes the bug cleanly (EINVAL replaces the #DE panic).
Files
poc.cβ CCDIOCSET withccio_ndisks=0β divide-by-zero.build.sh/run.shβccthe PoC;kldload ccd && ./poc /dev/ccd0.panic.txtβFatal trap 18 β¦ ccdinit+0x578serial-log signature.run.logβ fixed-module run:CCDIOCSET rejected: Invalid argument, guest up.run.2.log,run.3.logβ determinism re-runs (Γ3/3 EINVAL).fix.diffβgit apply-able: rejectndisks==0+ guardmaxsecsize==0.fix_build.logβ standaloneccd.kobuild log (compiles clean).fix_env.txtβkern.version, fixedccd.kosha256, kldstat.env.txtβ guest uname, cc,/dev/ccd0perms,maxxgroups.
Fix verification
fixedVALIDATED. SAME PoC (CCDIOCSET ccio_ndisks=0) on SAME #0 kernel base panics with unpatched ccd.ko (Fatal trap 18 integer divide fault at ccdinit+0x578 divl, guest down) and does NOT panic with fixed ccd.ko (ioctl returns EINVAL: 'CCDIOCSET rejected: Invalid argument (FIXED behavior)', guest up, deterministic x3/3 runs). Fix closes divide-by-zero cleanly. fix.diff applies (Hunk #1 at 511, Hunk #2 at 1321) and module compiles standalone.
BEFORE (unpatched): 'Fatal trap 18: integer divide fault while in kernel mode' / 'Stopped at ccdinit+0x578: divl %esi,%eax' (guest down). AFTER (fixed ccd.ko, same #0 kernel): 'poc: CCDIOCSET rejected: Invalid argument (FIXED behavior)' POC_EXIT=2 GUEST_UP=6.5-DEVELOPMENT, repeated run.log/run.2.log/run.3.log (x3/3 EINVAL, guest up). Fixed ccd.ko sha256=5189556a9a0aee32ac340f408e3a088d84a6cb1110c9c282ef5f43500047595f.
Confirmed kernel references
Detail
Exploit chain
none -- non-corruption (divide-by-zero / #DE local DoS), no memory-corruption primitive and no escalation chain. Realistic impact ceiling: local root->kernel panic (DoS). Trigger requires root: kldload ccd (root) and open /dev/ccd0 O_WRONLY for FWRITE check ccd.c:1313. /dev/ccd0 crw-r----- root:operator, maxx (uid 1001) NOT in operator, so root->kernel DoS by default. Deployment that pre-loads ccd AND grants account operator membership would expose as unprivileged local DoS, but that's deployment choice not default config.
Evidence (decisive lines)
BEFORE (unpatched ccd.ko, #0 kernel), serial boot.log: Fatal trap 18: integer divide fault while in kernel mode / instruction pointer = 0x8:0xffffffff82600e28 / Stopped at ccdinit+0x578: divl %esi,%eax / db> (guest down). AFTER (fixed ccd.ko, same #0 kernel), run.log: poc: issuing CCDIOCSET on /dev/ccd0 with ccio_ndisks=0 ... / poc: CCDIOCSET rejected: Invalid argument (FIXED behavior) / POC_EXIT=2 ; GUEST_UP=6.5-DEVELOPMENT (deterministic x3/3 runs).
PoC changes
Authored findings/poc/DF-2432/ from scratch (no scaffolding): poc.c opens /dev/ccd0 O_WRONLY and issues ioctl(fd, CCDIOCSET, &ccio) with self-contained struct ccd_ioctl mirror (32 bytes amd64) and MY_CCDIOCSET = _IOWR('F',16,struct ccd_ioctl), setting ccio_ndisks=0/ccio_ileave=0/ccio_flags=0 to take ccd.c:580 divide path; prints clear FIXED-behavior marker (EINVAL) vs expected panic. build.sh; run.sh (kldload ccd && ./poc).
Verified recommended fix
Two-part fix in sys/dev/disk/ccd/ccd.c (full git-apply-able diff in findings/poc/DF-2432/fix.diff): (1) root-cause -- in ccdioctl(CCDIOCSET), change bound check to 'if (ccio->ccio_ndisks == 0 || ccio->ccio_ndisks > CCD_MAXNDISKS) return EINVAL;' so zero-component config rejected before any allocation/divide; (2) defense-in-depth -- in ccdinit(), add 'if (maxsecsize == 0) { error = EINVAL; goto fail; }' immediately before divides at ccd.c:515/580 so future caller cannot re-introduce #DE. Matches finding proposal intent.
Verdict
REPRODUCED then FIXED+VALIDATED. CCDIOCSET (sys/dev/disk/ccd/ccd.c:1309) validates ccio->ccio_ndisks only against UPPER bound (CCD_MAXNDISKS, ccd.c:1319) and never rejects 0. With ccio_ndisks=0: kmalloc(0) returns non-NULL ZERO_LENGTH_PTR so alloc checks at ccd.c:1350 and ccd.c:405 pass, copyin of 0 bytes is no-op (ccd.c:1355), component loops at ccd.c:1372 and ccd.c:418 run zero times, leaving maxsecsize=0 (ccd.c:416). ccdinit's pseudo-geometry setup then divides by it: ccg->ccg_secsize=maxsecsize=0 (ccd.c:578); ccg->ccg_nsectors = 1024*1024 / ccg->ccg_secsize == 1048576/0 (ccd.c:580) -> #DE trap 18 -> kernel panic. (If ccio_ileave>0, earlier divide sc_ileave%(maxsecsize/DEV_BSIZE)==x%0 at ccd.c:515 faults first.) Confirmed on unpatched #0: 'Fatal trap 18: integer divide fault while in kernel mode' / 'Stopped at ccdinit+0x578: divl %esi,%eax', guest down. 100% reliable. Fix (reject ndisks==0 at CCDIOCSET layer + defense-in-depth maxsecsize==0 guard in ccdinit) rebuilt as standalone ccd.ko, reloaded on running kernel (ccd loadable, no reboot), re-run x3: 'CCDIOCSET rejected: Invalid argument (FIXED behavior)', guest stays up.
No comments yet.