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

Keysym free mutates the global interned-key rb-tree without the tree mutex β€” unsynchronized rb-tree rebalancing β†’ UAF/heap corruption

Summary

Keysyms are interned in one process/kernel-global rb-tree guarded by a mutex that every find/insert takes - but the keysym object type registers NO pot_lock/pot_unlock, so prop_object_release runs _prop_dict_keysym_free's _prop_rb_tree_remove_node on the shared tree unlocked whenever a keysym's refcount hits zero outside a dictionary-type free (dictionary remove/set-expand-failure/all_keys-array-release/emergency-free paths). Keysyms being interned, any two dictionaries sharing a key string share the object - the race cross-cuts unrelated dictionaries. Reproduced on-guest: 11/11 SIGSEGV inside _prop_rb_tree_remove_node; trylock probe counted 88-1145 unlocked frees per run; a second crash landed in the locked _prop_rb_tree_insert_node (the inserter walking the tree torn by the remover) proving both sides; noremove control clean 3/3; recursive-mutex fix validated 10/10. Userland heap UAF now; kernel side latent (the unlocked release exists in-kernel via prop_dictionary_remove <- _udev_dict_delete_key, which has zero callers today - one new driver call away from unprivileged kernel rb-tree corruption via the sys_vquotactl copyin path).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5674 Β· 20 files
FileTypeDescriptionSize
README.md β€” 4.4 KB ↓ raw
VERDICT.md β€” 5.2 KB ↓ raw
race_keysym.c β€” 3.5 KB view raw
race_attr.c β€” 2.4 KB view raw
race_probe.c β€” 2.4 KB view raw
proprop_dictionary.c β€” 35.3 KB view raw
pfix_dictionary.c β€” 35.1 KB view raw
pfix_object_impl.h β€” 14.2 KB view raw
build.sh β€” 162 B view raw
run.sh β€” 90 B view raw
run.log β€” 855 B view raw
run.2.log β€” 1.9 KB view raw
run.3.log β€” 1.7 KB view raw
fixrun-summary.txt β€” 1.0 KB view raw
console_crash.txt β€” 430 B view raw
env.txt β€” 310 B view raw
fix.diff β€” 1.1 KB view raw
code_hashes.txt β€” 200 B view raw
manifest.json β€” 1.6 KB view raw
verdict.json β€” 6.3 KB view raw

DF-5674 β€” prop_dictionary keysym free mutates global rb-tree without the tree mutex

What

sys/libprop/prop_dictionary.c β€” the interned dictionary-key symbol (prop_dictionary_keysym_t) registry is a process-global red-black tree _prop_dict_keysym_tree (prop_dictionary.c:203) guarded by _prop_dict_keysym_tree_mutex (prop_dictionary.c:206).

Every code path that looks up or inserts into that tree takes the mutex (_prop_dict_keysym_alloc, prop_dictionary.c:294-341), and the keysym frees that happen inside a dictionary free are covered only because the dictionary object type's pot_lock happens to be the same global mutex (prop_dictionary.c:134-135, taken by prop_object_release, sys/libprop/prop_object.c:1077-1078).

But the keysym object type itself registers no lock (prop_dictionary.c:148-153 β€” _prop_object_type_dict_keysym has no .pot_lock/.pot_unlock). Whenever a keysym's refcount drops to zero outside a dictionary-type free, _prop_dict_keysym_free() (prop_dictionary.c:233-242) runs _prop_rb_tree_remove_node() on the global tree with no mutex held, racing concurrent locked find/insert/remove from any other thread. Keysyms are interned process- or kernel-globally, so any two dictionaries that use the same key string share one keysym object β€” the race cross-cuts unrelated dictionaries.

