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)
PoC verification
Evidence pack
findings/poc/DF-0818 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
DF-0818 β HAMMER2 bulkfree div0 (latent) β reproduction pack
TL;DR
- Status: NOT REPRODUCED (latent / defense-in-depth).
- The unguarded divide at
hammer2_bulkfree.c:727is real source-level, but unreachable from any current mount path because the upstream guard athammer2_ondisk.c:292rejectsvolu_size == 0, and any non-zerovolu_size < 8 MiBfails alignment. The smallest validtotal_sizeis 8 MiB, making the divisor β₯ 838. No #DE trap can fire. - A defense-in-depth
fix.diffis 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.
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
fixedDefense-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.
Confirmed kernel references
- sys/vfs/hammer2/hammer2_bulkfree.c:727
- sys/vfs/hammer2/hammer2_bulkfree.c:593
- sys/vfs/hammer2/hammer2_ondisk.c:292
- sys/vfs/hammer2/hammer2_ondisk.c:352
- sys/vfs/hammer2/hammer2_ondisk.c:684
- sys/vfs/hammer2/hammer2_vfsops.c:1229
- sys/vfs/hammer2/hammer2_vfsops.c:1232
- sys/vfs/hammer2/hammer2_ioctl.c:144
- sys/vfs/hammer2/hammer2_ioctl.c:83
- sys/vfs/hammer2/hammer2_disk.h:261
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.
No comments yet.