# DF-5690 VERDICT — prop_array_util.c `_nocopy` borrowed-pointer lifetime hazards

**Status: reproduced** (primitive demonstrated deterministically on the
same-source rig, 3/3 stable reruns). **Impact: none today** (zero live
kernel callers of the array-side `_nocopy` pair) — filed as Info /
hardening, one future caller away from a kernel UAF-read/info-leak class.
**Fix validated in-rig** (baseline hazards gone with fix.diff applied).

## What was audited and how the negative results were proven

Pass-2 audit of sys/libprop/prop_array_util.c (251 LOC), sibling of
prop_array.c (DF-5678) and prop_dictionary_util.c. Checklist results:

**(a) get-side decoding.** The guard algebra of the numeric templates
(prop_array_util.c:89-98 for `get_intN`, :116-122 for `get_uintN`) is
*exactly correct* against `prop_number_size()`'s magnitude bucketing
(prop_number.c:391-418) and the two value accessors (prop_number.c:424-454):
signed v is accepted by `get_intN` iff `INTN_MIN<=v<=INTN_MAX`, by
`get_uintN` iff `0<=v<=INTN_MAX`; unsigned u by `get_intN` iff
`u<=INTN_MAX`, by `get_uintN` iff `u<=UINTN_MAX`; the casts at :100/:124
can therefore never truncate out-of-range values. Machine-confirmed: the
differential matrix ran 34 plist value forms (signed decimals incl.
INT-size boundaries, unsigned hex incl. UINT-size boundaries, `-0x1`) x 8
numeric getters plus wrong-type/OOB/NULL sweeps and roundtrips —
**811 checks, 0 failures** (run.log §1). The ERANGE/saturation class of
DF-2230 lives in prop_number.c:515-545 and gains no new foothold through
these guards; an informational xref row documents the userland/kernel
divergence for decimal >INT64_MAX (kernel: silent saturation to signed
INT64_MAX).

**(b) add-side encoding.** No buffers/formats in this file (number
externalization is prop_number.c:193-214). `prop_array_add_uintN`'s signed
creator (prop_array_util.c:187) is DF-2240 — NOT re-reported; the rig
documents two previously-undescribed *manifestations* of the same defect
(6 KNOWN rows): `add_uintN(max)` also breaks the
externalize→reinternalize→`get_uintN` roundtrip for every N, while
`set_uintN` (:155, unsigned creator) roundtrips correctly.

**(c) type-guard discipline** (DF-2229/5682 class). Every accessor guards
with `prop_object_type()` before any field access (prop_array_util.c:52,
86, 113, 212). `prop_object_type(NULL)` returns PROP_TYPE_UNKNOWN
(prop_object.c:1113-1122), so OOB indx (prop_array_get bounds-checks at
prop_array.c:591) and NULL arrays (prop_array.c:587) fail cleanly —
machine-confirmed by the OOB/NULL sweep (run.log §1). The DF-5682 hole
(unguarded `prop_number_unsigned`, prop_number.c:379-384) is *pre-guarded*
at both of its uses here (:89, :116 — type check at :86/:113 first).
`prop_bool_true` is internally guarded (prop_bool.c:178).

**(d) nocopy lifetimes — the finding.** Two hazards, both root-caused in
the borrowed-pointer contract the file's `_nocopy` wrappers export into
`_KERNEL` builds (declared unconditionally in prop_array.h:150-155):

1. *Get side* (prop_array_util.c:211-215): the returned `const char *`
   points into the prop_string's storage and nothing pins the object.
   Replacing the slot (prop_array_set releases the old object,
   prop_array.c:650-657), removing it, or releasing the array frees the
   backing store while the caller still holds the pointer.
2. *Set side* (prop_array_util.c:228): `prop_string_create_cstring_nocopy`
   (prop_string.c:188-200) stores `ps_immutable = cp` (caller-owned
   memory), sets PS_F_NOCOPY so `_prop_string_free` never frees it
   (prop_string.c:85-87), and freezes `ps_size = strlen(cp)` at creation.
   Later externalization (`prop_array_copyout_ioctl` path) re-reads the
   buffer **at copyout time** to its *current* NUL
   (prop_object.c:195-226, prop_string.c:101-105), and the copy variant
   `prop_string_cstring()` mallocs `ps_size+1` but `strcpy()`s to the
   current NUL (prop_string.c:302-304).

## Demonstrations (page-guard rig, same source, run.log §2/§3)

