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

Integer divide-by-zero in bulkfree_pass progress computation for total_size<10000 (crafted image volu_size=0)

Summary

hammer2_bulkfree.c:727 incr=bfi->sstop/(hmp->total_size/10000). NO guard total_size>=10000. hmp->total_size from volume header (hammer2_vfsops.c:1229-1232 voldata.volu_size for v1). volu_size=0 passes alignment check (0&MASK==0 hammer2_ondisk.c:302-358). total_size=0: scan loop :593 skipped falls through to :725 then :727 0/10000=0 sstop/0 = divide-by-zero trap panic. BULKFREE_SCAN ioctl missing privilege gate (DF-0815). Trigger: crafted HAMMER2 image volu_size=0 forged CRC mount then BULKFREE_SCAN ioctl. Fix: if(total_size<10000) incr=10000 else incr=sstop/(total_size/10000).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0818 Β· 17 files
FileTypeDescriptionSize
poc.c trigger-source Unprivileged HAMMER2IOC_BULKFREE_SCAN ioctl exerciser; proves the unprivileged path is reachable but does not panic for any mountable image. 1.7 KB view raw
craft_zero.py trigger-source Forges a CRC-valid HAMMER2 image with volu_size=0 (or any value) to demonstrate the upstream rejection at hammer2_ondisk.c:292. 4.5 KB view raw
build.sh build-script cc -o poc poc.c -I/usr/src/sys 167 B view raw
run.sh run-script ./poc /mnt/h2/trigger as the unprivileged maxx user 442 B view raw
fix.diff suggested-fix Defense-in-depth zero-guard at hammer2_bulkfree.c:727 (supersedes finding proposal, same shape with comment). 798 B view raw
VERDICT.md verdict Full narrative with line-by-line source trace. 5.9 KB ↓ raw
README.md readme Folder overview and reproduce instructions. 2.6 KB ↓ raw
build.log build-log Final cc build output as maxx. 104 B view raw
run.log run-log Baseline PoC run on unpatched 6.5-DEVELOPMENT #0; rc=0, sstop=134217728, no panic. 931 B view raw
run_patched.log run-log PoC run on patched #1 kernel; identical behavior (fix is a no-op for valid sizes). 926 B view raw
mount_reject.log dmesg Demonstrates volu_size=0 image is rejected at hammer2_ondisk.c:292 ('has size of 0'). 872 B view raw
fix_build.log build-log Full untrimmed patched-kernel nativekernel build log (NK_DONE rc=0). 5.6 MB ↓ download
incidental_panic.txt panic-signature UNRELATED panic (sysref assertion in devfs teardown) hit while unmounting an invalid HAMMER2 image -- NOT DF-0818. 629 B view raw
env.txt environment uname, cc version, sysctls. 322 B view raw
manifest.json manifest This file. 3.8 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 Folder overview and reproduce instructions.
↓ download raw

DF-0818 β€” HAMMER2 bulkfree div0 (latent) β€” reproduction pack

TL;DR

  • Status: NOT REPRODUCED (latent / defense-in-depth).
  • The unguarded divide at hammer2_bulkfree.c:727 is real source-level, but unreachable from any current mount path because the upstream guard at hammer2_ondisk.c:292 rejects volu_size == 0, and any non-zero volu_size < 8 MiB fails alignment. The smallest valid total_size is 8 MiB, making the divisor β‰₯ 838. No #DE trap can fire.
  • A defense-in-depth fix.diff is included and was build-validated against the audit kernel (no functional regression).

Files

File Purpose
poc.c Unprivileged HAMMER2IOC_BULKFREE_SCAN ioctl exerciser (proves path is reachable for valid images; does not panic).
craft_zero.py Forges a CRC-valid HAMMER2 image with volu_size = 0 (or any value) to demonstrate upstream rejection.
fix.diff Defense-in-depth zero-guard at hammer2_bulkfree.c:727.
build.sh cc -o poc poc.c -I/usr/src/sys.
run.sh Runs ./poc /mnt/h2/trigger as the unprivileged user.
VERDICT.md Full narrative and source-level trace.
build.log Final cc output.
run.log PoC output on unpatched baseline kernel.
run_patched.log PoC output on patched #1 kernel.
fix_build.log Full untrimmed patched-kernel build log.
mount_reject.log dmesg excerpt showing volu_size=0 is rejected at line 292.
env.txt Guest uname, cc version.
manifest.json Machine-readable artifact catalog.

Reproduce (unprivileged)

./build.sh
# admin precondition: root creates, formats, mounts and chowns a HAMMER2 PFS
ssh dfbsd '/bin/sh -c "vnconfig -c vn0 /root/h2.img && \
                       mount_hammer2 /dev/vn0@DATA /mnt/h2 && \
                       chown -R maxx:maxx /mnt/h2"'
