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)
PoC verification
Evidence pack
findings/poc/DF-2585 Β· 9 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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:
-
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. -
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).
-
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).
-
Use-shape: even if the read raced a free,
chain->hmpis 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
syncparked in h2inode) β run.log [4]. - No memory-corruption manifestation of any kind was observed or is constructible from the code shape (see VERDICT.md).
Recommended hardening (not a security fix)
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
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_testableNo 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
Confirmed kernel references
- sys/vfs/hammer2/hammer2_flush.c:1377
- sys/vfs/hammer2/hammer2_flush.c:1378
- sys/vfs/hammer2/hammer2_flush.c:1379
- sys/vfs/hammer2/hammer2_flush.c:1368
- sys/vfs/hammer2/hammer2_vfsops.c:2780
- sys/vfs/hammer2/hammer2_vfsops.c:2783
- sys/vfs/hammer2/hammer2_inode.c:407
- sys/vfs/hammer2/hammer2_inode.c:426
- sys/vfs/hammer2/hammer2_inode.c:1365
- sys/vfs/hammer2/hammer2_inode.c:1459
- sys/vfs/hammer2/hammer2_inode.c:886
- sys/vfs/hammer2/hammer2_inode.c:899
- sys/vfs/hammer2/hammer2_vfsops.c:455
- sys/vfs/hammer2/hammer2_vfsops.c:1455
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.
No comments yet.