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

prop_dictionary _nocopy twins export borrowed-pointer lifetime hazards into _KERNEL with 26 live dm/tbridge callers β€” dm_dev_status_ioctl:409 and dm_table_deps_ioctl:619-620 actively dangle the borrowed name/uuid pointers mid-function (latent kernel UAF-read; set twin: frozen-size UAF/leak/overflow, zero callers)

Summary

The dictionary _nocopy twins carry the DF-5690 class into the kernel - but unlike the array twin, the get variant has 25 LIVE call sites in dm_ioctl.c plus tbridge: two dm ioctls borrow name/uuid then REPLACE the same keys while the borrows are in scope (dm_dev_status:397->409 uuid; dm_table_deps:596-597->619-620 both) - the borrows dangle until function exit (line-traced: no post-replace deref today, so latent; any debug kprintf or reorder turns it into a kernel UAF-read). Set twin: frozen ps_size + current-NUL re-read = plist-outlives-buffer UAF read into copyout / plist-outgrows-buffer heap overflow; zero kernel callers. Page-guard rig 3/3: the exact dm patterns trap on replace-then-read, buffer-reuse leaks PWNED-BY-BUFFER-REUSE into externalize, frozen-size strcpy overruns; DML control (the exact in-tree sequence, no post-replace deref) stays clean. kernel-build-copies fix validated; dm caller fixes listed. A 2517-check matrix proves the rest of the file exact (guards byte-identical to the array twins; DF-2240 class ABSENT here - set_uint uses the unsigned creator).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5691 Β· 14 files
FileTypeDescriptionSize
README.md β€” 2.4 KB ↓ raw
VERDICT.md β€” 5.2 KB ↓ raw
df5691.c β€” 30.5 KB view raw
guardalloc.h β€” 2.1 KB view raw
build.sh β€” 78 B view raw
run.sh β€” 102 B view raw
build.log β€” 1.4 KB view raw
run.log β€” 3.9 KB view raw
run2.log β€” 3.9 KB view raw
run3.log β€” 3.9 KB view raw
env.txt β€” 1.1 KB view raw
fix.diff β€” 1.8 KB view raw
manifest.json β€” 1.2 KB view raw
verdict.json β€” 7.2 KB view raw

DF-5691 β€” prop_dictionary_util.c _nocopy twins + typed-helper matrix

File under audit: sys/libprop/prop_dictionary_util.c (pass 2) Sibling findings: DF-5690 (array _nocopy class, zero live kernel callers), DF-2240 (array add_uint signed-creator), DF-5674 (prop_dictionary.c keysym).

What this pack proves

  1. matrix (./df5691_plain matrix) β€” every typed helper in the file (get/set int8/16/32/64, uint8/16/32/64, bool, cstring, get_dict, set_and_rel, immutable-dict rows, NULL-dict rows, wrong-type sweep) differentially checked against the derived guard contract. Expected: 0 failures; the DF-2240 class is absent here (set_uintN uses the unsigned creator at prop_dictionary_util.c:167) and the matrix documents the known-good differential rows.
  2. nolife BASELINE (./df5691_guard nolife) β€” the _nocopy twins' lifetime hazards on the page-guard allocator (fork per case, SIGSEGV = deterministic trap): - T1: get_cstring_nocopy borrow β†’ same-key replace (the LIVE dm_dev_status_ioctl pattern, dm_ioctl.c:397β†’:409) β†’ deref traps. - T1b: dm_table_deps_ioctl pattern (borrow name+uuid β†’ replace BOTH keys, dm_ioctl.c:619-620) β†’ deref traps. - T2: dict release while borrowed β†’ trap. - T2b: set_cstring_nocopy β†’ caller frees buffer β†’ externalize reads freed page β†’ trap. - T3: frozen ps_size vs current NUL β†’ prop_string_cstring strcpy overruns its ps_size+1 allocation β†’ trap. - T4/DML/NCD controls: copy variant fine; the EXACT in-tree dm sequence without post-replace deref does NOT trap (the in-tree dangle is real but latent β€” documented).
  3. nolife FIXED (./df5691_fixed nolife-fixed) β€” same battery with fix.diff active (rig-only -DPROP_RIG_FORCE_NOCOPY_COPY): T2b/T3 no longer trap; T1/T1b/T2 still trap (documented caller-side borrow contract, same treatment as DF-5690 T1).

