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

Cluster array chain pointer read without cluster_spin UAF in volume dedup check

Summary

hammer2_xop_inode_flush volume-header dedup check :1377-1384 reads ip->cluster.array[j].chain dereferences chain->hmp without holding ip->cluster_spin. Cluster array concurrently modified under cluster_spin by hammer2_inode_repoint (inode.c:1400-1454) replaces chain pointers drops old chain ref outside spin (:1459-1461). If repoint runs between pointer read and hmp deref old chain freed UAF on chain->hmp. Proper pattern (inode.c:413-425) takes cluster_spin shared refs chain drops spin. Requires clustered HAMMER2 config.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2585 Β· 9 files
FileTypeDescriptionSize
README.md β€” 4.8 KB ↓ raw
VERDICT.md β€” 6.7 KB ↓ raw
build_cluster.sh β€” 896 B view raw
stress.sh β€” 707 B view raw
run.log β€” 1.9 KB view raw
cluster2.txt β€” 372 B view raw
env.txt β€” 800 B view raw
manifest.json β€” 1.4 KB view raw
verdict.json β€” 5.5 KB view raw

DF-2585 β€” hammer2_xop_inode_flush volume-dedup check reads ip->cluster.array[j].chain without cluster_spin (claimed UAF)

What (finding)

sys/vfs/hammer2/hammer2_flush.c:1377-1384 (hammer2_xop_inode_flush, the HAMMER2_XOP_VOLHDR volume-header dedup check):

for (j = clindex - 1; j >= 0; --j) { if ((chain = ip->cluster.array[j].chain) != NULL) { if (chain->hmp == hmp) { ... goto skip; } } }

reads the cluster-array chain pointer and dereferences chain->hmp WITHOUT holding ip->cluster_spin and without taking a reference. The proper pattern is hammer2_inode_chain() (hammer2_inode.c:407-426): cluster_spin shared, chain ref, drop spin, then use.

Verdict: NOT REPRODUCED (no reachable racing writer; benign use-shape)

The unsynchronized read is real (code-confirmed), but the claimed UAF is not reachable as a race on this kernel, and even a manufactured race cannot manifest:

  1. Precondition gate: the loop body only executes for clindex >= 1, i.e. iroot->cluster.nchains >= 2 β€” a multi-device hammer2 cluster. Single device mounts (the only configuration constructible by newfs_hammer2 + normal mounts) always run xops at clindex 0 and NEVER execute the loop.

  2. The only producer of HAMMER2_XOP_VOLHDR is vfs_sync stage 2 (hammer2_vfsops.c:2780-2786), which takes iroot->lock EXCLUSIVE (hammer2_mtx_ex) and holds it across hammer2_inode_chain_flush() β€” i.e. for the entire lifetime of the xop backend that performs the unlocked read (the backend itself holds no lock).

  3. Every in-tree writer of iroot->cluster.array[] is serialized against that by the same lock or by lifetime: - hammer2_pfsalloc() (the only adder of chains to iroot->cluster; vfsops.c:455-500) runs under hammer2_mtx_ex(&iroot->lock). - hammer2_inode_repoint() is called only on freshly created inodes (hammer2_inode.c:899-931, 1257) or from the final hammer2_inode_drop() (inode.c:640) β€” the xop holds an inode reference (hammer2_xop_alloc), so refs cannot reach 0 while the VOLHDR xop is in flight; the final drop's repoint cannot race. iroot is created with xop==NULL and is never a target of the found-path repoint in the hot path. - hammer2_inode_repoint_one() (sync-thread piecemeal update) is only called from hammer2_inode_get()'s found path, which takes nip->lock exclusive (hammer2_mtx_ex_try/hammer2_mtx_ex, inode.c:887-899) before repointing. - unmount/teardown paths are excluded by mount lifetime + the xop's inode ref (VFS_SYNC completes before unmount; pfsfree's chain removal runs only after pmp->mp is NULL).

  4. Use-shape: even if the read raced a free, chain->hmp is a single load from kernel heap memory (always mapped; cannot fault) whose value is used solely in an equality comparison (flush.c:1379) and never escapes. There is no panic, leak, or corruption shape; the worst case is an incorrect skip (or non-skip) of one redundant volume-header flush β€” an efficiency nit, not memory unsafety.

