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)
PoC verification
Evidence pack
findings/poc/DF-3168 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 forgecount=128on its conforming non-first leaf children (slice-0/1 leaves, in-buffer elms[127] scan-read), re-CRC the nodes;verifymode reads the counts back after a runtrigger.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 runpanic.txtβ decisive excerpt (add count 128 ... _kfree_obj panic)fixrun.logβ full fixed-kernel runbuild.logβ guest build of patch_leaf_rebalfix.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)
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-elementelms[]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) boundsrebal->saturationto [31,62]; it does NOT boundavg_elmswhen the forged counts already exceed saturation.- pack loop
for (i = 0; i < item->count; ++i)(:371) withelm = &base_item->copy->elms[base_count]; *elm = node->ondisk->elms[i];(:406-407) βbase_countruns up toavg_elms-1β 64-byte-granule writes past the end of the 4KB copy (heap OOB WRITE), andnode->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()storescopy->count = base_count> 63 (:541-543) whichhammer_btree_sync_copy()(:2519-2537) writes back to the on-disk node β persistent filesystem corruption; every laterbtree_searchinto 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) thenelm = &cursor.node->ondisk->elms[cursor.index].leaf(:194) β OOB read whose 40 bytes are returned to the ioctl caller inrebal->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)
mkimage.shβ 12G vn image, ~2500 dirents β B-Tree: root0x2103f000, mid-level internals, 156 leaves (count=32 each).patch_leaf_rebal walkβ selects parent0x21001000(first child at the root) and its 14 conforming non-first leaf children (slice 0/1 of their 16K buffers, in-bufferelms[127]).patch_leaf_rebal patchβ forgescount=128on those 14 leaves, re-CRCs each node. Expected runtime math: 31 children, total = 1732 + 14128 = 2336 βavg_elms = ceil(2336/31) = 76> 63.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 - 63granules; 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 cleanupon 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):
- scan loop (:146ff): validate
cursor.node->ondisk->countand type before the count is consumed (:174/:194) β EIO aborts the scan. rebalance_nodeaccumulation (:355ff): validate eachitem->countbeforecount += 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
- sys/vfs/hammer/hammer_rebalance.c:75-78 (saturation clamp β bounds saturation, not avg_elms)
- sys/vfs/hammer/hammer_rebalance.c:174,194-195 (scan consumes count)
- sys/vfs/hammer/hammer_rebalance.c:326-334 (unvalidated accumulation + int overflow)
- sys/vfs/hammer/hammer_rebalance.c:371,406-407 (copy loop: OOB read source + OOB write dest)
- sys/vfs/hammer/hammer_rebalance.c:541-543 (count > 63 synced to disk)
- sys/vfs/hammer/hammer_btree.c:2473 (item->count = child->ondisk->count)
- sys/vfs/hammer/hammer_ondisk.c:1313-1325 (load validates CRC only)
- sys/vfs/hammer/hammer_btree.h:225 (count: int32, max by convention)
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 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
Confirmed kernel references
- sys/vfs/hammer/hammer_rebalance.c:326-334
- sys/vfs/hammer/hammer_rebalance.c:371
- sys/vfs/hammer/hammer_rebalance.c:406-407
- sys/vfs/hammer/hammer_rebalance.c:174
- sys/vfs/hammer/hammer_rebalance.c:194-195
- sys/vfs/hammer/hammer_rebalance.c:541-543
- sys/vfs/hammer/hammer_btree.c:2473
- sys/vfs/hammer/hammer_ondisk.c:1313-1325
- sys/vfs/hammer/hammer_btree.h:225
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.
No comments yet.