Unprotected release paths in this file:

  • prop_dictionary_remove() β†’ _prop_dictionary_remove() releases the keysym at prop_dictionary.c:1104 while holding only the dictionary's own rwlock (WRLOCK at :1123), never the tree mutex.
  • prop_dictionary_set() error path releases a freshly allocated keysym at prop_dictionary.c:1009 (expand failure β€” reachable in userland on malloc failure).
  • keysyms returned into an array by prop_dictionary_all_keys() (prop_dictionary.c:843-871) are freed via array release β€” the array type also has no pot_lock (prop_array.c:72-80).
  • prop_object_release_emergency() calls pot_emergency_free after unlocking (prop_object.c:1042-1051) β†’ _prop_dictionary_emergency_free releases a keysym unlocked (prop_dictionary.c:411-423).

Consequence: unsynchronized red-black tree rebalancing on live nodes β†’ torn pointer reads / use-after-free of rb_nodes β†’ memory corruption.

Same source, two victims

  • Userland: lib/libprop compiles these exact sources into libprop.so. Any multithreaded program mixing prop_dictionary_remove/all_keys with prop_dictionary_set on any dictionaries (keysyms are global) can corrupt its own heap.
  • Kernel: sys/libprop is compiled into the kernel; the plist internalize path (prop_dictionary_copyin ← sys_vquotactl, sys/kern/vfs_quota.c:346, unprivileged) inserts keysyms under the mutex on every parse. Today no in-tree caller invokes prop_dictionary_remove (the only caller chain udev_dict_delete_key β†’ _udev_dict_delete_key, sys/kern/kern_udev.c:233,405, is dead code β€” zero callers), so the kernel race is currently latent: one new driver call away from unprivileged-user-triggerable kernel heap corruption. Reported as a defect with latent kernel impact + live userland impact.

Files

  • race_keysym.c β€” minimal reproducer (3 removers + 2 allocers, 96 shared keys).
  • race_attr.c β€” same, with SIGSEGV handler that names the faulting symbol (dladdr).
  • race_probe.c β€” same, plus DF5674_unlocked_frees counter fed by a trylock probe compiled into the instrumented library copy (proprop_dictionary.c): counts keysym frees executed while the tree mutex is not held.
  • build.sh / run.sh β€” exact commands used on the DragonFly guest.
  • fix.diff β€” the fix validated in this pack (keysym type takes the global lock; userland static proplib mutexes become recursive for the nested free path).

Reproduce (on the DragonFly guest)

sh build.sh     # builds baseline/probe/fixed libs + harnesses from /usr/src/sys/libprop
sh run.sh       # baseline: SIGSEGV in _prop_rb_tree_remove_node (5/5)
                # probe:    SIGSEGV + DF5674_unlocked_frees>0
                # fixed:    10/10 runs survive

Expected decisive output (baseline):

SIGSEGV in _prop_rb_tree_remove_node (/tmp/proporig/libprop_orig.so) addr=0x0
SIGSEGV in _prop_rb_tree_insert_node (/tmp/proprop/libprop_probe.so) addr=0x8   <- the LOCKED inserter, victim
DF5674_unlocked_frees=1145                                                       <- the UNLOCKED remover, culprit
VERDICT.md
↓ download raw

VERDICT β€” DF-5674

Status: reproduced (memory-corruption race proven on-guest, both sides attributed; fix validated 10/10). Impact class: userland heap corruption in any multithreaded libprop consumer (same source), latent kernel heap corruption β€” the kernel path exists and is compiled in, but its only in-tree trigger (udev_dict_delete_key β†’ prop_dictionary_remove) is dead code with zero callers today.

Root cause (path:line)

  • Keysyms are interned in ONE global rb-tree guarded by _prop_dict_keysym_tree_mutex: sys/libprop/prop_dictionary.c:203-206.
  • All lookups/inserts take that mutex (_prop_dict_keysym_alloc, prop_dictionary.c:294-341).
  • The keysym object type registers no pot_lock/pot_unlock (prop_dictionary.c:148-153). prop_object_release() only serializes pot_free through po_type->pot_lock (sys/libprop/prop_object.c:1077-1094); for keysyms that pointer is NULL, so _prop_dict_keysym_free() (prop_dictionary.c:233-242) executes _prop_rb_tree_remove_node() on the global tree with no lock whenever the refcount hits zero outside a dictionary-type free. The dictionary free path is accidentally safe only because the dictionary type's pot_lock happens to be the same global mutex (prop_dictionary.c:134-135).
  • Unprotected drop-to-zero sites: prop_dictionary_remove β†’ _prop_dictionary_remove release at prop_dictionary.c:1104 (holds only the per-dictionary rwlock, taken at :1123); prop_dictionary_set error path :1009; keysyms stored into arrays by prop_dictionary_all_keys (:843-871) β€” array type also lock-less (prop_array.c:72-80); emergency free (prop_object.c:1042-1051 β†’ prop_dictionary.c:411-423).
  • Keysyms are shared across ALL dictionaries (interning), so the race cross-cuts unrelated dictionaries that merely use the same key string.