./run.sh             # exits 0; bulkfree completes; no panic on any valid image

Crafting the volu_size=0 image (to demo upstream rejection)

python3 craft_zero.py /tmp/h2.img /tmp/h2_zero.img 0
scp /tmp/h2_zero.img dfbsd:/root/
ssh dfbsd 'vnconfig -c vn0 /root/h2_zero.img && mount_hammer2 /dev/vn0@DATA /mnt/h2'
# expect: mount: Invalid argument
# dmesg shows: hammer2_ondisk: /dev/vn0 has size of 0

Expected behavior on the FIXED kernel

Identical to baseline: BULKFREE_SCAN rc=0, sstop=<total_size>, no panic. The fix only changes behavior in the unreachable total_size < 10000 case, clamping incr = 10000 instead of dividing by zero.

VERDICT.md verdict Full narrative with line-by-line source trace.
↓ download raw

DF-0818 β€” Integer divide-by-zero in HAMMER2 bulkfree_pass progress (latent)

Verdict

NOT REPRODUCED (latent / defense-in-depth). The unguarded divide at sys/vfs/hammer2/hammer2_bulkfree.c:727 is a real source-level foot-gun, but the claimed trigger path (volu_size = 0 from a crafted image) is closed by an upstream guard. The bug is unreachable through any current mount path.

What the finding claimed

hammer2_bulkfree.c:727 computes incr = bfi->sstop / (hmp->total_size / 10000) with no guard against total_size < 10000. If total_size = 0, the integer divisor is 0 β†’ #DE trap β†’ kernel panic. The finding asserts that a crafted HAMMER2 image with volu_size = 0 can pass alignment checks and reach the bulkfree path via the (unprivileged) HAMMER2IOC_BULKFREE_SCAN ioctl.

What the source actually does β€” full trace

hmp->total_size is only ever assigned at three places: - sys/vfs/hammer2/hammer2_vfsops.c:1229 β€” hmp->total_size = hmp->voldata.total_size; (v2 multi-volume) - sys/vfs/hammer2/hammer2_vfsops.c:1232 β€” hmp->total_size = hmp->voldata.volu_size; (v1 single-volume) - sys/vfs/hammer2/hammer2_ioctl.c:1394 β€” hmp->total_size += delta; (growfs; only increases)

So at the moment hammer2_bulkfree_pass could be entered, hmp->total_size equals the on-disk volu_size (or its sum across volumes, plus any growfs delta β€” strictly non-decreasing). The reachability question reduces to: what values may voldata.volu_size legally take after a successful mount?

voldata.volu_size is read from the volume header in hammer2_read_volume_header (sys/vfs/hammer2/hammer2_ondisk.c:485) β€” only after the CRC triplet (sect0, sect1, volheader) and magic have validated. Then vol->size = voldata->volu_size at hammer2_ondisk.c:684. After all volumes are loaded, hammer2_verify_volumes calls hammer2_verify_volumes_common (the gate that runs for both v1 and v2):

/* sys/vfs/hammer2/hammer2_ondisk.c:292 */
if (vol->size == 0) {
    hprintf("%s has size of 0\n", path);
    return EINVAL;
}

This rejects volu_size == 0 (since vol->size == volu_size). The finding's claim that "volu_size=0 passes alignment check (0&MASK==0)" is true for the alignment check at line 352 in isolation but is moot: the earlier vol->size == 0 check at line 292 fires first.

The version-specific verifiers additionally require: - v1 (hammer2_verify_volumes_1, hammer2_ondisk.c:352): vol->size & HAMMER2_VOLUME_ALIGNMASK64 must be 0. The smallest non-zero aligned value is HAMMER2_VOLUME_ALIGN = 8 MiB = 0x800000 = 8,388,608 (hammer2_disk.h:261). - v2 (hammer2_verify_volumes_2, hammer2_ondisk.c:441 & :453): non-last volumes must be β‰₯ HAMMER2_FREEMAP_LEVEL1_SIZE (1 GiB), last volume must be aligned to 8 MiB.

So the smallest mountable total_size on any version is 8 MiB, which makes the line-727 divisor 8388608 / 10000 = 838 (non-zero). And total_size == 0 is rejected at line 292. No value of total_size < 10000 can survive mount validation in the current code.

Experimental confirmation

craft_zero.py produces a CRC-valid HAMMER2 image with volu_size = 0 (starting from a real newfs_hammer2 image, then recomputing the sect0/sect1/ volheader CRC32-C values). Mount attempts against it produce, in dmesg:

