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