Why it is exploitable-class

The unlocked side performs red-black tree rebalancing (rotation, pointer re-linking) on live nodes while a locked thread concurrently walks the same tree (_prop_rb_tree_find / _prop_rb_tree_insert_node, prop_rb.c:90-130). The loser reads a half-updated or freed rb_node, chases a wild child pointer, or writes through it β€” heap metadata corruption / UAF, the standard rb-tree race primitive. On the guest the crash lands inside _prop_rb_tree_remove_node and (run 2 of the probe) inside _prop_rb_tree_insert_node β€” the mutex-holding thread dying on a tree torn by the unlocked remover: 88-1145 unlocked frees per short run (probe counter), 100% crash rate across all baseline runs.

Kernel reachability (honest assessment)

  • The plist ioctl/syscall surface internalizes attacker XML into kernel dictionaries: sys_vquotactl (sys/kern/vfs_quota.c:344-349) is callable by any user and every parsed key does a locked insert into the global keysym tree (prop_dictionary_copyin β†’ prop_dictionary_internalize β†’ prop_dictionary_set β†’ _prop_dict_keysym_alloc).
  • The unprotected kernel side (prop_dictionary_remove) has exactly one in-tree caller chain β€” udev_dict_delete_key (sys/kern/kern_udev.c:405) β†’ _udev_dict_delete_key (kern_udev.c:229-235) β€” and udev_dict_delete_key has zero callers (dead code). prop_dictionary_all_keys has zero kernel callers. So today no unprivileged trigger exists; one new driver call re-activates unprivileged-user β†’ kernel heap corruption.
  • In-kernel, the analogous mutex is lockmgr-recursive (prop_object_impl.h:290-293), so the proposed fix nests correctly there.

Evidence (this pack)

  • run.log β€” baseline: SIGSEGV 5/5 (+ serial-console "exited on signal 11").
  • run.2.log β€” dladdr attribution: 5/5 in _prop_rb_tree_remove_node / keysym-tree walkers.
  • run.3.log β€” probe: DF5674_unlocked_frees=88..1145 (frees ran without the mutex); victim _prop_rb_tree_insert_node (the LOCKED side). Control (noremove, all ops locked): survived 3/3 β€” the unlocked remove is the necessary ingredient.
  • fixrun.*.log / summary β€” library rebuilt with fix.diff: 10/10 runs "survived", exit=0.

Fix

fix.diff (validated on guest): 1. Give _prop_object_type_dict_keysym .pot_lock/.pot_unlock = _prop_dictionary_lock/_prop_dictionary_unlock β€” every keysym free now happens under the global tree mutex, wherever the release originates (remove, all_keys-array release, emergency path, set error path). 2. Userland _PROP_MUTEX_INIT becomes PTHREAD_MUTEX_RECURSIVE (prop_object_impl.h): the nested dict-free β†’ keysym-free path re-takes the same mutex on one thread; the kernel side is already recursive (LK_CANRECURSE), userland must match.

Not fix-validated in-kernel

The kernel build with the fix was not rebuilt/booted: the kernel trigger is dead code, so no in-kernel PoC exists to A/B. The userland A/B (same sources) is the validation. guest_dirty=1 (crashed processes + /tmp artifacts); guest reset performed before returning.

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.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Library rebuilt from /usr/src/sys/libprop with fix.diff (userland A/B, identical workload): baseline 11/11 SIGSEGV vs fixed 10/10 survived. Bad behavior gone. Kernel rebuild not performed: the unprotected release has no live in-tree kernel caller (udev_dict_delete_key dead code), so no in-kernel A/B exists; kernel lockmgr is already recursive so the fix nests there by construction.

