# DF-5699 VERDICT — REPRODUCED (same-source userland rig; latent in-kernel)

## The bug

`prop_dictionary_ingest()` walks a driver-supplied rule table and, for each
present key, does:

```c
prop_ingest.c:133   obj = prop_dictionary_get(dict, pite->pite_key);
prop_ingest.c:134   ctx->pic_type = prop_object_type(obj);
...
prop_ingest.c:152   if ((*pite->pite_handler)(ctx, obj) == false) {
```

`prop_dictionary_get()` is a *borrowing* accessor:

```c
prop_dictionary.c:933  prop_dictionary_get(prop_dictionary_t pd, const char *key)
prop_dictionary.c:940      _PROP_RWLOCK_RDLOCK(pd->pd_rwlock);
prop_dictionary.c:941      po = _prop_dictionary_get(pd, key, true);
prop_dictionary.c:942      _PROP_RWLOCK_UNLOCK(pd->pd_rwlock);   <-- lock gone
prop_dictionary.c:944      return (po);                          <-- no retain
```

`_prop_dictionary_get` (:909-926) copies `pde->pde_objref` out with no
`prop_object_retain`. The ingest walker therefore runs an arbitrary,
unbounded driver callback at :152 on a pointer whose only live reference is
still owned by the dictionary — with no lock held. A concurrent
`prop_dictionary_remove()` / same-key `prop_dictionary_set()` /
`prop_object_release(dict)` drops that last reference and frees the object
under the handler: **use-after-free passed to driver code**, repeated for
every remaining rule in the table. Handlers routinely cache the object in
driver state (that is the API's point — translate plist into binary form),
extending the dangling lifetime arbitrarily past the call.

This is the same borrowed-pointer class already filed for the `_nocopy`
helpers (DF-5690, DF-5691), but here it is baked into the core walk API
itself: *every* current or future consumer inherits it for every key.

## Why Low severity (not higher)

`rg 'prop_ingest|PROP_INGEST' sys/` outside `sys/libprop` matches only
`sys/conf/files:2249`, which compiles the file into every kernel as
`standard`. **There are no in-tree kernel callers today** — the kernel
instantiation is latent; it becomes a real kernel UAF the moment any driver
adopts the documented ingestion pattern. The identical source also builds the
base userland libprop (`/usr/include/libprop/prop_ingest.h` exists on the
guest), where the hazard is live for multi-threaded programs now.

## Reproduction (same-source rig, unprivileged user `maxx` on the guest)

- `build.sh` compiles `/usr/src/sys/libprop/*.c` (unmodified) into
  `libprop_orig.so`; the harness links against it — the exact source audited.
- Harness: thread A ingests `{"victim": <number 0x1337beef00>}` with a
  handler that snapshots `obj`, sleeps 400 ms, then reads
  `prop_object_type(borrowed)` + `prop_number_integer_value(borrowed)`.
  Thread B (during the sleep) calls `prop_dictionary_remove(dict,"victim")`
  — the prop_number is *not* interned (prop_number.c:273
  `_prop_number_alloc` plain-mallocs; no intern tree), so the dictionary
  held its only reference and it is freed on the spot — then sprays
  0x41-filled allocations of the 40..96 byte size classes to reclaim the
  chunk. Control mode (`DF5699_CONTROL=1`): thread B sprays but never
  removes.

Results (all logs untrimmed in this directory):

| run | outcome |
|---|---|
| run.log (control)   | handler survives; `type 1852662386 -> 1852662386`, `value 0x1337beef00 -> 0x1337beef00`, `ingest=true`, exit 0 |
| run.log (experiment) | SIGBUS at the handler's post-sleep read-back, attempt 1, exit 138, core dumped |
| run.2.log           | SIGBUS attempt 1, exit 138 |
| run.3.log           | SIGBUS attempt 1, exit 138 |

3/3 deterministic. The crash lands exactly between
`handler: post-sleep read-back begins` and the missing
`handler: post-sleep read-back survived` — i.e. inside
`prop_object_type(borrowed)` dereferencing the reclaimed header
(`po_type` = 0x4141414141414141). The control isolates
`prop_dictionary_remove()`'s reference drop as the cause: identical spray,
no free, no effect. (1852662386 = 0x6E756D72 is `PROP_TYPE_NUMBER`; this
proplib's `prop_type_t` values are fourccs.)

Primitive, characterized: a freed proplib object of attacker-chosen type is
reused (spray) and then *read* through a stale pointer inside a callback the
consumer believes is safe — in a privileged kernel consumer this is the
classic slab-groom → stale-read (and, for handlers that store the pointer,
stale-*write*/arbitrary-free) setup. On the rig it is a SIGBUS; on this
guest no kernel path exists to push further (no callers), so uid=0 is not
reachable from this finding alone.

## Fix validation

`fix.diff` (one hunk against sys/libprop/prop_ingest.c): pin the object
across the callback —

```diff
+		prop_object_retain(obj);
 		if ((*pite->pite_handler)(ctx, obj) == false) {
+			prop_object_release(obj);
 			ctx->pic_error = PROP_INGEST_ERROR_HANDLER_FAILED;
 			return (false);
 		}
+		prop_object_release(obj);
```

`fix_build.sh` rebuilds the rig from the same sources with ONLY this hunk
(`prop_ingest_fixed.c`); `fix_run.sh` re-runs control + experiment:

| run | outcome |
|---|---|
| fix_run.log (control)    | stable, exit 0 |
| fix_run.log (experiment) | handler survives; type/value unchanged; `ingest=true`; exit 1 (not-realized) — `FIX VALIDATION PASSED` |
| fix_run.2.log            | `FIX VALIDATION PASSED` again |

A full fixed-kernel build was not performed: the function has zero in-tree
callers, so no kernel behavior can differ; the rig is the same source, and
the rig-level A/B is exact (only the hunk varies).

## Environment

DragonFly dfbsd 6.5-DEVELOPMENT x86_64 (kernel built Jul 2 2026), cc 8.3
[DragonFly], guest up throughout; userland crash only (core in /tmp,
cleaned). See env.txt.

## Negative notes (checked, not broken)

- Terminator walk (prop_ingest.c:131): table is driver-const; stops at
  `pite_key == NULL`; no index arithmetic; nothing plist-supplied feeds it.
- `prop_object_type(NULL)` is NULL-safe (prop_object.c:1114-1121), so the
  pre-NULL-check store at :134 is fine.
- Type gate (:147-151) skipped when `pite_type == PROP_TYPE_UNKNOWN` is
  documented contract; the unguarded accessor case (prop_number_unsigned)
  is already DF-5682.
- Error paths: no unwind of earlier handler side effects — documented
  NetBSD semantic (caller-side cleanup), no memory unsafety.
- `ctx` reuse resets only `pic_error` (:129); stale `pic_key`/`pic_type`
  after an empty-table success is a diagnostics wart, not memory unsafety.
- `prop_ingest_context_free(NULL)` → `kfree(NULL)`: caller-contract;
  alloc-failure callers get NULL back and the API never frees NULL itself
  on an ingest path.
- No recursion (DF-5675 class N/A), no length arithmetic, no copyin/out in
  this file.

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