β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-5690

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5690 Β· 13 files
FileTypeDescriptionSize
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 via prop_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 freezes ps_size at creation while prop_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.

VERDICT.md
↓ download raw

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.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Fix 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 transient-buffer, reads intact content with no trap, and copies 15 bytes with no overrun; T1 still traps as the documented caller contract (get-side borrowing is an API contract, addressed by caller discipline/documentation, not the fix); T4 control unchanged in both.

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.
↓ fix.diffper-fix-DF-5690

Confirmed kernel references

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 tags (FATAL, fixed); (2) free-then-externalize T2 was non-deterministic because externalize's own mmap recycled the just-freed address zero-filled (observed as an empty - itself evidence the read hits recycled heap); replaced by deterministic T2 buffer-reuse/leak and T2b no-intervening-alloc freed-read cases; (3) DF-2240 roundtrip rows reclassified from FAIL to KNOWN after tracing the add_uint signed-creator through externalize.

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 (PWNED-BY-BUFFER-REUSE, T2) - the prop_array_copyout_ioctl info-leak semantics. Kernel impact today is none reachable: the only in-tree kernel caller of the array get variant is #if 0 (dm_ioctl.c:273-316) and the set variant has zero kernel callers, so this is filed as Info hardening one caller away from a kernel UAF-read/leak/overflow class; same semantics ship in the userland libprop built from this source. The numeric get-side guard algebra and the full type-guard discipline of this file were simultaneously machine-confirmed clean (811-check differential matrix, 0 failures).