prop_array_util _nocopy helpers export borrowed-pointer lifetime hazards into _KERNEL: unpinned get_cstring_nocopy return dangles on slot replace/array release; set_cstring_nocopy references caller-owned kernel memory forever with ps_size frozen at creation while externalize/prop_string_cstring re-read to the current NUL (latent UAF read / plist info leak / heap overflow)
Summary
The _nocopy pair declared for kernel use traffics in raw borrowed pointers with no pinning: the get side returns a pointer into the prop_string storage that dangles when the slot is replaced; the set side stores ps_immutable on caller-owned memory, never frees it, and freezes ps_size at creation while externalization and prop_string_cstring re-read the buffer to the CURRENT NUL - kernel UAF read, kernel-heap contents leaked into plists copied back to userspace, and a ps_size-desync heap overflow when the buffer lengthens. Demonstrated deterministically on the same-source page-guard rig 3/3 (dangling TRAP, freed-buffer TRAP, strcpy-overrun TRAP, externalize leaking reused-buffer content); kernel-build-copies fix validated in-rig. Info because zero live kernel callers today (only #if 0 dm code; the set variant entirely unused) - one caller away from the kernel class, same semantics in userland libprop. An 811-check matrix proves the rest of the file clean (guard algebra exact, DF-2240/2230 gain no foothold).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5690 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 2.4 KB | β raw | |
| VERDICT.md | β | 8.4 KB | β raw | |
| df5690.c | β | 26.8 KB | view raw | |
| guardalloc.h | β | 2.1 KB | view raw | |
| fix.diff | β | 1.5 KB | view raw | |
| build.sh | β | 174 B | view raw | |
| run.sh | β | 145 B | view raw | |
| build.log | β | 1.3 KB | view raw | |
| run.log | β | 3.7 KB | view raw | |
| run.2.log | β | 503 B | view raw | |
| env.txt | β | 639 B | view raw | |
| manifest.json | β | 1.5 KB | view raw | |
| verdict.json | β | 5.5 KB | view raw |
DF-5690 β prop_array_util nocopy variants: borrowed-pointer lifetime hazards (pass 2, sys/libprop/prop_array_util.c)
Info / hardening. The _nocopy string helpers exported by
sys/libprop/prop_array_util.c traffic in raw borrowed pointers with no
pinning and no kernel-appropriate ownership:
prop_array_get_cstring_nocopy()(prop_array_util.c:205-218) returns a pointer into the prop_string's storage; replacing/removing the array slot (prop_array.c:650-657 releases the old object) or releasing the array frees the backing store while the caller still holds*cpp.prop_array_set_cstring_nocopy()(prop_array_util.c:220-235) builds the string viaprop_string_create_cstring_nocopy()(prop_string.c:188-200), which stores a pointer to CALLER-owned memory, never copies or frees it (prop_string.c:85-87), and freezesps_sizeat creation whileprop_string_cstring()/externalization re-read the buffer to its current NUL (prop_string.c:302-304, 101-105).
In a kernel plist path this is a latent use-after-free read / kernel-heap
info-leak into copyout'd plists (externalize-to-user) and a heap-overflow
write (frozen ps_size vs current NUL). Zero live kernel callers today
(the only in-tree caller of the array get variant is inside #if 0,
dm_ioctl.c:273-316; no kernel caller of the set variant exists), hence Info.
Reproduce (guest, root)
sh build.sh # 3 same-source rigs from /usr/src/sys/libprop sh run.sh # matrix + nolife baseline + nolife fixed
Expected
- matrix:
MATRIX: 811 checks, 0 failures, 6 known(DF-2240) rowsβ the numeric get-side guard algebra, the type-guard discipline, OOB/NULL handling and set/add/externalize roundtrips are machine-confirmed clean. - nolife BASELINE (page-guard rig): all expectations met β
T1 dangling read TRAP, T2 externalize leaks the reused buffer content
(
<string>PWNED-BY-BUFFER-REUSE</string>), T2b freed-page read TRAP, T3 strcpy overrun TRAP, T4 control clean. - nolife FIXED (+fix.diff): all expectations met β T2/T2b/T3 gone (frozen copy semantics), T1 still traps (documented caller contract: keep the parent array alive); control unchanged.
Files: df5690.c (harness), guardalloc.h (page-guard allocator), fix.diff (kernel-build nocopy-create copies; rig activation via -DPROP_RIG_FORCE_NOCOPY_COPY), build.log, run.log, run.2.log (3x stability rerun), env.txt, VERDICT.md, manifest.json, verdict.json.
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):
- 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. - Set side (prop_array_util.c:228):
prop_string_create_cstring_nocopy(prop_string.c:188-200) storesps_immutable = cp(caller-owned memory), sets PS_F_NOCOPY so_prop_string_freenever frees it (prop_string.c:85-87), and freezesps_size = strlen(cp)at creation. Later externalization (prop_array_copyout_ioctlpath) re-reads the buffer at copyout time to its current NUL (prop_object.c:195-226, prop_string.c:101-105), and the copy variantprop_string_cstring()mallocsps_size+1butstrcpy()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 ofcpβ 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 reusesbuf; 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_cstringstrcpy()s 8191 bytes into the 16-byteps_size+1allocation β SIGSEGV on the guard page. - T4 control: copy-variant
set_cstring+ owner free β clean externalize<string>control</string>(traps are specific to the_nocopysemantics, 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.
Fix verification
fixedFix validated on the same source in-rig (DF-5682 precedent; Info finding with zero kernel callers, pure C-level control-flow change on a path the rig compiles identically, so no kernel rebuild performed): baseline traps T2b/T3 and leaks T2; patched copy (exact fix.diff, activated by the rig-only -DPROP_RIG_FORCE_NOCOPY_COPY which is undefined in real builds) externalizes the frozen
run.log section 2 vs section 3: 'T2b ... TRAP(SIGSEGV)' -> 'T2b: NO TRAP, intact content "transient-buffer"'; 'T3 ... TRAP(SIGSEGV)' -> 'T3: NO TRAP copy=BBBBBBBBBBBBBBB... (len=15)'; 'NOLIFE(base): all expectations met' and 'NOLIFE(fixed): all expectations met'; fix.diff adds the _KERNEL copying branch at prop_string.c:188.
Confirmed kernel references
- sys/libprop/prop_array_util.c:205-218
- sys/libprop/prop_array_util.c:220-235
- sys/libprop/prop_array.h:145-155
- sys/libprop/prop_string.c:85-87
- sys/libprop/prop_string.c:188-200
- sys/libprop/prop_string.c:101-105
- sys/libprop/prop_string.c:302-304
- sys/libprop/prop_array.c:650-657
- sys/libprop/prop_object.c:195-226
- sys/dev/disk/dm/dm_ioctl.c:273-316
Detail
Evidence (decisive lines)
run.log section 2 (baseline): 'T1 ... TRAP(SIGSEGV) OK', 'T2 ... exit=0 OK' with the leaked '<string>PWNED-BY-BUFFER-REUSE</string>' externalize output, 'T2b ... TRAP(SIGSEGV) OK', 'T3 ... TRAP(SIGSEGV) OK', control T4 clean; section 1: 'MATRIX: 811 checks, 0 failures, 6 known(DF-2240) rows'; section 3 (fixed): 'NOLIFE(fixed): all expectations met' with frozen '<string>transient-buffer</string>'. run.2.log: 3/3 stable reruns. build.log: md5-identical guest/host sources, redirected allocator macros, fix.diff applied.
PoC changes
Authored fresh (no seed). Harness revisions: (1) matrix values initially not wrapped in
Verified recommended fix
Make prop_string_create_cstring_nocopy copy in _KERNEL builds (see fix.diff); callers of get_cstring_nocopy must keep the parent object alive or use the copy variant.
Verdict
prop_array_util.c's _nocopy helpers export borrowed-pointer semantics into _KERNEL builds: prop_array_get_cstring_nocopy (prop_array_util.c:205-218) returns an unpinned pointer into the prop_string's storage that dangles as soon as the slot is replaced/removed (prop_array.c:650-657) or the array released, and prop_array_set_cstring_nocopy (prop_array_util.c:220-235 -> prop_string.c:188-200) stores caller-owned memory forever (free skipped at prop_string.c:85-87) while ps_size is frozen at creation though externalize/prop_string_cstring re-read the buffer to its CURRENT NUL (prop_object.c:195-226, prop_string.c:302-304). Deterministically proven on the same-source page-guard rig: dangling read TRAPS (T1), freed-buffer read TRAPS (T2b), strcpy overrun into the ps_size+1 allocation TRAPS (T3), and externalize leaks whatever occupies the caller's reused buffer at copyout time (
No comments yet.