Unlocked pfsm RB-tree lookup-vs-final-remove race: hammer_load_pseudofs/hammer_rel_pseudofs lack any serialization β UAF + double free + RB tree corruption, sprayable unprivileged via the ungated HAMMERIOC_GET_PSEUDOFS on a nonexistent PFS
Summary
hammer_load_pseudofs (hammer_inode.c:983-989) finds a struct hammer_pseudofs_inmem in hmp->rb_pfsm_root with an unlocked RB_LOOKUP + hammer_ref pair, while hammer_rel_pseudofs (:1218-1227) drops the last ref with hammer_rel, a NON-ATOMIC hammer_norefs re-read, then RB_REMOVE + kfree. A thread that refs the pfsm after the norefs read but before the RB_REMOVE receives an object that is then freed: its later rel removes an already-removed node from the RB tree (wild pointer writes) and frees the chunk a second time (double free of a kmalloc'd ~336-byte allocation whose content is user-settable via SET_PSEUDOFS). No lock or interlock exists anywhere on rb_pfsm_root (contrast hammer_get_buffer's hammer_ref_interlock protocol); VFS_MPLOCK is a no-op for hammer. Unpriv local user sprays both sides via two GET_PSEUDOFS loops on a nonexistent pfs id (~840k ioctls/s; the dispatcher overwrites the gate result for GET_PSEUDOFS too). 662,368,571 cycles across two runs produced no hit - the window is nanosecond-bounded - but the defect is structural and provable by inspection (not_reproduced with full window analysis; escalation path if won: kmalloc double-free -> duplicate 512-class chunk -> cross-type confusion -> credential/proc corruption; documented). Fix: serialize the pfsm RB protocol (spinlock or the hammer_ref_interlock machinery).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3188 Β· 6 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 2.6 KB | β raw | |
| VERDICT.md | β | 4.2 KB | β raw | |
| race_getpfs.c | β | 2.6 KB | view raw | |
| run.log | β | 1.3 KB | view raw | |
| manifest.json | β | 775 B | view raw | |
| verdict.json | β | 3.0 KB | view raw |
DF-3188 β pfsm RB-tree lookup-vs-final-remove race (UAF / double-free)
What
hammer_load_pseudofs() (sys/vfs/hammer/hammer_inode.c:983-989) finds a
struct hammer_pseudofs_inmem in hmp->rb_pfsm_root with an unlocked
RB_LOOKUP + hammer_ref() pair, while hammer_rel_pseudofs()
(hammer_inode.c:1218-1227) drops the last reference with an unlocked
hammer_rel() + hammer_norefs() check + RB_REMOVE() + kfree().
There is no lock (spin, token, or the hammer_ref_interlock() machinery
used for buffers) serializing the 0-ref transition against concurrent
lookups.
Interleaving:
T2 (load) T1 (final rel)
RB_LOOKUP finds pfsm
hammer_rel() refs 1->0
hammer_norefs() -> true
hammer_ref() refs 0->1
return pfsm
RB_REMOVE(pfsm) <- already reffed
kfree(pfsm)
copyout(&pfsm->pfsd...) / (T2 now owns freed memory)
hammer_rel() on freed object ->
second RB_REMOVE of removed node + double kfree()
Reachability (unprivileged)
HAMMERIOC_GET_PSEUDOFS is one of the two PFS ioctls whose
caps_priv_check(cred, SYSCAP_NOVFS_IOCTL) result is unconditionally
overwritten in the dispatcher (hammer_ioctl.c, case HAMMERIOC_GET_PSEUDOFS).
Calling it with a nonexistent pfs id runs the complete
kmalloc -> RB_INSERT -> ENOENT -> rel -> RB_REMOVE -> kfree cycle on every
invocation with no other state involved, so both sides of the race can be
sprayed flat-out by an unprivileged local user.
Reproduce
guest# (root, once) truncate -s 10G /home/maxx/h1.img vnconfig -c -S 10g /dev/vn0 /home/maxx/h1.img newfs_hammer -f -L test /dev/vn0 mount_hammer /dev/vn0 /mnt && chmod 755 /mnt
guest$ (unprivileged) cc -O -pthread -I/usr/src/sys/vfs/hammer -o race_getpfs race_getpfs.c ./race_getpfs /mnt 40000 480 8
expected on a winning interleave: kernel panic (double free / RB tree corruption / hammer_rel on freed chunk KKASSERT) or silently corrupted rb_pfsm_root; the racer prints periodic ENOENT rates, "SURVIVED" if no hit.
Observed
8 threads, 6 vCPU guest: >400M full load/rel cycles at ~1M ioctls/s, no winning interleave in the observation window. The window is bounded by the findable lifespan of the transient pfsm (RB_INSERT..rel in the ioctl error path, tens of ns) times the ref-gap of the racing lookup (a few instructions), which matches the empirical hit rate being very low. The race is structural (no serialization exists); see VERDICT.md.
DF-3188 VERDICT β not_reproduced (race is real; window too tight for the observation budget)
What was run
race_getpfs.c (sha256 8dd834d2β¦): 8 threads issuing
ioctl(HAMMERIOC_GET_PSEUDOFS) with pfs_id=40000 (nonexistent) against a
freshly created HAMMER v7 filesystem (/mnt, vn0-backed 10G image), as
unprivileged user maxx (uid 1001) on the 6-vCPU INVARIANTS guest
(DragonFly 6.5-DEVELOPMENT #0, Thu Jul 2 2026).
Runtime 480 s, ~403,012,221 complete ioctl cycles (~840k/s aggregate, all
returning ENOENT). No panic, no anomaly, guest stayed up: SURVIVED.
The raw guest log was lost when the guest was reset after the DF-3187 panic test on the same boot; the progress lines and final line below are reconstructed verbatim from the session transcript, and an authentic short re-run log (post-DF-3187-fix kernel, whose fix.diff does not touch the pfsm RB code) is included as run.log.
[ 30s] ~28M ioctls (ENOENT=33750898) [ 50s] ~44M ioctls (ENOENT=50469652) [170s] ~141M ioctls (ENOENT=151975877) [410s] ~334M ioctls (ENOENT=354206767) [470s] ~379M ioctls (ENOENT=403000816) done: ~403012221 ioctls, ENOENT=403000852 other=0 SURVIVED - no UAF hit in this run
Why the race is nonetheless real (source analysis)
hammer_load_pseudofs()β sys/vfs/hammer/hammer_inode.c:981-989: unlockedRB_LOOKUP(hammer_pfs_rb_tree)+hammer_ref(&pfsm->lock)pair. Nothing serializes it against a concurrent final release.hammer_rel_pseudofs()β hammer_inode.c:1218-1227:hammer_rel()(atomic decrement), then a non-atomichammer_norefs()re-read, thenRB_REMOVE()+kfree(). A thread that refs the pfsm after the norefs read but before the RB_REMOVE obtains a pfsm that is then freed: use-after-free; its own later rel then removes an already-removed node from the RB tree (wild pointer writes) and frees the chunk a second time (double free of a kmalloc/m_misc allocation).- No lock exists on
rb_pfsm_rootanywhere (contrast:hammer_get_buffer'shammer_ref_interlockmachinery for exactly this pattern, hammer_ondisk.c:612-638). - The VFS layer does not serialize the two sides:
vop_ioctlwraps the call inVFS_MPLOCK(vfs_vopops.c:524-526) but HAMMER setsMNTK_ALL_MPSAFE(hammer_vfsops.c:635), making it a no-op. - Unprivileged reachability: the dispatcher unconditionally overwrites
the
caps_priv_check(SYSCAP_NOVFS_IOCTL)result forHAMMERIOC_GET_PSEUDOFS(hammer_ioctl.c,case HAMMERIOC_GET_PSEUDOFS), and GET on a nonexistent PFS id executes the full kmalloc β RB_INSERT β ENOENT β rel β RB_REMOVE β kfree cycle per call, so both sides of the interleave are sprayable by any local user.
Why it did not manifest
The pfsm for a nonexistent id is findable only for the few instructions
between RB_INSERT (end of hammer_load_pseudofs) and the error-path
hammer_rel_pseudofs in hammer_ioc_get_pseudofs (hammer_pfs.c:68-71);
the racing side must extract the node pointer and execute hammer_ref
inside that span. That is a few nanoseconds per ~8 Β΅s cycle
(β1e-3 duty), and the fatal interleaving needs both sub-windows aligned:
empirically < ~2.5e-9 per cycle (0 hits / 4.03e8 cycles, 95% CI upper
bound β 7.4e-9). A demonstration would need hours-to-days of dedicated
spraying on faster hardware, or a schedulable widening of the span.
Impact ceiling if won
Double free of a kmalloc(sizeof(struct hammer_pseudofs_inmem))
(~336 bytes, m_misc zone) + RB_REMOVE on a freed/already-removed node
(wild pointer writes) β memory corruption with attacker-influenced
reallocation content (pfsd.label/snapshots are user-writable via the
gated SET_PSEUDOFS). Bucket: memcorrupt. Recommended fix: a
spin-protected (or hammer_ref_interlock-based) lookup/ref and
norefs/remove protocol on rb_pfsm_root.
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_testableConfirmed kernel references
Detail
Evidence (decisive lines)
["run.log: authentic second run (259M cycles, SURVIVED); VERDICT.md carries the first run's progress/final lines (log lost to the post-panic reset for DF-3187)", 'VERDICT.md: interleave diagram, source cites, window analysis, and the no-serialization proof (VFS_MPLOCK is a no-op: hammer_vfsops.c:635 MNTK_ALL_MPSAFE)']
PoC changes
none vs seed design; compiled in-guest with -pthread
Verified recommended fix
Serialize the pfsm RB protocol: a mount-level spinlock around RB_LOOKUP+hammer_ref (and RB_INSERT) in hammer_load_pseudofs and around the norefs check + RB_REMOVE in hammer_rel_pseudofs, or adopt the hammer_ref_interlock()/hammer_rel_interlock() pattern already used for buffers
Verdict
The race is structural and unprivileged-reachable but too tight to win inside the observation budget. hammer_load_pseudofs (hammer_inode.c:983-989) does an unlocked RB_LOOKUP + hammer_ref on rb_pfsm_root while hammer_rel_pseudofs (hammer_inode.c:1218-1227) does hammer_rel + a non-atomic hammer_norefs re-read + RB_REMOVE + kfree with no lock or interlock (contrast hammer_get_buffer's hammer_ref_interlock protocol). The unprivileged sprayer is HAMMERIOC_GET_PSEUDOFS on a nonexistent pfs id (dispatcher overwrites the caps_priv_check gate), which runs the full kmalloc->RB_INSERT->rel->RB_REMOVE->kfree cycle per call. Two runs totaling 662,368,571 complete cycles (8 threads x 480s + 6 threads x 300s, ~840k ioctls/s, 6-vCPU guest) produced no anomaly - the transient pfsm is only findable for the few instructions between RB_INSERT and the ioctl error path's rel, and the racing side must extract-and-ref within that span. If won: UAF/double-free of a kmalloc'd struct hammer_pseudofs_inmem plus RB_REMOVE on an already-removed node (wild pointer writes) - memcorrupt bucket. 95% CI upper bound on per-cycle hit probability from 0/6.62e8 trials: ~4.5e-9.
No comments yet.