cam_calc_geometry() truncates u64 volume_size into uint32_t math β silent modular wrap of C/H/S geometry for crafted-capacity devices
Summary
All locals are uint32_t but ccg->volume_size is u64 (DFly widened it past FreeBSD): both quotients wrap mod 2^32 for crafted READ CAPACITY(16) answers (any malicious USB gadget/iSCSI/NBD/virtio-scsi backend fully controls it; every SIM funnels XPT_CALC_GEOMETRY here). Production-kernel harness: 2^43 sectors -> C=0 (truth 547531467), max-u64 -> C=1500097198 (truth 1.1e15); the degenerate cylinders=0 arrives from the success path that dasetgeom's error path exists to avoid. Ceiling is metadata confusion (in-kernel consumers guard secpercyl!=0; capacity flows through 64-bit d_media_blocks) - relevant because the same unchecked device-capacity path feeds sharper consumers. 64-bit-math + UINT32_MAX saturation fix validated on a rebuilt kernel (exact where it fits, saturates otherwise, guards byte-identical).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5554 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| calcgeo.c | β | 3.9 KB | view raw | |
| build.sh | β | 150 B | view raw | |
| run.sh | β | 146 B | view raw | |
| run.log | β | 3.2 KB | view raw | |
| run.patched.log | β | 2.7 KB | view raw | |
| calcgeo_build.log | β | 6.0 KB | view raw | |
| fix_build.log | β | 5.6 MB | β download | |
| fix.diff | β | 829 B | view raw | |
| env.txt | β | 981 B | view raw | |
| README.md | β | 2.2 KB | β raw | |
| VERDICT.md | β | 5.0 KB | β raw | |
| verdict.json | β | 5.3 KB | view raw |
DF-5554 β cam_calc_geometry() 64β32-bit truncation (sys/bus/cam/cam.c)
What
cam_calc_geometry() computes fake C/H/S geometry for a disk from
ccg->volume_size (u_int64_t, cam_ccb.h:779) but keeps every arithmetic
local in uint32_t (cam.c:378):
size_mb = ccg->volume_size / size_mb;(cam.c:389) β u64/u32 quotient truncated to u32;ccg->cylinders = ccg->volume_size / secs_per_cylinder;(cam.c:398) β same truncation into the u32 result field.
volume_size is filled by the da periph straight from device-reported
READ CAPACITY data (scsi_8btou64(rcaplong->addr), scsi_da.c:1851 β
dasetgeom scsi_da.c:2303), i.e. fully controlled by any SCSI target the
host negotiates with (malicious USB gadget, iSCSI/NBD export, virtio-scsi
backend). Every SIM in the tree funnels XPT_CALC_GEOMETRY into this one
function (ahci, sili, virtio_scsi, umass, mps/mpr, 20+ drivers).
Reproduce (guest, root)
sh build.sh # KLD df5554.ko via kmod.mk (production symbol) sh run.sh # kldload; dmesg | grep DF5554
Expected
Baseline (stock kernel #0, Jul 2 2026) β run.log:
- sane 8TB (1.56e10 sectors): ok (matches 64-bit math) β harness fidelity;
- 2^43 sectors: ker: H=64 S=32 C=0 vs tru: H=255 S=63 C=547531467
β both truncations fire (extended-translation decision flips because
size_mb wraps 2^32β0, and cylinders wraps 2^32β0);
- 2^32Γ16065 (+16064): C=0 (exact cylinder wrap);
- max-u64: C=1500097198 vs true 1148256711715502 (arbitrary modular value);
- block_size=0 / zero-volume guards behave.
Patched (kernel #1 with fix.diff) β run.patched.log:
- 2^43 sectors: H=255 S=63 C=547531467 β exact 64-bit truth;
- unrepresentable quotients saturate at C=4294967295 (UINT32_MAX clamp),
never silently wrap to 0/arbitrary;
- guards unchanged.
Impact ceiling
Wrong kernel-reported disk geometry (dmesg announce, diskinfo,
disk_info.d_ncylinders) for crafted-capacity devices. No memory-safety
impact; downstream consumers divide only by secpercyl (=16065/2048, never
0) and size from the 64-bit d_media_blocks. Severity Low (logic).
Guest is reset (vm.sh reset with-src) after each leg.
DF-5554 VERDICT β cam_calc_geometry() 64β32-bit truncation
Status: REPRODUCED (baseline) / FIXED (patched kernel, validated in-guest). Impact: none beyond wrong kernel-reported geometry (logic bug, Low). Confidence: certain.
Root cause
struct ccb_calc_geometry.volume_size is u_int64_t
(sys/bus/cam/cam_ccb.h:779 β DragonFly widened it for >2TB volumes;
FreeBSD's counterpart is u32), but cam_calc_geometry()
(sys/bus/cam/cam.c:375-400) still does all math in uint32_t
locals (cam.c:378):
uint32_t size_mb, secs_per_cylinder; /* cam.c:378 */
size_mb = (1024L * 1024L) / ccg->block_size; /* cam.c:384 */
size_mb = ccg->volume_size / size_mb; /* cam.c:389 u64βu32 WRAP #1 */
if (size_mb > 1024 && extended) { heads=255; spt=63; } else { heads=64; spt=32; }
secs_per_cylinder = ccg->heads * ccg->secs_per_track;
ccg->cylinders = ccg->volume_size / secs_per_cylinder; /* cam.c:398 u64βu32 WRAP #2 */
Both quotients wrap mod 2^32 when volume_size β₯ 2^32 Γ secs_per_cylinder
(β31 PB with 512B sectors in extended mode; β4 PiB in 64/32 mode). Wrap #1
silently flips the extended-translation decision; wrap #2 returns an
arbitrary modular cylinder count, including exactly 0.
Reachability (attacker-controlled input)
volume_size comes from the device, not from anywhere local:
- scsi_da.c:1849-1851:
maxsector = scsi_8btou64(rcaplong->addr)β READ CAPACITY(16) answer, 64 bits, fully chosen by the target; - scsi_da.c:2287
dasetgeom()β scsi_da.c:2303ccg->volume_size = dp->sectors; - xpt_action routes XPT_CALC_GEOMETRY to the SIM, and every SIM in the tree calls this same function: ahci_cam.c:981, sili_cam.c:936, virtio_scsi.c:802, umass.c:2482, atapi-cam.c:470, mpr/mps/amr/ciss/... .
A malicious USB gadget, iSCSI/NBD target, or virtio-scsi backend answers
RC(10) with 0xFFFFFFFF then RC(16) with e.g. 0xFFFFFFFFFFFFFFFF. No sanity
check exists on maxsector before dasetgeom.
What was run (evidence class: KLD harness on the production function)
calcgeo.c builds df5554.ko against the running kernel (kmod.mk) and
calls the production cam_calc_geometry() symbol with crafted
volume_size values, printing kernel-vs-true-64-bit math. No kernel source
is copied into the harness.
Baseline (stock kernel #0, Jul 2 2026) β run.log
| input volume_size | kernel result | true 64-bit result |
|---|---|---|
| 15,628,000,000 (8TB disk) | H=255 S=63 C=972798 β ok | same (fidelity check) |
| 2^43 (4PiB @512B) | H=64 S=32 C=0 | H=255 S=63 C=547531467 |
| 2^32Β·16065 | C=0 | C=2^32 |
| 2^32Β·16065+16064 | C=0 | C=2^32 |
| 0xFFFF...FFFF | C=1500097198 | C=1148256711715502 |
| block_size=0 | CAM_REQ_CMP_ERR (div-guard OK) | β |
Both wraps demonstrated: the 2^43 case flips geometry to 64/32 and returns cylinders=0 for a >1MB volume (dasetgeom's error path explicitly exists to avoid cylinders-based divide-by-zero scenarios β the success path can now produce the same degenerate values).
Fix validation (kernel #1, fix.diff applied in-guest) β run.patched.log
fix.diff (in pack): compute size_mb/secs_per_cylinder in uint64_t,
clamp cylinders to UINT32_MAX when the true quotient cannot fit.
- 2^43 case β
H=255 S=63 C=547531467β exactly the true value (the flip AND the zero-wrap are gone; 2^43/16065 fits u32); - 2^32Β·16065 (+16064) and max-u64 β
C=4294967295(deliberate saturation, never a silent modular wrap); - sane-8TB / zero-volume / block_size=0 guards byte-identical to baseline.
Baseline bad behaviors GONE on the patched kernel. Build:
make nativekernel KERNCONF=X86_64_GENERIC (fix_build.log, rc=0; 56 cc
lines for cam.c in log), installkernel, reboot into #1, same harness
re-run.
Why impact stays Low
The geometry is advisory metadata: it lands in
disk_info.d_ncylinders/d_nheads/d_secpertrack/d_secpercyl
(scsi_da.c:1866-1870, 2153-2157) and the dmesg announce line
(scsi_da.c:1857-1861). In-kernel consumers never divide by these fields
unguarded (subr_diskmbr.c:268/352 guard secpercyl != 0; subr_disklabel32
recomputes from 64-bit slice sizes; capacity logic uses d_media_blocks).
No OOB, no leak, no panic β wrong numbers only, for crafted-capacity
devices. Not a memory-safety bug; filed for correctness and because the
same crafted-capacity path feeds genuinely dangerous consumers elsewhere.
Attempts, environment
3 attempts total: (1) cam.h needs a stub opt_cam.h for standalone KLD
builds β fixed in build.sh; (2) baseline run captured, guest reset
with-src; (3) fix build first failed buildkernel (no buildworld in
snapshot) β nativekernel succeeded. Guest reset with-src after the
patched run. Guest uname lines in env.txt.
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
fixedfix.diff applied to guest /usr/src, X86_64_GENERIC built with make nativekernel (fix_build.log: '>>> Kernel build for X86_64_GENERIC completed', rc=0), installed, rebooted into kernel #1 (13:21:14 UTC). Exact same harness re-run: the 2^43-sector case now returns the exact true geometry (H=255 S=63 C=547531467, 'ok (matches 64-bit math)') instead of H=64 S=32 C=0; quotients that cannot fit u32 saturate at C=4294967295 instead of wrapping to 0/arbitrary values; sane-disk, zero-volume and block_size=0 guard outputs are byte-identical to baseline. Both baseline mis-behaviors (geometry flip, cylinder wrap) are GONE.
run.patched.log vs run.log (decisive lines: 'ker: H=255 S=63 C=547531467 ... ok' vs baseline 'ker: H=64 S=32 C=0'); fix_build.log; fix.diff; VERDICT.md 'Fix validation' section
Confirmed kernel references
- sys/bus/cam/cam.c:375-400
- sys/bus/cam/cam.c:378
- sys/bus/cam/cam.c:389
- sys/bus/cam/cam.c:398
- sys/bus/cam/cam_ccb.h:776-783
- sys/bus/cam/scsi/scsi_da.c:1849-1853
- sys/bus/cam/scsi/scsi_da.c:2284-2322
- sys/dev/disk/nata/atapi-cam.c:470
- sys/dev/virtual/virtio/scsi/virtio_scsi.c:801-802
- sys/bus/u4b/storage/umass.c:2480-2482
Detail
Evidence (decisive lines)
run.log (baseline, stock kernel #0): 'DF5554[2^43 sectors...] ker: H=64 S=32 C=0' vs 'tru: H=255 S=63 C=547531467'; 'DF5554[max-u64...] ker: C=1500097198' vs true 1148256711715502; 'DF5554[2^32*16065...] ker: C=0'. run.patched.log (kernel #1 with fix.diff): 2^43 case 'ker: H=255 S=63 C=547531467 ... ok (matches 64-bit math)'; wrap cases saturate at C=4294967295 instead of wrapping. fix_build.log: '>>> Kernel build for X86_64_GENERIC completed'. VERDICT.md has the full narrative and the reachability chain.
PoC changes
harness authored fresh (calcgeo.c KLD; no seed existed). One iteration: cam.h needs a stub opt_cam.h for standalone kmod.mk builds (-- fixed in build.sh). Baseline dmesg captured before the with-src reset; fix leg used make nativekernel (buildkernel refused: no buildworld in snapshot).
Verified recommended fix
Do the geometry math in uint64_t and saturate cylinders to UINT32_MAX instead of silently wrapping (see fix.diff); longer term follow FreeBSD and retire XPT_CALC_GEOMETRY.
Verdict
cam_calc_geometry() (sys/bus/cam/cam.c:375-400) keeps all arithmetic in uint32_t locals while ccg->volume_size is u_int64_t (cam_ccb.h:779): size_mb = volume_size / size_mb (cam.c:389) and cylinders = volume_size / secs_per_cylinder (cam.c:398) both wrap mod 2^32 for volumes >= 2^32 cylinders-worth of sectors. volume_size is filled straight from device-reported READ CAPACITY(16) (scsi_da.c:1851 scsi_8btou64 -> dasetgeom scsi_da.c:2303), so any crafted SCSI target (USB gadget, iSCSI/NBD export, virtio-scsi backend) controls it fully. KLD harness on the PRODUCTION kernel function shows: 2^43 sectors -> H=64 S=32 C=0 (extended-translation decision flips AND cylinders wraps to 0; truth H=255 S=63 C=547531467), 2^32*16065 -> C=0, max-u64 -> C=1500097198 (arbitrary modular value). Fixed kernel (fix.diff: 64-bit math + UINT32_MAX saturation) returns the exact true geometry for the 2^43 case and saturates unrepresentable quotients; guards unchanged. Logic bug only: the geometry is advisory metadata (disk_info.d_ncylinders, dmesg announce), in-kernel consumers guard secpercyl!=0 and size from 64-bit d_media_blocks -- no OOB, no leak, no panic. Low severity, filed for correctness and because the same crafted-capacity path feeds sharper consumers elsewhere.
No comments yet.