DF-3168 / verdict.json
{ "finding_id": "DF-3168", "status": "reproduced", "reproduced": 1, "impact": "panic", "confidence": "certain", "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 kmallocd 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.", "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 attackers volume; impact ceiling is kernel heap corruption during routine maintenance of a malicious volume (same threat model as DF-0776/DF-3167).", "evidence": [ "findings/poc/DF-3168/panic.txt \u2014 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 \u2014 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 \u2014 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 \u2014 validated two-site bounds check via hammer_node_max_elements()" ], "kernel_refs": [ "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" ], "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 roots 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.", "attempts": 1, "guest_uname": "DragonFly dfbsd 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (baseline); fixed kernel #1: Mon Sep 7 13:18:26 UTC 2026", "runtime_sec": 420, "guest_dirty": 0, "build_cmd": "cc -O2 -Wall -o /tmp/patch_leaf_rebal patch_leaf_rebal.c (guest); fix kernel: cd /usr/src && patch -p1 < fix.diff && make -j6 nativekernel KERNCONF=X86_64_GENERIC", "run_cmd": "sh mkimage.sh && /tmp/patch_leaf_rebal walk /build/poc.img && /tmp/patch_leaf_rebal patch /build/poc.img && sh trigger.sh", "code_hash": "7ca902812da304203bec5680b4a1aa5b2e4b17675e394063c6e456c9435c9b49", "fix_status": "fixed", "fix_kernel_uname": "DragonFly 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", "fix_baseline_reproduced": 1, "fix_patched_reproduced": 0, "fix_verdict": "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 baselines 31 leaves rewritten to count=76 plus the slab-corruption panic. Bad behavior GONE; guest healthy; clean umount.", "fix_evidence": "findings/poc/DF-3168/fixrun.log", "notes": "Priority-1..6 sweep of the file: (1) leaf_array/count accumulation int overflow folded into this finding (62 children x huge counts wrap the int sum at :330; KKASSERT(avg_elms>=0) INVARIANTS-only). (2) elm copy bounds = this finding; node CRCs are checked at load (hammer_ondisk.c:1313) but are recomputable by the image author; no per-element leaf validation is needed for the pack loop itself. (3) rebuild/saturation clamp (:75-78) is correct for the ioctl argument - it bounds saturation, not avg_elms; maxcount arithmetic is sound for valid counts. (4) cursor fixups: the DF-3110 again2 oindex bug lives in hammer_cursor.c (not re-reported); in this file the tracked-cursor calls (moved_element :449/:463, removed_node :495, deleted_element :497) are no-ops while rebalance holds exclusive child locks; cursor->leaf OOB pointer stores via inflated nindex (hammer_cursor.c:1035) share this findings root cause but need a tracked cursor, which cannot exist during the exclusive locks (dead path); :501 cursor->index > vs >= analyzed correct (index==count is the valid boundary position). (5) free discipline: deletions move tracked cursors off the node before hammer_delete_node; exclusive child locks exclude mirror cursors (ex_try->EDEADLK retry) - sound. (6) ioctl gating: caps_priv_check(SYSCAP_NOVFS_IOCTL) at hammer_ioctl.c:72 + ronly + nbuf checks; localization/key range validation (:64-74) and saturation clamp sound. Minor: rebal->key_cur copyout (:195) leaks the OOB-read 40 bytes to the privileged caller only (folded into this finding). Empty-leaf count==0 -> elms[-1] at :174/:194 is unreachable (hammer_btree_iterate immediately cursor-ups out of an empty leaf before returning it).", "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_nodes accumulation), returning EIO; the systemic complement is load-time validation in hammer_load_node()/hammer_get_node (DF-0776s locus)." } |