hammer2_ondisk: "/dev/vn0" zone=0 id=0 offset=0x0000000000000000 size=0x0000000000000000
hammer2_ondisk: /dev/vn0 has size of 0                <-- line 292 rejection

For a volu_size = 8192 (sub-8MiB, sub-10000) image, mount fails the v2 total_size != sum of volumes and/or the alignment check; never reaches hammer2_bulkfree_pass.

The unprivileged ioctl path itself is reachable (this is the separate issue tracked as DF-0815 β€” HAMMER2IOC_BULKFREE_{SCAN,ASYNC} are deliberately exempt from the caps_priv_check gate at hammer2_ioctl.c:83, see hammer2_ioctl.c:144-149). The PoC poc.c, run as maxx against a chowned-to-user HAMMER2 mount, successfully invokes the ioctl and the bulkfree code runs through line 727 β€” for a valid 128 MiB image the divisor is 13421, no panic, incr=10000, exit 0:

BULKFREE_SCAN rc=0 errno=0 (Undefined error: 0)
  sstop=134217728 ...

So the path is real; the trigger condition (total_size < 10000) is not.

Why this is still worth a fix.diff (defense-in-depth)

The divisor at line 727 lacks an explicit zero-guard. Today it is protected by upstream volume validation, but that is an implicit invariant β€” easily broken by any future change that loosens the size validation, by a future caller that bypasses mount, or by the (currently #undef'd) in-kernel remaster path at hammer2_bulkfree.c:504. A one-line guard converts a latent

DE panic into safe behavior. The fix follows the finding's proposed shape:

if (hmp->total_size < 10000)
    incr = 10000;
else
    incr = bfi->sstop / (hmp->total_size / 10000);

Exploit chain

none β€” not memory corruption, latent div0 unreachable from any current user-controlled input.

PoC changes

findings/poc/DF-0818/ did not exist when this run began. Authored: - poc.c β€” unprivileged HAMMER2IOC_BULKFREE_SCAN ioctl exerciser (proves the path is reachable, but does not panic for any mountable image). - craft_zero.py β€” forges a CRC-valid HAMMER2 image with volu_size = 0 (or any chosen value) to demonstrate the upstream rejection at hammer2_ondisk.c:292. - fix.diff β€” defense-in-depth zero-guard (supersedes the finding's proposal; same shape, with explanatory comment).

Fix validation

Built and booted a single-fix kernel (6.5-DEVELOPMENT #1) carrying the zero-guard. The PoC against a valid 128 MiB HAMMER2 image behaves identically on the unpatched baseline and the patched kernel (rc=0, sstop=134217728, bulkfree completes "100.00% storage processed", no panic). This is expected because the bug was never reachable in the first place; the patch is defense-in-depth only.

Fix verification

fixed
baseline no→ patch + rebuild →patched clean

Defense-in-depth BUILD-VALIDATED (bug unreachable): fix.diff applies+compiles+boots. Bulkfree runs identically (rc=0, 100.00%, no panic). No behavioral before/after.

Both #0 and #1: rc=0, 100.00% processed, no panic. fix.diff: patch Hunk #1 succeeded at 724. NK_DONE rc=0.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Mon Jul 13 23:40:50 UTC 2026 (sha256 2fbe02a4...)

Confirmed kernel references

Detail

Exploit chain

none -- latent div0 unreachable from any current user-controlled input. No memory corruption.

Evidence (decisive lines)

BULKFREE_SCAN as maxx on 128MiB image: rc=0, sstop=134217728, 100.00% processed, no panic. Crafted volu_size=0 image: mount rejected EINVAL 'size of 0' at hammer2_ondisk.c:292.

PoC changes

Authored from scratch: poc.c (unprivileged HAMMER2IOC_BULKFREE_SCAN exerciser), craft_zero.py (CRC-valid volu_size=0 image forger), fix.diff (defense-in-depth zero-guard at :727), build.sh, run.sh, VERDICT.md, manifest.json.

Verified recommended fix

Defense-in-depth zero-guard at hammer2_bulkfree.c:727: if(total_size<10000) incr=10000; else incr=... Latent foot-gun if mount validation relaxed. Full git-apply-able diff in findings/poc/DF-0818/fix.diff.

Verdict

FALSE POSITIVE on claimed trigger. Unguarded divide at hammer2_bulkfree.c:727 IS real, but finding's path (volu_size=0) is unreachable: hammer2_ondisk.c:292 rejects vol->size==0 before total_size is set. volu_size>=8MiB enforced by alignment at :352/:453, making divisor >=838 for every mountable image. Unprivileged ioctl path reachable (DF-0815 bypass) but total_size always >10000 on any mountable image.