Build / run (guest, root, /tmp/df5691)

sh build.sh     # 3 same-source libprop rigs: plain / guard / fixed
sh run.sh       # matrix + nolife baseline + nolife fixed

Expected output

  • MATRIX: <n> checks, 0 failures, 1 known(DF-2240-diff) rows
  • baseline: T1/T1b/T2/T2b/T3 β†’ TRAP(SIGSEGV) OK; T4/DML/NCD β†’ exit=0 OK; NOLIFE(base): all expectations met
  • fixed: T2b/T3 β†’ exit=0 OK (frozen copy); NOLIFE(fixed): all expectations met
VERDICT.md
↓ download raw

DF-5691 verdict β€” prop_dictionary_util.c _nocopy twins (pass 2)

File: sys/libprop/prop_dictionary_util.c Β· Mode: audit+verify (pass 2 GLM 5.3) Rig: same-source userland libprop builds from /usr/src/sys/libprop (md5 f17254fc6c6b827538f7c629861ee059 == audit tree) Γ— {plain, page-guard, fixed}. Guest: DragonFly 6.5-DEVELOPMENT, cc 8.3.

Bottom line

REPRODUCED (rig primitives; kernel impact today: none beyond a live, latent dangle). The dictionary _nocopy twins exported by this file (prop_dictionary_get_cstring_nocopy / prop_dictionary_set_cstring_nocopy, template at prop_dictionary_util.c:183-218, expanded :217-218) carry the DF-5690 borrowed-pointer class into the kernel with β€” unlike the array twin β€” 26 live kernel call sites (dm_ioctl.c Γ—25, tbridge.c Γ—1). Two of the dm sites actively invalidate the borrowed pointer mid-function:

  • dm_dev_status_ioctl borrows uuid at dm_ioctl.c:397 and replaces the UUID key at :409 (prop_dictionary_set releases the old object, prop_dictionary.c:988-993) β€” uuid dangles from :409 to function exit.
  • dm_table_deps_ioctl borrows name+uuid at :596-597 and replaces BOTH keys at :619-620 β€” both borrows dangle.

Line-by-line tracing (this run) confirms neither site dereferences the borrow after the replace β€” the in-tree dangle is real but latent, one added kprintf("%s", name) / refactor away from a kernel UAF-read. The set-side twin has zero kernel callers today (rg over sys/).

What the rig proved (deterministic, 3/3 identical runs)

Baseline (guard-page allocator; fork per case; SIGSEGV = trap):

case result
T1 dict-replace dangle (dm_dev_status :397β†’:409 pattern) TRAP(SIGSEGV)
T1b both-keys replace (dm_table_deps :619-620 pattern) TRAP(SIGSEGV)
T2 dict release while borrowed TRAP(SIGSEGV)
T2b set_cstring_nocopy β†’ caller frees β†’ proplib-handed ptr read TRAP(SIGSEGV)
T2c caller reuses buffer β†’ externalize (copyout analog) leaks <string>PWNED-BY-BUFFER-REUSE</string> (ps_size ignored, re-read to current NUL)
T3 frozen ps_size=15 vs current NUL@8190 β†’ get_cstring strcpy TRAP(SIGSEGV) (heap overflow of the ps_size+1 allocation)
T4 copy-variant control exit 0
DML exact in-tree dm sequence without post-replace deref exit 0, dict consistent β€” the latent-today proof
NCD alive-read sanity exit 0

