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)
PoC verification
Evidence pack
findings/poc/DF-5674 Β· 20 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 nopot_lock(prop_array.c:72-80). prop_object_release_emergency()callspot_emergency_freeafter unlocking (prop_object.c:1042-1051) β_prop_dictionary_emergency_freereleases 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/libpropcompiles these exact sources intolibprop.so. Any multithreaded program mixingprop_dictionary_remove/all_keyswithprop_dictionary_seton any dictionaries (keysyms are global) can corrupt its own heap. - Kernel:
sys/libpropis 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 invokesprop_dictionary_remove(the only caller chainudev_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, plusDF5674_unlocked_freescounter 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 β 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 serializespot_freethroughpo_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'spot_lockhappens to be the same global mutex (prop_dictionary.c:134-135). - Unprotected drop-to-zero sites:
prop_dictionary_removeβ_prop_dictionary_removerelease at prop_dictionary.c:1104 (holds only the per-dictionary rwlock, taken at :1123);prop_dictionary_seterror path :1009; keysyms stored into arrays byprop_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) β andudev_dict_delete_keyhas zero callers (dead code).prop_dictionary_all_keyshas 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 withfix.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
fixedLibrary 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
Confirmed kernel references
- sys/libprop/prop_dictionary.c:148-153
- sys/libprop/prop_dictionary.c:203-206
- sys/libprop/prop_dictionary.c:233-242
- sys/libprop/prop_dictionary.c:294-341
- sys/libprop/prop_dictionary.c:843-871
- sys/libprop/prop_dictionary.c:1006-1011
- sys/libprop/prop_dictionary.c:1082-1107
- sys/libprop/prop_dictionary.c:1123
- sys/libprop/prop_object.c:1061-1107
- sys/libprop/prop_array.c:72-80
- sys/kern/kern_udev.c:229-235
- sys/kern/kern_udev.c:405
- sys/kern/vfs_quota.c:344-349
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).
No comments yet.