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

prop_dictionary_ingest() runs driver handlers on a borrowed, unpinned object reference after the dictionary lock is dropped β€” concurrent key removal frees the object underneath the callback (CWE-416)

Summary

The walker obtains each rule's value via the borrowing prop_dictionary_get (no retain, rwlock dropped before return) then dispatches the arbitrary driver handler with no lock held and no reference pinned - any concurrent drop of the key's last reference (remove, same-key set, dict release) frees the object while the handler holds it. The DF-5690/5691 borrowed-pointer class baked into the core walk API: every consumer inherits it for every key. Rig (unpriv guest user): handler snapshots obj and sleeps while a second thread removes the key and sprays 0x41 allocations - post-sleep deref reads po_type=0x4141414141414141, SIGBUS 3/3; no-remove control stable. Kernel latent today (compiled standard into every kernel but ZERO in-tree callers) - becomes a genuine kernel UAF the moment any driver adopts the documented ingestion pattern with a shared dictionary; userland libprop ships the same source live. retain-around-callback fix validated (patched rig survives identical abuse 2/2).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5699 Β· 18 files
FileTypeDescriptionSize
README.md β€” 3.2 KB ↓ raw
VERDICT.md β€” 6.9 KB ↓ raw
df5699_ingest_race.c β€” 5.7 KB view raw
build.sh β€” 164 B view raw
run.sh β€” 184 B view raw
build.log β€” 9 B view raw
run.log β€” 647 B view raw
run.2.log β€” 635 B view raw
run.3.log β€” 635 B view raw
prop_ingest_fixed.c β€” 4.5 KB view raw
fix_build.sh β€” 488 B view raw
fix_run.sh β€” 577 B view raw
fix_build.log β€” 15 B view raw
fix_run.log β€” 776 B view raw
fix_run.2.log β€” 776 B view raw
env.txt β€” 251 B view raw
fix.diff β€” 770 B view raw
verdict.json β€” 5.5 KB view raw

DF-5699 β€” prop_dictionary_ingest() hands handlers a borrowed, unpinned object reference

File: sys/libprop/prop_ingest.c (pass 2, GLM 5.3) Severity: Low (latent in-kernel: zero in-tree callers today; realized UAF in the same-source userland libprop) Β· CWE-416 / CWE-667

What

prop_dictionary_ingest() (prop_ingest.c:121-159) obtains each value with prop_dictionary_get() (prop_ingest.c:133). That accessor copies the stored pointer out without prop_object_retain() and drops the dictionary rwlock before returning (prop_dictionary.c:933-945, unlock at :944). The walker then runs the arbitrary driver callback (*pite->pite_handler)(ctx, obj) (prop_ingest.c:152) for unbounded time holding only the dictionary's reference. Any concurrent drop of the key's last reference β€” prop_dictionary_remove(), prop_dictionary_set() on the same key, or release of the dictionary β€” frees the object while the handler is holding it: a use-after-free handed straight to driver callback code. The table walk itself also continues to the next rule afterwards, so the window repeats for every entry.

Reachability

  • Kernel: sys/conf/files:2249 compiles prop_ingest.c into every kernel (standard), and the header is exported β€” any kld may use the API. Today there are zero in-tree callers (rg 'prop_ingest|PROP_INGEST' sys/ outside libprop matches only conf/files), so the kernel instantiation is latent, not attacker-reachable on a stock system.
  • Userland: the identical source builds the base libprop (/usr/include/libprop/prop_ingest.h), exporting the same hazard to multi-threaded programs of any privilege level.

Reproduce (same-source rig on the DragonFly guest)

# on the guest, as unprivileged user maxx
cd /tmp/df5699
sh build.sh          # compiles /usr/src/sys/libprop/*.c into libprop_orig.so
sh run.sh            # control (no free) then experiment (free under handler)

Expected:

  • Control (spray only, no prop_dictionary_remove): handler survives, type 1852662386 -> 1852662386, value 0x1337beef00 -> 0x1337beef00, ingest=true, exit 0. (1852662386 = 0x6E756D72, the fourcc for PROP_TYPE_NUMBER in this proplib.)
  • Experiment: killer: prop_dictionary_remove() done β†’ handler's post-sleep prop_object_type(borrowed) faults β†’ exit 138 (SIGBUS), core dumped. Observed on attempt 1 of 1 in 3/3 runs (run.log, run.2.log, run.3.log).

Fix validation

fix.diff adds prop_object_retain(obj) before the handler dispatch and prop_object_release(obj) on both exits (prop_ingest.c:152 region). fix_build.sh / fix_run.sh rebuild the rig from the same sources with only that hunk applied: the experiment then prints type 1852662386 -> 1852662386, value 0x1337beef00 -> 0x1337beef00, ingest=true, no crash (fix_run.log, fix_run.2.log).