fixrun-summary.txt; fixrun.1-10.log (survived/exit=0); run.log & run.2.log (baseline crashes); fix.diff
↓ fix.diffper-fix-DF-5674

Confirmed kernel references

Detail

Exploit chain

userland: thread A prop_dictionary_remove(d1,K) drops the last keysym ref -> unlocked _prop_rb_tree_remove_node rebalances the global tree; thread B prop_dictionary_set(d2,K,...) -> _prop_dict_keysym_alloc find/insert (mutex-held) walks the torn tree -> wild rb_node pointer chase -> heap UAF/corruption (SIGSEGV on guest; with attacker-shaped heap reuse this is the standard rb-tree-race corruption primitive). kernel (latent, requires one future caller of prop_dictionary_remove / all_keys): unprivileged user pins CPU0 with vquotactl plist parses (locked inserts of chosen key strings) while CPU1 runs the unlocked keysym release -> kernel rb-tree corruption -> UAF. No in-kernel PoC today (dead trigger).

Evidence (decisive lines)

run.log (5/5 SIGSEGV + serial console signal-11 lines); run.2.log (dladdr: crashes in _prop_rb_tree_remove_node 5/5); run.3.log (probe DF5674_unlocked_frees=88..1145, victim _prop_rb_tree_insert_node = locked side; noremove control survived); fixrun-summary.txt + fixrun logs (fixed lib 10/10 survived); fix.diff (validated); console_crash.txt; env.txt

PoC changes

PoC authored from scratch (no seed): race_keysym.c minimal reproducer; race_attr.c adds SIGSEGV handler with dladdr symbol attribution (guest gdb cannot enumerate non-primary threads); race_probe.c adds unlocked-free counter fed by a trylock probe compiled into an instrumented copy of prop_dictionary.c; libprop baseline/probe/fixed variants built directly from /usr/src/sys/libprop on the guest (no installed prop headers in the image; include path -I/usr/src/sys, ).

Verified recommended fix

Register .pot_lock/_prop_dictionary_lock and .pot_unlock/_prop_dictionary_unlock on _prop_object_type_dict_keysym (prop_dictionary.c:148-153) and make the userland _PROP_MUTEX_INIT use PTHREAD_MUTEX_RECURSIVE (prop_object_impl.h:417) so the nested dictionary-free -> keysym-free path can re-take the same mutex (kernel lockmgr side is already LK_CANRECURSE); validated in fix.diff with 10/10 surviving runs.

Verdict

Race reproduced on-guest against libprop built from the exact kernel sources (/usr/src/sys/libprop). _prop_dict_keysym_free (prop_dictionary.c:233-242) runs _prop_rb_tree_remove_node on the global keysym rb-tree with NO mutex because the keysym object type (prop_dictionary.c:148-153) registers no pot_lock; prop_object_release (prop_object.c:1077) only serializes frees through po_type->pot_lock. A trylock probe counted 88-1145 unlocked keysym frees per short run; 11/11 baseline runs SIGSEGV inside _prop_rb_tree_remove_node/_prop_rb_tree_insert_node (the LOCKED inserter died on a tree torn by the UNLOCKED remover); control with removes disabled survived 3/3. Fix (keysym type takes the global lock + recursive userland mutex init) validated: 10/10 identical workloads survive. Impact: userland heap corruption/UAF in any multithreaded libprop consumer using prop_dictionary_remove/all_keys; in-kernel the defect is compiled in and the plist copyin path (sys_vquotactl, vfs_quota.c:346) provides the locked side from unprivileged users, but the only in-tree unlocked side (udev_dict_delete_key -> prop_dictionary_remove, kern_udev.c:233/405) is dead code with zero callers, so kernel exploitation is latent, not currently triggerable. Fix validated in userland only; kernel rebuild not performed (no in-kernel trigger to A/B).