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

rebalance_node trusts unvalidated on-disk B-Tree node counts: avg_elms pack loop writes past the 63-element elms[] array of the kmalloc'd 4096-byte node copies β€” attacker-controlled heap overflow (observed slab-metadata smash), OOB reads, and count>63 persisted to disk

Summary

hammer_btree_lock_children stores each child's raw on-disk count into item->count (hammer_btree.c:2473) with no bounds validation (node load checks CRC only, hammer_ondisk.c:1313-1325; count is int32 'maximum 62 INTERNAL / 63 LEAF' by convention only). rebalance_node then sums these unvalidated int32s into an int (:326-332, signed overflow possible), computes avg_elms=howmany(count, lockroot.count) (:333) - which the saturation clamp (:75-78) does NOT bound - and runs the pack loop for i < item->count (:371) writing *(&base_item->copy->elms[base_count]) = node->ondisk->elms[i] (:406-407) with base_count up to avg_elms-1, past the 63-element array of the 4096-byte kmalloc'd copy. The scan loop additionally consumes the count at :174/:194 (elms[count-1] OOB read returned in rebal->key_cur), and rebalance_closeout persists copy->count=base_count > 63 to disk (:541-543), so every later btree_search into the leaf OOB-reads. This is the DF-0776 family at THIS file's sinks. Threat: crafted HAMMER1 image (CRCs recomputed - CRC is not a MAC) + admin mounts + 'hammer rebalance'/'hammer cleanup' (standard maintenance of attacker-supplied volumes). avg_elms=76 drives 13x64-byte granule writes past each of ~30 kmalloc'd copies - attacker-chosen overflow length, content attacker-controlled for the first ~12KB. VERIFIED stock INVARIANTS: 'rebalance_node: lockroot count 31' + 17x'add count 32' + 14x'add count 128' + 31 closeouts, then 'panic: assertion slab->magic == KMALLOC_SLAB_MAGIC failed in _kfree_obj' via fdrop/closef/kern_close - the OOB write smashed adjacent slab metadata (run ~7 min, guest to DDB). Production kernels: silent exploitable heap corruption + count=76 (>63) leaves persisted, corrupting every subsequent search. No unpriv->root for this sink alone (SYSCAP_NOVFS_IOCTL gate) - ceiling matches DF-0776/DF-3167 precedent. Fix validated in-guest (hammer_node_max_elements bounds checks at both consumption sites, kernel #1): 'rebalance: bad node ... count 128', EIO, 0 nodes rebalanced, image readback shows only the 14 self-forged leaves, guest healthy.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3168 Β· 17 files
FileTypeDescriptionSize
mkimage.sh β€” 619 B view raw
patch_leaf_rebal.c β€” 11.6 KB view raw
trigger.sh β€” 1.2 KB view raw
trigger_fix.sh β€” 847 B view raw
console_full.log β€” 27.5 KB view raw
panic.txt β€” 3.1 KB view raw
run.log β€” 1.2 KB view raw
fixrun.log β€” 1.9 KB view raw
build.log β€” 378 B view raw
fix.diff β€” 2.1 KB view raw
code_hashes.txt β€” 314 B view raw
README.md β€” 2.9 KB ↓ raw
VERDICT.md β€” 7.9 KB ↓ raw
build.sh β€” 111 B view raw
run.sh β€” 202 B view raw
manifest.json β€” 1.5 KB view raw
verdict.json β€” 7.2 KB view raw

DF-3168 β€” hammer rebalance count-trust heap overflow

Crafted-image HAMMER1 B-Tree leaf with count > 63 (CRC recomputed by the image author) β†’ hammer rebalance (the HAMMER_IOC_REBALANCE ioctl behind hammer cleanup / hammer rebalance) packs elements using the unvalidated on-disk count, driving avg_elms past the 63-element elms[] array of the kmalloc'd 4KB node copies β†’ out-of-bounds kernel heap write.

Contents

  • mkimage.sh β€” stage 1: build a legitimate 12G HAMMER1 image with a multi-leaf B-Tree (~2500 dirents)
  • patch_leaf_rebal.c β€” stage 2: walk the image B-Tree, select a suitable parent (first child-index at the root) and forge count=128 on its conforming non-first leaf children (slice-0/1 leaves, in-buffer elms[127] scan-read), re-CRC the nodes; verify mode reads the counts back after a run
  • trigger.sh β€” stage 3: mount rw, hammer rebalance, umount, verify, remount + search (panic expected on stock kernel)
  • trigger_fix.sh β€” fix-validation variant (stops after verify)
  • console_full.log β€” FULL serial console of the baseline run
  • panic.txt β€” decisive excerpt (add count 128 ... _kfree_obj panic)
  • fixrun.log β€” full fixed-kernel run
  • build.log β€” guest build of patch_leaf_rebal
  • fix.diff β€” the validated fix (git-apply-able vs sys/)

Build (in guest)

cc -O2 -o /tmp/patch_leaf_rebal patch_leaf_rebal.c

Run (in guest, as root)

sh mkimage.sh
/tmp/patch_leaf_rebal walk   /build/poc.img   # pick targets
/tmp/patch_leaf_rebal patch  /build/poc.img   # forge count=128 + re-CRC
sh trigger.sh                                  # baseline: panic

Expected

Stock INVARIANTS kernel (baseline, console_full.log):

rebalance_node: lockroot ... count 31
rebalance_node: add count 128        (x14 -- unvalidated disk counts)
rebalance_closeout: 8000000021000000: (count update) ... (x31)
panic: assertion "slab->magic == KMALLOC_SLAB_MAGIC && delta != slab->ncount"
       failed in _kfree_obj at kern/kern_kmalloc.c:942
_kfree_obj -> fdrop -> closef -> kern_close -> syscall2

The rebalance pack loop wrote 64-byte granules past the end of the 4096-byte kmalloc'd copy buffers (base_count ran to avg_elms=76 > 63), corrupting adjacent kernel heap / slab metadata; an unrelated close() syscall detected it. On production kernels (no INVARIANTS slab-magic check) the overflow is silent β†’ attacker-influenced heap corruption (see VERDICT.md for the primitive characterization).

Fixed kernel (fixrun.log):

hammer_ioc_rebalance: rebalance: bad node 8000000021004000 type L count 128
Rebalance /mnt failed: Input/output error
VERIFY: 14 out-of-bounds counts found     (only the leaves we forged)
VERDICT.md
↓ download raw

DF-3168 β€” VERDICT

Status: reproduced (stock INVARIANTS kernel #0 Jul 2 06:02:54) β€” fix validated (patched kernel #1 Sep 7 13:18:26).

The bug

hammer_ioc_rebalance() (sys/vfs/hammer/hammer_rebalance.c:53) is the backend of hammer rebalance / hammer cleanup (HAMMERIOC_REBALANCE, gated by caps_priv_check(SYSCAP_NOVFS_IOCTL) at hammer_ioctl.c:72). For every internal node visited on the way up, rebalance_node() (:266) locks the node's children (hammer_btree_lock_children, hammer_btree.c:2401) which stores each child's on-disk count straight into item->count (hammer_btree.c:2473) β€” node load validates only the CRC (hammer_load_node, hammer_ondisk.c:1313-1325); the count is an unvalidated int32_t (hammer_btree.h:225: "maximum 62 for INTERNAL, 63 for LEAF" β€” convention only).

rebalance_node then consumes it with no bounds check:

  • count += item->count (:330) β€” signed-int accumulation of unvalidated int32s (sum can wrap negative; KKASSERT(avg_elms >= 0) :334 is INVARIANTS-only).
  • avg_elms = howmany(count, lockroot.count) (:333) β€” avg can exceed the 63-element elms[] array of the 4096-byte kmalloc'd node copies (hammer_btree_lock_copy, hammer_btree.c:2505). The saturation clamp in the ioctl (:75-78) bounds rebal->saturation to [31,62]; it does NOT bound avg_elms when the forged counts already exceed saturation.
  • pack loop for (i = 0; i < item->count; ++i) (:371) with elm = &base_item->copy->elms[base_count]; *elm = node->ondisk->elms[i]; (:406-407) β€” base_count runs up to avg_elms-1 β†’ 64-byte-granule writes past the end of the 4KB copy (heap OOB WRITE), and node->ondisk->elms[i] for i β‰₯ 63 reads past the node's 4KB slice (OOB READ, in-buffer up to +16KB, then adjacent kernel heap).
  • rebalance_closeout() stores copy->count = base_count > 63 (:541-543) which hammer_btree_sync_copy() (:2519-2537) writes back to the on-disk node β€” persistent filesystem corruption; every later btree_search into that leaf OOB-reads (KKASSERT at hammer_btree.c:1278 is INVARIANTS-only).
  • the scan loop also consumes the count: cursor.index = count - 1 (:174) then elm = &cursor.node->ondisk->elms[cursor.index].leaf (:194) β†’ OOB read whose 40 bytes are returned to the ioctl caller in rebal->key_cur (:195).

This is the DF-0776 family (missing on-disk count validation) at THIS file's sinks β€” DF-0776 covered the btree-core search/shift loops in hammer_btree.c; the rebalance pack loop is a distinct set of sinks that DF-0776's analysis did not cover and that needs its own validation (or a global load-time bounds check).

Threat model

Attacker crafts a HAMMER1 image (all CRCs recomputed — the CRC is not a MAC) and gets an administrator to mount it and run hammer rebalance / hammer cleanup on it (standard maintenance of attacker-supplied or attacker-corrupted volumes). The ioctl itself is privileged, so the unprivileged-user→root chain requires that admin interaction — same preconditions as DF-0776/DF-3167.

Reproduction (baseline, stock kernel)

  1. mkimage.sh β€” 12G vn image, ~2500 dirents β†’ B-Tree: root 0x2103f000, mid-level internals, 156 leaves (count=32 each).
  2. patch_leaf_rebal walk β€” selects parent 0x21001000 (first child at the root) and its 14 conforming non-first leaf children (slice 0/1 of their 16K buffers, in-buffer elms[127]).
  3. patch_leaf_rebal patch β€” forges count=128 on those 14 leaves, re-CRCs each node. Expected runtime math: 31 children, total = 1732 + 14128 = 2336 β†’ avg_elms = ceil(2336/31) = 76 > 63.
  4. trigger.sh β€” mount rw, hammer rebalance /mnt.

Observed (console_full.log / panic.txt):

rebalance_node: lockroot 0xfffff801166db308 count 31
rebalance_node: add count 32   (x17)
rebalance_node: add count 128  (x14)   <- unvalidated disk counts
rebalance_closeout: 8000000021000000: (count update) (parent bound update 1)
... (x31, includes the forged leaves 0x21004000 etc.)
panic: assertion "slab->magic == KMALLOC_SLAB_MAGIC && delta != slab->ncount"
       failed in _kfree_obj at /usr/src/sys/kern/kern_kmalloc.c:942
_kfree_obj() -> fdrop() -> closef() -> kern_close() -> syscall2()

The pack loop wrote elms[63..75] (13 granules x 64B = 832B) past the end of each of ~30 kmalloc'd 4KB node copies; the overflow smashed kmalloc slab metadata and was detected when an unrelated close() syscall freed a neighboring object. This is a direct, observed out-of-bounds kernel heap write, not a speculative one. The guest stayed in DDB (down).

Note: the panic interrupted the run before umount, so the >63 count sync-to-disk is evidenced by the (count update) closeout lines and by the fixed-kernel readback comparison below rather than by a baseline readback.

Primitive characterization (why this matters beyond the panic)

  • Length control: overflow extent = avg_elms - 63 granules; the image author chooses each child count (up to INT32_MAX; with 62 children the sum itself overflows int at :330), so the write length is attacker-chosen (bytes to megabytes).
  • Content control: the copied granules are node->ondisk->elms[i] β€” for i ≀ 62 these are the leaf's own on-image elements (fully attacker-controlled 64-byte blocks); for 63 ≀ i ≀ ~254 they are bytes of the same 16KB hammer buffer (on-image data of neighboring nodes or zeroed free space β€” still attacker-controlled at image-authoring time); beyond that, adjacent kernel heap (uncontrolled).
  • Placement: sequential forward overflow from a 4096-byte kmalloc from the hammer m_misc pool; on this INVARIANTS guest the smash was detected by the slab-magic check; on production kernels there is no such check (silent corruption of adjacent heap objects).
  • Gating: the sink requires a privileged ioctl caller (root runs hammer rebalance/hammer cleanup on the attacker's volume), so an unprivileged local user cannot reach it alone β€” the honest impact ceiling here is High (kernel heap corruption from a crafted volume during routine maintenance), matching DF-0776/DF-3167, not an unprivβ†’uid0 chain.

Fix validation

fix.diff adds two bounds checks using the existing hammer_node_max_elements() helper (hammer_btree.h:283):

  1. scan loop (:146ff): validate cursor.node->ondisk->count and type before the count is consumed (:174/:194) β€” EIO aborts the scan.
  2. rebalance_node accumulation (:355ff): validate each item->count before count += item->count β€” EIO, no packing.

Kernel #1 (Sep 7 13:18:26) built in-guest (make nativekernel KERNCONF=X86_64_GENERIC), same crafted image, same patch:

hammer_ioc_rebalance: rebalance: bad node 8000000021004000 type L count 128
Rebalance /mnt failed: Input/output error
Rebalance: 0 B-Tree nodes scanned, 0 rebalanced
VERIFY: 14 out-of-bounds counts found   <- ONLY the leaves we forged

vs. baseline (31 leaves rewritten with count=76 + slab-smash panic). Bad behavior GONE; guest healthy; clean umount.

Kernel references

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 adds count/type bounds validation (hammer_node_max_elements) at both consumption sites in hammer_rebalance.c (scan loop :146ff, rebalance_node accumulation :355ff). Patched kernel refuses the forged node with EIO ('rebalance: bad node ... type L count 128'), rebalances 0 nodes, performs no packing; image readback shows only the 14 self-forged leaves (count=128) vs baseline's 31 leaves rewritten to count=76 plus the slab-corruption panic. Bad behavior GONE; guest healthy; clean umount.

findings/poc/DF-3168/fixrun.log
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Mon Sep 7 13:18:26 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

attacker crafts HAMMER1 image (recomputed CRCs; 14 non-first leaf children of one internal parent forged to count=128) -> admin mounts it and runs 'hammer rebalance'/'hammer cleanup' (privileged ioctl HAMMERIOC_REBALANCE) -> scan enters forged leaves via iterate-descent (no count assert on that path) -> rebalance_node avg_elms=ceil(2336/31)=76 > 63 -> base_item->copy->elms[63..75] written past the 4KB kmalloc (attacker-chosen length: avg_elms scales with forged counts up to int overflow of the count sum; content attacker-controlled for the first ~12KB from in-buffer sources) + copy->count=76 synced to disk. No unpriv->uid0 chain exists for this sink alone because the ioctl gate (caps_priv_check SYSCAP_NOVFS_IOCTL, hammer_ioctl.c:72) requires an administrator to run rebalance on the attacker's volume; impact ceiling is kernel heap corruption during routine maintenance of a malicious volume (same threat model as DF-0776/DF-3167).

Evidence (decisive lines)

["findings/poc/DF-3168/panic.txt β€” 'rebalance_node: add count 128' x14 then panic slab->magic assertion in _kfree_obj via fdrop/closef/kern_close", "findings/poc/DF-3168/console_full.log β€” full baseline serial console: lockroot count 31, 17x 'add count 32' + 14x 'add count 128', 31 closeouts '(count update)' incl. the forged leaves, EDEADLK retries, panic", "findings/poc/DF-3168/fixrun.log β€” fixed kernel #1 (Sep 7 13:18:26): 'rebalance: bad node 8000000021004000 type L count 128' -> EIO, 0 nodes rebalanced, verify finds ONLY the 14 self-forged leaves (baseline rewrote 31 to count=76)", 'findings/poc/DF-3168/fix.diff β€” validated two-site bounds check via hammer_node_max_elements()']

PoC changes

seed harness (none existed for this finding) built new from the DF-3167 mkimage/patcher pattern: image walker in patch_leaf_rebal.c discovers the tree itself, selects the parent with lowest index at the root (so the root's own later rebalance cannot orphan the corrupted leaves) and forges count=128 on all its conforming non-first leaf children (slice 0/1 of their 16K buffer so the :194 elms[127] scan read stays in-buffer; verifies the 40 key bytes decode to a key that does not end the scan); renames howmany->hm_div (DragonFly macro collision); debug sysctl vfs.hammer.debug_general=0x1000 used to capture the 'add count' accumulation lines.

Verified recommended fix

Bounds-validate every on-disk node count/type against hammer_node_max_elements() before consumption in hammer_rebalance.c (both the scan loop and rebalance_node's accumulation), returning EIO; the systemic complement is load-time validation in hammer_load_node()/hammer_get_node (DF-0776's locus).

Verdict

hammer_rebalance.c consumes the unvalidated on-disk B-Tree node count (item->count, set verbatim in hammer_btree_lock_children at hammer_btree.c:2473; node load checks CRC only) as both the element copy bound and, via count/avg_elms, the write index into the fixed 63-element elms[] array of the kmalloc'd 4096-byte node copies. A crafted image with leaf count=128 (CRCs recomputed) drove avg_elms to 76; the pack loop (hammer_rebalance.c:406-407) wrote 13x64-byte granules past the end of ~30 copies, smashing kmalloc slab metadata: panic 'assertion slab->magic == KMALLOC_SLAB_MAGIC && delta != slab->ncount failed in _kfree_obj' on an unrelated close() syscall, after 'add count 128' accumulation lines and 31 count-update closeouts on the serial console. rebalance_closeout also persisted count=76 (>63) to the on-disk leaves. Root-cause class shared with DF-0776 (missing count bounds validation) but at distinct, previously-uncovered sinks in the rebalance pack loop; verified on the INVARIANTS guest, silent heap corruption on production kernels.