btree_remove() unbounded recursion β kernel stack overflow from crafted deep B-trees with all-valid counts/types/CRCs (bypasses DF-0776 hardening)
| Field | Value |
|---|---|
| ID | DF-3011 |
| Status | new |
| Severity | High |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H |
| CWE | CWE-674 (kernel stack exhaustion β adjacent kernel memory corruption) |
| File | sys/vfs/hammer/hammer_btree.c |
| Lines | 1979, 2002, 2007 (depth unbounded at mount/search) |
| Area | vfs/hammer |
| Confidence | certain |
| Discovered | 2026-09-02 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
Deleting the last record of a leaf makes btree_remove() recurse once per single-element internal-node ancestor on the caller's kernel stack. HAMMER1 never bounds B-tree depth (btree_search pushdown and hammer_btree_get_parent are iterative and unchecked; nothing at mount validates depth). A crafted image containing a chain of N internal nodes, each count==1/type 'I' with a VALID CRC32C β every field in-range, so DF-0776's count/type validation passes β makes any physical record deletion recurse N frames deep. With N=200 on the 16KB ioctl-thread kstack the recursion runs off the stack bottom: DOUBLE FAULT, rsp exactly at the page-aligned kstack floor, rip in hammer_ref_buffer. Reproduced twice from fresh mounts; depth 64 completes (bracket ~80-250 B/frame). Counts/types/CRCs all valid β the DF-0776 family cannot see it; the attacker-controlled quantity is tree depth (one 4KB node per level, image-size-bound only). Crafted HAMMER1 image (attacker media / vfs.usermount setups β same surface as DF-0776/3002/3003); mount + any physical record delete (snaprm shown; unlink on nohistory fs equally reachable, no privileges needed post-mount). INVARIANTS: guaranteed panic/double fault. Production: overflow writes stack frames below the guard-less kstack into adjacent kernel_map allocations β silent corruption of neighboring kernel objects (coarse content control: kernel pointers/valid return addresses; privesc plausible, not demonstrated).
Proof of contest
VERIFIED (findings/poc/DF-3011/): deepforge grafts a crafted internal
root (count=2: original tree | chainβSNAPSHOT leaf) with recomputed
CRCs at the btree zone blockmap append point; run.sh 200 β mount OK,
hammer snaprm β 'DOUBLE FAULT / rip=hammer_ref_buffer+0x0 / rsp=kstack
floor / panic: double fault', guest wedged in DDB; control run.sh 4 β
exactly 4 btree_remove frames. Fix validated in-guest (depth cap 32
returning EDEADLK β identical to the existing deadlock-defer
semantics): same depth-200 PoC returns rc=0, clean unmount, fs ops
regression-clean.
Timeline
- 2026-09-02 Discovered during pass-2 audit of hammer_btree.c (GLM 5.3); double fault reproduced twice + fix validated.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3011 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| deepforge.c | β | 14.0 KB | view raw | |
| icrc32.c | β | 43.1 KB | view raw | |
| build.sh | β | 285 B | view raw | |
| run.sh | β | 660 B | view raw | |
| run_d200.log | β | 415 B | view raw | |
| run_d200_run2.log | β | 415 B | view raw | |
| panic_d200.txt | β | 2.4 KB | view raw | |
| panic_d200_run2.txt | β | 2.1 KB | view raw | |
| run_d64.log | β | 411 B | view raw | |
| panic_d64_layer2artifact.txt | β | 2.5 KB | view raw | |
| run_control_d4.log | β | 409 B | view raw | |
| panic_layer2_d4.txt | β | 2.5 KB | view raw | |
| fix.diff | β | 2.7 KB | view raw | |
| fix_build.log | β | 807 B | view raw | |
| fix_run_d200.log | β | 506 B | view raw | |
| README.md | β | 7.2 KB | β raw | |
| VERDICT.md | β | 5.4 KB | β raw |
DF-3011 β btree_remove() unbounded recursion: kernel stack overflow from crafted deep (valid-count) HAMMER1 B-trees
Kernel: DragonFly 6.5-DEVELOPMENT #0 (X86_64_GENERIC, INVARIANTS), sys/vfs/hammer
File under audit: sys/vfs/hammer/hammer_btree.c (pass 2)
Verdict (short)
REPRODUCED (twice, fresh mounts): kernel stack overflow β DOUBLE FAULT β panic.
A crafted HAMMER1 image whose B-tree contains a chain of 200 internal nodes, each
with count == 1, type == 'I' and a valid CRC32C (every field passes the
DF-0776 count/type validation β the crafted values are all in-range), mounted with
vnconfig+mount_hammer, and one record deleted via hammer snaprm
(HAMMERIOC_DEL_SNAPSHOT β hammer_delete_at_cursor(HAMMER_DELETE_DESTROY) β
hammer_btree_delete β btree_remove), recurses once per chain level on the
ioctl thread's 16KB kernel stack and runs off the bottom of it:
DOUBLE FAULT
rip = 0xffffffff80949d20 (hammer_ref_buffer+0x0 β inside hammer_get_node
in the recursion's per-level node walk)
rsp = 0xfffff80118bb1000 (exactly the page-aligned kstack floor)
panic: double fault
Measured bracket: depth 64 completes the recursion (no overflow), depth 200
double-faults β per-level cost β 80β250 bytes on a 16KB stack
(LWKT_THREAD_STACK = UPAGES*PAGE_SIZE = 4*4096, kern_fork.c:818). Chain length
is attacker-chosen (one 4KB node per level, all CRCs recomputed by the forger β
CRC32C is not a security barrier), so any stack size is overflowable; the
image just needs to be large enough (200 levels β 800KB of nodes inside one 8MB
big-block at the B-tree zone append point).
Root cause
btree_remove()(hammer_btree.c:1938) recurses once per single-element internal level:if (parent->ondisk->count == 1) { hammer_cursor_up_locked(); error = btree_remove(cursor, ndelete); }(hammer_btree.c:1979-2007).- Nothing anywhere bounds HAMMER1 B-tree depth: the pushdown in
btree_search(hammer_cursor_down) is iterative; the parent walk (hammer_load_cursor_parent/hammer_btree_get_parent) holds one parent at a time; there is no depth check at mount, in the cursor protocol, or inbtree_removeitself. grepdepthin hammer_btree.c shows only the rebalancer'slcache/lock_childrendepths (caller-bounded). - Deletion of the last record of a leaf empties it
(
hammer_btree_deleteβondisk->count == 0βbtree_remove), and everycount==1ancestor recurses (hammer_btree.c:932-935, 1979-2007). - The delete is reachable from: unlink/rmdir on nohistory fs's
(hammer_object.c:2236
hammer_nohistory(ip)),hammer prune(hammer_prune.c:201),hammer snaprm(hammer_ioctl.c:833), mirroring (hammer_mirror.c:754,828), and PFS downgrade (hammer_pfs.c:520).
The PoC uses hammer snaprm because it is fully deterministic: it looks up the
crafted SNAPSHOT record by exact key and destroys it synchronously in the ioctl
thread (hammer_ioc_del_snapshot, hammer_ioctl.c:791-843).
Threat model
Crafted HAMMER image (attacker-supplied media / vfs.usermount setups) β same
class as DF-0776/DF-3002/DF-3003. Unlike DF-0776, all forged fields are
in-range (count=1 or 2, type 'I'/'L', CRCs valid), so the DF-0776 hardening
(count/type validation at node load) does not and cannot block it; the
attacker-controlled quantity is the depth of the tree.
Impact: local kernel stack overflow (CWE-674). On the INVARIANTS guest: double
fault / panic (DoS). On production (INVARIANTS-OFF) kernels the same recursion
has smaller frames (higher threshold) but the same unbounded growth: the
overflow writes stack frames below the kstack into adjacent kernel_map
allocations (user LWP kstacks are kmem_alloc_stack(kernel_map, ...) with no
guard page) β silent corruption of neighboring kernel objects, plausibly
exploitable (the written data are kernel stack frames: saved registers holding
node pointers/offsets the attacker influences, plus valid kernel return
addresses; control is coarse, not demonstrated to privesc here).
Reproduce (on the dfbsd QEMU guest, as root)
# one-time: base image + tools
dd if=/dev/zero of=/root/base.img bs=1m count=0 seek=12288
vnconfig -c vn0 /root/base.img && newfs_hammer -f -L p0 /dev/vn0
mount_hammer /dev/vn0 /mnt && echo hello > /mnt/f && mkdir /mnt/d \
&& echo world > /mnt/d/g && sync && umount /mnt && vnconfig -u vn0
sh build.sh # cc deepforge (needs /usr/src/sys headers)
sh run.sh 4 0x4000000000000000 # control: depth 4 β recurses 4 levels, unwinds
sh run.sh 200 0x4000000000000000 # attack: DOUBLE FAULT panic (see panic_d200.txt)
Expected: MOUNT_OK, ls works (original tree intact), then during
hammer snaprm the console shows the double fault above and ssh dies
(guest wedged in DDB). run.sh output + console captured in the logs.
Forger output (run_d200.log)
version=7 ... orig_root=8000000021000000 (raw 554205184) orig root: type=L count=11 btree blockmap next_offset=8000000021001000 (raw 554209280) <- append point FORGED depth=200 tid=4000000000000000 r2=8000000021001000 leaf=8000000021006900 MOUNT_OK depth=200 / d / f [hammer snaprm β panic: double fault]
What the forger builds (deepforge.c)
A new root R2 (internal, count=2): elms[0] = original left bound + subtree =
original B-tree (mount + root-inode lookups keep working); elms[1] = separator
(loc=1, obj=1, rec=SNAPSHOT, key=MIN_KEY, ctid=1) + subtree = crafted chain
C1..CN (each internal, count=1, bounds [S,RB), valid CRC); elms[2] = right
boundary (root_btree_end values, hammer_vfsops.c:429-437). Leaf holds one
SNAPSHOT record (obj_id=ROOT, key=tid, create_tid=2, delete_tid=0,
data_offset=0, data_len=0). vol0_btree_root repointed at R2, original root's
parent patched, all volume/node CRCs recomputed. Nodes are placed at the B-tree
zone blockmap append point (zone-8-owned layer2 big-block).
Fix (validated β see VERDICT.md)
Cap the upward recursion in btree_remove() at BTREE_REMOVE_DEPTH_MAX 32 and
return EDEADLK past the cap β semantics identical to the existing
deadlock-defer path (empty leaf stays attached, pruner cleans up later; nothing
has been modified at the level that bails). With the fix, depth-200 snaprm
returns cleanly (no panic); normal fs ops unaffected.
Files
deepforge.c/icrc32.c/build.shβ image forgerrun.shβ mount + snaprm trigger (run.sh <depth> [tid])run_d200.log,run_d200_run2.logβ the two attack runspanic_d200.txt,panic_d200_run2.txtβ console double-fault capturesrun_d64.log,panic_d64_layer2artifact.txtβ depth-64 bracketing run (recursion completes 64 deep; the laterlayer2->zone == zoneassert during the unwind-frees is an environmental artifact of the dd/newfs-created base image's freemap β it fires on any node free on this image, is unrelated to DF-3011, and is preempted entirely by the overflow at attack depths)run_control_d4.log,panic_layer2_d4.txtβ depth-4 control (trace shows exactly 4btree_removerecursion frames β proves depth is attacker-chosen)fix.diffβ git-apply-able depth cap (never applied to the audit tree)fix_build.log,fix_run_d200.logβ single-fix kernel build + re-run
DF-3011 β VERDICT
status: reproduced (twice) | impact: panic (kernel stack overflow; silent kstack smash into adjacent kernel_map memory on production builds) | confidence: certain | fix validated: fixed
What was verified on the guest (stock INVARIANTS kernel #0)
- The recursion is attacker-depth-controlled. Depth-4 crafted chain β
console backtrace shows exactly 4
btree_removerecursion frames (btree_remove+0x26aΓ3 underbtree_remove+0x2fd, panic_layer2_d4.txt / run_control_d4.log β that run later tripped an environmental freemap assert during the unwind frees, see "artifact" below). Depth-64: recursion completes (panic_d64_layer2artifact.txt β again the artifact, after 64 successful levels). Depth-200: DOUBLE FAULT before any unwind (panic_d200.txt, panic_d200_run2.txt β two independent fresh-mount runs):DOUBLE FAULT rip = 0xffffffff80949d20 -> hammer_ref_buffer+0x0 (nm /boot/kernel/kernel) rsp = 0xfffff80118bb1000 -> page-aligned kstack floor (16KB LWKT stack) panic: double fault - The mount itself is clean.
mount_hammer+ls /mntsucceed on the crafted image (original subtree intact under the grafted root R2); the original records remain reachable. Only the record deletion triggers the overflow β viahammer snaprm(HAMMERIOC_DEL_SNAPSHOT) which callshammer_delete_at_cursor(HAMMER_DELETE_DESTROY)βhammer_btree_deleteβbtree_removesynchronously in the ioctl thread. - All forged fields are in-range. deepforge writes count=1/2, type 'I'/'L', and recomputes every node CRC32C β the DF-0776 count/type validation (evidence pack DF-0776/fix.diff, hammer_ondisk.c) passes every crafted node. The attacker-controlled quantity is the chain length (tree depth), which nothing validates anywhere in HAMMER1.
Root cause trace (path:line)
sys/vfs/hammer/hammer_btree.c:932-935β empty leaf βbtree_remove(cursor, ndelete).sys/vfs/hammer/hammer_btree.c:1979βif (parent->ondisk->count == 1): every single-element internal level recurses.sys/vfs/hammer/hammer_btree.c:2002-2007βhammer_cursor_up_locked(cursor)thenerror = btree_remove(cursor, ndelete);β one C-stack frame per B-tree level, no depth bound.- No depth limit exists anywhere else:
btree_searchpushdown (hammer_btree.c:1255hammer_cursor_down) and the parent reload (hammer_cursor.c:490-521 hammer_load_cursor_parent) are iterative;grep -n depth sys/vfs/hammer/hammer_btree.conly finds the rebalancer's caller-boundedlcache/lock_childrendepths. - Caller thread stack: 16KB β
sys/sys/thread.h:472#define LWKT_THREAD_STACK (UPAGES * PAGE_SIZE),UPAGES 4(sys/cpu/x86_64/include/param.h:126), allocated for user LWPs atsys/kern/kern_fork.c:818viakmem_alloc_stack(kernel_map, ...)(sys/kern/lwkt_thread.c:393-395) β kernel_map, no guard page: overflow writes into whatever is mapped below the kstack on production builds. - Trigger reachability:
hammer_ioctl.c:833(snaprm),hammer_prune.c:201,hammer_mirror.c:754,828,hammer_object.c:2236(nohistory unlink),hammer_pfs.c:520,525.
Why panic and not uid=0 (honest impact ceiling)
The overflowing data are the recursion's own stack frames: saved RBP/RIP and
locals of btree_remove/hammer_cursor_up_locked/hammer_load_cursor_parent/
hammer_btree_get_parent/hammer_get_node. Content control is coarse (kernel
pointers and small integers influenced by crafted offsets/keys, valid kernel
return addresses). On the INVARIANTS guest the overflow manifests as a double
fault (proof of the primitive). On a production kernel the same overflow writes
below the kstack into adjacent kernel_map allocations β a memory-corruption
primitive β but turning it into a controlled hijack was not attempted within
this run's budget; no leak/groom work was done, and I am reporting DoS-class
impact with a corruption ceiling, not claiming privesc.
Environmental artifact (NOT part of this finding)
Sub-overflow depths unwind and free the chain nodes; the first
hammer_blockmap_free on these dd/newfs-created base images trips
KKASSERT(layer2->zone == zone) (hammer_blockmap.c:832). The on-disk freemap
of the fresh image decodes (via vol0_blockmap[4]) to layer1[0].phys = 0x5ba
(garbage) β an inconsistency of the test image environment (newfs on sparse
vn images) that affects any btree-node free, unrelated to the recursion bug.
The attack depth (200) always overflows before the first free, so the
artifact never masks the finding. (Worth a separate look by the project: the
8-byte offset between the on-disk layer1 table and the blockmap root.)
Fix validation (single-fix kernel)
fix.diffapplied to the guest's /usr/src copy only (audit tree untouched),make nativekernel KERNCONF=X86_64_GENERICβ build OK (fix_build.log).- Rebooted into the fixed kernel (uname recorded in fix_run_d200.log).
- Re-ran the exact depth-200 PoC: no panic β snaprm returns, mount stays healthy, unmount clean (fix_run_d200.log). The capped recursion defers via the existing EDEADLK semantics (empty leaf left for the pruner).
- Regression: normal newfs/mount/write/rm/sync/umount cycle on an uncrafted image passes on the fixed kernel.
Reproduce summary
build.sh # deepforge sh run.sh 200 0x4000000000000000 # console: DOUBLE FAULT, rsp = kstack floor, panic: double fault
Fix verification
fixedSingle-fix kernel #1 (fix.diff applied to guest /usr/src, make nativekernel + installkernel, reboot): exact same depth-200 PoC returns rc=0 (SNAPRM_RETURNED), mount stays healthy, clean unmount, guest up; normal nohistory mount/write/rm/sync/umount regression passes. Baseline double fault gone.
['fix_build.log (NK_RC=0, IK_RC=0, kernel #1 uname)', 'fix_run_d200.log (SNAPRM_RETURNED rc=0 / RUN_COMPLETE depth=200)']
Confirmed kernel references
- sys/vfs/hammer/hammer_btree.c:1979
- sys/vfs/hammer/hammer_btree.c:2002
- sys/vfs/hammer/hammer_btree.c:2007
- sys/vfs/hammer/hammer_btree.c:932
- sys/vfs/hammer/hammer_btree.c:1938
- sys/vfs/hammer/hammer_ioctl.c:833
- sys/vfs/hammer/hammer_object.c:2236
- sys/vfs/hammer/hammer_prune.c:201
- sys/vfs/hammer/hammer_mirror.c:754
- sys/vfs/hammer/hammer_cursor.c:490
- sys/sys/thread.h:472
- sys/cpu/x86_64/include/param.h:126
- sys/kern/kern_fork.c:818
Detail
Evidence (decisive lines)
["panic_d200.txt / panic_d200_run2.txt: 'DOUBLE FAULT / rip=0xffffffff80949d20 (= hammer_ref_buffer+0x0 via nm) / rsp=0xfffff80118bb1000 (kstack floor) / panic: double fault' - two independent runs", "run_d200.log / run_d200_run2.log: 'MOUNT_OK depth=200', ls works, then panic during hammer snaprm", 'run_control_d4.log + panic_layer2_d4.txt: backtrace shows exactly 4 btree_remove recursion frames (btree_remove+0x26a x3 under btree_remove+0x2fd) - recursion depth == crafted chain length', 'run_d64.log: depth 64 completes the recursion (bracketing; the later layer2->zone assert during unwind-frees is an environmental artifact of the dd/newfs base image, unrelated and always preempted at attack depths)', "fix_run_d200.log: on kernel #1 built with fix.diff: 'SNAPRM_RETURNED rc=0 ... RUN_COMPLETE depth=200'", "fix_build.log: nativekernel NK_RC=0, installkernel IK_RC=0, uname 'DragonFly 6.5-DEVELOPMENT #1: Sat Sep 5 07:16:55 UTC 2026'"]
PoC changes
Wrote the forger from scratch (deepforge.c): grafts a new internal root (count=2) onto an existing newfs_hammer image - original tree kept as left subtree (mount + root-inode lookups work), crafted count==1 internal chain + one-record SNAPSHOT leaf as right subtree, vol0_btree_root repointed, all node CRCs recomputed, nodes placed at the btree zone blockmap append point. Trigger chosen: hammer snaprm (deterministic synchronous DESTROY in the ioctl thread). No seed PoC existed for this finding.
Verified recommended fix
Cap btree_remove() recursion at BTREE_REMOVE_DEPTH_MAX (32) and return EDEADLK past the cap - identical to the existing deadlock-defer semantics (empty leaf stays attached, pruner cleans up); see fix.diff.
Verdict
btree_remove() (hammer_btree.c:1938) recurses once per single-element internal-node level (hammer_btree.c:1979->2007) and nothing in HAMMER1 bounds B-tree depth (search pushdown and parent walk are iterative/unbounded; no depth check at mount). A crafted image with a 200-level chain of count==1/type-'I' internal nodes carrying VALID CRC32Cs (all fields in-range, so the DF-0776 count/type validation passes) mounted via vnconfig+mount_hammer and hit with hammer snaprm <tid> (HAMMERIOC_DEL_SNAPSHOT -> hammer_delete_at_cursor(DESTROY) -> hammer_btree_delete -> btree_remove) overflowed the ioctl thread's 16KB kernel stack: DOUBLE FAULT with rsp exactly at the page-aligned kstack floor, rip in hammer_ref_buffer (inside the per-level hammer_get_node). Reproduced twice from fresh mounts; depth 4 and 64 bracket the threshold (recursion completes at 64, double-faults by 200; ~80-250 bytes/frame). Impact class: kernel stack exhaustion/memory corruption (CWE-674): guaranteed panic on INVARIANTS builds; on production builds the same overflow writes below the guard-less kstack (kmem_alloc_stack on kernel_map, no guard page) into adjacent kernel objects - coarse-content corruption, privesc plausible but not demonstrated within this run. Fix validated: depth cap 32 returning EDEADLK (existing defer semantics; empty leaf left for the pruner) built as kernel #1 -> same depth-200 PoC returns rc=0, no panic, clean unmount; normal fs ops regression-clean.
No comments yet.