Flush depth limit panics instead of deferring DoS via deeply nested crafted image
Summary
hammer2_flush_core panics panic(hammer2: flush depth limit) when recursion depth reaches HAMMER2_FLUSH_DEPTH_LIMIT(60) at :584. Header comment :370-373 documents elements should be placed on list for re-execution deferral never implemented. Recovery code implements deferral pattern (HAMMER2_RECOVERY_MAXDEPTH=10 TAILQ list vfsops.c:2281-2292) flush code does not. Crafted image >60 levels single-child indirect nesting causes kernel panic during mount recovery calls hammer2_flush.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2584 Β· 22 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 5.2 KB | β raw | |
| VERDICT.md | β | 5.8 KB | β raw | |
| craft_deep.py | β | 5.3 KB | view raw | |
| build_image.sh | β | 824 B | view raw | |
| build.sh | β | 185 B | view raw | |
| run.sh | β | 165 B | view raw | |
| panic.txt | β | 2.1 KB | view raw | |
| run.log | β | 1.2 KB | view raw | |
| run.pristine.log | β | 375 B | view raw | |
| run.d30.log | β | 196 B | view raw | |
| run.d35.log | β | 230 B | view raw | |
| run.d40.log | β | 260 B | view raw | |
| run.d55.log | β | 234 B | view raw | |
| run.persist10.log | β | 529 B | view raw | |
| run.persist60.log | β | 773 B | view raw | |
| fix.diff | β | 4.1 KB | view raw | |
| fix_build.log | β | 5.6 MB | β download | |
| fix_install.log | β | 80.4 KB | view raw | |
| fix_run.log | β | 1.0 KB | view raw | |
| env.txt | β | 1.1 KB | view raw | |
| manifest.json | β | 1.4 KB | view raw | |
| verdict.json | β | 7.3 KB | view raw |
DF-2584 β hammer2 flush depth limit: no deferral => kernel stack exhaustion / panic on crafted deeply-nested image
What (finding)
sys/vfs/hammer2/hammer2_flush.c:580-584: hammer2_flush_core() panics with panic("hammer2: flush depth limit") when recursion depth reaches HAMMER2_FLUSH_DEPTH_LIMIT (60). The header comment (flush.c:369-373) documents that elements at the limit are supposed to be placed on a list for re-execution after the stack has been popped β the deferral was never implemented (recovery has one: HAMMER2_RECOVERY_MAXDEPTH=10 + TAILQ deferral list, hammer2_vfsops.c:2278-2292; the flush code does not).
Reproduced (stock kernel, DragonFly 6.5-DEVELOPMENT #0 Jul 2 2026, INVARIANTS)
- Craft: 60-level chain of single-entry INDIRECT blocks (16KB each, check methods=0x00, key=0/keybits=16) inserted between file /f's inode block table and its original 32KB DATA block. craft_deep.py builds it from a pristine image built by build_image.sh (newfs_hammer2 via vnconfig); the patcher disables the three parent-link check codes along the modified path and recomputes the three volume-header CRC-32Cs (pattern established by the DF-5836 pack).
- Trigger: mount TESTFS (root), then an UNPRIVILEGED user (uid 1001) runs
dd if=/dev/zero of=/mnt/h2/f bs=32768 count=1 conv=notrunc+sync(the crafted image marks /f world-writable, meta.mode 0644->0666). - Result:
Fatal double fault ... panic: double faultβ the flush recursion (hammer2_flush_core <-> hammer2_flush_recurse, sys/vfs/hammer2/ hammer2_flush.c:1254-1256) exhausts the 16KB kernel thread stack; rip symbolizes to hammer2_chain_load_data (called from the chain_lock in hammer2_flush_recurse:1197). The stock serial console shows the panic (panic.txt).
Depth characterization (stock kernel)
| chain depth | result on sync |
|---|---|
| pristine(1) | survives, clean umount |
| 10 | survives (also on patched kernel, write persists) |
| 30 | survives, clean umount |
| 35 | survives, clean umount |
| 40 | Fatal double fault |
| 55 | Fatal double fault |
| 60 | Fatal double fault (root or user trigger) |
The explicit panic("hammer2: flush depth limit") at flush.c:584 is UNREACHABLE on this build: the C-stack dies between recursion depth 35 and 40, i.e. before the depth==60 check can fire. The missing deferral therefore manifests as an uncontrolled kernel-stack exhaustion (double fault) rather than the designed panic β same root cause, worse symptom. (On non-INVARIANTS builds with smaller frames the literal panic may fire first; untested.)
Fix (validated A/B)
fix.diff (git-apply-able, authored against the read-only sys/ tree): 1. lowers HAMMER2_FLUSH_DEPTH_LIMIT 60 -> 16 (empirical stack budget), 2. implements the documented deferral: at the limit the chain is referenced, put on info->defer_list and re-driven by hammer2_flush() with the depth reset to 0 once the recursion has unwound (mirror of the recovery pattern). First iteration kept a hammer2_chain_setflush(parent) at deferral time which re-flagged the ancestor path and livelocked the flush (pagedaemon "Continuous paging"); final version drops it (drain list alone guarantees re-execution).
A/B on the same image, guest kernel DragonFly 6.5-DEVELOPMENT #1: * baseline (stock #0): double fault (panic.txt) * patched (#1): mount + dd + sync -> SYNC-SURVIVED, clean UMOUNT-OK (fix_run.log) Legitimate topologies never exceed ~10 indirect levels (128 entries per 16KB indirect block => logarithmic), so the deferral never engages for real filesystems β the fix is a no-op there.
Known caveat (documented, crafted-media only): on the patched kernel a write to the >16-deep crafted chain is not persisted to media (silent loss for that file; verified vs depth-10 control which persists fine β persistence logs in run.persist60.log / run.persist10.log). The stock kernel cannot be compared because it panics mid-flush. Upstream should mature the deferral's UPDATE propagation; the security property (kernel crash from a crafted image) is fixed.
Files
craft_deep.py image patcher (self-verifying, depth argument) build_image.sh pristine image builder (run as root in guest) run.sh trigger (mount + dd + sync) panic.txt stock serial console: double fault during user-triggered sync run.log canonical unprivileged run (uid 1001) -> hang/panic run.pristine.log control: pristine image survives run.d30.log / run.d35.log / run.d40.log / run.d55.log depth sweep (stock) fix.diff the fix (limit 16 + deferral) fix_build.log patched kernel build (nativekernel) fix_run.log patched kernel A/B: survives, clean umount run.persist60.log / run.persist10.log persistence check (see caveat) env.txt guest environment
Threat model
Mounting the crafted image requires root (or vfs.usermount=1 with a user-owned device); after mount the crash is triggerable by any unprivileged user with write permission on one crafted file. Severity per finding: Medium (local DoS via crafted image; no code execution β the corruption is confined to the kernel stack guard and the resulting controlled panic).
DF-2584 VERDICT β flush depth limit panics instead of deferring (hammer2_flush.c:580-584)
VERDICT: REPRODUCED (impact: panic / local DoS via crafted image; unprivileged trigger).
1. Root cause confirmed in source
hammer2_flush_core() (sys/vfs/hammer2/hammer2_flush.c:483) recurses via hammer2_flush_recurse() (flush.c:1164, ++info->depth at flush.c:1254) one C stack level pair per topology level, descending every chain flagged with ONFLUSH|MODIFIED|UPDATE|DESTROY (FLUSH_MASK, hammer2.h:401). At info->depth == HAMMER2_FLUSH_DEPTH_LIMIT (60, flush.c:56) the code executes panic("hammer2: flush depth limit") (flush.c:584). The deferral list the header comment describes (flush.c:369-373 "Elements at the limit are placed on a list for re-execution after the stack has been popped") does not exist; recovery implements exactly that pattern (HAMMER2_RECOVERY_MAXDEPTH=10, TAILQ deferral, sys/vfs/hammer2/hammer2_vfsops.c:2278-2292 + 2197-2208), the flush path does not.
2. Reachability of depth >35 from media
Legitimate filesystems cannot build such topologies (indirect blocks hold 128 blockrefs each => depth is logarithmic, ~10 max). A crafted image can: the media block tables are fully set-associative with no structural validation of nesting on the read path (hammer2_chain_lookup descent, hammer2_chain.c:2676-2684, only range-checks the key). craft_deep.py rewires file /f's blockset[0] through N single-entry INDIRECT blocks (16KB, methods=0x00 = HAMMER2_CHECK_NONE so no per-entry CRC maintenance, key=0/keybits=16 chain) ending at the original DATA block. Only the three parent-link check codes on the modified path are disabled (volheader sroot_blockref -> superroot -> TESTFS PFS-root -> file inode) and the three volume-header CRC-32Cs recomputed (scheme proven in the DF-5836 pack).
Flag propagation at runtime: one write into the deep data chain -> hammer2_assign_physical (strategy.c:717) looks up through all N levels -> hammer2_chain_modify sets MODIFIED on the data chain and hammer2_chain_setflush (chain.c:147-167) walks UP setting ONFLUSH through every indirect to the inode. sync -> vfs_sync stage-1 flushes the dirty inode (vfsops.c:2717-2722, INODE_STOP only stops at INODE children) -> flush_core descends; depth increments once per level.
3. On-guest reproduction (stock kernel DragonFly 6.5-DEVELOPMENT #0 Jul 2 2026)
Canonical run (run.log + panic.txt): root mounts the crafted TESTFS; user maxx (uid=1001) runs dd (32KB overwrite of world-writable /f) + sync. ssh session hangs; serial console:
DOUBLE FAULT Fatal double fault rip = 0xffffffff8096f204 <- hammer2_chain_load_data (nm-verified) rsp = 0xfffff801193e5000 <- thread stack bottom panic: double fault
hammer2_chain_load_data is called from the hammer2_chain_lock() inside hammer2_flush_recurse (flush.c:1197); the double fault is the 16KB kernel stack running out under flush_core/flush_recurse recursion. Four independent reproductions at depths 60/55/40/60 (root and user triggers).
Controls (all stock kernel): - pristine image: identical mount+dd+sync survives, clean umount (run.pristine.log). - depth 30, 35: survive, clean umount (run.d30.log, run.d35.log). - depth 40, 55: double fault (run.d40.log, run.d55.log). => the crash is depth-driven and sits in the flush recursion; the explicit flush.c:584 panic is UNREACHABLE on this build because the stack dies at ~35-40 recursion levels, before the depth==60 check. The missing deferral manifests as uncontrolled stack exhaustion (worse than the designed panic).
4. Fix validation (fix.diff, A/B on the same image)
fix.diff: (a) HAMMER2_FLUSH_DEPTH_LIMIT 60 -> 16 (empirical stack budget, 35-40 death point, 2x margin), (b) the documented deferral implemented: at the limit the chain is ref'd, appended to info->defer_list and re-driven by hammer2_flush() with depth reset to 0 after the recursion unwinds (processed iteratively; processing may queue more deferrals). An initial variant called hammer2_chain_setflush(parent) at deferral time; that re-flagged the ancestor path and livelocked the flush (pagedaemon "Continuous paging" - kept in the notes, removed in the final diff).
Baseline vs patched (same crafted depth-60 image, same commands): - stock #0 Jul 2: Fatal double fault during sync. - fixed #1 Oct 14 (nativekernel rebuild, installkernel, reboot): SYNC returns, UMOUNT-OK, guest healthy (fix_run.log).
Legitimate topologies (~<=10 levels) never reach the limit: the fix is a no-op for real filesystems.
Documented caveat (crafted-media only): on the patched kernel the dd write to the 60-deep crafted chain is not persisted to media (file still reads the original bytes after remount), while the identical write on a depth-10 crafted chain (no deferral engaged) persists correctly (run.persist60.log vs run.persist10.log). The re-driven deferred subtree apparently does not complete the media persistence for that chain's in-place data write; upstream should mature the deferral's UPDATE/data propagation. This does not affect the security verdict: the stock kernel PANICS (losing the sync and the machine) on the same input, and no legitimate filesystem can engage the deferral.
5. Impact
Crafted hammer2 image -> mount (root or vfs.usermount=1) -> one write + sync by any user with write permission on the crafted file -> kernel panic (double fault). Local DoS; no code execution primitive demonstrated or plausible (guard-page hit -> controlled panic).
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
fixedBaseline stock kernel (#0 Jul 2): crafted depth-60 image + mount + dd + sync -> Fatal double fault during sync (panic.txt). Patched kernel (#1 Oct 14, fix.diff via nativekernel): same image, same commands -> SYNC-SURVIVED, clean UMOUNT-OK, guest healthy; repeated mount/write/sync/remount cycles stable. Crash eliminated. Crafted-media caveat: the write to the deferred 60-deep chain is not persisted (file reads original bytes after remount; depth-10 no-deferral control persists correctly) - upstream should mature the deferral's UPDATE propagation; legitimate topologies (~<=10 indirect levels) never engage the deferral.
fix_build.log / fix_install.log (patched kernel build), fix_run.log (A/B run), run.persist10.log vs run.persist60.log (caveat capture), panic.txt (baseline)
Confirmed kernel references
- sys/vfs/hammer2/hammer2_flush.c:580
- sys/vfs/hammer2/hammer2_flush.c:584
- sys/vfs/hammer2/hammer2_flush.c:56
- sys/vfs/hammer2/hammer2_flush.c:1254
- sys/vfs/hammer2/hammer2_flush.c:1197
- sys/vfs/hammer2/hammer2_flush.c:369
- sys/vfs/hammer2/hammer2_vfsops.c:2278
- sys/vfs/hammer2/hammer2_vfsops.c:2292
- sys/vfs/hammer2/hammer2_chain.c:147
- sys/vfs/hammer2/hammer2_strategy.c:717
- sys/vfs/hammer2/hammer2_vfsops.c:2717
Detail
Exploit chain
attacker supplies crafted hammer2 image (60-deep single-child indirect nesting under a world-writable file, parent-link check codes disabled, volheader CRCs repaired) -> victim root mounts it (or vfs.usermount=1) -> any unprivileged user with write permission on the crafted file overwrites it (dd) -> chain lookup loads all 60 indirect chains, chain_modify+setflush flags the whole path ONFLUSH/MODIFIED -> sync -> vfs_sync stage-1 per-inode flush -> hammer2_flush_core/hammer2_flush_recurse recursion exhausts the kernel thread stack -> Fatal double fault -> system down (panic). No code-execution route: the overflow terminates at the stack guard and the double-fault handler panics.
Evidence (decisive lines)
panic.txt (full serial console of the unprivileged-trigger run: DOUBLE FAULT, rip=0xffffffff8096f204 symbolized via nm to hammer2_chain_load_data); run.log (canonical uid=1001 dd+sync -> hang -> panic); run.pristine.log / run.d30.log / run.d35.log (surviving controls) vs run.d40.log / run.d55.log (double faults) - depth-driven attribution; craft_deep.py (self-verifying patcher); fix.diff + fix_build.log + fix_run.log (A/B: stock panics, patched survives with clean umount); run.persist10.log vs run.persist60.log (fix caveat); VERDICT.md (full narrative with path:line)
PoC changes
PoC authored from scratch (no seed). Two patcher iterations: (1) meta.mode is a bare unix mode word (type lives in meta.type, inum at meta+0x58) - first assertion assumed S_IFMT bits; (2) added the world-writable bit (0644->0666) so the trigger runs as uid 1001 instead of root. Trigger used dd bs=32768 (the file's own block size) to avoid hammer2_chain_resize on the crafted chain. Fix development required three iterations: limit-60-only deferral still double-faulted (stack dies ~35-40), limit 16 + setflush(parent) at deferral livelocked, final = limit 16 + plain deferral list.
Verified recommended fix
Lower HAMMER2_FLUSH_DEPTH_LIMIT to a value the kernel stack can actually sustain (16) and implement the documented deferral: at the limit, reference the chain, queue it on a flush-deferral TAILQ, and re-drive each queued chain from hammer2_flush() with the depth reset to 0 (see fix.diff).
Verdict
REPRODUCED on the guest (impact: local DoS/panic via crafted image, unprivileged trigger). hammer2_flush_core() has no depth deferral (the list the header comment flush.c:369-373 describes was never implemented; recovery has one at vfsops.c:2278-2292); a crafted image rewrites file /f's block table into a chain of N single-entry 16KB INDIRECT blocks (check methods=0x00, key=0/kb=16, no nesting validation on the read path) ending at the original DATA block. One write into the file flags the whole chain (chain_modify -> setflush walk-up, chain.c:147-167) and sync flushes it: flush_core<->flush_recurse recurses one C-frame pair per level. On this INVARIANTS build the 16KB kernel thread stack dies between depth 35 and 40, so the crafted 40/55/60-deep images all produce 'Fatal double fault ... panic: double fault' with rip inside hammer2_chain_load_data (called by the chain_lock in flush_recurse flush.c:1197) BEFORE the depth==60 panic check at flush.c:584 can ever fire - the explicit panic() is unreachable on this build; the missing deferral manifests as uncontrolled kernel-stack exhaustion, which is worse than the designed panic. Controls: pristine image and depth-30/35 images survive identical mount+write+sync. Canonical run: root mounts the crafted TESTFS, unprivileged uid=1001 dd-overwrites the world-writable /f and runs sync -> double fault (panic.txt, run.log). Fix validated A/B: fix.diff lowers the limit to 16 and implements the deferral list (re-drive deferred chains with depth reset); patched kernel (#1 Oct 14) survives the exact same PoC with clean sync+umount while the stock kernel panics; a first fix variant that called setflush(parent) at deferral time livelocked (pagedaemon continuous paging) and was dropped. Documented caveat: on the patched kernel the write to the 60-deep crafted chain does not persist to media (depth-10 control persists), i.e. the deferral's UPDATE/data propagation needs upstream maturing; legitimate topologies (~<=10 levels, logarithmic) never engage the deferral so real filesystems are unaffected.
No comments yet.