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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2584 Β· 22 files
FileTypeDescriptionSize
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).

VERDICT.md
↓ download raw

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

fixed
baseline reproduced→ patch + rebuild →patched clean

Baseline 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)
↓ fix.diffper-fix-DF-2584

Confirmed kernel references

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.