NULL deref in hammer2_chain_lastdrop no-parent retry path β parent->core.spin released when parent is NULL
Summary
hammer2_chain_lastdrop :646 else (no-parent case): parent==NULL. :650 if atomic_cmpset_int(&chain->refs,1,0)==0 (concurrent ref bump raced 1->0). :654 hammer2_spin_unex(&parent->core.spin) dereferences NULL+offsetof(core.spin) ~= address 0x40 unmapped zero page = kernel panic. Parent case :593 correctly acquires parent->core.spin at :594 before releasing at :643/599. Else branch never acquires parent spinlock (no parent) yet tries to release it. hammer2_chain_ref :258 atomic_fetchadd_int no lock requirement any thread with stale pointer to detached chain can bump refs during race window. Trigger: unprivileged local user heavy concurrent create/delete/rename/write on HAMMER2 filesystem chain transiently parentless during blocktable maintenance. Fix: remove hammer2_spin_unex(&parent->core.spin) at :654.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0762 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| h2_churn.c | trigger-source | multi-thread HAMMER2 churn harness (create/delete/rename/mkdir/sync) run as unprivileged user | 4.9 KB | view raw |
| setup_h2.sh | setup-script | root-side: vnconfig + newfs_hammer2 + mount + chown to maxx | 937 B | view raw |
| build.sh | build-script | cc -O2 -pthread -o h2_churn h2_churn.c | 113 B | view raw |
| run.sh | run-script | runs the churn harness as unprivileged user | 213 B | view raw |
| build.log | build-log | harness build output (rc=0, no warnings) | 185 B | view raw |
| run.log | run-log | baseline #0 churn runs (no panic; narrow race) | 1.6 KB | view raw |
| fix_run.log | run-log | patched #1 churn run (no panic, no regression) | 1.2 KB | view raw |
| fix_build.log | build-log | full single-fix kernel build (NK_DONE rc=0) | 5.6 MB | β download |
| bootlog_excerpt.txt | panic-signature | serial console excerpt - no panic/fatal-trap lines during churn | 381 B | view raw |
| env.txt | environment | uname, cc version, mount/sysctl state | 318 B | view raw |
| fix.diff | suggested-fix | git-apply-able diff: remove bogus hammer2_spin_unex(&parent->core.spin) in no-parent retry branch | 655 B | view raw |
| VERDICT.md | verdict | full narrative: mechanism, reachability, fix validation | 7.2 KB | β raw |
| README.md | readme | human-facing repro + impact ceiling | 2.2 KB | β raw |
| manifest.json | manifest | this catalog | 3.2 KB | view raw |
| ../fix_build_combined.log | build-log | Combined 41-finding kernel build (rc=0, -Werror clean) | 5.6 MB | β download |
| ../fix_build_summary.txt | build-summary | Summary of the combined 41-finding kernel build | 826 B | view raw |
DF-0762 β NULL deref in hammer2_chain_lastdrop no-parent retry path
Bug: sys/vfs/hammer2/hammer2_chain.c hammer2_chain_lastdrop() β the no-parent
else branch (where parent == NULL) has a 1β0-retry path that executes
hammer2_spin_unex(&parent->core.spin) (old line 654), dereferencing NULL and
releasing a spinlock that was never acquired in that branch. Result: kernel page-fault
panic (local DoS) when the concurrent-ref race is won on a transiently-parentless
chain during HAMMER2 topology teardown.
Severity: Medium (DoS only β NULL-deref at a fixed offset, no escalation path).
Files
h2_churn.cβ multi-thread HAMMER2 churn harness (create/delete/rename/mkdir/sync), run as the unprivileged user on a mounted HAMMER2 filesystem.setup_h2.shβ root-side: creates a HAMMER2 image (vnconfig+newfs_hammer2), mounts it at/h2test, chowns tomaxx.build.shβcc -O2 -pthread -o h2_churn h2_churn.c.run.shβ runs the churn harness.fix.diffβ removes the bogushammer2_spin_unex(&parent->core.spin)in the no-parent retry branch (the only correct, minimal change).VERDICT.mdβ full analysis, mechanism, reachability, fix validation.manifest.jsonβ artifact catalog.
Reproduce
# root on guest: set up HAMMER2 image + mount + chown to maxx
ssh dfbsd '/bin/sh /root/poc/DF-0762/setup_h2.sh 2048'
# unprivileged: build + run the churn
ssh dfbsd-maxx 'cd /h2test && /root/poc/DF-0762/h2_churn 120 10 6 6'
Expected (bug present): under heavy churn the kernel may panic with a page-fault
at a low address inside hammer2_chain_lastdrop (serial console / dfbsd-qemu/boot.log).
The race is narrow; the bug itself is a static certainty at
sys/vfs/hammer2/hammer2_chain.c:654 (parent is unconditionally NULL in the
else branch).
Preconditions (acceptable mount-threat-model): a HAMMER2 filesystem image mounted by root (mount is root-only), made usable by the unprivileged user. The trigger itself (churn) is entirely unprivileged.
Impact ceiling
NULL-deref at a fixed low offset β pure DoS (panic). No attacker-controlled write content, no pivot β no privilege-escalation chain is derivable.
DF-0762 β NULL deref in hammer2_chain_lastdrop no-parent retry path
Verdict
REPRODUCED (code-level certain) β local DoS (kernel panic) on a HAMMER2 filesystem under churn.
The NULL-deref is a provable static fact (not probabilistic); the only probabilistic
element is winning the concurrent-ref-bump race that reaches the buggy retry line.
The live race did not panic within bounded churn time, but the bug is unambiguous and
the missing check is confirmed line-by-line below. Fix VALIDATED on a single-fix
#1 kernel: offending line removed, patched kernel compiles/boots, identical hammer2
churn runs clean with no panic and no regression.
| field | value |
|---|---|
| status | reproduced |
| impact | dos (NULL-deref β kernel panic) |
| confidence | certain (static proof) |
| severity (finding) | Medium |
| preconditions | a mounted HAMMER2 filesystem (mount is root-only β acceptable mount-threat-model precondition); heavy concurrent churn (unprivileged) wins the race |
Mechanism (trigger β primitive β effect)
hammer2_chain_lastdrop() (sys/vfs/hammer2/hammer2_chain.c) is reached when a
chain's refcount is dropping 1β0 (caller hammer2_chain_drop, line 357-359). At
the topology-teardown tail of lastdrop:
- L577
parent = chain->parent;βparentmay legitimately beNULL(a chain detached from the topology, or a PFS-root chain whose own parent is gone). - L593
if (parent) {β the parent case. It correctly acquireshammer2_spin_ex(&parent->core.spin)at L594 before the cmpset, and releases it at L599 (retry) / L643 (success). - L646
} else {β the No-parent case, whereparent == NULL. - L650
if (atomic_cmpset_int(&chain->refs, 1, 0) == 0) {β the 1β0 transition failed because a concurrenthammer2_chain_ref(atomic bump) raced refs 1β2 in the window. This is the retry path. - L654
hammer2_spin_unex(&parent->core.spin);β BUG. In thiselsebranchparentisNULL, and no parent spinlock was ever acquired in this branch.&parent->core.spinevaluates toNULL + offsetof(hammer2_chain, core) + offsetof(hammer2_chain_core, spin).coreis the 2nd field ofhammer2_chain(afterhammer2_mtx_t lock,sys/vfs/hammer2/hammer2.h:325-326) andspinis the 1st field ofhammer2_chain_core(hammer2.h:238-239), so the deref lands atNULL + sizeof(hammer2_mtx_t)β a low, unmapped address β page fault β kernel panic (DoS). The line is doubly wrong: it dereferences NULL and releases a spinlock that was never held.
Compare the parent retry (L599), which legitimately releases parent->core.spin
because L594 acquired it. The no-parent retry is a copy/paste of that block without
the corresponding acquire; it should only release the locks actually held in this
branch β chain->core.spin (L655) and chain->lock (L656) β which were acquired
earlier in lastdrop.
Reachability / threat model
parent == NULLon entry to the teardown tail is routine: it happens for any chain detached from the topology whose refs are being dropped (e.g. a parent chain that lost its last child via the recursiverdropreturn at L698 and itself has no parent), and for PFS-root chains during unmount.- The cmpset at L650 fails when a concurrent
hammer2_chain_refbumps refs 1β2. The kernel itself acknowledges such races exist (h2race1detection kprintf athammer2_chain.c:403). - Trigger (unprivileged): once a HAMMER2 filesystem is mounted (root precondition β an admin has mounted a filesystem image, an acceptable mount-threat-model precondition), heavy concurrent create/delete/rename/sync churn by an unprivileged user drives blocktable maintenance and topology teardown, transiently producing parentless chains whose last-drop races a concurrent ref β the buggy retry fires β panic.
A NULL-deref at a fixed low offset is a pure DoS β no escalation chain is
possible (no attacker-controlled write content, no pivoting). This finding is DoS
only; there is no uid=0 path.
Reproduction attempt (live)
- Built
h2_churn(multi-thread create/delete/rename/mkdir-tree/sync storm) as the unprivileged usermaxxon a 2 GB HAMMER2 image mounted at/h2test. - Ran ~3.5 min total of aggressive churn (4/2/2 β 8/4/4 β 10/6/6 threads) on the
unpatched
#0kernel. No panic fired: the race window (cmpset losing to a concurrent ref bump on a transiently-parentless chain) is genuinely narrow. - This is expected; the bug is nonetheless a static certainty β once the
else-branch retry is reached,
parentis unconditionallyNULLand L654 unconditionally faults. Per the run-brief, a deterministic code-level confirmation is acceptable when the live race is too narrow; the line-by-line trace above is that confirmation.
Fix
fix.diff removes the bogus hammer2_spin_unex(&parent->core.spin); at the
no-parent retry (old L654) and clarifies the comment. The no-parent branch never
acquires parent->core.spin (parent is NULL), so removing its release is the only
correct, minimal change. The remaining two releases (chain->core.spin,
chain->lock) are legitimate (held since earlier in lastdrop).
Fix validation (Phase 8)
- Baseline (
#0, unpatched):kern.version=6.5-DEVELOPMENT #0, Thu Jul 2 06:02:54 UTC 2026. Bug linehammer2_spin_unex(&parent->core.spin);present at L654. (Live race did not panic in bounded churn β narrow.) - Applied
fix.diffto in-guest/usr/src; line confirmed removed. - Built single-fix kernel
make -j6 nativekernel KERNCONF=X86_64_GENERIC(warm obj;hammer2_chain.orecompiled,kernel.debugrelinked; rc=0;options HAMMER2builds hammer2 into the kernel, so the rebuiltkernel.strippedcarries the fix β no module reinstall needed). - Installed
/boot/kernel/kernelβkernel.stripped, rebooted. - Patched (
#1):kern.version=6.5-DEVELOPMENT #1, Thu Jul 9 12:55:57 UTC 2026. Bug line gone; comment updated. - Re-ran the identical churn workload (
./h2_churn 120 10 6 6) asmaxx: no panic, no regression,BG_DONE rc=0, guest stayed up. - Verdict: the offending NULL-deref line is removed by construction and the patched kernel is functionally clean; the live race is too narrow to serve as a panic before/after, so validation rests on the code-level removal (clean before/after of L654) + compile/boot + no-regression churn.
PoC changes
- Authored the evidence pack from scratch (no prior PoC folder existed):
h2_churn.c(multi-thread HAMMER2 churn harness),setup_h2.sh(creates a HAMMER2 image viavnconfig+newfs_hammer2, mounts, chowns tomaxx),build.sh,run.sh,fix.diff, thisVERDICT.md,manifest.json, full logs. setup_h2.shuses DragonFly'svnconfig(not Linuxmdconfig) onvn0.
Reproduce
ssh dfbsd # root: set up the hammer2 image + mount /bin/sh /root/poc/DF-0762/setup_h2.sh 2048 # creates /h2test, chowns to maxx ssh dfbsd-maxx # unprivileged: run the churn cd /h2test && /root/poc/DF-0762/h2_churn 120 10 6 6 # watch dfbsd-qemu/boot.log for a fatal-trap/page-fault in hammer2_chain_lastdrop # (the race is narrow; the bug is a static certainty at hammer2_chain.c:654)
Fix verification
fixedVALIDATED. Baseline #0 kernel source/kernel carries the buggy line at L654 (parent==NULL deref confirmed). Applied fix.diff to /usr/src (hunk succeeded at 645), built make -j6 nativekernel KERNCONF=X86_64_GENERIC (warm obj: hammer2_chain.o recompiled, kernel.debug relinked, NK_DONE rc=0), installed kernel.stripped->/boot/kernel/kernel (hammer2 is built-in via options HAMMER2, so this carries the fix -- kldstat shows no hammer2.ko loaded), rebooted to #1. On the #1 kernel the offending line is gone and the IDENTICAL churn workload (./h2_churn 120 10 6 6 as maxx) runs with no panic and no regression (BG_DONE rc=0, guest up, boot.log delta=0). The live concurrent-ref race is too narrow to serve as a panic before/after on either kernel, so validation rests on the clean code-level removal of L654 (#0 has it, #1 does not) + compile/boot + functional no-regression. fix.diff applies cleanly to the read-only sys/ tree.
baseline #0 src L654: "hammer2_spin_unex(&parent->core.spin);" [BUG present]; kern.version=6.5-DEVELOPMENT #0. patched #1 src L654: line removed, only "hammer2_spin_unex(&chain->core.spin)" remains [BUG gone]; kern.version=6.5-DEVELOPMENT #1. patched #1 churn: BG_DONE rc=0, guest up, no panic, no regression.
Confirmed kernel references
Detail
Exploit chain
none -- this is a NULL pointer dereference at a fixed low offset (NULL + sizeof(hammer2_mtx_t)). There is no attacker-controlled write content and no pivot, so no privilege-escalation chain is derivable. Impact ceiling = local DoS (kernel panic) on a mounted HAMMER2 filesystem when the concurrent-ref race is won during topology teardown. Precondition (a mounted HAMMER2 image, root-mount) is an acceptable mount-threat-model precondition; the trigger itself (heavy create/delete/rename/sync churn) is entirely unprivileged.
Evidence (decisive lines)
Baseline #0 source line 654 (no-parent retry): "hammer2_spin_unex(&parent->core.spin);" with parent==NULL in the else branch (L646) -- provable NULL deref. Aggressive churn as maxx on /h2test (2GB HAMMER2): 3 runs (90s/150s/200s, up to 10+6+6 threads) all BG_DONE rc=0, guest up, boot.log delta=0 (race too narrow to panic live). Kernel self-acknowledges such refs races exist (h2race1 kprintf at hammer2_chain.c:403).
PoC changes
Authored the full evidence pack from scratch (no prior poc/DF-0762/ folder existed). h2_churn.c = multi-thread HAMMER2 churn harness (create/delete/rename/mkdir-tree/sync) run as the unprivileged user; setup_h2.sh = root-side HAMMER2 image creation via DragonFly vnconfig+newfs_hammer2, mount at /h2test, chown to maxx; build.sh/run.sh; fix.diff removing the bogus line; VERDICT.md; manifest.json; full logs. fix.diff = remove hammer2_spin_unex(&parent->core.spin) at the no-parent retry (old L654) and clarify the comment (matches finding proposal: 'remove hammer2_spin_unex(&parent->core.spin) at :654').
Verified recommended fix
In hammer2_chain_lastdrop() no-parent else-branch retry path (sys/vfs/hammer2/hammer2_chain.c, old L654), remove the line hammer2_spin_unex(&parent->core.spin);. parent is NULL in that branch and its spinlock is never acquired there, so the release is both a NULL deref and an unheld-lock release; the remaining two releases (chain->core.spin + chain->lock) are the only locks actually held. This matches the finding markdown's Recommended fix proposal. Full git-apply-able diff in findings/poc/DF-0762/fix.diff.
Verdict
REPRODUCED (code-level certain) -- local DoS (NULL-deref panic) on a mounted HAMMER2 filesystem. hammer2_chain_lastdrop() (sys/vfs/hammer2/hammer2_chain.c) at the no-parent teardown tail sets parent=chain->parent (L577), which may be NULL; the parent-case if(parent){...} (L593) correctly acquires/releases parent->core.spin (L594/L599/L643), but the else no-parent branch (L646, parent==NULL) has a 1->0-retry path (L650 cmpset failed due to a concurrent hammer2_chain_ref race) that executes hammer2_spin_unex(&parent->core.spin) at L654 -- dereferencing NULL (at NULL+sizeof(hammer2_mtx_t), an unmapped low address) AND releasing a spinlock never acquired in that branch. Once the else-branch retry is reached, the NULL deref is unconditional (static certainty); the only probabilistic element is winning the concurrent-ref-bump race that reaches L650 failing. The live race did NOT panic in ~3.5 min of aggressive unprivileged churn (4/2/2 -> 8/4/4 -> 10/6/6 threads) on a 2GB mounted HAMMER2 image, but the bug is unambiguous and confirmed line-by-line. A NULL-deref at a fixed offset is pure DoS -- no escalation chain.
No comments yet.