Fixed (fix.diff = kernel-build prop_string_create_cstring_nocopy copies; rig-activated via -DPROP_RIG_FORCE_NOCOPY_COPY): T2b reads intact "transient-buffer" (no trap), T2c externalizes the frozen copy (exit 3 semantics), T3 copies exactly 15 bytes β€” while T1/T1b/T2 still trap, which is the documented caller-side borrow contract (the get side is fixed by caller discipline: retain the parent or use the copy variant β€” see the dm hardening note in fix.diff's comment).

Simultaneously machine-confirmed clean (negative results)

  • (b) get-side numeric guards: 2517-check differential matrix, 0 failures. The intN/uintN guard algebra is byte-identical to the array twins (prop_dictionary_util.c:101-110/:128-137 vs prop_array_util.c: 89-98/:116-122) and exactly matches the derived range contract for every width, signedness, and cross-width case; prop_number_size minimal-width semantics (prop_number.c:392-418) included.
  • (c) DF-2240 signed-creator class is ABSENT here: prop_dictionary_set_uint##size uses prop_number_create_unsigned_integer (prop_dictionary_util.c:167); set_uint64(UINT64_MAX)β†’get_uint64 roundtrips exactly (known-good differential row). The array bug was add_uint's signed creator (prop_array_util.c:187); no such mismatch exists in this file.
  • (d) misc: prop_dictionary_set_and_rel (:222-232) release dance is balanced β€” prop_dictionary_set never retains on a false return (prop_dictionary.c:975-1046: every failure goto out precedes the retain); get_dict (:44-54), get_bool (:56-70), the copy-variant get_cstring and all type guards are NULL-safe (prop_object_type(NULL)=UNKNOWN, prop_object.c:1114-1121) and wrong-type-safe (matrix). No other hazards found in the file.

Why not kernel-impact today

dm is optional dm (sys/conf/files:48) β€” module/optional dm only, /dev/mapper/control is root-only in default devfs; tbridge is a LINT64 regression device. The two dangling sites hold the borrow un-dereferenced. Filed Low / hardening (severity per DF-5682/DF-5690 precedent, upgraded one notch from Info because the dangle is created by live in-tree code, not merely one-caller-away).

Artifacts

run.log (decisive), run2.log/run3.log (stability, 18/18 OK each, byte-identical results), build.log (3 rigs + md5 provenance), df5691.c (harness), fix.diff (validated), guardalloc.h, env.txt, manifest.json, verdict.json.

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-5690/DF-5682 precedent; pure C-level control-flow change on a path the rig compiles identically, zero live kernel callers of the set twin, so no kernel rebuild performed): baseline traps T2b/T3 and leaks T2c; patched copy (exact fix.diff, activated by the rig-only -DPROP_RIG_FORCE_NOCOPY_COPY which is undefined in real builds) reads intact 'transient-buffer' with no trap, externalizes the frozen transient-buffer, and copies exactly 15 bytes with no overrun; T1/T1b/T2 still trap as the documented caller contract (get-side borrowing is an API contract addressed by caller discipline β€” the dm sites must retain or use the copy variant β€” not by the creator fix); T4/DML/NCD controls unchanged in both.

run.log section 2 vs section 3: 'T2b ... TRAP(SIGSEGV)' -> 'T2b: NO TRAP, intact content "transient-buffer"'; T2c '<string>PWNED-BY-BUFFER-REUSE</string>' -> '<string>transient-buffer</string>' (exit=0 -> exit=3); '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 (git-apply-able, hunk headers regenerated from a real diff).
↓ fix.diffper-fix-DF-5691

Confirmed kernel references

Detail

Evidence (decisive lines)

run.log section 2 (baseline): 'T1 ... TRAP(SIGSEGV) OK', 'T1b ... TRAP(SIGSEGV) OK', 'T2 ... TRAP(SIGSEGV) OK', 'T2b ... TRAP(SIGSEGV) OK', T2c leaked '<string>PWNED-BY-BUFFER-REUSE</string>' with 'exit=0 OK', 'T3 ... TRAP(SIGSEGV) OK', 'DML ... NOT dereferenced (latent)'; section 1: 'MATRIX: 2517 checks, 0 failures, 1 known(DF-2240-diff) rows'; section 3 (fixed): 'NOLIFE(fixed): all expectations met' with T2b intact "transient-buffer", T2c frozen copy exit=3, T3 len=15. run2.log/run3.log: 3/3 byte-identical stable reruns (18 OK rows each). build.log: md5-identical guest/host sources, redirected allocator macros, fix.diff applied.