- **T1** get-side dangling: `cp = get_cstring_nocopy(arr,0)`;
  `set_cstring(arr,0,"replacement")` frees the old string; read of `cp`
  → **SIGSEGV on the unmapped page** (deterministic UAF read).
- **T2** stale-content leak through the copyout analog: after
  `set_cstring_nocopy(arr,0,buf)` the caller *reuses* `buf`; externalize
  emits `<string>PWNED-BY-BUFFER-REUSE</string>` — userland receives
  whatever occupies the caller's kernel buffer at copyout time, ignoring
  the frozen ps_size (kernel info-leak semantics).
- **T2b** freed-buffer read: caller frees the buffer; the borrowed
  pointer is read with no intervening allocation → **SIGSEGV**. (An
  earlier free-then-externalize variant returned an *empty string*
  instead: externalize's own mmap recycled the just-freed address
  zero-filled — i.e. the "leaked" content was recycled-heap state;
  recorded in run history, superseded by the deterministic T2/T2b.)
- **T3** heap-overflow write: ps_size frozen at 15, caller lengthens the
  same buffer to 8191 chars, `get_cstring` strcpy()s 8191 bytes into the
  16-byte `ps_size+1` allocation → **SIGSEGV on the guard page**.
- **T4** control: copy-variant `set_cstring` + owner free → clean
  externalize `<string>control</string>` (traps are specific to the
  `_nocopy` semantics, not the rig).

## Kernel reachability (why Info)

In-tree kernel callers of this file's helpers (rg over sys/):
dm_target.c:246 (`add_uint32`, kernel-fed version constants),
dm_ioctl.c:644-658 (`get/add_uint64`, output dedup array, kernel-fed),
dm_ioctl.c:1077-1094 (`get/set_uint32`, plist-fed — see below),
vfs_quota.c:185-212 (`add_and_rel`, output dicts). The only kernel caller
of `prop_array_get_cstring_nocopy` is inside `#if 0` (dm_dev_rename_ioctl,
dm_ioctl.c:273-316). **No kernel caller of `prop_array_set_cstring_nocopy`
exists** (dictionary twin getters are live in dm but belong to
prop_dictionary_util.c's audit). DF-5678 is not reachable through these
helpers: dm_check_version's `set_uint32(ver, i, …)` with i∈{0,1,2} always
satisfies idx ≤ count+1 after the loop. Cross-ref for the dm_ioctl.c
re-audit: dm_ioctl.c:1074-1086 uses `int dm_version[3]` UNINITIALIZED and
ignores `prop_array_get_uint32`'s return, so a plist omitting version
elements feeds uninit stack to the comparison (and to a root-visible
dmdebug line); that is dm_ioctl.c's defect, not this file's — the
"returns false, output untouched" contract is standard.

Same defect exists verbatim in the userland libprop built from this
source (the rig *is* that build) — local processes using the nocopy pair
against libprop.so get the same UAF/leak semantics.

## Fix validation (fix.diff, in-rig, no kernel rebuild)

Fix at the root choke point `prop_string_create_cstring_nocopy`
(prop_string.c): in `_KERNEL` builds it becomes a copying creator, so no
plist can ever reference caller-owned transient memory (kills T2/T2b/T3
for every wrapper, including the dictionary twins). Rig activation via
`-DPROP_RIG_FORCE_NOCOPY_COPY` (rig-only define, undefined in every real
build — the tested artifact is otherwise byte-identical to fix.diff).
Result (run.log §3): T2 externalizes the frozen `<string>transient-buffer</string>`
(exit=3 semantics), T2b reads intact content with **no trap**, T3 copies
15 bytes with **no trap**, T4 control unchanged. T1 still traps by design:
the get-side borrowing is the documented caller contract ("valid while the
parent holds the object") and cannot be fixed without an API change —
callers must use the copy variant or keep the parent alive (VERDICT:
recommend the dm-style pattern of copying before the array is released).
Kernel-rebuild validation was not performed: the finding is Info with zero
kernel callers, and the change is pure C-level control flow on a path the
same-source rig compiles identically (DF-5682 precedent).

## Rig notes

`guardalloc.h` gives every allocation a PROT_NONE guard page and munmaps
on free (guest cc 8.3 has no usable ASAN runtime). The allocator redirect
touches only scratch build copies under /tmp/df5690; sys/ untouched;
guest not dirtied (no modules, no kernel build, no panic; scratch removed
after evidence capture).

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