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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3188 Β· 6 files
FileTypeDescriptionSize
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.

VERDICT.md
↓ download raw

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: unlocked RB_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-atomic hammer_norefs() re-read, then RB_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_root anywhere (contrast: hammer_get_buffer's hammer_ref_interlock machinery for exactly this pattern, hammer_ondisk.c:612-638).
  • The VFS layer does not serialize the two sides: vop_ioctl wraps the call in VFS_MPLOCK (vfs_vopops.c:524-526) but HAMMER sets MNTK_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 for HAMMERIOC_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_testable
per-fix-DF-3188

Confirmed 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.