PoC changes

Authored fresh (no seed). Harness revisions across 3 attempts: (1) fixture needed the full plist XML preamble (bare is rejected by prop_dictionary_internalize); root shell on the guest is csh so all remote commands wrapped in /bin/sh -c. (2) wrong-type sweep originally included right-typed keys (a string key IS a hit for get_cstring, a bool key for get_bool, a dict key for get_dict) and the u>INT64_MAX expected value used a lossy ternary β€” both expectation bugs fixed. (3) nolife: borrowed pointers must actually be READ through a volatile sink (assignment alone optimizes away); proplib-returned pointers must never be free()d on the guard rig (DragonFly libc malloc asserts); T1b must read right after the FIRST key replace β€” reading after BOTH replaces lets the second set_cstring's own mmap recycle the just-freed page (gap reuse DF-5690 also observed); T3 needs an 8190-byte current NUL to cross the page-guard; fork requires fflush before fork to stop duplicated stdio buffers; T2c uses exit-code semantics (0=leak baseline, 3=frozen fixed).

Verified recommended fix

Make prop_string_create_cstring_nocopy copy in _KERNEL builds (see fix.diff); dm callers of get_cstring_nocopy must retain the parent (or use the copy variant) whenever the key can be replaced β€” dm_dev_status_ioctl:409 and dm_table_deps_ioctl:619-620 replace borrowed-from keys while the borrows are in scope.

Verdict

prop_dictionary_util.c's _nocopy twins (template at :183-218) export the DF-5690 borrowed-pointer class into _KERNEL with, unlike the array twin, 26 live kernel call sites: prop_dictionary_get_cstring_nocopy returns an unpinned pointer into the prop_string's sole-owned buffer that dangles the moment the same dict key is replaced (prop_dictionary.c:988-993 releases the old object), the dict is released, or the key removed; two in-tree dm sites actively create that dangle mid-function β€” dm_dev_status_ioctl borrows uuid at dm_ioctl.c:397 then replaces the UUID key at :409, and dm_table_deps_ioctl borrows name+uuid at :596-597 then replaces both keys at :619-620 β€” while prop_dictionary_set_cstring_nocopy stores caller-owned kernel memory forever (free skipped at prop_string.c:85-87) with ps_size frozen at creation although externalize/get_cstring re-read the buffer to its CURRENT NUL (prop_string.c:101-105, :302-304). Deterministically proven on the same-source page-guard rig: dict-replace dangle TRAPS (T1/T1b, the two exact dm patterns), dict-release dangle TRAPS (T2), freed-buffer read via the proplist pointer TRAPS (T2b), buffer-reuse externalize leaks PWNED-BY-BUFFER-REUSE (T2c, the dm copyout info-leak semantics), and the frozen-ps_size strcpy overruns its ps_size+1 allocation TRAPPING (T3); control T4 and the exact no-deref in-tree dm sequence (DML) stay clean, proving the in-tree dangle is real but latent today (line-by-line: neither dm site dereferences the borrow after the replace). Kernel impact today none-beyond-latent: dm is optional (sys/conf/files:48) behind a root-only control node, tbridge is LINT64-only, and the set twin has zero kernel callers; filed Low hardening one edit away from a kernel UAF-read/leak/overflow class. Negative results machine-confirmed in the same run: 2517-check differential matrix 0 failures (get-side numeric guard algebra byte-identical to the array twins and exact), DF-2240 signed-creator class ABSENT (set_uintN uses the unsigned creator at :167; UINT64_MAX roundtrips), set_and_rel release dance balanced, all type guards and NULL handling correct.