Files

  • df5699_ingest_race.c β€” harness (control/experiment differential)
  • build.sh / run.sh β€” baseline rig
  • fix_build.sh / fix_run.sh β€” patched rig (fix_validation)
  • prop_ingest_fixed.c β€” the patched copy fix.diff was generated from
  • build.log run.log run.2.log run.3.log fix_build.log fix_run.log fix_run.2.log env.txt
  • fix.diff β€” git-apply-able unified diff against sys/libprop/prop_ingest.c
VERDICT.md
↓ download raw

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:

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:

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 β€”

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

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Rig-level A/B: baseline (unmodified /usr/src/sys/libprop) SIGBUS 3/3 at the handler's UAF read; patched rig (identical sources + only the retain/release hunk from fix.diff) survives the identical abuse 2/2 with type/value stable and ingest=true. Kernel rebuild not performed β€” zero in-tree callers means no kernel behavior can differ; the rig compiles the exact audited source.

fix_run.log + fix_run.2.log end with 'FIX VALIDATION PASSED: object pinned across handler, no crash, no differential'; compare run.log/run.2.log/run.3.log baseline 'attempt 1 exit=138 ... Bus error (core dumped)'. fix.diff sha256 a14cf526a13fbbaa21f98e558b643f5d80a3bef86c23e04e83a42eee30c6ed51.
↓ fix.diffper-fix-DF-5699

Confirmed kernel references

Detail

Exploit chain

Same-source userland rig: thread A prop_dictionary_ingest() on {"victim": prop_number 0x1337beef00} with a handler that snapshots obj and sleeps 400ms; thread B prop_dictionary_remove(dict,"victim") during the sleep (number not interned -> dict held the only ref -> freed on the spot) then sprays 0x41-filled 40..96B allocations; handler's post-sleep prop_object_type(borrowed) derefs po_type=0x4141414141414141 -> SIGBUS (exit 138) on attempt 1, 3/3 runs. In-kernel: no chain exists yet β€” zero in-tree callers (rg 'prop_ingest|PROP_INGEST' sys/ outside libprop matches only sys/conf/files:2249); the primitive for a future kernel consumer is freed-object reuse read (and stale pointer retained into driver state) inside a supposedly-safe callback.

Evidence (decisive lines)

run.log/run.2.log/run.3.log: control stable then 'attempt 1 exit=138 ... Bus error (core dumped)' between 'post-sleep read-back begins' and the absent 'survived' line β€” crash is exactly the UAF read. fix_run.log/fix_run.2.log: 'FIX VALIDATION PASSED' (handler survives, type 1852662386 -> 1852662386, value 0x1337beef00 -> 0x1337beef00, ingest=true). VERDICT.md has the full path:line trace.

PoC changes

No seed existed (pass-2 new finding); harness authored from scratch: control/experiment differential (DF5699_CONTROL env), 0x41 sentinel size-class spray 40..96B, 400ms free window, prop_number victim (not interned β€” prop_number.c:273 has no intern tree), crash-or-differential dual detection.

Verified recommended fix

Pin the object across the handler callback: prop_object_retain(obj) before the dispatch at prop_ingest.c:152, prop_object_release(obj) on the handler-failed return and after the successful call (see fix.diff).

Verdict

prop_dictionary_ingest() (sys/libprop/prop_ingest.c:121-159) obtains each rule's value with the borrowing accessor prop_dictionary_get() (prop_ingest.c:133), which copies pde->pde_objref out without prop_object_retain and drops the dictionary rwlock before returning (prop_dictionary.c:933-945, unlock at :944). The walker then runs the arbitrary driver handler (prop_ingest.c:152) for unbounded time on that unpinned pointer with no lock held, repeating for every table entry: any concurrent prop_dictionary_remove()/same-key set/release of the dictionary frees the object underneath the handler β€” a use-after-free handed directly to consumer callback code, and handlers are documented to stash the object into driver state, extending the dangling lifetime arbitrarily. Reproduced deterministically (3/3 runs, SIGBUS on attempt 1) on the same-source userland rig (/usr/src/sys/libprop compiled as-is, unprivileged guest user): control (spray, no free) stable; experiment (prop_dictionary_remove under the sleeping handler) faults inside prop_object_type(borrowed) on the 0x4141-reclaimed header. Fixed by pinning (prop_object_retain/release around the dispatch): patched rig survives identical abuse 2/2 with type/value stable and ingest=true. Severity Low because the kernel instantiation is latent today β€” sys/conf/files:2249 compiles the file standard into every kernel, but rg shows zero in-tree callers; the same source ships as the base userland libprop (/usr/include/libprop/prop_ingest.h) where the hazard is live now for multi-threaded programs of any privilege.