Guest evidence (stock kernel, DragonFly 6.5-DEVELOPMENT #0 Jul 2 2026)

  • The ':' multi-volume mount syntax cannot construct the precondition ("volume id 0 already initialized", EINVAL) β€” run.log [1].
  • A 2-chain pmp WAS constructed via the join-mount path (dd-copied MASTER pair sharing pfs_clid): second mount matches the same pmp (pmp=0xfffff80118e20000) and then fails "PFS already mounted!" (vfsops.c:1455) leaving the cluster 2-wide; clindex-1 xop threads verified live (ps: 109 h2xop-CLUS threads incl. the .06-.11 clindex-1 group); light write/read probes work β€” run.log [2],[3], cluster2.txt.
  • 60 s of parallel churn (file creates/deletes/renames/chmod + chmod on the mount root = direct iroot metadata churn, stress.sh) against a tight sync loop (dozens of VOLHDR flush cycles executing the loop at clindex 1 with maximal concurrent iroot activity): NO crash, NO panic, NO kernel messages; guest alive; only the independent local-multi-master quorum wedge appears (a sync parked in h2inode) β€” run.log [4].
  • No memory-corruption manifestation of any kind was observed or is constructible from the code shape (see VERDICT.md).

Wrap the loop in the hammer2_inode_chain() pattern (cluster_spin shared + chain ref, inode.c:407-426), e.g. snapshot the chain pointers under cluster_spin with refs, drop the spin, then compare hmp's. Cheap, correctness-preserving, removes the protocol violation the finding correctly flags.

Files

build_cluster.sh cluster construction attempt (multi-volume + join paths) stress.sh churn generator (files + iroot metadata) run.log full guest session (4 phases, annotated) cluster2.txt serial-console excerpt of the join-mount env.txt guest environment VERDICT.md full narrative with path:line analysis manifest.json / verdict.json

VERDICT.md
↓ download raw

DF-2585 VERDICT β€” unlocked ip->cluster.array read in hammer2_xop_inode_flush volume dedup check (flush.c:1377-1384)

STATUS: not_reproduced (impact none). The code pattern is real; the claimed UAF is not reachable as a race on this kernel, and the raced read has no fault/leak/corruption shape. Full reasoning below; guest session in run.log.

1. The code

hammer2_xop_inode_flush() (flush.c:1292), gated by HAMMER2_XOP_VOLHDR (flush.c:1368) and ispfsroot (1370), runs the dedup check:

flush.c:1377   for (j = clindex - 1; j >= 0; --j) {
flush.c:1378       if ((chain = ip->cluster.array[j].chain) != NULL) {
flush.c:1379           if (chain->hmp == hmp) { ... goto skip; }

No ip->cluster_spin, no chain reference β€” unlike the canonical accessor hammer2_inode_chain() (hammer2_inode.c:407-426): cluster_spin shared, chain = cluster->array[clindex].chain, hammer2_chain_ref(chain), spin unshared, then use. hammer2_inode_repoint() (inode.c:1365-1463) swaps array[i].chain under cluster_spin and drops the old chain outside the spin (inode.c:1459-1462); if that drop were the last reference the chain would be freed and flush.c:1379 would deref freed memory. That is the finding's mechanism, and it is correctly described at the code level.

2. Reachability gate 1 β€” who executes the loop

The loop body runs only for j >= 0, i.e. clindex >= 1. XOP cluster index is the caller's slot; a single-device hammer2 mount has nchains==1, all xops run at clindex 0, j starts at -1: the loop NEVER executes. Reaching it requires iroot->cluster.nchains >= 2 (multi-device cluster).

3. Reachability gate 2 β€” who issues HAMMER2_XOP_VOLHDR

grep: HAMMER2_XOP_VOLHDR is set in exactly one place β€” vfs_sync stage 2 (sys/vfs/hammer2/hammer2_vfsops.c:2780-2786):

if ((ip = pmp->iroot) != NULL) {
    hammer2_inode_ref(ip);
    hammer2_mtx_ex(&ip->lock);                       /* EXCLUSIVE */
    hammer2_inode_chain_sync(ip);
    hammer2_inode_chain_flush(ip, ...|HAMMER2_XOP_VOLHDR);
    hammer2_inode_unlock(ip);    /* unlock+drop */
}

hammer2_inode_chain_flush() starts the xop and blocks in hammer2_xop_collect(HAMMER2_XOP_COLLECT_WAITALL) β€” so the xop backend thread executes flush.c:1377-1384 while the FRONTEND holds iroot->lock exclusively for the entire xop lifetime. (hammer2_ioctl.c:933 has VOLHDR commented out.)

4. Writer enumeration for iroot->cluster.array[]

(a) hammer2_pfsalloc() β€” the only code that ADDS chains to a mounted pmp's iroot->cluster (invoked from hammer2_update_pmps()/mount paths). It takes hammer2_inode_ref(iroot) + hammer2_mtx_ex(&iroot->lock) around the array update (vfsops.c:455-500). Serialized against (3) by that lock. (b) hammer2_inode_repoint() β€” called from hammer2_inode_create/connect paths on freshly allocated inodes (inode.c:899-931, 1257): never the mounted pmp's iroot; and from the final hammer2_inode_drop() transition to refs==0 (inode.c:607-640). The VOLHDR xop holds an inode reference for its whole lifetime (hammer2_xop_alloc(xop->head.ip1)), so iroot's refs cannot reach 0 while the read is in flight; the drop-side repoint cannot run concurrently. (c) hammer2_inode_repoint_one() β€” reachable only via hammer2_inode_get()'s found-in-hash path, which acquires nip->lock exclusive (hammer2_mtx_ex_try / hammer2_mtx_ex, inode.c:886-899) before calling repoint/repoint_one. Serialized against (3). (d) unmount teardown / hammer2_pfsfree_scan chain removal β€” gated by mount lifecycle: VFS_SYNC has returned before unmount proceeds; pmp->mp is NULL before pfs teardown drops iroot chains. => No in-tree writer can swap or free iroot->cluster.array[j].chain while the VOLHDR xop backend sits between the pointer load (flush.c:1378) and the hmp deref (1379). A racing thread would have to bypass iroot->lock β€” no such caller exists (a KLD manufacturing one would test a synthetic state, not a reachable vulnerability).

5. Use-shape (even under a manufactured race)

chain->hmp is one load from the kernel heap (freed chain memory remains mapped; the read cannot fault). The value is consumed by an equality compare against hmp (flush.c:1379) and never escapes: a stale/garbage value can at worst cause an incorrect skip (or missed skip) of a redundant per-device volume-header flush. No panic, no info leak, no memory corruption shape exists on this path.

6. Guest session (stock kernel, fresh boots; full transcript run.log)

[1] Multi-volume ':' syntax: rejected β€” "volume id 0 already initialized" (EINVAL). Cannot construct the 2-chain precondition that way. [2] Join-mount construction: dd-copied MASTER pair (identical pfs_clid) on vn0/vn1; mount vn0@CLUS -> FIRST-OK; mount_hammer2 vn1@CLUS -> kernel matches the SAME pmp (hmp=0xfffff801194bc000 pmp=0xfffff80118e20000) then "PFS already mounted!" (vfsops.c:1455) and EBUSY, leaving the cluster 2-wide. Verified clindex-1 xop threads live (ps: 109 h2xop-CLUS threads, incl. the clindex-1 group .06-.11) β€” the loop's execution context exists (cluster2.txt). [3] Light probes on the 2-chain mount: write+ls OK. [4] Stress: 60 s parallel churn (file create/rename/chmod/delete loops PLUS chmod on the mount root β€” direct iroot metadata churn, so vfs_sync stage 2 flushes iroot with VOLHDR every sync) against a tight sync loop. Result: NO crash, NO panic, NO hammer2 kernel messages; guest alive and idle (load 0.00). The only anomaly is the independent local multi-master quorum wedge (a sync process parked in D-state on h2inode) β€” a usability property of 2-master dd-copy clusters on this kernel, unrelated to the flush.c:1377 read.

7. Conclusion

not_reproduced, impact none, confidence certain: - The locking-protocol violation is real and worth fixing (cheap hardening: use the hammer2_inode_chain() pattern around the loop), but - the claimed UAF requires a racing writer that does not exist on any reachable path (all in-tree writers serialized by iroot->lock held across the only VOLHDR producer's xop, or excluded by the xop's inode ref), and - the raced read itself is a non-faulting equality comparison whose result never escapes the kernel.

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.

(No fix.diff for this item: documented recommendation only - see Fix above.)

Fix verification

not_testable
baseline no→ patch + rebuild →patched clean

No fix authored: not a memory-corruption bug (not reproduced; no reachable racing writer; benign use-shape). The hardening change in recommended_fix is a one-liner pattern swap for upstream.

VERDICT.md sections 2-5
per-fix-DF-2585

Confirmed kernel references

Detail

Evidence (decisive lines)

run.log (4-phase guest session: multi-volume rejection, join-mount EBUSY with shared pmp + 109 h2xop-CLUS threads incl. clindex-1 group, working light probes, 60s churn+sync stress with no manifestation); cluster2.txt (serial excerpt of the join-mount); VERDICT.md sections 2-5 (reachability gates + writer enumeration with path:line); hammer2_inode.c:407-426 vs hammer2_flush.c:1377-1384 (the missing pattern)

PoC changes

Rig authored from scratch (no seed). Cluster construction went through three attempts: per-device labels in ':' syntax (rejected - the parser takes ONE label after the last device), /dev/vn0:/dev/vn1@CLUS (rejected - duplicate volume id), and finally the join-mount path (mount vn0, then mount_hammer2 vn1 -> same pmp + EBUSY) which does produce a live 2-chain cluster.

Verified recommended fix

Wrap the volume-dedup loop in the hammer2_inode_chain() pattern: take ip->cluster_spin shared, ref the array chains, drop the spin, then compare chain->hmp and drop the refs (mirrors hammer2_inode.c:407-426).

Verdict

NOT REPRODUCED, impact none. The unsynchronized read is real (hammer2_flush.c:1377-1384 loads ip->cluster.array[j].chain and derefs chain->hmp with no cluster_spin/ref, unlike the canonical accessor hammer2_inode_chain() at hammer2_inode.c:407-426), but the claimed UAF is not reachable as a race on this kernel: (1) the loop body executes only for clindex>=1, i.e. iroot->cluster.nchains>=2 - a multi-device cluster; single-device mounts never run it; (2) the ONLY producer of HAMMER2_XOP_VOLHDR is vfs_sync stage 2 (vfsops.c:2780-2786), which holds iroot->lock EXCLUSIVE across the whole chain_flush xop, so the backend executes the read while the frontend pins every lock-taking writer out; (3) every in-tree writer of iroot->cluster.array[] is serialized by that same lock (pfsalloc vfsops.c:455-500) or excluded by the xop's inode reference (repoint-at-final-drop inode.c:640 cannot fire while the xop holds a ref; repoint_one is only called under nip->lock from hammer2_inode_get's found path, inode.c:886-899; repoint in create/connect paths only touches fresh inodes); (4) even under a manufactured race the 'use' is a single non-faulting load from the kernel heap consumed by an equality comparison (flush.c:1379) whose result never escapes - no panic, leak, or corruption shape exists. Guest evidence: a 2-chain pmp was actually constructed via the join-mount path (dd-copied MASTER pair; second mount matched pmp=0xfffff80118e20000 then EBUSY 'PFS already mounted!' leaving the cluster 2-wide; clindex-1 xop threads verified live via ps, 109 h2xop-CLUS threads); light probes worked; 60s of parallel file churn + direct iroot metadata churn against a tight sync loop produced NO crash, panic, or kernel message (only the independent local-multi-master quorum wedge: one sync parked in h2inode, guest alive at load 0.00). The multi-volume ':' syntax cannot even construct the precondition (duplicate volume id rejected). Recommended hardening (not a security fix): snapshot the chain pointers under cluster_spin with references (hammer2_inode_chain pattern) before the hmp comparison.