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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5554 Β· 12 files
FileTypeDescriptionSize
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.

VERDICT.md
↓ download raw

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:2303 ccg->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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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
↓ fix.diffper-fix-DF-5554

Confirmed kernel references

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.